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

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 07:38:54 +02:00
parent 621fbfba64
commit 3396fa0598
402 changed files with 234697 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Ignorar arquivos de configuração sensíveis locais se copiados acidentalmente
gcal_key.json
ssh_alerts_config.json
*.log
.DS_Store
@@ -0,0 +1,319 @@
# Baileys Engine Swap — Plano de Implementação
**Data:** 2026-04-29
**Contexto:** Mensagens ricas (botões, lista, carrossel) não renderizam no WhatsApp Web com `@whiskeysockets/baileys` padrão. A solução é usar o fork InfiniteAPI que implementa esses tipos nativamente dentro do `sendMessage()`.
---
## 1. Problema Raiz
O `@whiskeysockets/baileys` v6.7.18 (oficial) **não suporta** os campos:
- `nativeButtons`
- `nativeList`
- `nativeCarousel`
O WhatsApp Web exige nós binários adicionais (`<biz><interactive>`, `<bot biz_bot="1"/>`) injetados no stanza via `relayMessage(additionalNodes)`. O `rich-message.ts` atual tenta replicar isso manualmente, mas **ainda falha no WhatsApp Web**.
**Erro observado:** "Não foi possível carregar a mensagem. Use seu celular para acessá-la."
### Por que o InfiniteAPI funciona
O fork `github:rsalcara/InfiniteAPI` (v7.0.0-rc.9, package name: `baileys`) implementa toda a lógica de `additionalNodes` internamente no `generateWAMessageContent()` / `relayMessage()`. O dev só chama `sock.sendMessage(jid, { nativeButtons: [...] })` e o fork faz o resto.
### Por que whatsmeow (Go) NÃO resolve
Pesquisa confirmada (abril 2026):
- `InteractiveMessage` + `NativeFlowMessage` → struct existe, botões **não aparecem** (issue #711, sem solução)
- `ListMessage`**não exibe** na conversa do destinatário (issue #669)
- `CarouselMessage`**não existe** (exclusivo da Cloud API)
- Migração seria ~23 meses de reescrita para o mesmo resultado quebrado
---
## 2. Solução Escolhida: Engine Swap com Fallback
Instalar **os dois** packages (nomes diferentes, convivem no mesmo `node_modules`) e criar uma camada de abstração `engine/` que permite trocar via variável de ambiente + restart automático.
**Downtime por troca:** ~510 segundos (restart + reconexão automática, sem novo QR — auth state no disco é compatível entre os dois, InfiniteAPI é fork direto).
---
## 3. Estrutura de Arquivos
```
backend/
src/
modules/whatsapp/
engine/
official.ts ← re-exporta @whiskeysockets/baileys
infinite.ts ← re-exporta baileys (InfiniteAPI)
index.ts ← exporta conforme process.env.BAILEYS_ENGINE
connection/
WhatsAppConnectionManager.ts ← muda import para '../engine'
handlers/
MessageHandler.ts ← muda import para '../../engine' (ou relativo)
ContactHandler.ts ← idem
rich-message.ts ← simplificado + fallback comentado
modules/chatbot/
chatbot.service.ts ← muda import
shared/utils/
whatsapp.ts ← muda import
```
---
## 4. Implementação Passo a Passo
### 4.1 Instalar os dois packages
```bash
cd /home/deploy/projetos/newwhats.local/backend
# Manter o oficial
# @whiskeysockets/baileys já está em package.json
# Adicionar InfiniteAPI com alias para não conflitar
npm install baileys@github:rsalcara/InfiniteAPI
```
Ambos convivem no `node_modules` porque têm nomes diferentes:
- `node_modules/@whiskeysockets/baileys/`
- `node_modules/baileys/`
### 4.2 Criar `engine/official.ts`
```typescript
// Re-exporta tudo do Baileys oficial
export * from '@whiskeysockets/baileys'
export { proto } from '@whiskeysockets/baileys'
```
### 4.3 Criar `engine/infinite.ts`
```typescript
// Re-exporta tudo do InfiniteAPI
export * from 'baileys'
export { proto } from 'baileys'
```
### 4.4 Criar `engine/index.ts`
```typescript
// Seleciona engine conforme BAILEYS_ENGINE (padrão: infinite)
// IMPORTANTE: process.env lido em tempo de inicialização do módulo.
// Para trocar a engine: alterar BAILEYS_ENGINE + reiniciar o processo.
const engine = process.env.BAILEYS_ENGINE ?? 'infinite'
if (engine === 'infinite') {
module.exports = require('./infinite')
} else {
module.exports = require('./official')
}
```
> **Nota TypeScript:** usar `require()` condicional ou um `export *` com re-export dinâmico.
> Alternativa mais simples: dois arquivos de entrada separados e o `tsconfig` aponta para um deles via path alias.
### 4.5 Trocar imports nos 6 arquivos
| Arquivo | Import atual | Novo import |
|---|---|---|
| `WhatsAppConnectionManager.ts` | `@whiskeysockets/baileys` | `../engine` |
| `handlers/MessageHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `handlers/ContactHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `rich-message.ts` | `@whiskeysockets/baileys` (3 imports) | `./engine` |
| `modules/chatbot/chatbot.service.ts` | `@whiskeysockets/baileys` | `../whatsapp/engine` |
| `shared/utils/whatsapp.ts` | (verificar) | path relativo para engine |
### 4.6 Simplificar `rich-message.ts` para InfiniteAPI
Com InfiniteAPI, o `sendRichMessage()` fica:
```typescript
export async function sendRichMessage(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: proto.IMessageKey; message: proto.IMessage },
): Promise<string> {
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
if (poll) {
const sent = await sock.sendMessage(jid, {
poll: { name: poll.name, values: poll.values, selectableCount: poll.selectableCount ?? 1 },
}, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `poll-${Date.now()}`
}
if (nativeButtons) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeButtons, // InfiniteAPI trata tudo internamente
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `btn-${Date.now()}`
}
if (nativeList) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeList,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `list-${Date.now()}`
}
if (nativeCarousel) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeCarousel,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `carousel-${Date.now()}`
}
// Texto simples
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `text-${Date.now()}`
}
```
> **Fallback:** Manter o código atual do proto manual em `rich-message.official.ts` caso precise debugar com a engine oficial.
### 4.7 Adicionar BAILEYS_ENGINE ao .env
```bash
# /home/deploy/projetos/newwhats.local/backend/.env
BAILEYS_ENGINE=infinite # ou: official
```
### 4.8 Endpoint de toggle no painel admin
**Backend** — novo endpoint em `whatsapp.routes.ts` ou `admin.routes.ts`:
```typescript
// POST /api/admin/engine
// Body: { engine: 'infinite' | 'official' }
router.post('/engine', adminOnly, async (req, res) => {
const { engine } = req.body
if (!['infinite', 'official'].includes(engine)) return res.status(400).json({ error: 'invalid engine' })
// 1. Persiste no banco (tabela config ou settings)
await prisma.config.upsert({
where: { key: 'BAILEYS_ENGINE' },
update: { value: engine },
create: { key: 'BAILEYS_ENGINE', value: engine },
})
// 2. Reinicia o processo (pm2 ou systemctl)
// Usar exec() com o comando correto para o ambiente
exec('pm2 restart newwhats-backend', (err) => {
if (err) return res.status(500).json({ error: 'restart failed' })
res.json({ ok: true, engine })
})
})
// GET /api/admin/engine — retorna engine ativa
router.get('/engine', adminOnly, async (req, res) => {
const config = await prisma.config.findUnique({ where: { key: 'BAILEYS_ENGINE' } })
res.json({ engine: config?.value ?? process.env.BAILEYS_ENGINE ?? 'infinite' })
})
```
**Frontend** — componente simples na página de administração:
```tsx
// Toggle na página /admin/settings ou /admin/system
<div>
<span>Engine WhatsApp</span>
<select value={engine} onChange={handleSwitch}>
<option value="infinite">InfiniteAPI (botões/lista/carrossel)</option>
<option value="official">Baileys Oficial (estável, sem botões)</option>
</select>
<span className="text-yellow-500"> Troca reinicia o servidor (~10s)</span>
</div>
```
---
## 5. Tipos Suportados por Engine
| Tipo de Mensagem | Baileys Oficial | InfiniteAPI |
|---|---|---|
| Texto simples | ✅ | ✅ |
| Reply / Quote | ✅ | ✅ |
| Menções (@) | ✅ | ✅ |
| Mídia (foto/vídeo/doc/áudio) | ✅ | ✅ |
| Enquete (poll) | ✅ | ✅ |
| Botões (`nativeButtons`) — reply, url, copy, call | ❌ (quebrado no Web) | ✅ |
| Lista (`nativeList`) | ❌ (quebrado no Web) | ✅ |
| Carrossel (`nativeCarousel`) | ❌ (quebrado no Web) | ✅ |
| Botão com header imagem/vídeo | ❌ | ✅ (`headerImage`, `headerVideo`) |
### Tipos de botão suportados pelo InfiniteAPI
```typescript
type NativeButton =
| { type: 'reply'; id: string; text: string } // Quick reply
| { type: 'url'; text: string; url: string } // Abrir URL
| { type: 'copy'; text: string; copyText: string } // Copiar texto
| { type: 'call'; text: string; phoneNumber: string } // Ligar
```
---
## 6. Riscos e Mitigações
| Risco | Mitigação |
|---|---|
| InfiniteAPI é RC (não estável) | Engine swap — volta pro oficial em 10s |
| Repo `github:rsalcara/InfiniteAPI` pode sair do ar | Fazer fork próprio no GitHub interno |
| Breaking changes na API do fork | Wrapper `engine/` isola o impacto |
| Auth state incompatível após troca | Não é problema — InfiniteAPI usa o mesmo formato `useMultiFileAuthState` |
| TypeScript: tipos diferentes entre engines | Usar `as any` nos campos extras do InfiniteAPI |
---
## 7. Estimativa de Esforço
| Tarefa | Tempo |
|---|---|
| Instalar packages + criar `engine/` | 30 min |
| Trocar imports nos 6 arquivos | 30 min |
| Simplificar `rich-message.ts` | 30 min |
| Endpoint admin + frontend toggle | 2 horas |
| Teste real (celular + Web) | 1 hora |
| **Total** | **~45 horas** |
---
## 8. Arquivos-Chave para Referência
| Arquivo | Descrição |
|---|---|
| `backend/src/modules/whatsapp/rich-message.ts` | Lógica atual de envio rico (proto manual) |
| `backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts` | Core da conexão Baileys (1265 linhas) |
| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | Processa mensagens recebidas (865 linhas) |
| `backend/src/modules/whatsapp/whatsapp.send.routes.ts` | Rotas de envio (757 linhas) |
| `/home/deploy/projetos/whatsbotoes/baileys_interactive/` | Projeto de referência usando InfiniteAPI |
| `/home/deploy/projetos/whatsbotoes/tipos_de_disparo.md` | Documentação dos tipos de disparo |
| `node_modules/baileys/lib/Types/Message.d.ts` | Tipos do InfiniteAPI (após instalar) |
---
## 9. Comandos Úteis
```bash
# Compilar backend
cd /home/deploy/projetos/newwhats.local/backend
npx tsc --skipLibCheck
# Compilar só rich-message
npx tsc src/modules/whatsapp/rich-message.ts --outDir dist/modules/whatsapp --skipLibCheck --esModuleInterop --module commonjs --moduleResolution node --target es2020
# Reiniciar backend
pm2 restart newwhats-backend # ou o nome do processo
# Ver engine ativa
grep BAILEYS_ENGINE .env
```
+211
View File
@@ -0,0 +1,211 @@
# 🚀 Guia de Desenvolvimento - NewWhats
Este documento descreve como configurar e rodar o ambiente de desenvolvimento com **hot-reload** para desenvolvimento rápido.
---
## 📋 Portas
| Serviço | Produção | Desenvolvimento |
|---------|----------|-----------------|
| Frontend (Next.js) | 3003 | 4004 |
| Backend (Express) | 8008 | 8009 |
---
## 🛠️ Setup Inicial
### 1. Requisitos
- Node.js (verifique com `node -v`)
- npm (verifique com `npm -v`)
- PostgreSQL rodando
- DragonflyDB (Redis-compatible) rodando
- NATS JetStream rodando
- Temporal.io rodando
### 2. Instalar Dependências
```bash
# Frontend
cd frontend
npm install
# Backend
cd ../backend
npm install
```
---
## 🏃 Executar em Desenvolvimento (Hot-Reload)
### Opção 1: Usar Serviços Systemd (Recomendado)
#### Iniciar Ambos os Serviços
```bash
# Iniciar dev services
sudo systemctl start newwhats-frontend-dev newwhats-backend-dev
# Verificar status
sudo systemctl status newwhats-frontend-dev newwhats-backend-dev
# Ver logs em tempo real
sudo journalctl -u newwhats-frontend-dev -f
sudo journalctl -u newwhats-backend-dev -f
```
#### Parar Serviços Dev
```bash
sudo systemctl stop newwhats-frontend-dev newwhats-backend-dev
```
#### Ativar Auto-start na Boot
```bash
sudo systemctl enable newwhats-frontend-dev newwhats-backend-dev
```
---
### Opção 2: Rodar Manualmente (Terminal)
#### Terminal 1 - Frontend (Hot-Reload)
```bash
cd /home/deploy/projetos/newwhats.local/frontend
npm run dev
# Output esperado:
# ▲ Next.js 14.2.35
# - Local: http://localhost:4004
# ✓ Ready in X.Xs
```
#### Terminal 2 - Backend (Hot-Reload)
```bash
cd /home/deploy/projetos/newwhats.local/backend
npm run dev
# Output esperado:
# Server running on port 8009
# Watching for file changes...
```
---
## 📝 Variáveis de Ambiente
### Frontend
- `NODE_ENV=development` → Ativa React DevTools e hot-reload
- Porta: `4004` (configurado em `package.json`)
### Backend
- `NODE_ENV=development` → Logs detalhados, sem compressão
- `PORT=8009` → Porta dev (não conflita com produção 8008)
- `.env` já está configurado com valores corretos
---
## 🔄 Hot-Reload em Ação
### Frontend
- Edite arquivos em `frontend/pages/`, `frontend/components/`, etc.
- Next.js recompila automaticamente e atualiza o navegador
- Para mudanças em `next.config.mjs`, reinicie o serviço
### Backend
- Edite arquivos em `backend/src/`
- `tsx watch` reinicia o servidor automaticamente
- Mudanças em `.env` requerem restart manual
---
## 🚨 Troubleshooting
### Porta já em uso
```bash
# Verificar qual processo usa a porta
lsof -i :4004 # Frontend
lsof -i :8009 # Backend
# Matar processo (último recurso)
sudo kill -9 <PID>
```
### Serviço não inicia
```bash
# Verificar logs detalhados
sudo systemctl status newwhats-frontend-dev
sudo journalctl -u newwhats-frontend-dev -n 50
# Recarregar systemd após editar .service
sudo systemctl daemon-reload
```
### Mudanças não aparecem
- **Frontend**: Limpar cache do navegador (Ctrl+Shift+Del)
- **Backend**: Aguarde a mensagem de restart no terminal
---
## ⚙️ Scripts Disponíveis
### Frontend
```bash
npm run dev # Desenvolvimento com hot-reload (porta 4004)
npm run build # Build para produção
npm run start # Rodar build (produção, porta 3003)
npm run lint # Linting
```
### Backend
```bash
npm run dev # Desenvolvimento com tsx watch (porta 8009)
npm run build # Compilar TypeScript
npm run start # Rodar compilado (produção, porta 8008)
npm run db:migrate # Migrar database
npm run db:push # Push schema para database
npm run db:studio # Prisma Studio
```
---
## 🔗 URLs de Desenvolvimento
- **Frontend**: http://localhost:4004
- **Backend API**: http://localhost:8009
- **Prisma Studio**: `npm run db:studio` (backend)
---
## 📌 Produção vs Desenvolvimento
| Aspecto | Produção | Desenvolvimento |
|---------|----------|-----------------|
| Hot-reload | ❌ | ✅ |
| Compilação | ✅ Build prévio | ❌ On-the-fly |
| Logs | Estruturados (JSON) | Pretty-printed |
| Otimização | ✅ | ❌ |
| Velocidade Dev | Lenta | ⚡ Rápida |
---
## 🤝 Boas Práticas
1. **Sempre use dev em desenvolvimento** — hot-reload economiza tempo
2. **Teste em produção antes de deploy** — verifique otimizações
3. **Mantenha `.env` sincronizado** — mudanças críticas requerem restart
4. **Limpe logs regularmente**`journalctl --vacuum=time=7d`
5. **Verifique portas antes de mudar** — evite conflitos
---
## 📞 Suporte
Para problemas ou dúvidas, verifique:
- Logs dos serviços: `journalctl -u newwhats-*-dev -f`
- Status das dependências: `systemctl status postgresql dragonfly nats`
- Conectividade: `curl http://localhost:8009/health`
---
**Última atualização**: 2026-04-10
**Ambiente**: Linux (Debian/Ubuntu)
+95
View File
@@ -0,0 +1,95 @@
# 📝 Relatório Técnico de Implementação — NewWhats SaaS
Este relatório descreve as ações executadas, decisões arquiteturais consolidadas, riscos mapeados e próximos passos recomendados para o ecossistema NewWhats.
---
## 🏗️ Decisões Arquiteturais e Infraestrutura
Para atender à premissa de manter o host limpo (sem dependências locais de Node/npm/PM2) e em conformidade com o design "V8 Bunker", a aplicação foi **100% containerizada**:
1. **Estrutura de Containerização (Multi-Stage) e Resolução do Erro do Prisma**:
* **newwhats-backend**: Inicialmente construído com `node:20-alpine`. Identificamos que a engine binária do Prisma requer `libssl.so.1.1` (Glibc/OpenSSL 1.1) que foi removido no Alpine moderno.
* **Solução Proativa**: Migramos a base do backend para `node:20-slim` (Debian-based) e instalamos nativamente as dependências `openssl` e `ca-certificates`.
* Adicionamos `"debian-openssl-3.0.x"` no bloco `binaryTargets` do `schema.prisma` para compilar o driver otimizado. O backend agora compila e inicializa instantaneamente em conformidade de segurança máxima.
2. **Reverse Proxy Unificado e Resiliente (Nginx + Traefik)**:
* Mantivemos o Traefik gerenciando os certificados SSL (`HTTPS`) de forma nativa e automática na borda (Portas `80/443`).
* **Correção no Nginx (`default.conf`)**: Corrigimos o typo da variável de cabeçalho (`$proxy_add_x_forwarded-for` para `$proxy_add_x_forwarded_for` com sublinha).
* **Configuração de Alta Resiliência**: Para evitar que o Nginx falhasse caso o backend estivesse em inicialização, adicionamos a dependência `depends_on` no Compose e configuramos o resolver DNS interno do Docker (`resolver 127.0.0.11 valid=30s`) com variáveis dinâmicas de proxy. O Nginx agora inicia de forma instantânea e tolerante a falhas.
3. **Persistência de Sessões**:
* O Baileys armazena os arquivos físicos de conexões das contas do WhatsApp. Criamos um volume compartilhado que mapeia o diretório interno do container para a pasta física do host `/home/deploy/newwhats-sessions`, garantindo que os usuários não precisem re-escanear o QR code quando os containers forem atualizados ou recriados.
---
## 📂 Arquivos Modificados e Criados
* 📝 [docker-compose.yml](file:///home/deploy/stack/clube67/docker-compose.yml): Registra os novos serviços, regras de dependências e volumes compartilhados.
* 📝 [default.conf](file:///home/deploy/stack/clube67/nginx/default.conf): Mapeamento de proxies Nginx resilientes e regras de conexões WebSockets.
* 📝 [Dockerfile (Backend)](file:///home/deploy/stack/clube67/newwhats.local/backend/Dockerfile): Definição de build modular baseada em Debian Slim com suporte total ao Prisma Engine.
* 📝 [schema.prisma](file:///home/deploy/stack/clube67/newwhats.local/backend/prisma/schema.prisma): Configuração de compilação multi-target para garantir compatibilidade OpenSSL 3.0.
* 📝 [TAREFAS.md](file:///home/deploy/stack/clube67/newwhats.local/TAREFAS.md): Planejamento detalhado por entregáveis.
---
## 🚨 Pontos de Atenção e Homologação da VPS 3
> [!WARNING]
> **Status das Dependências de Rede na VPS 3 (`10.99.0.3`)**:
> Ao inicializar o backend, verificamos nos logs do container que a conexão com o DragonflyDB (Redis local no compose) conectou perfeitamente (`{"msg":"DragonflyDB conectado"}`), mas o Prisma parou na barreira de inicialização:
>
> `Can't reach database server at 10.99.0.3:5432`
>
> * **Diagnóstico**: O IP da VPS 3 responde aos pings da VPN instantaneamente, mas a porta `5432` retorna `Connection Refused` do host de destino.
---
## 🛡️ Guia de Ativação do PostgreSQL na VPS 3
Para liberar o acesso do backend (VPS 1) ao PostgreSQL na VPS 3 de forma totalmente segura, siga os passos abaixo diretamente na VPS 3 (como usuário `deploy`):
### 1. Configurar escuta do PostgreSQL na interface VPN
Abra o arquivo de configuração principal (o caminho exato depende da versão instalada, ex: `14` ou `15`):
```bash
sudo nano /etc/postgresql/15/main/postgresql.conf
```
Procure pela linha `listen_addresses` e altere para:
```ini
listen_addresses = 'localhost,10.99.0.3'
```
*(Isso instrui o PostgreSQL a aceitar conexões locais e conexões vindas apenas da interface segura do WireGuard, mantendo a porta fechada para a internet pública).*
### 2. Autorizar o IP da VPS 1 no controle de acesso do PostgreSQL
Abra o arquivo de controle de conexões do cliente:
```bash
sudo nano /etc/postgresql/15/main/pg_hba.conf
```
Adicione a seguinte linha de liberação ao final do arquivo:
```text
# Liberar conexões seguras da VPS 1 (Produção) para o NewWhats
host newwhats newwhats 10.99.0.1/32 scram-sha-256
```
*(Substitua `scram-sha-256` por `md5` se o seu banco estiver configurado com criptografia legado).*
### 3. Reiniciar o serviço do PostgreSQL
Aplique as novas configurações recarregando o serviço:
```bash
sudo systemctl restart postgresql
```
### 4. Validar as regras de Firewall (nftables) na VPS 3
Se a VPS 3 usar `nftables` (como indicado no layout "V8 Bunker"), verifique se as regras atuais permitem o tráfego de entrada na porta `5432` vindo de `10.99.0.1`.
Para listar e validar as regras atuais na VPS 3:
```bash
sudo nft list ruleset
```
Certifique-se de que há uma regra de aceitação similar a esta na tabela de entrada da VPN:
```text
ip saddr 10.99.0.1 tcp dport 5432 accept
```
---
## ⏭️ Próximos Passos
1. **Liberar PostgreSQL na VPS 3**: Execute os passos acima na VPS 3 para abrir o canal seguro de conexão.
2. **Atualização Automática**: Assim que a porta `5432` estiver liberada, o container `clube67-newwhats-backend-1` (que está configurado para tentar se reconectar automaticamente) se conectará instantaneamente, executará as rotinas de inicialização e o painel em `clube67.com` estará 100% no ar!
+51
View File
@@ -0,0 +1,51 @@
# 📋 LISTA DE TAREFAS — DEPLOY NEWWHATS (FRENTE A: WASABI STORAGE)
Este documento registra o escopo de implementação física, build, deploy e validação das correções da **Frente A (Wasabi Storage)** realizadas no ambiente de produção da VPS 1.
---
## 🚀 FRENTE A: Correções Wasabi Storage
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes |
|---|---|---|---|---|
| **T1** | Evitar vazamento de mídia local | `MessageHandler.ts` | **CONCLUÍDO** | Adicionado `fs.unlink()` assíncrono para limpar arquivos locais após upload para Wasabi. |
| **T2** | Correção de `mediaPath` no DB | `MessageHandler.ts` | **CONCLUÍDO** | Salvamento de `mediaPath = null` para mídias salvas com sucesso em storage remoto, forçando o carregamento direto do remote link. |
| **T3** | Evitar duplicação de handlers Express | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Adicionada flag privada `routesRegistered` para evitar múltiplos registros de rotas HTTP no Express em reativações. |
| **T4** | Interface para deleção recursiva | `types.ts`, `StorageProvider.ts` | **CONCLUÍDO** | Adicionado o método `deletePrefix` no contrato `StorageProviderInterface` e wrapper no `StorageProviderRegistry`. |
| **T5** | Implementação de limpeza Wasabi + Fallback | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Implementado `deletePrefix` usando `ListObjectsV2Command` + `DeleteObjectsCommand` paginados e remoção recursiva local. |
| **T6** | Limpeza de mídias ao deletar instância | `whatsapp.routes.ts` | **CONCLUÍDO** | Integrada chamada assíncrona ao `storageProvider.deletePrefix` na rota `DELETE /:id` usando prefixo correto. |
| **T7** | Correção de carregamento de plugins (Container) | `Dockerfile`, `docker-compose.yml` | **CONCLUÍDO** | Corrigida a ausência da pasta de plugins no container montando-a como volume e criando symlinks para resolução de imports. |
---
## 📈 Resumo do Status
- **Fase de Planejamento:** 100% Concluído
- **Implementação do Código:** 100% Concluído
- **Compilação e Transpilação (Plugins):** 100% Concluído
- **Build da Imagem e Re-deploy (Docker):** 100% Concluído
- **Validação de Logs e Ativação de Plugins:** 100% Concluído (Todos os plugins ativos sem erros!)
---
## 🔒 Próximos Passos recomendados ao Usuário
1. **Reconexão de Instâncias:** Reconectar as instâncias no painel administrativo para reativar as conexões de mensagens e testar o envio de mídias direto ao Wasabi.
2. **Validação do Bucket:** Confirmar que os novos arquivos estão sendo gerados sob o prefixo correto `whatsapp/{tenantId}/{instanceId}/` dentro do bucket `whatsbasemidia`.
3. **Validação da Exclusão:** Excluir uma instância de teste e verificar a remoção automática das mídias correspondentes tanto localmente quanto no bucket Wasabi.
---
*Última atualização: 08 de Maio de 2026 às 02:00 (UTC).*
---
## 🚀 FRENTE B: Baileys Engine Swap (Mensagens Ricas no WA Web)
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes / Critérios de Aceite |
|---|---|---|---|---|
| **B1** | Instalar fork InfiniteAPI | `package.json` | **CONCLUÍDO** | Instalar `baileys@github:rsalcara/InfiniteAPI` sob alias/coexistência no backend. |
| **B2** | Camada de abstração `engine` | `engine/official.ts`, `engine/infinite.ts`, `engine/index.ts` | **CONCLUÍDO** | Criar arquivos de ponte de re-export dinâmico dependentes de `BAILEYS_ENGINE` (`infinite` ou `official`). |
| **B3** | Mapeamento de imports | `WhatsAppConnectionManager.ts`, `MessageHandler.ts`, `ContactHandler.ts`, `rich-message.ts`, etc. | **CONCLUÍDO** | Redirecionar todos os imports diretos de `@whiskeysockets/baileys` para a nova camada unificada `./engine`. |
| **B4** | Simplificação de envio de mídias ricas | `rich-message.ts` | **CONCLUÍDO** | Refatorar a lógica complexa de binary nodes para invocar os campos nativos do fork InfiniteAPI com fallback inteligente. |
| **B5** | Endpoints de Administração & Reinício | `admin.routes.ts`, `whatsapp.routes.ts` | **CONCLUÍDO** | Adicionar rotas `GET /engine` e `POST /engine` para persistir escolha no arquivo .env e auto-reiniciar o processo (PM2/Docker). |
| **B6** | Componente de Toggle no Painel Admin | Frontend (Páginas Admin) | **CONCLUÍDO** | Criar interface visual de seleção de engine com aviso de reinicialização (~10s) na aba Sistema de admin/settings.tsx. |
+247
View File
@@ -0,0 +1,247 @@
# 📐 Arquitetura de Rede e Topologia de Servidores
Este documento descreve detalhadamente a arquitetura de servidores e rede privada projetada para garantir **alta performance, isolamento de riscos, segurança de dados e facilidade de escalonamento**.
---
## 🗺️ Visão Geral da Topologia (Rede VPN WireGuard)
Toda a comunicação crítica entre os servidores trafega por dentro de uma rede privada criptografada baseada no protocolo **WireGuard** (`10.99.0.0/24`). Os servidores possuem seus firewalls públicos trancados, permitindo apenas acessos necessários e isolando os dados vitais.
```mermaid
graph TD
subgraph "Rede Pública (Internet)"
Internet((Internet Pública))
Client([Notebook Rui])
end
subgraph "Rede Privada VPN (10.99.0.0/24)"
VPS1["🖥️ VPS 1 (Produção) <br> IP: 10.99.0.1 <br> (Traefik / Apps)"]
VPS3["🗄️ VPS 3 (Banco de Dados) <br> IP: 10.99.0.3 <br> (PostgreSQL Dedicado)"]
VPS4["🧪 VPS 4 (Desenvolvimento) <br> IP: 10.99.0.4 <br> (Staging / Builds)"]
VPS5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.99.0.5 <br> (Bots / APIs)"]
end
%% Conexões Públicas
Internet == HTTP/HTTPS (80/443) ==> VPS1
Client == Conexão VPN Criptografada ==> VPS1
%% Conexões Privadas VPN
VPS1 <== Canal Seguro VPN ==> VPS3
VPS1 <== Canal Seguro VPN ==> VPS4
VPS1 <== Canal Seguro VPN ==> VPS5
Client <== Acesso SSH Seguro ==> VPS1
Client <== Acesso SSH Seguro ==> VPS3
Client <== Acesso SSH Seguro ==> VPS4
Client <== Acesso SSH Seguro ==> VPS5
%% Conexões de Banco de Dados Internas
VPS1 -- PostgreSQL (Porta 5432) --> VPS3
VPS4 -- PostgreSQL (Porta 5432) --> VPS3
VPS5 -- PostgreSQL (Porta 5432) --> VPS3
style VPS3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff
style VPS1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style VPS4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
style VPS5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff
```
---
## 📋 Especificação Detalhada dos Servidores
### 1. 🗄️ VPS 3: Servidor de Banco de Dados Dedicado (Appliance)
* **Função Principal**: Hospedar exclusivamente o banco de dados **PostgreSQL Bare-Metal (Nativo)** e serviços fundamentais de persistência.
* **IP Privado VPN**: `10.99.0.3`
* **Tecnologias**: PostgreSQL (Instalação Nativa via Host), WireGuard, nftables. (O Docker é **estritamente omitido** para reduzir superfície de ataque e otimizar I/O).
* **Políticas do Firewall (nftables)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Liberada **apenas** para o IP reservado do administrador via VPN (`10.99.0.10`).
* **Porta 5432 (PostgreSQL)**: **Bloqueada publicamente**. Liberada **apenas** para conexões originadas de IPs autorizados na VPN (`10.99.0.1` e `10.99.0.5`).
* **Vantagens de Segurança & Performance**:
* **Zero vazamento de dados**: O banco não pode ser acessado de fora da VPN sob hipótese alguma.
* **Conceito de Appliance**: Sem Docker daemon (sem socket de root adicional) ou compiladores, a superfície de invasão é reduzida ao menor nível matemático possível.
* **Performance Máxima (Tuning de Host)**: Como a máquina é nativa e dedicada, as configurações de cache e buffers de memória do PostgreSQL podem usufruir livremente do Page Cache nativo do Linux e Huge Pages do Kernel, garantindo o máximo rendimento físico do SSD e RAM.
---
### 🚀 2. VPS 1: Servidor de Sistemas em Produção
* **Função Principal**: Gateway de entrada do tráfego público e hospedagem das aplicações em produção.
* **IP Privado VPN**: `10.99.0.1` (Atua como Hub WireGuard)
* **Políticas do Firewall (UFW)**:
* **Portas 80 (HTTP) e 443 (HTTPS)**: **Abertas ao público** para permitir o acesso dos clientes aos sites e sistemas.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Porta 3002 (Antigo Dev)**: **Bloqueada publicamente** no Docker (atrelada apenas ao IP privado `10.99.0.1` do container).
* **Vantagens de Segurança & Performance**:
* **Serviços sem Estado (Stateless)**: A VPS 1 apenas processa as requisições web e consome os dados da VPS 3. Caso a VPS 1 sofra um pico extremo de acessos, o banco de dados continua intacto e seguro.
* **Centralização com Traefik**: Proxy reverso centralizado gerenciando SSL e direcionamento interno seguro de containers.
---
### 🧪 3. VPS 4: Servidor de Desenvolvimento e Homologação (Staging)
* **Função Principal**: Testar novos recursos, hospedar containers de staging e realizar builds de desenvolvimento.
* **IP Privado VPN**: `10.99.0.4`
* **Políticas do Firewall (UFW)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Portas de teste (ex: 3002, 8080)**: **Bloqueadas publicamente** ou atreladas apenas ao IP privado da VPN para acesso exclusivo dos desenvolvedores autorizados.
* **Tecnologias**: Docker, Docker Compose, PostgreSQL 18 (Standby Replica em Container).
* **Vantagens de Segurança & Performance**:
* **Replicação Streaming Real-Time (Staging)**: Hospeda um container PostgreSQL 18 configurado como réplica de leitura (Standby) do banco de dados nativo de produção da VPS 3. Isso permite testar queries pesadas e validar migrações com dados reais em tempo de desenvolvimento de forma 100% segura e com zero impacto no banco principal.
* **Isolamento de Riscos**: Compilações de código (`npm run build`, `docker build`) e testes pesados de carga rodam aqui e consomem CPU sem comprometer os servidores de produção e banco de dados.
* **Ambiente Espelho**: Homologação idêntica à produção para validações precisas antes de aplicar modificações em produção.
---
### 💬 4. VPS 5: Servidor de Aplicação Proprietária (newwhats)
* **Função Principal**: Hospedar o backend e workers da plataforma proprietária **newwhats** (Backend Express com Socket.IO, Workers do Temporal e broker NATS).
* **IP Privado VPN**: `10.99.0.5`
* **Políticas do Firewall (UFW)**:
* **Portas de Integração Web**: Liberadas apenas para os Webhooks e requisições públicas de Socket.IO mapeadas via gateway.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Vantagens de Segurança & Performance**:
* **Isolamento de Estado**: Executar o backend Node.js (Prisma/Knex), os workers de workflows do Temporal e o broker de mensagens NATS na VPS 5 impede que oscilações no tráfego de mensagens do WhatsApp ou alto uso de CPU nos workers interfiram com o banco de dados principal de produção (VPS 3).
* **Separação de CPU**: O processamento pesado das sessões de WhatsApp e emulação de instâncias roda na VPS 5, consumindo sua CPU e RAM de forma isolada do Gateway (VPS 1) e do Banco (VPS 3).
---
## 🔒 Diretrizes de Segurança Corporativa (Resumo)
1. **Acesso Administrativo**: Realizado **exclusivamente via chave SSH criptográfica**. Autenticação por senhas desativada em todas as máquinas.
2. **Login de Root Desativado**: Bloqueado login direto de root via SSH. Todos conectam como o usuário `deploy` e elevam privilégios de forma segura via `sudo` após a conexão.
3. **Ponto Único de Falha**: Se a VPS 1 (Hub WireGuard) cair, a comunicação VPN é temporariamente pausada. O console VNC da Contabo é mantido ativo como canal redundante de suporte imediato.
4. **Logs Automatizados**: Logins SSH bem-sucedidos ou suspeitos geram alertas em segundo plano com marcações de 30 minutos na agenda central do Google, permitindo controle visual de acessos em tempo real.
---
## 🛡️ Fase 2: Hardening Avançado e Arquitetura Zero-Trust (Roadmap)
Esta seção documenta as decisões estratégicas e o plano de ação de segurança de curto e médio prazo projetados para elevar a maturidade da infraestrutura de rede e servidores ao nível **Enterprise / DevSecOps Sênior**.
### 🗺️ Visão Geral do Fluxo de Segurança na Borda
```mermaid
graph TD
subgraph "Camadas de Defesa (Defense in Depth)"
Internet((Internet Pública)) --> PM[1. Proteção de Borda do Provedor]
PM --> NFT[2. nftables + CrowdSec <br> Bloqueio Dinâmico / Comportamental]
NFT --> TF[3. Traefik Reverse Proxy <br> SSL/TLS & Roteamento]
TF --> DK[4. Containers Docker <br> Rootless / Hardened]
end
```
---
### 1. 🌐 Desativação Proativa de IPv6 (Curto/Médio Prazo)
Para eliminar vetores de bypass acidentais de firewall e reduzir a complexidade da superfície de ataque, o suporte a IPv6 é **totalmente desativado** em todas as VPSs.
* **Ações de Configuração**:
Para desativar em tempo de execução e garantir persistência pós-reboot, o arquivo `/etc/sysctl.d/99-disable-ipv6.conf` deve ser criado com as seguintes diretivas:
```text
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
```
*Comando para aplicar imediatamente:* `sudo sysctl --system`
* **Gatilhos para Reativação do IPv6**:
O IPv6 só será reabilitado caso surja uma necessidade real de negócio, tais como:
* Requisitos de conformidade corporativa (*Enterprise Compliance*).
* Integração obrigatória com CDNs IPv6-only ou arquiteturas de borda distribuída (*Anycast*).
* Casos em que a reativação exigirá a implementação completa de **nftables dual-stack, filtros de Router Advertisement (RA), NDP e SLAAC seguros**.
---
### 2. 🐋 Isolamento de Redes Docker (Prevenção de Movimentação Lateral)
O uso da rede de ponte padrão (`bridge`) do Docker é estritamente proibido em produção. Os containers são segmentados em **redes de domínios funcionais isolados**, limitando severamente o raio de colisão (*blast radius*) caso um container de aplicação (especialmente bots de CRM/WhatsApp ou navegadores invisíveis) seja comprometido.
#### 🔀 Matriz de Conectividade de Dados (newwhats):
```mermaid
graph TD
subgraph "VPS 1: Gateway de Borda pública"
Traefik[Traefik Router]
end
subgraph "VPS 5: Camada de Aplicação (newwhats)"
Express[Backend Express & Socket.IO]
TempWorker[Temporal Worker]
NATS[NATS Broker :4222]
Express <--> NATS
TempWorker <--> Express
end
subgraph "Túnel VPN Criptografado (wg0)"
WG0((Canal Privado WireGuard))
end
subgraph "VPS 3: Appliance de Dados Bare-Metal"
Postgres[(PostgreSQL :5432)]
Dragonfly[(DragonflyDB :6379)]
Temporal[(Temporal Server :7233)]
end
%% Roteamento Web e Sockets
Traefik == HTTPS Proxy / Sockets ==> WG0 ==> Express
%% Conexões de Dados da Aplicação via VPN
Express ==> WG0 ==> Postgres
Express ==> WG0 ==> Dragonfly
TempWorker ==> WG0 ==> Temporal
Temporal -. Persistência Interna .-> Postgres
style Postgres fill:#1E293B,stroke:#3B82F6,stroke-width:2px,color:#fff
style Dragonfly fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style Temporal fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
```
* **Diretrizes de Conectividade**:
* **Tráfego de Borda**: O Traefik na VPS 1 encaminha o tráfego HTTP/HTTPS e conexões persistentes do Socket.IO diretamente para a VPS 5 (`10.99.0.5`) pela interface de túnel criptografado, mantendo as portas da VPS 5 públicas totalmente fechadas.
* **Persistência de Negócio (Prisma + Knex)**: Ambas as ORMs/Query Builders centralizadas na VPS 5 apontam diretamente para o IP interno `10.99.0.3:5432` da VPS 3.
* **Cache e Estados Rápidos**: O DragonflyDB na VPS 3 atende instantaneamente às solicitações de sessões e QR codes da VPS 5 via `10.99.0.3:6379`, provendo tempos de resposta na faixa de microssegundos sem concorrência local de CPU.
* **Workflows Assíncronos**: O Temporal Server processa o agendamento de tarefas e status de reputação na VPS 3, enquanto o **Temporal Worker** na VPS 5 consome e executa as atividades pesadas locais da aplicação.
---
### 3. 🔑 Zero-Trust Interno no WireGuard (ACLs no Firewall)
A rede VPN WireGuard (`10.99.0.0/24`) deixa de ser tratada como uma zona de confiança irrestrita. Implementa-se uma política de privilégio mínimo na comunicação entre os servidores da VPN.
* **O Problema de Movimentação Lateral**:
Se a máquina de desenvolvimento/staging (VPS 4) for comprometida por um script malicioso, o atacante não pode ter permissão para escaneá-la ou conectar-se ao banco de dados de produção (VPS 3), exceto para o fluxo estritamente necessário de replicação de dados.
* **Regras de Isolamento Lateral (Exemplo no Firewall da VPS 3)**:
```text
# Bloquear por padrão toda conexão vinda da VPN exceto as estritamente autorizadas:
- Permitir VPS 1 (Produção) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 5 (CRM) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 4 (Staging) -> Porta 5432 (PostgreSQL - Apenas 'replicator') [ACEITAR]
- Permitir VPS 4 (Staging) -> VPS 3 (Produção - Outras portas / serviços) [BLOQUEAR]
```
---
### 4. 🎛️ Migração de Borda: nftables + CrowdSec
Substitui-se a pilha clássica e reativa do `iptables` / `Fail2Ban` por uma arquitetura ativa, dinâmica e de alta performance na VPS pública 1.
* **Por que `nftables`?**
A sintaxe nativa do `nftables` permite a criação de conjuntos dinâmicos (*sets*) de IP de forma extremamente eficiente, processados diretamente no kernel com consumo mínimo de CPU durante ataques volumétricos.
* **Por que `CrowdSec`?**
Diferente do Fail2Ban (que analisa logs localmente e de forma reativa por expressões regulares), o CrowdSec utiliza detecção baseada em comportamentos locais de ataque e sincroniza uma base global de reputação de IPs (*inteligência coletiva*). Se um IP atacou outra infraestrutura na internet, ele é bloqueado na sua VPS antes mesmo de fazer a primeira requisição ao seu Traefik.
---
### 5. 💔 Mitigação do Ponto Único de Falha (SPOF) da VPS 1
Atualmente, a VPS 1 acumula as funções de **Gateway de Borda (Traefik)**, **Hub Central de VPN (WireGuard)** e **Hospedagem de Aplicações**.
* **Visão de Evolução Futura**:
Para eliminar o risco de interrupção total dos serviços em caso de queda da VPS 1, planeja-se a separação física de responsabilidades em médio prazo:
1. **VPS Edge Dedicated**: Uma máquina leve rodando exclusivamente o gateway de entrada (`Traefik`), `nftables` e o concentrador `WireGuard`.
2. **VPS App Dedicated**: Servidores internos que hospedam as aplicações (sem expor portas SSH ou HTTP à internet), consumindo tráfego encaminhado diretamente pelo Gateway da Edge.
---
### 6. 🔒 Hardening Avançado de Containers
Para garantir que a invasão de um container não resulte no comprometimento do sistema operacional host, aplicam-se as seguintes diretrizes de segurança no provisionamento de containers Docker:
* **No-New-Privileges**: Impede que processos dentro do container ganhem novos privilégios via binários `setuid` ou `setgid`.
* **Read-Only Root Filesystem**: Monta o sistema de arquivos do container como apenas leitura, forçando gravações temporárias apenas em volumes declarados ou `/tmp` na memória (tmpfs).
* **CAP Drop**: Remove capacidades de sistema desnecessárias ao container (ex: `NET_ADMIN`, `SYS_ADMIN`, `SYS_CHROOT`).
* **Rootless Containers**: Sempre que suportado pela aplicação, os containers devem rodar com IDs de usuário não privilegiados (non-root).
@@ -0,0 +1,9 @@
node_modules
dist
.git
*.log
.env
sessions
media
storage
uploads
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8008
NODE_ENV=development
JWT_SECRET=troque_por_secret_forte_aqui
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/newwhats?schema=public"
# ─────────────────────────────────────────────
# DragonflyDB (compatível com Redis)
# ─────────────────────────────────────────────
DRAGONFLY_URL=redis://localhost:6379
DRAGONFLY_TTL_SECONDS=86400
# ─────────────────────────────────────────────
# NATS JetStream
# ─────────────────────────────────────────────
NATS_URL=nats://localhost:4222
# ─────────────────────────────────────────────
# Temporal.io
# ─────────────────────────────────────────────
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=newwhats-queue
# ─────────────────────────────────────────────
# IA (OpenAI + Google Gemini)
# ─────────────────────────────────────────────
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AIza...
# ─────────────────────────────────────────────
# WhatsApp / Baileys
# ─────────────────────────────────────────────
# Pasta raiz onde as sessões multi-device são armazenadas (por instância)
BAILEYS_SESSIONS_PATH=./sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://localhost:3000
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:20-slim AS runner
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
RUN ln -s /app/plugins /plugins && \
ln -s /app /app/backend && \
ln -s /app/dist /app/src
EXPOSE 8008
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
{
"name": "newwhats-backend",
"version": "1.0.0",
"description": "SaaS Omnichannel Backend — NewWhats",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json || true",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1029.0",
"@aws-sdk/lib-storage": "^3.1029.0",
"@google/generative-ai": "^0.24.1",
"@prisma/client": "^5.22.0",
"@temporalio/activity": "^1.15.0",
"@temporalio/client": "^1.15.0",
"@temporalio/worker": "^1.15.0",
"@temporalio/workflow": "^1.15.0",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@whiskeysockets/baileys": "^6.7.18",
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"croner": "^10.0.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^5.4.1",
"jimp": "^1.6.0",
"jsonwebtoken": "^9.0.3",
"knex": "^3.2.9",
"multer": "^2.1.1",
"nats": "^2.28.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.5",
"openai": "^6.34.0",
"pg": "^8.20.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"recharts": "^3.8.1",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"systeminformation": "^5.31.5",
"tsx": "^4.19.2",
"uuid": "^11.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"prisma": "^5.22.0",
"qrcode-terminal": "^0.12.0",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,533 @@
// ─────────────────────────────────────────────────────────────────────────────
// Prisma Schema — NewWhats SaaS Omnichannel
// ─────────────────────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── PLANOS ──────────────────────────────────────────────────────────────────
model Plan {
id String @id @default(uuid())
name String
maxInstances Int @default(1)
maxAgents Int @default(5)
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
// ─── USUÁRIOS (ADMIN + TENANTS) ───────────────────────────────────────────────
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
role UserRole @default(USER)
isActive Boolean @default(true)
planId String?
trialEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plan Plan? @relation(fields: [planId], references: [id])
instances Instance[]
sectors Sector[]
teams Team[]
apiKeys ApiKey[]
aiCredentials AICredential[]
aiBots AIBot[]
broadcasts Broadcast[]
pluginPairs PluginPair[]
extWebhooks ExtWebhook[]
messageTemplates MessageTemplate[]
scheduledMessages ScheduledMessage[]
@@map("users")
}
enum UserRole {
ADMIN
USER
}
// ─── API KEYS ─────────────────────────────────────────────────────────────────
model ApiKey {
id String @id @default(uuid())
tenantId String
key String @unique @default(uuid())
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
expiresAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("api_keys")
}
// ─── INSTÂNCIAS WHATSAPP ──────────────────────────────────────────────────────
model Instance {
id String @id @default(uuid())
tenantId String
name String
phone String?
avatar String?
status InstanceStatus @default(DISCONNECTED)
engine String @default("infinite")
sessionPath String?
webhookUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contacts Contact[]
chats Chat[]
messages Message[]
broadcasts Broadcast[]
@@unique([tenantId, name])
@@map("instances")
}
enum InstanceStatus {
DISCONNECTED
CONNECTING
QR_PENDING
CONNECTED
BANNED
}
// ─── SETORES ─────────────────────────────────────────────────────────────────
model Sector {
id String @id @default(uuid())
tenantId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
teams Team[]
protocols Protocol[]
@@unique([tenantId, name])
@@map("sectors")
}
// ─── EQUIPES ─────────────────────────────────────────────────────────────────
model Team {
id String @id @default(uuid())
tenantId String
sectorId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade)
protocols Protocol[]
@@unique([tenantId, sectorId, name])
@@map("teams")
}
// ─── CONTATOS ─────────────────────────────────────────────────────────────────
model Contact {
id String @id @default(uuid())
tenantId String
instanceId String
jid String
lidJid String?
// Prioridade: name (agenda) > verifiedName > notify (pushname)
name String?
verifiedName String?
notify String?
phone String?
avatarUrl String?
scoreReputacao Int @default(100)
flagRestricao Boolean @default(false)
isBlocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chats Chat[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
@@index([tenantId, instanceId, lidJid])
@@map("contacts")
}
// ─── CHATS ────────────────────────────────────────────────────────────────────
model Chat {
id String @id @default(uuid())
tenantId String
instanceId String
contactId String?
jid String
name String? // Subject para grupos (@g.us); null para individuais
unreadCount Int @default(0)
lastMessageAt DateTime?
isPinned Boolean @default(false)
isArchived Boolean @default(false)
// IA Chatbot
botPaused Boolean @default(false) // human takeover — pausa o bot neste chat
botSummary String? // Cérebro: resumo de 2 frases da conversa
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id])
messages Message[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
// Índice composto para a query principal da inbox:
// WHERE tenantId + instanceId + isArchived (filtro)
// ORDER BY isPinned DESC, lastMessageAt DESC (ordenação)
@@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)])
@@map("chats")
}
// ─── MENSAGENS ────────────────────────────────────────────────────────────────
model Message {
id String @id @default(uuid())
tenantId String
instanceId String
chatId String
remoteJid String
messageId String
fromMe Boolean @default(false)
type MessageType @default(TEXT)
body String?
caption String?
mediaUrl String?
mediaPath String?
mimeType String?
fileName String?
duration Int?
pushName String? // Nome do remetente no momento do envio (grupos: quem enviou)
senderJid String? // JID do remetente real (grupos: key.participant)
replyToId String? // ID da mensagem citada (reply)
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
status MessageStatus @default(PENDING)
timestamp DateTime
createdAt DateTime @default(now())
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
replies Message[] @relation("MessageReply")
@@unique([instanceId, messageId])
// Índice para "última mensagem por chat" — usado no include da inbox
// ORDER BY timestamp DESC LIMIT 1 (por chatId)
@@index([chatId, timestamp(sort: Desc)])
// Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC
// WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL
@@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)])
@@map("messages")
}
enum MessageType {
TEXT
IMAGE
VIDEO
AUDIO
DOCUMENT
STICKER
LOCATION
CONTACT
REACTION
POLL
BUTTONS
LIST
CAROUSEL
INTERACTIVE
UNSUPPORTED
}
enum MessageStatus {
PENDING
SENT
DELIVERED
READ
FAILED
}
// ─── PROTOCOLOS DE ATENDIMENTO ────────────────────────────────────────────────
model Protocol {
id String @id @default(uuid())
tenantId String
number String @unique // Número sequencial rastreável: #0001-2024
chatId String
contactId String
sectorId String?
teamId String?
agentId String?
status ProtocolStatus @default(OPEN)
summary String? // Resumo de 2 frases gerado pelo Temporal
deadline DateTime?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chat Chat @relation(fields: [chatId], references: [id])
contact Contact @relation(fields: [contactId], references: [id])
sector Sector? @relation(fields: [sectorId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
// Índice para filtro de protocolos abertos por chat (usado no include da inbox)
// WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC
@@index([chatId, status, createdAt(sort: Desc)])
@@map("protocols")
}
enum ProtocolStatus {
OPEN
IN_PROGRESS
WAITING_CLIENT
WAITING_AGENT
RESOLVED
CANCELLED
}
// ─── IA MULTI-AGENTE ──────────────────────────────────────────────────────────
model AICredential {
id String @id @default(uuid())
tenantId String
instanceId String
name String
provider LLMProvider @default(GEMINI)
apiKey String
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
bots AIBot[]
@@unique([tenantId, instanceId, name])
@@map("ai_credentials")
}
model AIBot {
id String @id @default(uuid())
tenantId String
instanceId String
credentialId String
name String
systemPrompt String @db.Text
model String @default("gemini-1.5-flash")
enabled Boolean @default(false)
triggerMode TriggerMode @default(ALL)
keywords String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
credential AICredential @relation(fields: [credentialId], references: [id])
@@unique([tenantId, instanceId])
@@map("ai_bots")
}
enum LLMProvider {
GEMINI
OPENAI
}
enum TriggerMode {
ALL
KEYWORD
}
// ─── BROADCAST (DISPARO EM MASSA) ─────────────────────────────────────────────
model Broadcast {
id String @id @default(uuid())
tenantId String
instanceId String
message String @db.Text
totalCount Int @default(0)
sentCount Int @default(0)
failedCount Int @default(0)
status String @default("RUNNING") // RUNNING | DONE | FAILED
createdAt DateTime @default(now())
finishedAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
recipients BroadcastRecipient[]
@@map("broadcasts")
}
// ─── PLUGIN PAIRS ─────────────────────────────────────────────────────────────
// Registra sistemas externos pareados com uma conta NewWhats.
// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*.
model PluginPair {
id String @id @default(uuid())
userId String
clientSystem String // identificador técnico ex: "alemao-conveniencias"
clientName String // nome amigável ex: "Alemão Conveniências"
clientUrl String? // URL base do sistema cliente
integrationKey String @unique
lastSeenAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("plugin_pairs")
}
// ─── EXT WEBHOOKS ─────────────────────────────────────────────────────────────
// Endpoints externos registrados por tenant para receber push de eventos.
model ExtWebhook {
id String @id @default(uuid())
tenantId String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("ext_webhooks")
}
model BroadcastRecipient {
id String @id @default(uuid())
broadcastId String
jid String
status String @default("PENDING") // PENDING | SENT | FAILED
createdAt DateTime @default(now())
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
@@map("broadcast_recipients")
}
// ─── TEMPLATES DE MENSAGEM ────────────────────────────────────────────────────
model MessageTemplate {
id String @id @default(uuid())
tenantId String
name String
description String?
type TemplateType @default(TEXT)
payload Json
tags String[]
usageCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
schedules ScheduledMessage[]
@@map("message_templates")
}
enum TemplateType {
TEXT
BUTTONS
INTERACTIVE
LIST
POLL
CAROUSEL
}
// ─── AGENDAMENTOS DE MENSAGEM ─────────────────────────────────────────────────
model ScheduledMessage {
id String @id @default(uuid())
tenantId String
instanceId String
name String
templateId String?
payload Json
recipientMode RecipientMode @default(MANUAL)
recipients Json
scheduleType ScheduleType @default(ONCE)
cronExpr String?
scheduledAt DateTime?
eventType EventType?
timezone String @default("America/Sao_Paulo")
status ScheduleStatus @default(ACTIVE)
lastRunAt DateTime?
nextRunAt DateTime?
runCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template MessageTemplate? @relation(fields: [templateId], references: [id])
logs ScheduledMessageLog[]
@@map("scheduled_messages")
}
model ScheduledMessageLog {
id String @id @default(uuid())
scheduleId String
status String
sentCount Int @default(0)
failedCount Int @default(0)
error String?
runAt DateTime @default(now())
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@map("scheduled_message_logs")
}
enum RecipientMode {
MANUAL
ALL_CONTACTS
TAG_FILTER
}
enum ScheduleType {
ONCE
RECURRING
EVENT
}
enum EventType {
BIRTHDAY
ANNIVERSARY
SIGNUP_DAYS
}
enum ScheduleStatus {
ACTIVE
PAUSED
DONE
CANCELLED
}
@@ -0,0 +1,45 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const ROUNDS = 10
const users = [
{
name: 'Usuário Padrão',
email: 'user@user.com',
password: '12345678',
role: 'USER' as const,
},
{
name: 'Admin',
email: 'ruibto@gmail.com',
password: 'Rc362514',
role: 'ADMIN' as const,
},
]
for (const u of users) {
const passwordHash = await bcrypt.hash(u.password, ROUNDS)
const existing = await prisma.user.findUnique({ where: { email: u.email } })
if (existing) {
await prisma.user.update({
where: { email: u.email },
data: { passwordHash, name: u.name, role: u.role },
})
console.log(`✅ Atualizado: ${u.email}`)
} else {
await prisma.user.create({
data: { name: u.name, email: u.email, passwordHash, role: u.role },
})
console.log(`✅ Criado: ${u.email} (${u.role})`)
}
}
}
main()
.catch((e) => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())
@@ -0,0 +1,81 @@
/**
* Script standalone para testar a conexão Baileys e exibir o QR Code no terminal.
* Não requer banco de dados nem DragonflyDB.
* Uso: node qr-test.mjs
*/
import {
makeWASocket,
useMultiFileAuthState,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
} from '@whiskeysockets/baileys'
import { Boom } from '@hapi/boom'
import qrcode from 'qrcode-terminal'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SESSION_DIR = path.join(__dirname, 'sessions', 'qr-test')
async function start() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR)
const { version, isLatest } = await fetchLatestBaileysVersion()
console.log(`\n📱 Baileys v${version} (latest: ${isLatest})`)
console.log('🔄 Iniciando conexão...\n')
const sock = makeWASocket({
version,
auth: state,
printQRInTerminal: false, // controlamos manualmente
qrTimeout: 60_000,
browser: ['NewWhats', 'Chrome', '124.0.0'],
syncFullHistory: false,
})
sock.ev.on('creds.update', saveCreds)
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
if (qr) {
console.clear()
console.log('📲 Escaneie o QR Code abaixo com o WhatsApp:\n')
qrcode.generate(qr, { small: true })
console.log('\n⏳ Aguardando escaneamento...')
}
if (connection === 'open') {
const me = sock.user
console.log('\n✅ Conectado com sucesso!')
console.log(` Número: ${me?.id}`)
console.log(` Nome: ${me?.name}`)
console.log('\n🎉 Sessão salva em:', SESSION_DIR)
console.log(' Agora rode o servidor completo com: npm run dev\n')
}
if (connection === 'close') {
const code = (lastDisconnect?.error)?.output?.statusCode
const reason = DisconnectReason
console.log(`\n❌ Conexão encerrada (código: ${code})`)
const shouldReconnect =
code !== reason.loggedOut &&
code !== reason.forbidden
if (shouldReconnect) {
console.log('🔄 Reconectando...\n')
setTimeout(start, 3000)
} else {
console.log('🚫 Sessão encerrada definitivamente.')
process.exit(0)
}
}
})
}
start().catch((err) => {
console.error('Erro fatal:', err)
process.exit(1)
})
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = exports.config = void 0;
// Re-export unificado das configs para compatibilidade com plugins
var env_1 = require("./config/env");
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
var env_2 = require("./config/env");
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
//# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":";;;AAAA,mEAAmE;AACnE,oCAA4C;AAAnC,6FAAA,GAAG,OAAU;AACtB,oCAAkC;AAAzB,0FAAA,GAAG,OAAA"}
@@ -0,0 +1,3 @@
// Re-export unificado das configs para compatibilidade com plugins
export { env as config } from './config/env'
export { env } from './config/env'
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = void 0;
const zod_1 = require("zod");
require("dotenv/config");
const envSchema = zod_1.z.object({
PORT: zod_1.z.string().default('8008'),
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: zod_1.z.string().min(16),
DATABASE_URL: zod_1.z.string().url(),
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Variáveis de ambiente inválidas:');
console.error(parsed.error.format());
process.exit(1);
}
exports.env = parsed.data;
//# sourceMappingURL=env.js.map
@@ -0,0 +1 @@
{"version":3,"file":"env.js","sourceRoot":"","sources":["env.ts"],"names":[],"mappings":";;;AAAA,6BAAuB;AACvB,yBAAsB;AAEtB,MAAM,SAAS,GAAG,OAAC,CAAC,MAAM,CAAC;IACzB,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;IAChC,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;IAC9E,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9B,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC9B,aAAa,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC;IAC3D,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAClD,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACrD,gBAAgB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACtD,kBAAkB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,mBAAmB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACzD,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IACvD,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;CAC1D,CAAC,CAAA;AAEF,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;AAE/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAA;IACnD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAEY,QAAA,GAAG,GAAG,MAAM,CAAC,IAAI,CAAA"}
@@ -0,0 +1,27 @@
import { z } from 'zod'
import 'dotenv/config'
const envSchema = z.object({
PORT: z.string().default('8008'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: z.string().min(16),
DATABASE_URL: z.string().url(),
DRAGONFLY_URL: z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: z.string().default('86400'),
NATS_URL: z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: z.string().default('default'),
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
FRONTEND_URL: z.string().default('http://localhost:3000'),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('❌ Variáveis de ambiente inválidas:')
console.error(parsed.error.format())
process.exit(1)
}
export const env = parsed.data
@@ -0,0 +1,15 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.logger = void 0;
const pino_1 = __importDefault(require("pino"));
const env_1 = require("./env");
exports.logger = (0, pino_1.default)({
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: env_1.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
});
//# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AACvB,+BAA2B;AAEd,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,SAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;IACvD,SAAS,EACP,SAAG,CAAC,QAAQ,KAAK,YAAY;QAC3B,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE,EAAE;QACvF,CAAC,CAAC,SAAS;CAChB,CAAC,CAAA"}
@@ -0,0 +1,10 @@
import pino from 'pino'
import { env } from './env'
export const logger = pino({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
})
@@ -0,0 +1,58 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.storageProvider = void 0;
const logger_1 = require("../config/logger");
/**
* Singleton registry para o storage provider ativo.
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
*/
class StorageProviderRegistry {
constructor() {
this.provider = null;
this.healthy = false;
}
register(provider) {
this.provider = provider;
logger_1.logger.info('[StorageProvider] Provider registrado');
}
isRegistered() {
return this.provider !== null;
}
isHealthy() {
return this.healthy;
}
async uploadFile(payload) {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
}
return this.provider.upload(payload);
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth() {
if (!this.provider) {
this.healthy = false;
return;
}
try {
this.healthy = await this.provider.checkHealth();
}
catch {
this.healthy = false;
}
}
async deletePrefix(prefix) {
if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
return { deleted: 0 };
}
return this.provider.deletePrefix(prefix);
}
unregister() {
this.provider = null;
this.healthy = false;
logger_1.logger.info('[StorageProvider] Provider removido');
}
}
exports.storageProvider = new StorageProviderRegistry();
//# sourceMappingURL=StorageProvider.js.map
@@ -0,0 +1 @@
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAGvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;IAgDzB,CAAC;IA9CC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACtB,CAAC;IACH,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
@@ -0,0 +1,109 @@
import { logger as rootLogger } from '../config/logger'
import type { StorageProviderInterface, StorageUploadPayload } from './types'
export type StorageHealthReason =
| 'ok'
| 'not_configured'
| 'needs_restart'
| 'auth_error'
| 'billing_error'
| 'bucket_not_found'
| 'network_error'
| 'unknown'
export interface StorageHealthStatus {
healthy: boolean
configured: boolean
reason: StorageHealthReason
message: string
}
/**
* Singleton registry para o storage provider ativo.
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
*/
class StorageProviderRegistry {
private provider: StorageProviderInterface | null = null
private healthy = false
private lastHealthStatus: StorageHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
}
register(provider: StorageProviderInterface): void {
this.provider = provider
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' }
rootLogger.info('[StorageProvider] Provider registrado')
}
isRegistered(): boolean {
return this.provider !== null
}
isHealthy(): boolean {
return this.healthy
}
getLastHealthStatus(): StorageHealthStatus {
return this.lastHealthStatus
}
setHealthStatus(status: StorageHealthStatus): void {
this.lastHealthStatus = status
this.healthy = status.healthy
}
async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.')
}
return this.provider.upload(payload)
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth(): Promise<void> {
if (!this.provider) {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
}
return
}
try {
const status = await this.provider.checkDetailedHealth()
this.healthy = status.healthy
this.lastHealthStatus = status
} catch {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
}
}
}
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
return { deleted: 0 }
}
return this.provider.deletePrefix(prefix)
}
unregister(): void {
this.provider = null
this.healthy = false
rootLogger.info('[StorageProvider] Provider removido')
}
}
export const storageProvider = new StorageProviderRegistry()
export type { StorageUploadPayload, StorageProviderInterface }
@@ -0,0 +1,55 @@
import knex, { Knex } from 'knex'
import { env } from '../config/env'
import { logger as rootLogger } from '../config/logger'
/**
* Instância Knex compartilhada entre todos os plugins.
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
*/
function createKnex(): Knex {
// DATABASE_URL formato: postgresql://user:pass@host:port/db
const connection = env.DATABASE_URL
const instance = knex({
client: 'pg',
connection,
pool: { min: 1, max: 5 },
acquireConnectionTimeout: 10000,
})
instance.on('query-error', (err: Error, query: { sql: string }) => {
rootLogger.error({ err, sql: query.sql }, '[Knex] Query error')
})
return instance
}
export const db = createKnex()
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
export async function ensurePluginTables(knexInstance: Knex): Promise<void> {
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs')
if (!hasJanitorLogs) {
await knexInstance.schema.createTable('janitor_logs', (t) => {
t.increments('id').primary()
t.string('filename').notNullable()
t.string('category').notNullable()
t.string('status').notNullable()
t.text('message').nullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela janitor_logs criada')
}
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage')
if (!hasNanobanaUsage) {
await knexInstance.schema.createTable('nanobana_usage', (t) => {
t.increments('id').primary()
t.string('partner_id').notNullable()
t.string('type').notNullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela nanobana_usage criada')
}
}
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hookBus = exports.HookBus = void 0;
const logger_1 = require("../config/logger");
/**
* Event bus entre plugins.
* emit() executa todos os handlers registrados e retorna array de resultados.
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
*/
class HookBus {
constructor() {
this.handlers = new Map();
}
register(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event).push(handler);
logger_1.logger.debug({ event }, '[HookBus] Handler registrado');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emit(event, data) {
const handlers = this.handlers.get(event);
if (!handlers || handlers.length === 0)
return [];
const results = [];
for (const handler of handlers) {
try {
const result = await handler(data);
results.push(result);
}
catch (err) {
logger_1.logger.error({ err, event }, '[HookBus] Erro em handler');
results.push(null);
}
}
return results;
}
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
removeAll(event) {
this.handlers.delete(event);
}
/** Lista eventos registrados — útil para debug */
listEvents() {
return Array.from(this.handlers.keys());
}
}
exports.HookBus = HookBus;
exports.hookBus = new HookBus();
//# sourceMappingURL=hook-bus.js.map
@@ -0,0 +1 @@
{"version":3,"file":"hook-bus.js","sourceRoot":"","sources":["hook-bus.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAKvD;;;;GAIG;AACH,MAAa,OAAO;IAApB;QACU,aAAQ,GAAG,IAAI,GAAG,EAAyB,CAAA;IAuCrD,CAAC;IArCC,QAAQ,CAAC,KAAa,EAAE,OAAoB;QAC1C,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;QAC9B,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACvC,eAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,8BAA8B,CAAC,CAAA;IAC7D,CAAC;IAED,8DAA8D;IAC9D,KAAK,CAAC,IAAI,CAAC,KAAa,EAAE,IAAS;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAA;QAEjD,MAAM,OAAO,GAAc,EAAE,CAAA;QAE7B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;gBAClC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,2BAA2B,CAAC,CAAA;gBAC7D,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACpB,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;IAED,6EAA6E;IAC7E,SAAS,CAAC,KAAa;QACrB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC;IAED,kDAAkD;IAClD,UAAU;QACR,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IACzC,CAAC;CACF;AAxCD,0BAwCC;AAEY,QAAA,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA"}
@@ -0,0 +1,53 @@
import { logger as rootLogger } from '../config/logger'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HookHandler = (data: any) => Promise<any>
/**
* Event bus entre plugins.
* emit() executa todos os handlers registrados e retorna array de resultados.
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
*/
export class HookBus {
private handlers = new Map<string, HookHandler[]>()
register(event: string, handler: HookHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, [])
}
this.handlers.get(event)!.push(handler)
rootLogger.debug({ event }, '[HookBus] Handler registrado')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emit(event: string, data: any): Promise<any[]> {
const handlers = this.handlers.get(event)
if (!handlers || handlers.length === 0) return []
const results: unknown[] = []
for (const handler of handlers) {
try {
const result = await handler(data)
results.push(result)
} catch (err) {
rootLogger.error({ err, event }, '[HookBus] Erro em handler')
results.push(null)
}
}
return results
}
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
removeAll(event: string): void {
this.handlers.delete(event)
}
/** Lista eventos registrados — útil para debug */
listEvents(): string[] {
return Array.from(this.handlers.keys())
}
}
export const hookBus = new HookBus()
@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pluginConfig = exports.PluginConfigStore = void 0;
const dragonfly_1 = require("../infra/cache/dragonfly");
const logger_1 = require("../config/logger");
const KEY_PREFIX = 'plugin:config:';
// Config de plugins não expira — TTL longo (1 ano)
const TTL_SECONDS = 365 * 24 * 3600;
/**
* Config store para plugins, backed por DragonflyDB.
* get() usa cache in-memory para evitar round-trips no hot-path.
* set() persiste no Dragonfly e invalida o cache local.
*/
class PluginConfigStore {
constructor() {
this.cache = new Map();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Lê config de um plugin. Retorna {} se não houver config salva. */
get(pluginName) {
return this.cache.get(pluginName) ?? {};
}
/** Persiste config no Dragonfly e atualiza cache local. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async set(pluginName, config) {
this.cache.set(pluginName, config);
await dragonfly_1.dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS);
logger_1.logger.debug({ pluginName }, '[PluginConfig] Config salva');
}
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
async loadAll(pluginNames) {
await Promise.all(pluginNames.map(async (name) => {
const stored = await dragonfly_1.dragonfly.getJson(`${KEY_PREFIX}${name}`);
if (stored) {
this.cache.set(name, stored);
logger_1.logger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly');
}
}));
}
/** Retorna config de todos os plugins (para a API admin). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllCached() {
const result = {};
for (const [name, config] of this.cache.entries()) {
result[name] = config;
}
return result;
}
}
exports.PluginConfigStore = PluginConfigStore;
exports.pluginConfig = new PluginConfigStore();
//# sourceMappingURL=plugin-config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin-config.js","sourceRoot":"","sources":["plugin-config.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AACpD,6CAAuD;AAEvD,MAAM,UAAU,GAAG,gBAAgB,CAAA;AACnC,mDAAmD;AACnD,MAAM,WAAW,GAAG,GAAG,GAAG,EAAE,GAAG,IAAI,CAAA;AAEnC;;;;GAIG;AACH,MAAa,iBAAiB;IAA9B;QACU,UAAK,GAAG,IAAI,GAAG,EAAmC,CAAA;IAsC5D,CAAC;IApCC,8DAA8D;IAC9D,qEAAqE;IACrE,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAA;IACzC,CAAC;IAED,2DAA2D;IAC3D,8DAA8D;IAC9D,KAAK,CAAC,GAAG,CAAC,UAAkB,EAAE,MAA2B;QACvD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QAClC,MAAM,qBAAS,CAAC,OAAO,CAAC,GAAG,UAAU,GAAG,UAAU,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAA;QAC1E,eAAU,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,EAAE,6BAA6B,CAAC,CAAA;IACjE,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,OAAO,CAAC,WAAqB;QACjC,MAAM,OAAO,CAAC,GAAG,CACf,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YAC7B,MAAM,MAAM,GAAG,MAAM,qBAAS,CAAC,OAAO,CAA0B,GAAG,UAAU,GAAG,IAAI,EAAE,CAAC,CAAA;YACvF,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;gBAC5B,eAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,EAAE,8CAA8C,CAAC,CAAA;YAC5E,CAAC;QACH,CAAC,CAAC,CACH,CAAA;IACH,CAAC;IAED,6DAA6D;IAC7D,8DAA8D;IAC9D,YAAY;QACV,MAAM,MAAM,GAAwC,EAAE,CAAA;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAA;QACvB,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAvCD,8CAuCC;AAEY,QAAA,YAAY,GAAG,IAAI,iBAAiB,EAAE,CAAA"}
@@ -0,0 +1,54 @@
import { dragonfly } from '../infra/cache/dragonfly'
import { logger as rootLogger } from '../config/logger'
const KEY_PREFIX = 'plugin:config:'
// Config de plugins não expira — TTL longo (1 ano)
const TTL_SECONDS = 365 * 24 * 3600
/**
* Config store para plugins, backed por DragonflyDB.
* get() usa cache in-memory para evitar round-trips no hot-path.
* set() persiste no Dragonfly e invalida o cache local.
*/
export class PluginConfigStore {
private cache = new Map<string, Record<string, unknown>>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Lê config de um plugin. Retorna {} se não houver config salva. */
get(pluginName: string): Record<string, any> {
return this.cache.get(pluginName) ?? {}
}
/** Persiste config no Dragonfly e atualiza cache local. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async set(pluginName: string, config: Record<string, any>): Promise<void> {
this.cache.set(pluginName, config)
await dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS)
rootLogger.debug({ pluginName }, '[PluginConfig] Config salva')
}
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
async loadAll(pluginNames: string[]): Promise<void> {
await Promise.all(
pluginNames.map(async (name) => {
const stored = await dragonfly.getJson<Record<string, unknown>>(`${KEY_PREFIX}${name}`)
if (stored) {
this.cache.set(name, stored)
rootLogger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly')
}
}),
)
}
/** Retorna config de todos os plugins (para a API admin). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllCached(): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {}
for (const [name, config] of this.cache.entries()) {
result[name] = config
}
return result
}
}
export const pluginConfig = new PluginConfigStore()
@@ -0,0 +1,119 @@
import path from 'path'
import fs from 'fs'
import { logger as rootLogger } from '../config/logger'
import type { PluginInstance, PluginManifest } from './types'
export interface LoadedPlugin {
instance: PluginInstance
manifest: PluginManifest
dir: string
}
// Em produção: __dirname = backend/dist/core → 3 níveis acima = raiz do projeto
// Em dev com tsx: __dirname = backend/src/core → 3 níveis acima = raiz do projeto
const PLUGINS_DIR = path.resolve(__dirname, '../../../plugins')
/**
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
*/
function topoSort(plugins: LoadedPlugin[]): LoadedPlugin[] {
const byName = new Map(plugins.map((p) => [p.manifest.name, p]))
const inDegree = new Map<string, number>()
const dependents = new Map<string, string[]>()
for (const p of plugins) {
inDegree.set(p.manifest.name, 0)
dependents.set(p.manifest.name, [])
}
for (const p of plugins) {
for (const dep of p.manifest.dependencies) {
if (!byName.has(dep)) {
rootLogger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada')
continue
}
dependents.get(dep)!.push(p.manifest.name)
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1)
}
}
const queue = plugins
.filter((p) => inDegree.get(p.manifest.name) === 0)
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1))
const sorted: LoadedPlugin[] = []
while (queue.length > 0) {
const current = queue.shift()!
sorted.push(current)
for (const depName of dependents.get(current.manifest.name) ?? []) {
const newDegree = (inDegree.get(depName) ?? 1) - 1
inDegree.set(depName, newDegree)
if (newDegree === 0) {
queue.push(byName.get(depName)!)
}
}
}
if (sorted.length !== plugins.length) {
rootLogger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original')
return plugins
}
return sorted
}
/**
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
* Retorna plugins ordenados por dependência.
*/
export async function loadPlugins(): Promise<LoadedPlugin[]> {
if (!fs.existsSync(PLUGINS_DIR)) {
rootLogger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado')
return []
}
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name)
const loaded: LoadedPlugin[] = []
for (const dirName of pluginDirs) {
const pluginDir = path.join(PLUGINS_DIR, dirName)
const manifestPath = path.join(pluginDir, 'manifest.json')
const indexTs = path.join(pluginDir, 'index.ts')
const indexJs = path.join(pluginDir, 'index.js')
if (!fs.existsSync(manifestPath)) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando')
continue
}
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
const entryPoint = fs.existsSync(indexJs) ? indexJs : fs.existsSync(indexTs) ? indexTs : null
if (!entryPoint) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando')
continue
}
try {
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const mod = await import(entryPoint)
const instance: PluginInstance = mod.default ?? mod
if (typeof instance.activate !== 'function') {
rootLogger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando')
continue
}
loaded.push({ instance, manifest, dir: pluginDir })
rootLogger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado')
} catch (err) {
rootLogger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin')
}
}
return topoSort(loaded)
}
@@ -0,0 +1,122 @@
import { logger as rootLogger } from '../config/logger'
import type { PluginContext, PluginInstance, PluginManifest } from './types'
import type { LoadedPlugin } from './plugin-loader'
export interface RegistryEntry {
instance: PluginInstance
manifest: PluginManifest
active: boolean
dir: string
}
/**
* Registry central dos plugins.
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
*/
class PluginRegistry {
private entries = new Map<string, RegistryEntry>()
private ctx!: PluginContext
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
setContext(ctx: PluginContext): void {
this.ctx = ctx
}
/** Registra plugins carregados (sem ativar ainda) */
register(plugins: LoadedPlugin[]): void {
for (const { instance, manifest, dir } of plugins) {
this.entries.set(manifest.name, { instance, manifest, active: false, dir })
}
}
/** Ativa todos os plugins marcados como enabled no manifest.
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
async activateAll(): Promise<void> {
for (const [name, entry] of this.entries) {
if (!entry.manifest.enabled) continue
try {
await this.activate(name)
} catch (err: any) {
rootLogger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização')
}
}
}
async activate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (entry.active) return
// Verifica dependências
for (const dep of entry.manifest.dependencies) {
const depEntry = this.entries.get(dep)
if (!depEntry?.active) {
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`)
}
}
try {
await entry.instance.activate(this.ctx)
entry.active = true
entry.manifest.enabled = true
rootLogger.info({ name }, '[PluginRegistry] Plugin ativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin')
throw err
}
}
async deactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (!entry.active) return
if (!entry.manifest.canDisable) throw new Error(`Plugin "${name}" não pode ser desativado`)
// Verifica se algum plugin ativo depende deste
for (const [depName, depEntry] of this.entries) {
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`)
}
}
try {
await entry.instance.deactivate(this.ctx)
entry.active = false
entry.manifest.enabled = false
rootLogger.info({ name }, '[PluginRegistry] Plugin desativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin')
throw err
}
}
/** Retorna lista de todos os plugins com status para a API admin */
list(): Array<RegistryEntry & { name: string }> {
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }))
}
getEntry(name: string): RegistryEntry | undefined {
return this.entries.get(name)
}
/**
* Força reativação de um plugin sem passar pelo deactivate.
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
*/
async reactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
entry.active = false
await this.activate(name)
rootLogger.info({ name }, '[PluginRegistry] Plugin reativado com nova config')
}
/** Retorna menu items de todos os plugins ativos */
getActiveMenuItems(): Array<{ pluginName: string; items: PluginManifest['frontend'] }> {
return Array.from(this.entries.values())
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }))
}
}
export const pluginRegistry = new PluginRegistry()
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,134 @@
import type { Express } from 'express'
import type { Server as HttpServer } from 'http'
import type { Server as SocketServer } from 'socket.io'
import type { Knex } from 'knex'
import type { PrismaClient } from '@prisma/client'
import type { Logger } from 'pino'
import type { HookBus } from './hook-bus'
import type { PluginConfigStore } from './plugin-config'
// ── Manifest ─────────────────────────────────────────────────────────────────
export interface MenuItem {
id: string
label: string
icon: string
href: string
roles: string[]
order: number
}
export interface Widget {
id: string
component: string
span?: number
roles: string[]
order: number
}
export interface PluginManifest {
name: string
displayName: string
version: string
description: string
author: string
category: 'core' | 'business' | 'integration' | 'utility'
enabled: boolean
canDisable: boolean
dependencies: string[]
backend?: {
routePrefix: string
hasMigrations: boolean
globalMiddleware?: string[]
}
frontend?: {
menuItems: MenuItem[]
widgets?: Widget[]
pages?: Array<{ path: string; component: string; roles: string[] }>
}
hooks?: {
subscribes: string[]
emits: string[]
}
/** Schema de configuração para gerar o formulário no admin */
configSchema?: PluginConfigField[]
}
export interface PluginConfigField {
key: string
label: string
type: 'text' | 'password' | 'select' | 'toggle'
placeholder?: string
description?: string
tooltip?: string
options?: Array<{ label: string; value: string }>
required?: boolean
group?: string
default?: string
}
// ── Context ───────────────────────────────────────────────────────────────────
export interface PluginContext {
/** Express app — para registrar rotas */
app: Express
/** Knex — acesso às tabelas dinâmicas dos plugins */
db: Knex
/** Prisma — acesso ao core do sistema */
prisma: PrismaClient
/** Logger (pino) */
logger: Logger
/** Event bus entre plugins */
hooks: HookBus
/** Config store backed por DragonflyDB */
config: PluginConfigStore
/** Socket.IO — para eventos em tempo real */
io: SocketServer
/** HTTP server nativo — para plugins que precisam interceptar upgrade (WS raw) */
httpServer: HttpServer
}
// ── Instance ──────────────────────────────────────────────────────────────────
export interface PluginInstance {
manifest: PluginManifest
activate(ctx: PluginContext): Promise<void>
deactivate(ctx: PluginContext): Promise<void>
}
// ── Storage ───────────────────────────────────────────────────────────────────
export type StorageCategory =
| 'cdi'
| 'xrays'
| 'documents'
| 'exports'
| 'posts'
| 'cards'
| 'partners'
| 'whatsapp'
| 'media'
export interface StorageUploadPayload {
category: StorageCategory
partnerId?: string
benefitId?: string
postId?: string
whatsappId?: string
/** Identificador do usuário/tenant no sistema — usado para compor o path de mídia WhatsApp */
usuarioSis?: string
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
sessionJID?: string
file: {
originalname: string
buffer: Buffer
mimetype: string
}
}
export interface StorageProviderInterface {
checkHealth(): Promise<boolean>
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
deletePrefix(prefix: string): Promise<{ deleted: number }>
}
@@ -0,0 +1,55 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.dragonfly = void 0;
const ioredis_1 = __importDefault(require("ioredis"));
const env_1 = require("../../config/env");
const logger_1 = require("../../config/logger");
class DragonflyClient {
constructor() {
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro'));
}
async connect() {
await this.client.connect();
}
async get(key) {
return this.client.get(key);
}
async getJson(key) {
const raw = await this.client.get(key);
if (!raw)
return null;
return JSON.parse(raw);
}
async set(key, value, ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl);
}
async setJson(key, value, ttlSeconds) {
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl);
}
async del(key) {
await this.client.del(key);
}
async exists(key) {
const count = await this.client.exists(key);
return count > 0;
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel, message) {
await this.client.publish(channel, message);
}
raw() {
return this.client;
}
}
exports.dragonfly = new DragonflyClient();
//# sourceMappingURL=dragonfly.js.map
@@ -0,0 +1 @@
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,oBAAoB,EAAE,CAAC;YACvB,gBAAgB,EAAE,IAAI;YACtB,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,CAAA;IACzE,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;IAClB,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IAC7C,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
@@ -0,0 +1,102 @@
import Redis from 'ioredis'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
class DragonflyClient {
private client: Redis
private readonly ttl: number
constructor() {
this.ttl = parseInt(env.DRAGONFLY_TTL_SECONDS, 10)
this.client = new Redis(env.DRAGONFLY_URL, {
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times: number) {
const delay = Math.min(200 * 2 ** times, 30_000)
return delay
},
})
this.client.on('connect', () => logger.info('DragonflyDB conectado'))
this.client.on('ready', () => logger.info('DragonflyDB pronto'))
this.client.on('error', (err) => logger.error({ msg: 'DragonflyDB erro', code: (err as any).code }))
this.client.on('close', () => logger.warn('DragonflyDB conexão fechada — reconectando...'))
}
/** true se o cliente está conectado e pronto */
isAlive(): boolean {
return this.client.status === 'ready'
}
async connect(): Promise<void> {
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { /* reconexão em background via retryStrategy */ })
}
async get(key: string): Promise<string | null> {
try { return await this.client.get(key) } catch { return null }
}
async getJson<T>(key: string): Promise<T | null> {
try {
const raw = await this.client.get(key)
if (!raw) return null
return JSON.parse(raw) as T
} catch { return null }
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl)
} else {
await this.client.set(key, value)
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl)
} else {
await this.client.set(key, JSON.stringify(value))
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key: string, ttlMs: number, value: string): Promise<void> {
try { await this.client.psetex(key, ttlMs, value) } catch { }
}
async del(key: string): Promise<void> {
try { await this.client.del(key) } catch { }
}
async exists(key: string): Promise<boolean> {
try {
const count = await this.client.exists(key)
return count > 0
} catch { return false }
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel: string, message: string): Promise<void> {
try { await this.client.publish(channel, message) } catch { }
}
raw(): Redis {
return this.client
}
}
export const dragonfly = new DragonflyClient()
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const client_1 = require("@prisma/client");
exports.prisma = global.__prisma ??
new client_1.PrismaClient({
log: process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = exports.prisma;
}
//# sourceMappingURL=prisma.js.map
@@ -0,0 +1 @@
{"version":3,"file":"prisma.js","sourceRoot":"","sources":["prisma.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAQhC,QAAA,MAAM,GACjB,MAAM,CAAC,QAAQ;IACf,IAAI,qBAAY,CAAC;QACf,GAAG,EACD,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YACnC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YACzE,CAAC,CAAC,EAAE;KACT,CAAC,CAAA;AAEJ,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,MAAM,CAAC,QAAQ,GAAG,cAAM,CAAA;AAC1B,CAAC"}
@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { logger } from '../../config/logger'
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined
}
export const prisma =
global.__prisma ??
new PrismaClient({
log:
process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
})
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma
}
@@ -0,0 +1,78 @@
import { Server as SocketIOServer } from 'socket.io'
import type { Server as HttpServer } from 'http'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import { dragonfly } from '../cache/dragonfly'
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
const PRESENCE_TTL = 70
interface SocketUser {
sub: string // userId / tenantId (matches JWT payload)
role: string
}
export function buildSocketServer(httpServer: HttpServer): SocketIOServer {
const io = new SocketIOServer(httpServer, {
cors: {
origin: env.FRONTEND_URL,
credentials: true,
},
transports: ['websocket', 'polling'],
})
// Autenticação por token JWT no handshake
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
if (!token) {
next(new Error('Token não fornecido'))
return
}
try {
const payload = jwt.verify(token, env.JWT_SECRET) as SocketUser
socket.data.userId = payload.sub
socket.data.role = payload.role
next()
} catch {
next(new Error('Token inválido'))
}
})
io.on('connection', (socket) => {
const userId: string = socket.data.userId
logger.debug({ userId, socketId: socket.id }, 'Socket conectado')
// Cliente entra na sala do seu tenant automaticamente
socket.join(`tenant:${userId}`)
// Registra presença do usuário no Dragonfly com TTL
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
// Cliente pode entrar em salas específicas de instância ou chat
socket.on('join:instance', (instanceId: string) => {
socket.join(`instance:${instanceId}`)
})
socket.on('join:chat', (chatId: string) => {
socket.join(`chat:${chatId}`)
})
socket.on('leave:chat', (chatId: string) => {
socket.leave(`chat:${chatId}`)
})
// Heartbeat do frontend a cada 30s — renova TTL de presença
socket.on('user:heartbeat', () => {
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
})
socket.on('disconnect', (reason) => {
logger.debug({ userId, reason }, 'Socket desconectado')
// Remove presença imediatamente ao desconectar
dragonfly.del(`presence:user:${userId}`).catch(() => {})
})
})
return io
}
@@ -0,0 +1,87 @@
/**
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
*/
import { prisma } from '../database/prisma'
import { logger } from '../../config/logger'
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
export async function liberarSlotAgenda(input: {
solicitanteId: string
tituloSlot: string
chatIdSolicitante: string
}): Promise<void> {
logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante')
// Aqui você envia mensagem via WhatsApp avisando o solicitante
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
// Por ora, marca no banco como disponível
await prisma.protocol.updateMany({
where: {
contactId: input.solicitanteId,
status: 'WAITING_CLIENT',
},
data: { status: 'OPEN' },
})
}
export async function notificarConflito(input: {
chatIdB: string
solicitanteNome: string
tituloSlot: string
}): Promise<void> {
logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot')
// Dispara mensagem via NATS → WhatsApp handler
}
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
export async function decrementarScore(input: {
contactId: string
motivo: string
pontos: number
}): Promise<{ novoScore: number; restrito: boolean }> {
const contact = await prisma.contact.findUnique({
where: { id: input.contactId },
})
if (!contact) throw new Error(`Contato ${input.contactId} não encontrado`)
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos)
const restrito = novoScore <= 20
await prisma.contact.update({
where: { id: input.contactId },
data: {
scoreReputacao: novoScore,
flagRestricao: restrito,
},
})
logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo },
'[Temporal] Score de reputação atualizado')
return { novoScore, restrito }
}
export async function bloquearAgendamentosAutomaticos(input: {
contactId: string
}): Promise<void> {
await prisma.contact.update({
where: { id: input.contactId },
data: { flagRestricao: true },
})
logger.warn({ contactId: input.contactId },
'[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados')
}
export async function buscarScoreContato(contactId: string): Promise<{
score: number
restrito: boolean
}> {
const c = await prisma.contact.findUnique({
where: { id: contactId },
select: { scoreReputacao: true, flagRestricao: true },
})
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false }
}
@@ -0,0 +1,19 @@
import { Client, Connection } from '@temporalio/client'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
let _client: Client | null = null
export async function getTemporalClient(): Promise<Client> {
if (_client) return _client
const connection = await Connection.connect({ address: env.TEMPORAL_ADDRESS })
_client = new Client({
connection,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ address: env.TEMPORAL_ADDRESS }, 'Temporal Client conectado')
return _client
}
@@ -0,0 +1,25 @@
/**
* Worker do Temporal — roda como processo separado.
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
*/
import { Worker } from '@temporalio/worker'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import * as activities from './activities'
async function runWorker() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: env.TEMPORAL_TASK_QUEUE,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ taskQueue: env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado')
await worker.run()
}
runWorker().catch((err) => {
logger.error(err, 'Falha fatal no Temporal Worker')
process.exit(1)
})
@@ -0,0 +1,76 @@
/**
* Workflow: Conflito de Agenda (1 Hora)
*
* Regra de negócio:
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
* - Se B não responder em 1h → slot é liberado e transferido para A.
*/
import {
proxyActivities,
sleep,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
liberarSlotAgenda,
notificarConflito,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal enviado quando B confirma o slot
export const confirmarSlotSignal = defineSignal<[{ confirmado: boolean }]>('confirmarSlot')
export interface ConflitoAgendaInput {
solicitanteId: string // Pessoa A
detentorId: string // Pessoa B
chatIdSolicitante: string
chatIdDetentor: string
tituloSlot: string // ex: "Consulta 14h do dia 10/04"
}
export async function conflitoAgendaWorkflow(input: ConflitoAgendaInput): Promise<{
resultado: 'MANTIDO_B' | 'LIBERADO_A'
}> {
let bConfirmou = false
// Registra o handler do signal de confirmação de B
setHandler(confirmarSlotSignal, ({ confirmado }) => {
bConfirmou = confirmado
log.info('Signal recebido de B', { confirmado })
})
// Notifica B sobre a disputa
await notificarConflito({
chatIdB: input.chatIdDetentor,
solicitanteNome: input.solicitanteId,
tituloSlot: input.tituloSlot,
})
log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot })
// Aguarda signal de B OU timeout de 1 hora
const bRespondeu = await condition(() => bConfirmou !== false, '1 hour')
if (bRespondeu && bConfirmou) {
log.info('B confirmou o slot — slot mantido com B')
return { resultado: 'MANTIDO_B' }
}
// B não respondeu ou recusou → libera para A
log.info('B não confirmou em 1 hora — liberando slot para A')
await liberarSlotAgenda({
solicitanteId: input.solicitanteId,
tituloSlot: input.tituloSlot,
chatIdSolicitante: input.chatIdSolicitante,
})
return { resultado: 'LIBERADO_A' }
}
@@ -0,0 +1,2 @@
export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow'
export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow'
@@ -0,0 +1,83 @@
/**
* Workflow: Gestão de Reputação de Contato
*
* Regra de negócio:
* - Cada falta/infração reduz o score_reputacao do contato.
* - Score ≤ 20 → contato entra em flag_restricao = true.
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
* - Score é recuperável manualmente pelo agente (fora deste workflow).
*/
import {
proxyActivities,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
decrementarScore,
bloquearAgendamentosAutomaticos,
buscarScoreContato,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal para registrar uma nova infração
export const registrarInfracaoSignal = defineSignal<[{
motivo: string
pontos: number
}]>('registrarInfracao')
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
export const encerrarMonitoramentoSignal = defineSignal('encerrarMonitoramento')
export interface ReputacaoInput {
contactId: string
tenantId: string
}
export async function reputacaoWorkflow(input: ReputacaoInput): Promise<void> {
let encerrado = false
const infracoesPendentes: Array<{ motivo: string; pontos: number }> = []
setHandler(registrarInfracaoSignal, (payload) => {
log.info('Infração registrada via signal', payload)
infracoesPendentes.push(payload)
})
setHandler(encerrarMonitoramentoSignal, () => {
encerrado = true
})
// Loop durável — o Temporal mantém o estado mesmo após reinícios
while (!encerrado) {
// Aguarda próxima infração ou encerramento
await condition(() => infracoesPendentes.length > 0 || encerrado)
if (encerrado) break
const infracao = infracoesPendentes.shift()!
const { novoScore, restrito } = await decrementarScore({
contactId: input.contactId,
motivo: infracao.motivo,
pontos: infracao.pontos,
})
log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId })
if (restrito) {
await bloquearAgendamentosAutomaticos({ contactId: input.contactId })
log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
contactId: input.contactId,
scoreAtual: novoScore,
})
// Continua monitorando (restrição é revertida manualmente pelo agente)
}
}
log.info('Monitoramento de reputação encerrado', { contactId: input.contactId })
}
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.authMiddleware = exports.authenticate = void 0;
// Re-export do middleware de autenticação para compatibilidade com plugins
var auth_middleware_1 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } });
var auth_middleware_2 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } });
//# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":";;;AAAA,2EAA2E;AAC3E,yEAAsF;AAA7E,+GAAA,cAAc,OAAgB;AACvC,yEAAsE;AAA7D,iHAAA,cAAc,OAAA"}
@@ -0,0 +1,3 @@
// Re-export do middleware de autenticação para compatibilidade com plugins
export { authMiddleware as authenticate } from '../shared/middlewares/auth.middleware'
export { authMiddleware } from '../shared/middlewares/auth.middleware'
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireRole = requireRole;
// Stub de RBAC — aceita qualquer role válida
function requireRole(..._roles) {
return (_req, _res, next) => next();
}
//# sourceMappingURL=rbac.js.map
@@ -0,0 +1 @@
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,kCAEC;AAHD,6CAA6C;AAC7C,SAAgB,WAAW,CAAC,GAAG,MAAgB;IAC7C,OAAO,CAAC,IAAa,EAAE,IAAc,EAAE,IAAkB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;AACtE,CAAC"}
@@ -0,0 +1,6 @@
import type { Request, Response, NextFunction } from 'express'
// Stub de RBAC — aceita qualquer role válida
export function requireRole(..._roles: string[]) {
return (_req: Request, _res: Response, next: NextFunction) => next()
}
@@ -0,0 +1,93 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import crypto from 'crypto'
export function buildApiKeyRoutes(): Router {
const router = Router()
// GET /api/api-keys
router.get('/', async (req: Request, res: Response) => {
const keys = await prisma.apiKey.findMany({
where: { tenantId: req.tenantId! },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
key: true,
isActive: true,
createdAt: true,
expiresAt: true,
},
})
// Mask key: show only last 8 chars
const masked = keys.map((k) => ({
...k,
keyPreview: `••••••••${k.key.slice(-8)}`,
key: undefined,
}))
res.json(masked)
})
// POST /api/api-keys — cria nova chave
router.post('/', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
expiresAt: z.string().datetime().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const key = `nw_${crypto.randomBytes(24).toString('hex')}`
const created = await prisma.apiKey.create({
data: {
tenantId: req.tenantId!,
name: body.name,
key,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
},
})
// Retorna a chave completa apenas uma vez
res.status(201).json({
id: created.id,
name: created.name,
key: created.key, // plaintext — única vez
isActive: created.isActive,
createdAt: created.createdAt,
expiresAt: created.expiresAt,
})
})
// PATCH /api/api-keys/:id — ativar/desativar
router.patch('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const schema = z.object({ isActive: z.boolean() })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
const updated = await prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } })
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive })
})
// DELETE /api/api-keys/:id
router.delete('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
await prisma.apiKey.delete({ where: { id } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,375 @@
/**
* Rotas exclusivas do ADMIN (dono do SaaS).
* Prefixo: /api/admin/
*/
import path from 'path'
import fs from 'fs/promises'
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import multer from 'multer'
import si from 'systeminformation'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { pluginsRouter } from '../plugins/plugins.routes'
const SETTINGS_KEY = 'system:settings'
const DEFAULT_SETTINGS = {
systemName: 'NewWhats',
timezone: 'America/Sao_Paulo',
logoUrl: null as string | null,
faviconUrl: null as string | null,
accentColor: '#3b82f6',
sounds: { newMessage: true, notification: true, connectionStatus: true },
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
registration: { mode: 'open' as 'open' | 'closed' | 'invite', defaultPlanId: null as string | null },
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
webhook: { url: '', secret: '' },
}
async function getSettings() {
const stored = await dragonfly.getJson<typeof DEFAULT_SETTINGS>(SETTINGS_KEY)
return { ...DEFAULT_SETTINGS, ...stored }
}
const uploadAsset = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true)
else cb(new Error('Apenas imagens são permitidas'))
},
})
export const adminRouter = Router()
// ── Plugins ───────────────────────────────────────────────────────────────────
adminRouter.use('/plugins', pluginsRouter)
// ── Plans ─────────────────────────────────────────────────────────────────────
adminRouter.get('/plans', async (_req: Request, res: Response) => {
try {
const plans = await prisma.plan.findMany({
select: { id: true, name: true }
})
res.json(plans)
} catch {
res.status(500).json({ error: 'Erro ao listar planos' })
}
})
// ── Settings ──────────────────────────────────────────────────────────────────
adminRouter.get('/settings', async (_req: Request, res: Response) => {
try {
const settings = await getSettings()
// Nunca expõe a senha SMTP ao frontend
const { smtp, ...rest } = settings
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } })
} catch {
res.status(500).json({ error: 'Erro ao buscar configurações' })
}
})
adminRouter.patch('/settings', async (req: Request, res: Response) => {
try {
const current = await getSettings()
const body = req.body as Partial<typeof DEFAULT_SETTINGS>
// Se o frontend devolver o placeholder de senha, mantém a senha atual
if (body.smtp?.password === '••••••••') {
body.smtp = { ...body.smtp, password: current.smtp.password }
}
const updated = { ...current, ...body }
await dragonfly.setJson(SETTINGS_KEY, updated, 0) // TTL 0 = sem expiração
res.json({ ok: true })
} catch {
res.status(500).json({ error: 'Erro ao salvar configurações' })
}
})
// Upload de logo
adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
const ext = req.file.originalname.split('.').pop() ?? 'png'
const dest = path.resolve('./media/system/logo.' + ext)
await fs.writeFile(dest, req.file.buffer)
const url = `/media/system/logo.${ext}`
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0)
res.json({ url })
} catch {
res.status(500).json({ error: 'Erro ao salvar logo' })
}
})
// Upload de favicon
adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
const ext = req.file.originalname.split('.').pop() ?? 'ico'
const dest = path.resolve('./media/system/favicon.' + ext)
await fs.writeFile(dest, req.file.buffer)
const url = `/media/system/favicon.${ext}`
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0)
res.json({ url })
} catch {
res.status(500).json({ error: 'Erro ao salvar favicon' })
}
})
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
adminRouter.get('/settings/public', async (_req: Request, res: Response) => {
try {
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings()
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance })
} catch {
res.status(500).json({ error: 'Erro' })
}
})
// ── Métricas do sistema ───────────────────────────────────────────────────────
adminRouter.get('/metrics', async (_req: Request, res: Response) => {
try {
const [cpuLoad, mem, disk] = await Promise.all([
si.currentLoad(),
si.mem(),
si.fsSize(),
])
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0]
const [connectedInstances, totalUsers] = await Promise.all([
prisma.instance.count({ where: { status: 'CONNECTED' } }),
prisma.user.count(),
])
res.json({
cpu: { percent: Math.round(cpuLoad.currentLoad) },
ram: {
total: mem.total,
used: mem.used,
percent: Math.round((mem.used / mem.total) * 100),
},
disk: {
total: rootDisk?.size ?? 0,
used: rootDisk?.used ?? 0,
percent: Math.round(rootDisk?.use ?? 0),
},
uptime: Math.floor(process.uptime()),
connectedInstances,
totalUsers,
})
} catch {
res.status(500).json({ error: 'Erro ao coletar métricas' })
}
})
// Listar todos os tenants com presença online
adminRouter.get('/users', async (_req: Request, res: Response) => {
try {
const [users, presenceKeys] = await Promise.all([
prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
_count: { select: { instances: true } },
},
orderBy: { createdAt: 'desc' },
}),
dragonfly.raw().keys('presence:user:*'),
])
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')))
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })))
} catch (err) {
res.status(500).json({ error: 'Erro ao listar usuários' })
}
})
// Detalhes de um tenant
adminRouter.get('/users/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
instances: { select: { id: true, name: true, status: true } },
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
},
})
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' })
return
}
res.json(user)
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar usuário' })
}
})
// Ativar / desativar tenant
adminRouter.patch('/users/:id/status', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { isActive } = z.object({ isActive: z.boolean() }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: { isActive },
select: { id: true, name: true, email: true, isActive: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'isActive deve ser boolean' })
return
}
res.status(500).json({ error: 'Erro ao atualizar status' })
}
})
// Alterar plano do tenant
adminRouter.patch('/users/:id/plan', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { planId } = z.object({ planId: z.string().uuid() }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: { planId },
select: { id: true, name: true, planId: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'planId inválido' })
return
}
res.status(500).json({ error: 'Erro ao atualizar plano' })
}
})
// Estender trial de um tenant
adminRouter.patch('/users/:id/trial', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { days } = z.object({ days: z.number().int().min(1).max(365) }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: {
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
},
select: { id: true, name: true, trialEndsAt: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'days inválido' })
return
}
res.status(500).json({ error: 'Erro ao estender trial' })
}
})
// Resetar senha de um tenant (admin)
adminRouter.patch('/users/:id/reset-password', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { newPassword } = z
.object({ newPassword: z.string().min(8).max(128) })
.parse(req.body)
const passwordHash = await bcrypt.hash(newPassword, 12)
await prisma.user.update({ where: { id }, data: { passwordHash } })
res.json({ message: 'Senha redefinida com sucesso' })
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'newPassword inválido' })
return
}
res.status(500).json({ error: 'Erro ao redefinir senha' })
}
})
// Obter engine ativa
adminRouter.get('/engine', async (_req: Request, res: Response) => {
try {
const settings = await getSettings()
const storedEngine = (settings as any).baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite'
res.json({ engine: storedEngine })
} catch (err) {
console.error('[Admin] Erro ao obter engine:', err)
res.status(500).json({ error: 'Erro ao obter a engine ativa' })
}
})
// Altera a engine e reinicia o backend (via PM2 ou Docker)
adminRouter.post('/engine', async (req: Request, res: Response) => {
try {
const { engine } = z.object({ engine: z.enum(['infinite', 'official']) }).parse(req.body)
// 1. Persiste nas configurações globais
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0)
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
const envPath = path.resolve('./.env')
try {
let content = await fs.readFile(envPath, 'utf-8')
if (content.includes('BAILEYS_ENGINE=')) {
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`)
} else {
content += `\nBAILEYS_ENGINE=${engine}\n`
}
await fs.writeFile(envPath, content, 'utf-8')
} catch (envErr) {
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr)
}
// 3. Responde ao frontend antes de desligar/reiniciar
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' })
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
setTimeout(() => {
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`)
const { exec } = require('child_process')
exec('pm2 restart newwhats-backend', (err: any) => {
if (err) {
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...')
process.exit(0)
}
})
}, 1500)
} catch (err) {
console.error('[Admin] Erro ao alternar engine:', err)
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' })
return
}
res.status(500).json({ error: 'Erro ao salvar a engine' })
}
})
@@ -0,0 +1,60 @@
import { prisma } from '../../infra/database/prisma'
import type { UserRole } from '@prisma/client'
export class AuthRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email } })
}
async findById(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async create(data: {
name: string
email: string
passwordHash: string
role?: UserRole
planId?: string
}) {
return prisma.user.create({
data: {
name: data.name,
email: data.email,
passwordHash: data.passwordHash,
role: data.role ?? 'USER',
planId: data.planId,
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
},
select: {
id: true,
name: true,
email: true,
role: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async updatePassword(id: string, passwordHash: string) {
return prisma.user.update({ where: { id }, data: { passwordHash } })
}
async setActive(id: string, isActive: boolean) {
return prisma.user.update({ where: { id }, data: { isActive } })
}
}
@@ -0,0 +1,127 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { AuthService } from './auth.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new AuthService()
// ─── Schemas de validação ─────────────────────────────────────────────────────
const registerSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
planId: z.string().uuid().optional(),
})
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
const refreshSchema = z.object({
refreshToken: z.string().min(1),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
})
// ─── Helper para erros de validação / serviço ─────────────────────────────────
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const authRouter = Router()
/**
* POST /api/auth/register
* Cria novo tenant (usuário com role USER).
* Retorna access + refresh token e dados do usuário.
*/
authRouter.post('/register', async (req: Request, res: Response) => {
try {
const body = registerSchema.parse(req.body) as any
const result = await service.register(body)
res.status(201).json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/login
* Autentica usuário. Retorna access + refresh token.
*/
authRouter.post('/login', async (req: Request, res: Response) => {
try {
const body = loginSchema.parse(req.body) as any
const result = await service.login(body)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/refresh
* Renova o access token usando o refresh token.
*/
authRouter.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = refreshSchema.parse(req.body)
const result = await service.refresh(refreshToken)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/logout
* Revoga o refresh token do usuário autenticado.
*/
authRouter.post('/logout', authMiddleware, async (req: Request, res: Response) => {
try {
await service.logout(req.tenantId!)
res.json({ message: 'Sessão encerrada' })
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/auth/me
* Retorna dados do usuário autenticado + dias restantes do trial.
*/
authRouter.get('/me', authMiddleware, async (req: Request, res: Response) => {
try {
const user = await service.me(req.tenantId!)
res.json(user)
} catch (err) {
handleError(res, err)
}
})
/**
* PATCH /api/auth/password
* Altera a senha do usuário autenticado.
*/
authRouter.patch('/password', authMiddleware, async (req: Request, res: Response) => {
try {
const body = changePasswordSchema.parse(req.body) as any
await service.changePassword(req.tenantId!, body)
res.json({ message: 'Senha alterada com sucesso' })
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,176 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { AuthRepository } from './auth.repository'
import { dragonfly } from '../../infra/cache/dragonfly'
const BCRYPT_ROUNDS = 12
const ACCESS_EXPIRES = '8h'
const REFRESH_EXPIRES = '30d'
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
interface TokenPair {
accessToken: string
refreshToken: string
}
interface JwtPayload {
sub: string
role: string
type: 'access' | 'refresh'
}
export class AuthService {
private repo = new AuthRepository()
// ─── Register ─────────────────────────────────────────────────────────────
async register(input: {
name: string
email: string
password: string
planId?: string
}) {
const existing = await this.repo.findByEmail(input.email)
if (existing) {
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 })
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS)
const user = await this.repo.create({
name: input.name,
email: input.email,
passwordHash,
planId: input.planId,
})
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Login ────────────────────────────────────────────────────────────────
async login(input: { email: string; password: string }) {
const user = await this.repo.findByEmail(input.email)
if (!user) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
const valid = await bcrypt.compare(input.password, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
const { passwordHash: _, ...safeUser } = user
return { user: safeUser, ...tokens }
}
// ─── Refresh ──────────────────────────────────────────────────────────────
async refresh(refreshToken: string) {
let payload: JwtPayload
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as JwtPayload
} catch {
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 })
}
if (payload.type !== 'refresh') {
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 })
}
// Valida que o token ainda está no cache (não foi revogado)
const stored = await dragonfly.get(this.refreshKey(payload.sub))
if (stored !== refreshToken) {
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 })
}
const user = await this.repo.findById(payload.sub)
if (!user || !user.isActive) {
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Logout ───────────────────────────────────────────────────────────────
async logout(userId: string) {
await dragonfly.del(this.refreshKey(userId))
}
// ─── Me ───────────────────────────────────────────────────────────────────
async me(userId: string) {
const user = await this.repo.findById(userId)
if (!user) {
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
}
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null
const daysRemaining = trialEndsAt
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000))
: null
return { ...user, daysRemaining }
}
// ─── Change Password ──────────────────────────────────────────────────────
async changePassword(userId: string, input: { currentPassword: string; newPassword: string }) {
const user = await this.repo.findByEmail(
(await this.repo.findById(userId))?.email ?? ''
)
if (!user) throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
const valid = await bcrypt.compare(input.currentPassword, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 })
}
const newHash = await bcrypt.hash(input.newPassword, BCRYPT_ROUNDS)
await this.repo.updatePassword(userId, newHash)
await this.logout(userId) // invalida todas as sessões
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private issueTokens(userId: string, role: string): TokenPair {
const base = { sub: userId, role }
const accessToken = jwt.sign(
{ ...base, type: 'access' },
env.JWT_SECRET,
{ expiresIn: ACCESS_EXPIRES }
)
const refreshToken = jwt.sign(
{ ...base, type: 'refresh' },
env.JWT_SECRET,
{ expiresIn: REFRESH_EXPIRES }
)
return { accessToken, refreshToken }
}
private async saveRefreshToken(userId: string, token: string) {
await dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS)
}
private refreshKey(userId: string): string {
return `auth:refresh:${userId}`
}
}
@@ -0,0 +1,68 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
const prisma_1 = require("../../infra/database/prisma");
// ─── Credentials ─────────────────────────────────────────────────────────────
exports.credentialRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data) => prisma_1.prisma.aICredential.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
name: data.name,
provider: data.provider ?? 'GEMINI',
apiKey: data.apiKey,
},
select: { id: true, name: true, provider: true, createdAt: true },
}),
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
};
// ─── Bots ─────────────────────────────────────────────────────────────────────
exports.botRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data) => prisma_1.prisma.aIBot.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
credentialId: data.credentialId,
name: data.name,
systemPrompt: data.systemPrompt,
model: data.model ?? 'gemini-1.5-flash',
enabled: data.enabled ?? false,
triggerMode: data.triggerMode ?? 'ALL',
keywords: data.keywords ?? [],
},
}),
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
};
// ─── Chat bot state ───────────────────────────────────────────────────────────
exports.chatBotState = {
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId) => prisma_1.prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
};
//# sourceMappingURL=chatbot.repository.js.map
@@ -0,0 +1 @@
{"version":3,"file":"chatbot.repository.js","sourceRoot":"","sources":["chatbot.repository.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,wDAAoD;AAEpD,gFAAgF;AAEnE,QAAA,cAAc,GAAG;IAC5B,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,YAAY,CAAC,QAAQ,CAAC;QAC3B,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;QACjE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAMR,EAAE,EAAE,CACH,eAAM,CAAC,YAAY,CAAC,MAAM,CAAC;QACzB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,QAAQ;YACnC,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB;QACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;KAClE,CAAC;IAEJ,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,YAAY,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC9D,CAAA;AAED,iFAAiF;AAEpE,QAAA,OAAO,GAAG;IACrB,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACpB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE;QAC7E,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,WAAW,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE;QAC9C,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,QAAQ,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACzC,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;QACvB,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAUR,EAAE,EAAE,CACH,eAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAClB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,kBAAkB;YACvC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,KAAK;YAC9B,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,KAAK;YACtC,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;SAC9B;KACF,CAAC;IAEJ,MAAM,EAAE,CACN,EAAU,EACV,QAAgB,EAChB,IAQE,EACF,EAAE,CACF,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAE5D,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CACvD,CAAA;AAED,iFAAiF;AAEpE,QAAA,YAAY,GAAG;IAC1B,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;IAE1E,SAAS,EAAE,CAAC,MAAc,EAAE,EAAE,CAC5B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC;IAE3E,aAAa,EAAE,CAAC,MAAc,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,EAAE,CAAC;IAErE,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;QACrB,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9C,CAAC;CACL,CAAA"}
@@ -0,0 +1,121 @@
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
import { prisma } from '../../infra/database/prisma'
// ─── Credentials ─────────────────────────────────────────────────────────────
export const credentialRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data: {
tenantId: string
instanceId: string
name: string
provider?: 'GEMINI' | 'OPENAI'
apiKey: string
}) =>
prisma.aICredential.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
name: data.name,
provider: data.provider ?? 'GEMINI',
apiKey: data.apiKey,
},
select: { id: true, name: true, provider: true, createdAt: true },
}),
delete: (id: string, tenantId: string) =>
prisma.aICredential.deleteMany({ where: { id, tenantId } }),
}
// ─── Bots ─────────────────────────────────────────────────────────────────────
export const botRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId: string, instanceId: string) =>
prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id: string, tenantId: string) =>
prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data: {
tenantId: string
instanceId: string
credentialId: string
name: string
systemPrompt: string
model?: string
enabled?: boolean
triggerMode?: 'ALL' | 'KEYWORD'
keywords?: string[]
}) =>
prisma.aIBot.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
credentialId: data.credentialId,
name: data.name,
systemPrompt: data.systemPrompt,
model: data.model ?? 'gemini-1.5-flash',
enabled: data.enabled ?? false,
triggerMode: data.triggerMode ?? 'ALL',
keywords: data.keywords ?? [],
},
}),
update: (
id: string,
tenantId: string,
data: Partial<{
name: string
systemPrompt: string
model: string
enabled: boolean
triggerMode: 'ALL' | 'KEYWORD'
keywords: string[]
credentialId: string
}>
) =>
prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id: string, tenantId: string) =>
prisma.aIBot.deleteMany({ where: { id, tenantId } }),
}
// ─── Chat bot state ───────────────────────────────────────────────────────────
export const chatBotState = {
pauseBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
updateSummary: (chatId: string, botSummary: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId: string) =>
prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
}
@@ -0,0 +1,193 @@
/**
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
*
* Montado em /api/chatbot (ver server.ts)
* Todos os endpoints requerem authMiddleware.
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { credentialRepo, botRepo, chatBotState } from './chatbot.repository'
import { logger } from '../../config/logger'
export function buildChatbotRoutes(): Router {
const router = Router()
// ── Helpers ──────────────────────────────────────────────────────────────
const validateInstance = async (instanceId: string, tenantId: string) => {
return prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
}
// ─────────────────────────────────────────────────────────────────────────
// CREDENTIALS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/credentials/:instanceId
router.get('/credentials/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const creds = await credentialRepo.findAll(tenantId, instanceId)
res.json(creds)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar credenciais')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/credentials/:instanceId
router.post('/credentials/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
apiKey: z.string().min(10),
provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(cred)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar credencial')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/credentials/:instanceId/:credId
router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const credId = req.params['credId'] as string
await credentialRepo.delete(credId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar credencial')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOTS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/bots/:instanceId
router.get('/bots/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const bots = await botRepo.findAll(tenantId, instanceId)
res.json(bots)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar bots')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/bots/:instanceId
router.post('/bots/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid(),
name: z.string().min(1).max(80),
systemPrompt: z.string().min(10),
model: z.string().default('gemini-1.5-flash'),
enabled: z.boolean().default(false),
triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'),
keywords: z.array(z.string()).default([]),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const existing = await botRepo.findAll(tenantId, instanceId)
if (existing.length > 0) {
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
return
}
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(bot)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar bot')
res.status(500).json({ error: err.message })
}
})
// PUT /api/chatbot/bots/:instanceId/:botId
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid().optional(),
name: z.string().min(1).max(80).optional(),
systemPrompt: z.string().min(10).optional(),
model: z.string().optional(),
enabled: z.boolean().optional(),
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
keywords: z.array(z.string()).optional(),
})
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
const body = schema.parse(req.body)
await botRepo.update(botId, tenantId, body as any)
const updated = await botRepo.findById(botId, tenantId)
res.json(updated)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao atualizar bot')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/bots/:instanceId/:botId
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
await botRepo.delete(botId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar bot')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOT STATE — Human Takeover por chat
// ─────────────────────────────────────────────────────────────────────────
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
try {
await chatBotState.pauseBot(req.params['chatId'] as string)
res.json({ paused: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
try {
await chatBotState.resumeBot(req.params['chatId'] as string)
res.json({ paused: false })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
return router
}
@@ -0,0 +1,165 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ChatbotService = void 0;
/**
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
*
* Padrão Cérebro (instrucoes.md §5):
* - Não envia histórico completo para economizar tokens
* - Mantém resumo de 2 frases por chat (botSummary no banco)
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
*/
const generative_ai_1 = require("@google/generative-ai");
const prisma_1 = require("../../infra/database/prisma");
const logger_1 = require("../../config/logger");
const chatbot_repository_1 = require("./chatbot.repository");
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
const geminiClients = new Map();
function getGeminiClient(apiKey) {
if (!geminiClients.has(apiKey)) {
geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey));
}
return geminiClients.get(apiKey);
}
function getModel(apiKey, modelName) {
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName });
}
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
async function classifyIntent(userMsg, summary, systemPrompt, model) {
const prompt = [
`Você é um classificador de intenção para um chatbot de atendimento.`,
`Prompt do atendente: ${systemPrompt}`,
summary ? `Contexto da conversa até agora: ${summary}` : '',
`Nova mensagem do cliente: "${userMsg}"`,
`Responda APENAS com o número:`,
`1 = Consigo responder essa pergunta dentro do meu papel`,
`2 = Precisa de um agente humano`,
].filter(Boolean).join('\n');
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 2, temperature: 0 },
});
const text = result.response.text().trim();
return text.startsWith('2') ? 'escalate' : 'resolve';
}
catch (err) {
logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve');
return 'resolve';
}
}
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(userMsg, summary, systemPrompt, model) {
const prompt = [
systemPrompt,
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
`\nMensagem do cliente: "${userMsg}"`,
`\nResponda de forma natural, breve e direta. Não use markdown.`,
].filter(Boolean).join('\n');
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
});
return result.response.text().trim();
}
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
async function updateSummary(currentSummary, userMsg, botMsg, model) {
const prompt = [
currentSummary
? `Resumo atual da conversa: ${currentSummary}`
: 'Esta é a primeira troca da conversa.',
`Nova troca:`,
`Cliente: "${userMsg}"`,
`Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
`Responda apenas com o resumo, sem introdução.`,
].join('\n');
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
});
return result.response.text().trim();
}
catch {
// Em caso de erro, mantém o resumo anterior
return currentSummary ?? '';
}
}
// ─── Interface pública ────────────────────────────────────────────────────────
class ChatbotService {
constructor(io) {
this.io = io;
}
/**
* Ponto de entrada para cada mensagem recebida.
* Chamado pelo MessageHandler após persistir a mensagem.
*/
async handleIncoming(opts) {
const { tenantId, instanceId, chatId, jid, text, sock } = opts;
// 1. Verifica se há bot ativo para esta instância
const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId);
if (!bot)
return;
// 2. Verifica o estado do chat (human takeover)
const chatState = await chatbot_repository_1.chatBotState.getState(chatId);
if (!chatState || chatState.botPaused)
return;
// 3. Aplica modo de gatilho
if (bot.triggerMode === 'KEYWORD') {
const lowerText = text.toLowerCase();
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
if (!hasKeyword)
return;
}
try {
const model = getModel(bot.credential.apiKey, bot.model);
const summary = chatState.botSummary ?? null;
// 4. Micro-prompt: intenção
const intent = await classifyIntent(text, summary, bot.systemPrompt, model);
if (intent === 'escalate') {
// Pausa o bot e notifica via Socket.IO
await chatbot_repository_1.chatBotState.pauseBot(chatId);
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
chatId,
message: 'Bot pausado — atendente humano necessário',
});
logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano');
return;
}
// 5. Gera resposta
const responseText = await generateResponse(text, summary, bot.systemPrompt, model);
// 6. Envia via Baileys
const sent = await sock.sendMessage(jid, { text: responseText });
// 7. Persiste mensagem do bot no banco
const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } });
if (chat) {
const botMsg = await prisma_1.prisma.message.create({
data: {
tenantId,
instanceId,
chatId,
remoteJid: jid,
messageId: sent?.key.id ?? `bot-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: responseText,
status: 'SENT',
timestamp: new Date(),
},
});
// Notifica frontend
this.io.to(`chat:${chatId}`).emit('message:new', botMsg);
}
// 8. Atualiza Cérebro
const newSummary = await updateSummary(summary, text, responseText, model);
await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary);
logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada');
}
catch (err) {
logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem');
}
}
}
exports.ChatbotService = ChatbotService;
//# sourceMappingURL=chatbot.service.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
/**
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
*
* Padrão Cérebro (instrucoes.md §5):
* - Não envia histórico completo para economizar tokens
* - Mantém resumo de 2 frases por chat (botSummary no banco)
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
*/
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai'
import type { WASocket } from '../whatsapp/engine'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import { botRepo, chatBotState } from './chatbot.repository'
import type { Server as SocketIOServer } from 'socket.io'
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
const geminiClients = new Map<string, GoogleGenerativeAI>()
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
if (!geminiClients.has(apiKey)) {
geminiClients.set(apiKey, new GoogleGenerativeAI(apiKey))
}
return geminiClients.get(apiKey)!
}
function getModel(apiKey: string, modelName: string): GenerativeModel {
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName })
}
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
async function classifyIntent(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<'resolve' | 'escalate'> {
const prompt = [
`Você é um classificador de intenção para um chatbot de atendimento.`,
`Prompt do atendente: ${systemPrompt}`,
summary ? `Contexto da conversa até agora: ${summary}` : '',
`Nova mensagem do cliente: "${userMsg}"`,
`Responda APENAS com o número:`,
`1 = Consigo responder essa pergunta dentro do meu papel`,
`2 = Precisa de um agente humano`,
].filter(Boolean).join('\n')
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 2, temperature: 0 },
})
const text = result.response.text().trim()
return text.startsWith('2') ? 'escalate' : 'resolve'
} catch (err) {
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
return 'resolve'
}
}
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<string> {
const prompt = [
systemPrompt,
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
`\nMensagem do cliente: "${userMsg}"`,
`\nResponda de forma natural, breve e direta. Não use markdown.`,
].filter(Boolean).join('\n')
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
})
return result.response.text().trim()
}
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
async function updateSummary(
currentSummary: string | null,
userMsg: string,
botMsg: string,
model: GenerativeModel
): Promise<string> {
const prompt = [
currentSummary
? `Resumo atual da conversa: ${currentSummary}`
: 'Esta é a primeira troca da conversa.',
`Nova troca:`,
`Cliente: "${userMsg}"`,
`Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
`Responda apenas com o resumo, sem introdução.`,
].join('\n')
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
})
return result.response.text().trim()
} catch {
// Em caso de erro, mantém o resumo anterior
return currentSummary ?? ''
}
}
// ─── Interface pública ────────────────────────────────────────────────────────
export class ChatbotService {
constructor(private io: SocketIOServer) {}
/**
* Ponto de entrada para cada mensagem recebida.
* Chamado pelo MessageHandler após persistir a mensagem.
*/
async handleIncoming(opts: {
tenantId: string
instanceId: string
chatId: string
jid: string
text: string
sock: WASocket
}): Promise<void> {
const { tenantId, instanceId, chatId, jid, text, sock } = opts
// 1. Verifica se há bot ativo para esta instância
const bot = await botRepo.findEnabled(tenantId, instanceId)
if (!bot) return
// 2. Verifica o estado do chat (human takeover)
const chatState = await chatBotState.getState(chatId)
if (!chatState || chatState.botPaused) return
// 3. Aplica modo de gatilho
if (bot.triggerMode === 'KEYWORD') {
const lowerText = text.toLowerCase()
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()))
if (!hasKeyword) return
}
try {
const model = getModel(bot.credential.apiKey, bot.model)
const summary = chatState.botSummary ?? null
// 4. Micro-prompt: intenção
const intent = await classifyIntent(text, summary, bot.systemPrompt, model)
if (intent === 'escalate') {
// Pausa o bot e notifica via Socket.IO
await chatBotState.pauseBot(chatId)
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
chatId,
message: 'Bot pausado — atendente humano necessário',
})
logger.info({ chatId, jid }, '[Chatbot] Escalação para humano')
return
}
// 5. Gera resposta
const responseText = await generateResponse(text, summary, bot.systemPrompt, model)
// 6. Envia via Baileys
const sent = await sock.sendMessage(jid, { text: responseText })
// 7. Persiste mensagem do bot no banco
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
if (chat) {
const botMsg = await prisma.message.create({
data: {
tenantId,
instanceId,
chatId,
remoteJid: jid,
messageId: sent?.key.id ?? `bot-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: responseText,
status: 'SENT',
timestamp: new Date(),
},
})
// Notifica frontend
this.io.to(`chat:${chatId}`).emit('message:new', botMsg)
}
// 8. Atualiza Cérebro
const newSummary = await updateSummary(summary, text, responseText, model)
await chatBotState.updateSummary(chatId, newSummary)
logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada')
} catch (err) {
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
}
}
}
@@ -0,0 +1,133 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../../infra/database/prisma'
export class ChatRepository {
async findAllByInstance(tenantId: string, instanceId: string, opts: {
limit?: number
search?: string
archived?: boolean
} = {}) {
const { limit = 200, search, archived = false } = opts
return prisma.chat.findMany({
where: {
tenantId,
instanceId,
isArchived: archived,
NOT: { OR: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
]},
...(search
? {
OR: [
{ jid: { contains: search, mode: 'insensitive' as const } },
{ contact: { name: { contains: search, mode: 'insensitive' as const } } },
{ contact: { notify: { contains: search, mode: 'insensitive' as const } } },
{ contact: { phone: { contains: search, mode: 'insensitive' as const } } },
],
}
: {}),
},
include: {
contact: {
select: {
name: true,
notify: true,
verifiedName: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
protocols: {
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
select: { number: true, status: true },
take: 1,
orderBy: { createdAt: 'desc' },
},
messages: {
select: {
body: true,
fromMe: true,
status: true,
timestamp: true,
type: true,
pushName: true,
},
orderBy: { timestamp: 'desc' },
take: 1,
},
},
orderBy: [
{ isPinned: 'desc' },
// COALESCE: usa lastMessageAt se existir, senão createdAt (chats novos sem msg)
// Garante que chats recém-criados não caiam para o fundo da lista
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
{ createdAt: 'desc' },
],
take: limit,
})
}
async findById(id: string, tenantId: string) {
return prisma.chat.findFirst({
where: { id, tenantId },
include: {
contact: true,
protocols: {
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
take: 1,
orderBy: { createdAt: 'desc' },
},
},
})
}
async findByJid(tenantId: string, instanceId: string, jid: string) {
return prisma.chat.findUnique({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
include: { contact: true },
})
}
async clearUnread(id: string, tenantId: string) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { unreadCount: 0 },
})
}
async setArchived(id: string, tenantId: string, archived: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isArchived: archived },
})
}
async setPinned(id: string, tenantId: string, pinned: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isPinned: pinned },
})
}
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
const result = await prisma.chat.aggregate({
where: {
tenantId,
instanceId,
isArchived: false,
NOT: { OR: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
]}
},
_sum: { unreadCount: true },
})
return result._sum.unreadCount ?? 0
}
}
@@ -0,0 +1,480 @@
/**
* Rotas de Chats — API REST para a inbox do frontend.
*
* Endpoints:
* GET /api/chats — Lista chats (com filtros e último msg)
* GET /api/chats/search/messages — Busca full-text em mensagens
* GET /api/chats/stats/overview — Contagens para o dashboard
* GET /api/chats/:id — Detalhe de um chat
* POST /api/chats/:id/read — Zera unread_count
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
* PATCH /api/chats/:id/pin — Fixa/desfixa
* DELETE /api/chats/:id — Remove chat e mensagens
* GET /api/chats/:id/messages — Histórico paginado por cursor
* GET /api/chats/:id/protocol — Protocolos do chat
* POST /api/chats/:id/protocol — Abre novo protocolo
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
*
* RESOLUÇÃO DE NOME (displayName):
* - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert)
* - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName
* - Se nenhum nome disponível: retorna null → frontend formata o telefone
*
* @see ChatRepository — queries otimizadas com JOINs
* @see MessageRepository — busca e paginação de mensagens
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { ChatRepository } from './chat.repository'
import { MessageRepository } from './message.repository'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
const chatRepo = new ChatRepository()
const msgRepo = new MessageRepository()
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
res.status(500).json({ error: (err as Error).message ?? 'Erro interno' })
}
/**
* Constrói as rotas de chat.
*
* Montadas em: /api/chats
* O WhatsAppConnectionManager é injetado mas não usado diretamente aqui
* (reservado para futuras features como typing indicator).
*/
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
const router = Router()
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats — Lista chats com snapshot da última mensagem
// ═══════════════════════════════════════════════════════════════════════════
/**
* Lista chats do tenant para uma instância, com:
* - Último mensagem (preview para a lista de chats)
* - Protocolo ativo (se houver)
* - Dados do contato (nome resolvido, avatar, reputação)
*
* Query params:
* - instanceId: UUID (obrigatório)
* - search?: string (busca por nome/telefone)
* - archived?: boolean (filtrar por arquivados)
* - limit?: number (1-200)
*
* REGRA DE RESOLUÇÃO DE NOME (resolvedName):
* Grupos: Chat.name (subject do grupo)
* Individuais:
* 1. Contact.name (agenda do telefone — via contacts.upsert)
* 2. Contact.verifiedName (conta business verificada)
* 3. Contact.notify (pushname — via contacts.upsert ou mensagem)
* 4. lastMsg.pushName (fallback: pushName da última mensagem recebida)
* 5. null (frontend formata o telefone como "55 11 99999-9999")
*/
router.get('/', async (req: Request, res: Response) => {
try {
const { instanceId, search, archived, limit } = z.object({
instanceId: z.string().uuid(),
search: z.string().optional(),
archived: z.coerce.boolean().optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}).parse(req.query)
const chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, {
search,
archived,
limit,
})
// Serializa cada chat com nome resolvido e dados formatados para o frontend
const result = chats.map((c) => {
const contact = c.contact
const isGroup = c.jid.endsWith('@g.us')
// Última mensagem (já vem do JOIN no repository, limitado a 1)
const lastMsg = c.messages[0]
// Protocolo ativo mais recente (OPEN ou IN_PROGRESS)
const activeProtocol = c.protocols[0] ?? null
// ── Resolução de nome ─────────────────────────────────────────────
// Regra: nunca retorna o JID bruto como nome — retorna null e deixa
// o frontend fazer o fallback (telefone formatado ou "Grupo").
//
// Para individuais: usa hierarchia Contact > lastMsg.pushName > null
// Para grupos: usa Chat.name (subject) ou null
//
// O fallback lastMsg.pushName cobre contatos que vieram do history
// sync sem nome (a maioria — só ~22 de ~3000 têm verifiedName).
const resolvedName = isGroup
? (c.name ?? null)
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
?? null)
return {
id: c.id,
instanceId: c.instanceId,
jid: c.jid,
name: c.name ?? null,
isPinned: c.isPinned,
isArchived: c.isArchived,
unreadCount: c.unreadCount,
lastMessageAt: c.lastMessageAt,
createdAt: c.createdAt,
contact: contact
? {
name: resolvedName, // null quando não há nome real
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: isGroup
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage: lastMsg
? {
body: lastMsg.body,
fromMe: lastMsg.fromMe,
status: lastMsg.status,
type: lastMsg.type,
timestamp: lastMsg.timestamp,
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
}
: null,
protocol: activeProtocol
? { number: activeProtocol.number, status: activeProtocol.status }
: null,
}
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/search/messages — Busca full-text em mensagens
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca full-text no corpo das mensagens.
* MUST ser definido ANTES de /:id para evitar captura de "search" como param.
*
* Query params:
* - instanceId: UUID
* - q: string (mínimo 2 chars)
* - limit?: number (1-50)
*/
router.get('/search/messages', async (req: Request, res: Response) => {
try {
const { instanceId, q, limit } = z.object({
instanceId: z.string().uuid(),
q: z.string().min(2),
limit: z.coerce.number().int().min(1).max(50).optional(),
}).parse(req.query)
const results = await msgRepo.search(req.tenantId!, instanceId, q, limit)
res.json(results)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/stats/overview — Contagens para o dashboard
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna métricas resumidas para o dashboard do frontend.
* MUST ser definido ANTES de /:id para evitar captura de "stats" como param.
*
* Response: { totalUnread, openProtocols, totalChats }
*
* Cache DragonflyDB de 30s para não sobrecarregar o banco em dashboards
* com refresh automático.
*/
router.get('/stats/overview', async (req: Request, res: Response) => {
try {
const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query)
const cacheKey = `stats:${req.tenantId}:${instanceId}`
const cached = await dragonfly.getJson(cacheKey)
if (cached) { res.json(cached); return }
// Executa as 3 queries em paralelo para minimizar latência
const [totalUnread, openProtocols, totalChats] = await Promise.all([
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
prisma.protocol.count({
where: {
tenantId: req.tenantId!,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
prisma.chat.count({
where: {
tenantId: req.tenantId!,
instanceId,
isArchived: false,
// Exclui chats @lid (artefatos internos do WhatsApp)
NOT: { jid: { endsWith: '@lid' } }
},
}),
])
const stats = { totalUnread, openProtocols, totalChats }
await dragonfly.setJson(cacheKey, stats, 30) // cache 30s
res.json(stats)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id — Detalhe de um chat
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
res.json(chat)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/chats/:id/read — Zera contador de não-lidas
// ═══════════════════════════════════════════════════════════════════════════
/** Marca todas as mensagens do chat como lidas (zera unreadCount) */
router.post('/:id/read', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
await chatRepo.clearUnread(id, req.tenantId!)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/archive — Arquivar/desarquivar
// ═══════════════════════════════════════════════════════════════════════════
/** Body: { archived: boolean } */
router.patch('/:id/archive', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { archived } = z.object({ archived: z.boolean() }).parse(req.body)
await chatRepo.setArchived(id, req.tenantId!, archived)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/pin — Fixar/desfixar
// ═══════════════════════════════════════════════════════════════════════════
/** Body: { pinned: boolean } */
router.patch('/:id/pin', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body)
await chatRepo.setPinned(id, req.tenantId!, pinned)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// DELETE /api/chats/:id — Remove chat e mensagens
// ═══════════════════════════════════════════════════════════════════════════
/**
* Remove um chat e TODAS as suas mensagens permanentemente.
* Operação irreversível — use com cuidado.
* Remove mensagens primeiro (FK constraint) e depois o chat.
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.message.deleteMany({ where: { chatId: id } })
await prisma.chat.delete({ where: { id } })
res.status(204).send()
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id/messages — Histórico paginado
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna mensagens do chat com paginação por cursor (timestamp ISO).
*
* Query params:
* - before?: ISO datetime (mensagens anteriores a este timestamp)
* - after?: ISO datetime (mensagens posteriores a este timestamp)
* - limit?: number (1-100, default definido no repository)
*
* Response: { messages: Message[], hasMore: boolean }
*
* O frontend usa infinite scroll: carrega as mais recentes primeiro,
* depois pede mais antigas passando `before` do timestamp mais antigo.
*/
router.get('/:id/messages', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { before, after, limit } = z.object({
before: z.string().datetime().optional(),
after: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
}).parse(req.query)
// Valida que o chat pertence ao tenant (segurança multi-tenant)
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
before,
after,
limit,
})
res.json({ messages, hasMore })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PROTOCOLOS DE ATENDIMENTO
// ═══════════════════════════════════════════════════════════════════════════
/**
* GET /api/chats/:id/protocol — Últimos 10 protocolos do chat.
* Inclui setor e equipe associados.
*/
router.get('/:id/protocol', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
const protocols = await prisma.protocol.findMany({
where: { chatId: id },
orderBy: { createdAt: 'desc' },
take: 10,
include: {
sector: { select: { name: true } },
team: { select: { name: true } },
},
})
res.json(protocols)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/chats/:id/protocol — Abre novo protocolo de atendimento.
*
* Body (opcional):
* - sectorId?: UUID — setor responsável
* - teamId?: UUID — equipe responsável
*
* O número do protocolo é gerado sequencialmente: #0001-2026, #0002-2026, etc.
* Exige que o chat tenha um contactId vinculado (não funciona para chats órfãos).
*/
router.post('/:id/protocol', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { sectorId, teamId } = z.object({
sectorId: z.string().uuid().optional(),
teamId: z.string().uuid().optional(),
}).parse(req.body)
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return }
// Gera número de protocolo sequencial com ano
const count = await prisma.protocol.count({ where: { chatId: id } })
const year = new Date().getFullYear()
const number = `#${String(count + 1).padStart(4, '0')}-${year}`
const protocol = await prisma.protocol.create({
data: {
tenantId: req.tenantId!,
number,
chatId: id,
contactId: chat.contactId,
sectorId,
teamId,
status: 'OPEN',
},
})
res.status(201).json(protocol)
} catch (err) {
handleError(res, err)
}
})
/**
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo.
*
* Body:
* - status?: enum — novo status do protocolo
* - summary?: string (max 500) — resumo do atendimento
*
* Se status='RESOLVED', preenche resolvedAt automaticamente.
*
* Status possíveis:
* OPEN → IN_PROGRESS → WAITING_CLIENT / WAITING_AGENT → RESOLVED / CANCELLED
*/
router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => {
try {
const chatId = req.params['chatId'] as string
const protocolId = req.params['protocolId'] as string
const { status, summary } = z.object({
status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
summary: z.string().max(500).optional(),
}).parse(req.body)
const protocol = await prisma.protocol.updateMany({
where: { id: protocolId, chatId, tenantId: req.tenantId! },
data: {
...(status && { status }),
...(summary && { summary }),
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
},
})
res.json({ updated: protocol.count })
} catch (err) {
handleError(res, err)
}
})
return router
}
@@ -0,0 +1,86 @@
import { prisma } from '../../infra/database/prisma'
export class MessageRepository {
/**
* Histórico paginado por cursor (timestamp).
* Retorna `limit` mensagens anteriores ao cursor dado.
* Se cursor não for fornecido, retorna as mais recentes.
*/
async findByChatPaginated(chatId: string, opts: {
limit?: number
before?: string // timestamp ISO — cursor de paginação
after?: string // timestamp ISO — buscar mensagens mais novas
} = {}) {
const { limit = 40, before, after } = opts
const where: Record<string, unknown> = { chatId }
if (before) where['timestamp'] = { lt: new Date(before) }
if (after) where['timestamp'] = { gt: new Date(after) }
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
// para retornar as mensagens mais recentes em ordem cronológica.
// Scroll-down (after): busca ASC naturalmente.
const needsReverse = !after
const messages = await prisma.message.findMany({
where,
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
take: limit,
include: {
replyTo: {
select: {
id: true,
messageId: true,
body: true,
fromMe: true,
type: true,
mediaUrl: true,
},
},
},
})
// Inverte para ordem cronológica (mais antiga → mais recente)
if (needsReverse) messages.reverse()
const hasMore = messages.length === limit
return { messages, hasMore }
}
async findById(id: string) {
return prisma.message.findUnique({ where: { id } })
}
async search(tenantId: string, instanceId: string, query: string, limit = 20) {
return prisma.message.findMany({
where: {
tenantId,
instanceId,
body: { contains: query, mode: 'insensitive' },
},
include: {
chat: {
select: {
jid: true,
contact: { select: { name: true, notify: true, avatarUrl: true } },
},
},
},
orderBy: { timestamp: 'desc' },
take: limit,
})
}
async updateStatus(messageId: string, instanceId: string, status: string) {
return prisma.message.updateMany({
where: { messageId, instanceId },
data: { status: status as any },
})
}
async countByChat(chatId: string): Promise<number> {
return prisma.message.count({ where: { chatId } })
}
}
@@ -0,0 +1,160 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { normalizeJid } from '../../shared/utils/whatsapp'
export function buildContactRoutes(): Router {
const router = Router()
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
router.get('/stats/overview', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
const [total, blocked, flagged] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.count({ where: { ...where, isBlocked: true } }),
prisma.contact.count({ where: { ...where, flagRestricao: true } }),
])
res.json({ total, blocked, flagged, active: total - blocked })
})
// ── GET /api/contacts — lista paginada ────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const search = req.query['search'] as string | undefined
const blocked = req.query['blocked'] as string | undefined
const limit = Math.min(Number(req.query['limit'] ?? 50), 200)
const offset = Number(req.query['offset'] ?? 0)
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
if (blocked === 'true') where['isBlocked'] = true
if (blocked === 'false') where['isBlocked'] = false
if (search) {
where['OR'] = [
{ name: { contains: search, mode: 'insensitive' } },
{ verifiedName: { contains: search, mode: 'insensitive' } },
{ notify: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search } },
{ jid: { contains: search } },
]
}
const [total, contacts] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
take: limit,
skip: offset,
select: {
id: true,
instanceId: true,
jid: true,
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
isBlocked: true,
createdAt: true,
updatedAt: true,
},
}),
])
res.json({
total,
contacts: contacts.map(c => ({
...c,
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : normalizeJid(c.jid).split('@')[0])
}))
})
})
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const contact = await prisma.contact.findFirst({
where: { id, tenantId },
include: {
chats: {
orderBy: { lastMessageAt: 'desc' },
take: 1,
select: { id: true, unreadCount: true, lastMessageAt: true },
},
},
})
if (!contact) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
res.json(contact)
})
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(255).optional(),
isBlocked: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try {
body = schema.parse(req.body)
} catch (err: any) {
res.status(400).json({ error: err.errors ?? err.message })
return
}
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
const updated = await prisma.contact.update({ where: { id }, data: body })
res.json(updated)
})
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
// Desvincular chats antes de deletar o contato
await prisma.chat.updateMany({
where: { contactId: id },
data: { contactId: null },
})
await prisma.contact.delete({ where: { id } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,120 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { PairService } from './pair.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new PairService()
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Schemas ──────────────────────────────────────────────────────────────────
const requestSchema = z.object({
email: z.string().email(),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
const confirmSchema = z.object({
email: z.string().email(),
code: z.string().length(6),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
// ─── Router ───────────────────────────────────────────────────────────────────
export const pairRouter = Router()
/**
* POST /api/pair/request
* Inicia pareamento: gera OTP e envia por e-mail.
* Público (chamado pelo sistema cliente).
*/
pairRouter.post('/request', async (req: Request, res: Response) => {
try {
const body = requestSchema.parse(req.body) as any
const result = await service.request({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/pair/confirm
* Confirma o OTP e retorna a integration_key.
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
*/
pairRouter.post('/confirm', async (req: Request, res: Response) => {
try {
const body = confirmSchema.parse(req.body) as any
const result = await service.confirm({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/verify
* Verifica se uma integration_key é válida.
* Público — autenticado pelo header x-nw-key.
*/
pairRouter.get('/verify', async (req: Request, res: Response) => {
try {
const raw = req.headers['x-nw-key']
const key = Array.isArray(raw) ? raw[0] : raw
if (!key) {
res.status(401).json({ error: 'Header x-nw-key ausente' })
return
}
const result = await service.verify(key)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/connections
* Lista pares ativos do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.get('/connections', authMiddleware, async (req: Request, res: Response) => {
try {
const pairs = await service.list(req.tenantId!)
res.json(pairs)
} catch (err) {
handleError(res, err)
}
})
/**
* DELETE /api/pair/connections/:id
* Revoga um par do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.delete('/connections/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const result = await service.revoke(req.tenantId!, String(req.params.id))
res.json(result)
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,265 @@
import crypto from 'crypto'
import nodemailer from 'nodemailer'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
const OTP_TTL = 10 * 60 // 10 minutos
const KEY_TTL = 90 * 24 * 3600 // 90 dias
interface OtpPayload {
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}
interface KeyPayload {
userId: string
email: string
clientSystem: string
clientName: string
}
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
const otpKey = (email: string) => `pair:otp:${email}`
const intKey = (integrationKey: string) => `pair:key:${integrationKey}`
// ─── Email sender ─────────────────────────────────────────────────────────────
async function sendOtpEmail(to: string, code: string, clientName: string): Promise<boolean> {
// Lê config SMTP do admin settings
const raw = await dragonfly.getJson<any>('system:settings')
const smtp = raw?.smtp ?? {}
if (!smtp.host || !smtp.user) {
logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.')
logger.info(`[pair] OTP para ${to}: ${code}`) // fallback: loga localmente
return false
}
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port ?? 587,
secure: smtp.secure ?? false,
auth: { user: smtp.user, pass: smtp.password },
})
await transporter.sendMail({
from: smtp.from ?? smtp.user,
to,
subject: `[NewWhats] Código de pareamento — ${clientName}`,
text: [
`Olá!`,
``,
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
``,
`Seu código de confirmação: ${code}`,
``,
`Válido por 10 minutos.`,
``,
`Se você não solicitou isso, ignore este email.`,
].join('\n'),
html: `
<p>Olá!</p>
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
`,
})
logger.info(`[pair] OTP enviado para ${to}`)
return true
}
// ─── PairService ──────────────────────────────────────────────────────────────
export class PairService {
// Etapa 1 — solicitar pareamento
async request(input: {
email: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user) {
// Resposta genérica — não revela se o email existe ou não
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
// Gera OTP de 6 dígitos
const code = Math.floor(100_000 + Math.random() * 900_000).toString()
const payload: OtpPayload = {
code,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
}
await dragonfly.setJson(otpKey(input.email), payload, OTP_TTL)
const emailSent = await sendOtpEmail(input.email, code, input.clientName)
// Quando SMTP não está configurado, devolve o código na resposta
// para que o admin possa completar o pareamento manualmente.
if (!emailSent) {
return { ok: true, message: 'SMTP não configurado.', dev_code: code }
}
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
// Etapa 2 — confirmar com o código recebido
async confirm(input: {
email: string
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const stored = await dragonfly.getJson<OtpPayload>(otpKey(input.email))
if (!stored) {
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 })
}
if (stored.code !== input.code) {
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 })
}
if (stored.clientSystem !== input.clientSystem) {
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 })
}
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user || !user.isActive) {
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 })
}
// Revoga par anterior do mesmo sistema (evita duplicatas)
await prisma.pluginPair.updateMany({
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
data: { revokedAt: new Date() },
})
// Gera integration_key exclusiva
const integrationKey = `nwk_${crypto.randomUUID().replace(/-/g, '')}`
const pair = await prisma.pluginPair.create({
data: {
userId: user.id,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
integrationKey,
},
})
// Cache rápido para validação sem bater no banco
const keyPayload: KeyPayload = {
userId: user.id,
email: user.email,
clientSystem: input.clientSystem,
clientName: input.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
// Limpa OTP usado
await dragonfly.del(otpKey(input.email))
logger.info(`[pair] ${user.email}${input.clientName} pareados (${pair.id})`)
return {
integration_key: integrationKey,
account: { name: user.name, email: user.email },
paired_at: pair.createdAt,
}
}
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
async verify(integrationKey: string) {
// Fast path — Dragonfly
const cached = await dragonfly.getJson<KeyPayload>(intKey(integrationKey))
if (cached) {
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
prisma.pluginPair.updateMany({
where: { integrationKey, revokedAt: null },
data: { lastSeenAt: new Date() },
}).catch(() => {})
return { valid: true, account: { email: cached.email }, client: cached.clientSystem }
}
// Slow path — banco
const pair = await prisma.pluginPair.findUnique({
where: { integrationKey },
include: { user: { select: { name: true, email: true, isActive: true } } },
})
if (!pair || pair.revokedAt || !pair.user.isActive) {
return { valid: false }
}
// Restaura cache
const keyPayload: KeyPayload = {
userId: pair.userId,
email: pair.user.email,
clientSystem: pair.clientSystem,
clientName: pair.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
await prisma.pluginPair.update({
where: { integrationKey },
data: { lastSeenAt: new Date() },
})
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem }
}
// Lista pares ativos do usuário autenticado
async list(userId: string) {
const pairs = await prisma.pluginPair.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: {
id: true,
clientSystem: true,
clientName: true,
clientUrl: true,
lastSeenAt: true,
createdAt: true,
},
})
return pairs
}
// Revoga um par (usuário autenticado só pode revogar os seus)
async revoke(userId: string, pairId: string) {
const pair = await prisma.pluginPair.findFirst({
where: { id: pairId, userId, revokedAt: null },
})
if (!pair) {
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 })
}
await prisma.pluginPair.update({
where: { id: pairId },
data: { revokedAt: new Date() },
})
// Remove do cache imediatamente
await dragonfly.del(intKey(pair.integrationKey))
logger.info(`[pair] Par ${pairId} revogado por ${userId}`)
return { ok: true }
}
}
@@ -0,0 +1,105 @@
import { Router } from 'express'
import { pluginRegistry } from '../../core/plugin-registry'
import { pluginConfig } from '../../core/plugin-config'
import { storageProvider } from '../../core/StorageProvider'
/**
* Rotas de gerenciamento de plugins — exclusivo admin.
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
*/
export const pluginsRouter = Router()
// GET /api/admin/plugins — lista todos os plugins com status e config atual
pluginsRouter.get('/', (_req, res) => {
const plugins = pluginRegistry.list().map((entry) => ({
name: entry.name,
displayName: entry.manifest.displayName,
version: entry.manifest.version,
description: entry.manifest.description,
author: entry.manifest.author,
category: entry.manifest.category,
active: entry.active,
canDisable: entry.manifest.canDisable,
dependencies: entry.manifest.dependencies,
configSchema: entry.manifest.configSchema ?? [],
config: pluginConfig.get(entry.name),
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
}))
res.json(plugins)
})
// POST /api/admin/plugins/:name/enable — ativa plugin
pluginsRouter.post('/:name/enable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.activate(name)
res.json({ ok: true, message: `Plugin "${name}" ativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
pluginsRouter.post('/:name/disable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.deactivate(name)
res.json({ ok: true, message: `Plugin "${name}" desativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// GET /api/admin/plugins/:name/config — retorna config atual
pluginsRouter.get('/:name/config', (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
res.json({
name,
config: pluginConfig.get(name),
schema: entry.manifest.configSchema ?? [],
})
})
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
pluginsRouter.get('/storage/health', async (_req, res) => {
if (!storageProvider.isRegistered()) {
const cfg = pluginConfig.get('UnifiedStorageProvider')
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket)
return res.json({
healthy: false,
configured,
reason: configured ? 'needs_restart' : 'not_configured',
message: configured
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
})
}
await storageProvider.updateHealth()
return res.json(storageProvider.getLastHealthStatus())
})
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
pluginsRouter.put('/:name/config', async (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
const newConfig = req.body as Record<string, unknown>
await pluginConfig.set(name, newConfig)
// Reativa o plugin para aplicar a nova config imediatamente
if (entry.active) {
try {
await pluginRegistry.reactivate(name)
} catch (err: any) {
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` })
}
}
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig })
})
@@ -0,0 +1,232 @@
/**
* Rotas de Agendamentos de Mensagem
*
* GET /api/scheduled — Lista agendamentos do tenant
* POST /api/scheduled — Cria agendamento
* GET /api/scheduled/:id — Detalhe
* PUT /api/scheduled/:id — Atualiza
* DELETE /api/scheduled/:id — Remove
* POST /api/scheduled/:id/pause — Pausa
* POST /api/scheduled/:id/resume — Retoma
* POST /api/scheduled/:id/run-now — Executa imediatamente
* GET /api/scheduled/:id/logs — Histórico de execuções
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import type { SchedulerService } from './scheduler.service'
const recipientSchema = z.object({
jid: z.string(),
name: z.string().optional(),
birthday: z.string().optional(),
anniversary: z.string().optional(),
signupDate: z.string().optional(),
})
const scheduleSchema = z.object({
instanceId: z.string(),
name: z.string().min(1).max(100),
templateId: z.string().optional(),
payload: z.record(z.unknown()),
recipientMode: z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
recipients: z.array(recipientSchema).default([]),
scheduleType: z.enum(['ONCE', 'RECURRING', 'EVENT']),
cronExpr: z.string().optional(),
scheduledAt: z.string().datetime().optional(),
eventType: z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
timezone: z.string().default('America/Sao_Paulo'),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
function calcNextRun(data: Partial<z.infer<typeof scheduleSchema>>): Date | null {
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
return new Date(data.scheduledAt)
}
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
try {
const job = new Cron(data.cronExpr, { paused: true })
return job.nextRun() ?? null
} catch { return null }
}
return null
}
export function buildScheduledRoutes(_scheduler: SchedulerService): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'] as string | undefined
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] as string | undefined
const schedules = await prisma.scheduledMessage.findMany({
where: {
tenantId,
...(status ? { status: status as any } : {}),
...(type ? { scheduleType: type as any } : {}),
},
include: {
template: { select: { id: true, name: true, type: true } },
_count: { select: { logs: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json(schedules)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = scheduleSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun(data)
const schedule = await prisma.scheduledMessage.create({
data: {
tenantId,
instanceId: data.instanceId,
name: data.name,
templateId: data.templateId,
payload: data.payload as any,
recipientMode: data.recipientMode,
recipients: data.recipients as any,
scheduleType: data.scheduleType,
cronExpr: data.cronExpr,
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
eventType: data.eventType ?? null,
timezone: data.timezone,
nextRunAt,
},
})
res.status(201).json(schedule)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sched = await prisma.scheduledMessage.findFirst({
where: { id: getId(req), tenantId },
include: {
template: true,
logs: { orderBy: { runAt: 'desc' }, take: 5 },
},
})
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
res.json(sched)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const parse = scheduleSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun({ ...exists, ...data } as any)
const { payload, recipients, scheduledAt, eventType, ...rest } = data
const updated = await prisma.scheduledMessage.update({
where: { id },
data: {
...rest,
...(payload !== undefined ? { payload: payload as any } : {}),
...(recipients !== undefined ? { recipients: recipients as any } : {}),
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt as string) } : {}),
...(eventType !== undefined ? { eventType } : {}),
status: 'ACTIVE',
nextRunAt,
},
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.delete({ where: { id } })
res.status(204).send()
})
// ── PAUSE ─────────────────────────────────────────────────────────────────
router.post('/:id/pause', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } })
res.json({ ok: true })
})
// ── RESUME ────────────────────────────────────────────────────────────────
router.post('/:id/resume', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } })
res.json({ ok: true })
})
// ── RUN NOW ───────────────────────────────────────────────────────────────
router.post('/:id/run-now', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const sched = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
await prisma.scheduledMessage.update({
where: { id },
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
})
res.json({ ok: true, message: 'Execução agendada para o próximo tick' })
})
// ── LOGS ──────────────────────────────────────────────────────────────────
router.get('/:id/logs', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const logs = await prisma.scheduledMessageLog.findMany({
where: { scheduleId: id },
orderBy: { runAt: 'desc' },
take: 50,
})
res.json(logs)
})
return router
}
@@ -0,0 +1,227 @@
/**
* SchedulerService — executa agendamentos de mensagens ricos.
*
* Tipos suportados:
* ONCE — executa uma vez em scheduledAt
* RECURRING — cron expression (cronExpr)
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
* verificado diariamente às 08:00 contra dados dos contatos
*
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
* Eventos são checados uma vez por dia via job separado.
*
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
* (sem passar pelo HTTP — evita latência e auth overhead).
*/
import { schedule as cronSchedule } from 'node-cron'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export class SchedulerService {
private manager: WhatsAppConnectionManager
private minuteJob: ReturnType<typeof cronSchedule> | null = null
private eventJob: ReturnType<typeof cronSchedule> | null = null
constructor(manager: WhatsAppConnectionManager) {
this.manager = manager
}
start() {
// Tick a cada minuto — processa ONCE e RECURRING vencidos
this.minuteJob = cronSchedule('* * * * *', () => {
this.processTimeBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
)
})
// Tick diário às 08:00 — processa agendamentos por EVENTO
this.eventJob = cronSchedule('0 8 * * *', () => {
this.processEventBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de evento')
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
}
stop() {
this.minuteJob?.stop()
this.eventJob?.stop()
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
private async processTimeBased() {
const now = new Date()
const schedules = await prisma.scheduledMessage.findMany({
where: {
status: 'ACTIVE',
scheduleType: { in: ['ONCE', 'RECURRING'] },
OR: [
{ nextRunAt: { lte: now } },
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
],
},
})
for (const sched of schedules) {
await this.executeSchedule(sched)
}
}
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
private async processEventBased() {
const today = new Date()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const todayMmDd = `${mm}-${dd}`
const schedules = await prisma.scheduledMessage.findMany({
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
})
for (const sched of schedules) {
const recipients = sched.recipients as Recipient[]
const matched: Recipient[] = []
for (const r of recipients) {
if (!r.jid) continue
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
// birthday format: "YYYY-MM-DD" or "MM-DD"
const parts = r.birthday.split('-')
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
if (bMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
const parts = r.anniversary.split('-')
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
if (aMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
// Dispara N dias após o signup — N fica em cronExpr como "30"
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
const signup = new Date(r.signupDate)
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
if (diffDays === daysAfter) matched.push(r)
}
}
if (matched.length > 0) {
await this.executeSchedule(sched, matched)
}
}
}
// ── Executa um agendamento ─────────────────────────────────────────────────
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
const sock = this.manager.getSocket(sched.instanceId)
if (!sock) {
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
return
}
let sentCount = 0
let failedCount = 0
const errors: string[] = []
for (const recipient of recipients) {
if (!recipient.jid) continue
try {
const payload = this.buildPayload(sched.payload as any, recipient)
await sock.sendMessage(recipient.jid, payload)
sentCount++
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
} catch (err: any) {
failedCount++
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
}
}
// Persiste log
await prisma.scheduledMessageLog.create({
data: {
scheduleId: sched.id,
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
sentCount,
failedCount,
error: errors.length ? errors.join('\n') : null,
},
})
// Calcula próximo nextRunAt ou marca como DONE
if (sched.scheduleType === 'ONCE') {
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
status: 'DONE',
lastRunAt: new Date(),
runCount: { increment: 1 },
nextRunAt: null,
},
})
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
const next = this.nextCronDate(sched.cronExpr)
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
lastRunAt: new Date(),
nextRunAt: next,
runCount: { increment: 1 },
},
})
} else {
// EVENT — apenas registra execução
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
})
}
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
}
// ── Monta payload Baileys com substituição de variáveis ────────────────────
// Variáveis: {{name}}, {{birthday}}, etc.
private buildPayload(raw: any, recipient: Recipient): any {
const replace = (str: string) =>
str
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
const recurse = (node: any): any => {
if (typeof node === 'string') return replace(node)
if (Array.isArray(node)) return node.map(recurse)
if (node && typeof node === 'object') {
const out: any = {}
for (const k of Object.keys(node)) out[k] = recurse(node[k])
return out
}
return node
}
return recurse(raw)
}
// ── Calcula próxima data de um cron ───────────────────────────────────────
private nextCronDate(expr: string): Date | null {
try {
const job = new Cron(expr, { paused: true })
return job.nextRun() ?? null
} catch {
return null
}
}
}
@@ -0,0 +1,136 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
export function buildSectorRoutes(): Router {
const router = Router()
// GET /api/sectors
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectors = await prisma.sector.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { teams: true, protocols: true } } },
})
res.json(sectors)
})
// POST /api/sectors
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
try {
const sector = await prisma.sector.create({ data: { tenantId, name: body.name } })
res.status(201).json(sector)
} catch {
res.status(409).json({ error: 'Setor com este nome já existe.' })
}
})
// PATCH /api/sectors/:id
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
const updated = await prisma.sector.update({ where: { id }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:id
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
await prisma.team.deleteMany({ where: { sectorId: id } })
await prisma.sector.delete({ where: { id } })
res.status(204).send()
})
// GET /api/sectors/:id/teams
router.get('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const teams = await prisma.team.findMany({
where: { sectorId, tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { protocols: true } } },
})
res.json(teams)
})
// POST /api/sectors/:id/teams
router.post('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const sector = await prisma.sector.findFirst({ where: { id: sectorId, tenantId } })
if (!sector) { res.status(404).json({ error: 'Setor não encontrado' }); return }
try {
const team = await prisma.team.create({ data: { tenantId, sectorId, name: body.name } })
res.status(201).json(team)
} catch {
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' })
}
})
// PATCH /api/sectors/:sectorId/teams/:teamId
router.patch('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
const updated = await prisma.team.update({ where: { id: teamId }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:sectorId/teams/:teamId
router.delete('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
await prisma.team.delete({ where: { id: teamId } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,134 @@
/**
* Rotas de Templates de Mensagem
*
* GET /api/templates — Lista templates do tenant
* POST /api/templates — Cria template
* GET /api/templates/:id — Detalhe do template
* PUT /api/templates/:id — Atualiza template
* DELETE /api/templates/:id — Remove template
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'] as const
const templateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
type: z.enum(templateTypeValues),
payload: z.record(z.unknown()),
tags: z.array(z.string()).default([]),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
export function buildTemplateRoutes(): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type']
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search']
const templates = await prisma.messageTemplate.findMany({
where: {
tenantId,
...(type ? { type: type as any } : {}),
...(search ? { name: { contains: search as string, mode: 'insensitive' } } : {}),
},
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
})
res.json(templates)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = templateSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { name, description, type, payload, tags } = parse.data
const template = await prisma.messageTemplate.create({
data: { tenantId, name, description, type, payload: payload as any, tags },
})
res.status(201).json(template)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const template = await prisma.messageTemplate.findFirst({
where: { id: getId(req), tenantId },
})
if (!template) { res.status(404).json({ error: 'Template não encontrado' }); return }
res.json(template)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
const parse = templateSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { payload, ...rest } = parse.data
const updated = await prisma.messageTemplate.update({
where: { id },
data: { ...rest, ...(payload !== undefined ? { payload: payload as any } : {}) },
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.delete({ where: { id } })
res.status(204).send()
})
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
router.post('/:id/use', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.update({
where: { id },
data: { usageCount: { increment: 1 } },
})
res.json({ ok: true })
})
return router
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
import * as official from './official'
import * as infinite from './infinite'
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite'
function resolveEngine(type: string) {
return type === 'infinite' ? infinite : official
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
export function getEngine(engineType?: string | null): {
makeWASocket: any
useMultiFileAuthState: any
fetchLatestBaileysVersion: any
makeCacheableSignalKeyStore: any
downloadMediaMessage: any
getContentType: any
generateMessageIDV2: any
generateWAMessageFromContent: any
isJidGroup: any
isJidStatusBroadcast: any
} {
const e = resolveEngine(engineType ?? defaultEngineType)
return {
makeWASocket: e.makeWASocket as any,
useMultiFileAuthState: e.useMultiFileAuthState as any,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any,
downloadMediaMessage: e.downloadMediaMessage as any,
getContentType: e.getContentType as any,
generateMessageIDV2: e.generateMessageIDV2 as any,
generateWAMessageFromContent: e.generateWAMessageFromContent as any,
isJidGroup: e.isJidGroup as any,
isJidStatusBroadcast: e.isJidStatusBroadcast as any,
}
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType)
export const makeWASocket = selectedEngine.makeWASocket as any
export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any
export const DisconnectReason = selectedEngine.DisconnectReason as any
export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any
export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any
export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any
export const getContentType = selectedEngine.getContentType as any
export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any
export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any
export const isJidGroup = selectedEngine.isJidGroup as any
export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any
export { proto } from './official'
// Exportação estática de tipos para garantir segurança em tempo de compilação
export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official'
@@ -0,0 +1 @@
export * from 'baileys'
@@ -0,0 +1 @@
export * from '@whiskeysockets/baileys'
@@ -0,0 +1,495 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContactHandler = void 0;
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
// forem feitas em curto período. Estas constantes controlam o throttling.
/** Quantos avatares buscar em paralelo por lote */
const AVATAR_BATCH_SIZE = 5;
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900;
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
const MAX_MISSING_AVATAR_SYNC = 80;
class ContactHandler {
constructor(
/** UUID da instância WhatsApp */
instanceId,
/** UUID do tenant (isolamento multi-tenant) */
tenantId,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
sock,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
io) {
this.instanceId = instanceId;
this.tenantId = tenantId;
this.sock = sock;
this.io = io;
/** Fila de JIDs aguardando busca de avatar */
this.avatarQueue = [];
/** Flag de mutex simples — evita processamento concorrente da fila */
this.processingAvatars = false;
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
resolveName(c) {
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não há campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts) {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar = [];
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0;
for (const c of contacts) {
if (!c.id)
continue;
const isLid = c.id.endsWith('@lid');
const isBroadcast = c.id.includes('@broadcast');
if (isBroadcast)
continue;
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = c.imgUrl;
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = c.lid;
try {
// Para contatos @lid que possuem campo `jid` (vem do history sync),
// usa o JID real como chave primária. Caso contrário, usa c.id como está.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const contactJid = isLid && c.jid ? c.jid : c.id;
const contact = await prisma_1.prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
// Salva mapeamento LID:
// Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid
// Se o contato TEM campo lid (history sync): salva lid como lidJid
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
},
});
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
await prisma_1.prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
contactId: null,
},
data: { contactId: contact.id },
});
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA)
if (!isLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++;
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: c.id,
avatarUrl: imgUrl,
});
}
else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(c.id);
}
}
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
}
}
logger_1.logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar;
this.enqueueAvatars(toQueue);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys — diferente do upsert,
* aqui só atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts) {
for (const c of contacts) {
if (!c.id)
continue;
const resolvedName = this.resolveName(c);
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
continue;
try {
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
});
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId) {
if (!this.sock?.user?.id)
return null;
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
if (url) {
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
return url;
}
}
catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages() {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
});
if (unnamed.length === 0) {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
return;
}
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
let updated = 0;
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma_1.prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
});
if (msg?.pushName) {
await prisma_1.prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
});
updated++;
}
}
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
});
}
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" — erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync() {
if (!this.sock)
return;
try {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
}
catch (err) {
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars() {
try {
const missing = await prisma_1.prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
});
if (missing.length === 0)
return;
const jids = missing.map((c) => c.jid);
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
this.enqueueAvatars(jids);
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou já expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars() {
try {
const contacts = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
});
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
if (!m)
return false;
return parseInt(m[1], 16) < threshold;
})
.map(c => c.jid);
if (expiring.length === 0)
return;
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
});
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila já está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids) {
const inQueue = new Set(this.avatarQueue);
for (const jid of jids) {
if (!inQueue.has(jid))
this.avatarQueue.push(jid);
}
this.processAvatarQueue();
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
async processAvatarQueue() {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0)
return;
this.processingAvatars = true;
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock.profilePictureUrl(jid, 'image');
if (url) {
// Persiste no banco
await prisma_1.prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
});
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
});
}
}
catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}));
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
}
}
this.processingAvatars = false;
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
}
}
exports.ContactHandler = ContactHandler;
//# sourceMappingURL=ContactHandler.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,559 @@
/**
* ContactHandler — Gerencia contatos e avatares do WhatsApp.
*
* Responsabilidades:
* 1. Upsert de contatos em massa (evento contacts.upsert do Baileys)
* 2. Update parcial de contatos (evento contacts.update)
* 3. Sincronização de avatares com rate-limit (fila com batch + delay)
* 4. Reconciliação de nomes via pushName das mensagens (pós history sync)
* 5. Mapeamento LID → phone JID (captura do campo `lid` nos contatos)
* 6. Sincronização do avatar da própria instância
*
* CONTEXTO LID:
* O Baileys pode enviar contatos com id no formato @lid (Linked ID).
* Quando o contato inclui o campo `jid` (history sync), usamos o JID real
* e armazenamos o LID em contacts.lidJid para resolução futura.
* @see MessageHandler.resolveLidToPhoneJid()
*
* REGRA DE PRIORIDADE DE NOMES:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
* Essa hierarquia é respeitada tanto no upsert quanto na resolução pelo frontend.
*
* @see WhatsAppConnectionManager — registra os event listeners
* @see MessageHandler — consome os contatos para vincular a mensagens
*/
import type { Contact, WASocket } from '../engine'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import type { Server as SocketIOServer } from 'socket.io'
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
// forem feitas em curto período. Estas constantes controlam o throttling.
/** Quantos avatares buscar em paralelo por lote */
const AVATAR_BATCH_SIZE = 5
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
const MAX_MISSING_AVATAR_SYNC = 80
export class ContactHandler {
/** Fila de JIDs aguardando busca de avatar */
private avatarQueue: string[] = []
/** Flag de mutex simples — evita processamento concorrente da fila */
private processingAvatars = false
constructor(
/** UUID da instância WhatsApp */
private instanceId: string,
/** UUID do tenant (isolamento multi-tenant) */
private tenantId: string,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
private sock?: WASocket,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
private io?: SocketIOServer
) {}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
private resolveName(c: Partial<Contact>): string | undefined {
return c.name ?? c.verifiedName ?? c.notify ?? undefined
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não há campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts: Contact[]): Promise<void> {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar: string[] = []
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0
for (const c of contacts) {
if (!c.id) continue
const isLid = c.id.endsWith('@lid')
const isBroadcast = c.id.includes('@broadcast')
if (isBroadcast) continue
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = (c as any).imgUrl as string | null | undefined
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = (c as any).lid as string | undefined
try {
// Para contatos @lid que possuem o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = (c as any).jid as string | undefined
?? (c as any).phoneNumber as string | undefined
const contactJid = isLid && pnJid ? pnJid : c.id
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined)
const contact = await prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
})
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
})
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid')
if (!resolvedIsLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: contactJid,
avatarUrl: imgUrl,
})
} else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid)
}
}
} catch (err) {
logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato')
}
}
logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API')
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar
this.enqueueAvatars(toQueue)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys — diferente do upsert,
* aqui só atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts: Partial<Contact>[]): Promise<void> {
for (const c of contacts) {
if (!c.id) continue
const resolvedName = this.resolveName(c)
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined) continue
try {
await prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
})
} catch (err) {
logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato')
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId: string): Promise<string | null> {
if (!this.sock?.user?.id) return null
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image')
if (url) {
await prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } })
logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado')
return url
}
} catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages(): Promise<void> {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
})
if (unnamed.length === 0) {
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome')
return
}
logger.info({ instanceId: this.instanceId, count: unnamed.length },
'[ContactHandler] Reconciliação: buscando pushNames nas mensagens')
let updated = 0
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
})
if (msg?.pushName) {
await prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
})
updated++
}
}
logger.info({ instanceId: this.instanceId, total: unnamed.length, updated },
'[ContactHandler] Reconciliação de nomes concluída')
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
})
}
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" — erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync(): Promise<void> {
if (!this.sock) return
try {
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state')
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false)
logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído')
} catch (err) {
logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars(): Promise<void> {
try {
const missing = await prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
})
if (missing.length === 0) return
const jids = missing.map((c) => c.jid)
logger.info({ instanceId: this.instanceId, count: jids.length },
'[ContactHandler] Sync pós-conexão: buscando avatares ausentes')
this.enqueueAvatars(jids)
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes')
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou já expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars(): Promise<void> {
try {
const contacts = await prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
})
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600 // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl!.match(/[?&]oe=([0-9a-f]+)/i)
if (!m) return false
return parseInt(m[1], 16) < threshold
})
.map(c => c.jid)
if (expiring.length === 0) return
logger.info({ instanceId: this.instanceId, count: expiring.length },
'[ContactHandler] Renovando avatares próximos de expirar')
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
})
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC))
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila já está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids: string[]): void {
const inQueue = new Set(this.avatarQueue)
for (const jid of jids) {
if (!inQueue.has(jid)) this.avatarQueue.push(jid)
}
this.processAvatarQueue()
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
private async processAvatarQueue(): Promise<void> {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0) return
this.processingAvatars = true
logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId },
'[ContactHandler] Iniciando sync de avatares')
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE)
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock!.profilePictureUrl(jid, 'image')
if (url) {
// Persiste no banco
await prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
})
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
})
}
} catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}))
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise<void>((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE))
}
}
this.processingAvatars = false
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído')
}
}
@@ -0,0 +1,810 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageHandler = void 0;
/**
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
*
* Responsabilidades:
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
* 3. Criar/atualizar Contact, Chat e Message no banco
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
* 6. Disparar chatbot para mensagens recebidas com texto
*
* CONTEXTO LID (Linked ID):
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
* - key.participantPn: idem, usado em contextos de grupo
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
*
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
* @see ContactHandler — gerencia contatos e avatares
*/
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const baileys_1 = require("@whiskeysockets/baileys");
const client_1 = require("@prisma/client");
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
const whatsapp_1 = require("../../../shared/utils/whatsapp");
const StorageProvider_1 = require("../../../core/StorageProvider");
const hook_bus_1 = require("../../../core/hook-bus");
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path_1.default.resolve('./media');
/**
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
*/
const PROTOCOL_CONTENT_TYPES = new Set([
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
'messageContextInfo', // só metadados, sem conteúdo
'appStateSyncKeyShare', // sync de estado do app (protocolo)
'appStateFatalExceptionNotification', // notificação interna (protocolo)
'keepInChatMessage', // manter no chat (protocolo)
]);
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg) {
let cur = msg;
let depth = 0;
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo');
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k));
if (!wrapperKey)
return cur;
const inner = cur[wrapperKey]?.message;
if (!inner || typeof inner !== 'object')
return cur;
cur = inner;
depth++;
}
return cur;
}
class MessageHandler {
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
sock,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
instanceId,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
tenantId,
/** Socket.IO server — emite eventos em tempo real para o frontend */
io,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
chatbotService) {
this.sock = sock;
this.instanceId = instanceId;
this.tenantId = tenantId;
this.io = io;
this.chatbotService = chatbotService;
/**
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
*
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
*
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
* Baileys NÃO expõe em `decode-wa-message.js`.
*
* Por isso, o WhatsAppConnectionManager registra um listener extra em
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
* consome a dica e remove a entrada (mapa efêmero, não cresce).
*/
this.peerRecipientHints = new Map();
}
/**
* Registra uma dica de destinatário extraída da stanza bruta.
*
* Chamado pelo listener `sock.ws.on('CB:message')` no
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
*/
setPeerRecipientHint(msgId, peerRecipientPn) {
this.peerRecipientHints.set(msgId, peerRecipientPn);
setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).unref();
}
/**
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
*
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
*
* Diferença entre os modos:
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
*/
async handle({ messages, type }) {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg);
}
catch (err) {
logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem');
}
}
}
else if (type === 'append') {
await this.handleHistory(messages);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa mensagens históricas (history sync / append).
*
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
* por grupo, minimizando round-trips ao banco.
*
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
* Se não for possível resolver, a mensagem é descartada silenciosamente
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/
async handleHistory(messages) {
if (messages.length === 0)
return;
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map();
for (const msg of messages) {
let jidRaw = msg.key.remoteJid;
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message)
continue;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw);
if (ownJid && normalizedRaw === ownJid)
continue;
// ── Resolução LID → phone JID para mensagens históricas ──────────
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
if (jidRaw.endsWith('@lid')) {
// Tenta key.senderPn (disponível quando o protocolo WA inclui sender_pn)
const senderPn = msg.key.senderPn || msg.key.participantPn;
if (senderPn) {
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
if (!jidRaw.endsWith('@s.whatsapp.net'))
continue;
}
else {
// Fallback: busca mapeamento LID → phone já salvo no banco
// (populado por phoneNumberShare ou processMessage anterior)
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
});
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid;
}
else {
// Sem resolução possível, ou contato ainda armazenado com JID @lid
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn).
continue;
}
}
}
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
const list = byJid.get(jid) ?? [];
list.push(msg);
byJid.set(jid, list);
}
let totalSaved = 0;
for (const [jid, msgs] of byJid) {
try {
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0);
const lastAt = latestTs ? new Date(latestTs * 1000) : null;
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
},
update: {},
});
// 2. Garantir que o Chat existe e está vinculado ao Contact
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
contactId: contact.id,
lastMessageAt: lastAt,
unreadCount: 0
},
update: {
contactId: contact.id,
...(lastAt && { lastMessageAt: lastAt })
},
});
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
const rows = msgs.flatMap((msg) => {
const { key, messageTimestamp } = msg;
let message = msg.message;
if (!message || !key.id)
return [];
message = unwrapInnerMessage(message);
const contentType = (0, baileys_1.getContentType)(message);
if (!contentType)
return [];
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return [];
const msgType = this.mapContentType(contentType);
if (msgType === client_1.MessageType.UNSUPPORTED)
return [];
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date();
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
null;
const isGroup = jid.endsWith('@g.us');
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid: jid,
messageId: key.id,
fromMe: key.fromMe ?? false,
type: msgType,
body,
// pushName: nome público do remetente (só confiável em msgs recebidas)
pushName: (key.fromMe ? null : msg.pushName) ?? null,
// senderJid: em grupos, identifica quem enviou (JID do participante)
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ', // históricas são consideradas já lidas
timestamp: ts,
}];
});
if (rows.length > 0) {
const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
totalSaved += count;
}
}
catch (err) {
logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
}
}
if (totalSaved > 0) {
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPO REAL — Processa uma única mensagem (notify)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa uma mensagem recebida em tempo real.
*
* Fluxo completo:
* 1. Filtra broadcasts, newsletters
* 2. Resolve LID → telefone (se @lid)
* 3. Cria/atualiza Contact com pushName (se aplicável)
* 4. Cria/atualiza Chat vinculado ao Contact
* 5. Persiste a Message no banco
* 6. Emite socket events (message:new, chat:upsert)
* 7. Agenda download de mídia (assíncrono)
* 8. Dispara chatbot (assíncrono)
*/
async processMessage(msg) {
const { key, 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, baileys_1.getContentType)(message);
if (!contentType)
return;
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return;
const msgType = this.mapContentType(contentType);
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
if (msgType === client_1.MessageType.UNSUPPORTED)
return;
const fromMe = key.fromMe ?? false;
const remoteJidRaw = key.remoteJid;
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
const messageId = key.id;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
if (ownJid && remoteJid === ownJid)
return;
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
// - Para GRUPOS: pushName é do remetente individual, não do grupo
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
const isGroupMessage = remoteJid.endsWith('@g.us');
const participantJid = key.participant ?? msg.participant ?? null;
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
name: safePushName,
notify: safePushName,
phone: isGroupMessage ? null : remoteJid.split('@')[0],
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
...(originalLidJid && { lidJid: originalLidJid }),
},
update: {
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
...(safePushName && { notify: safePushName }),
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
// que ainda não tinham o mapeamento (ex: criados via history sync).
...(originalLidJid && { lidJid: originalLidJid }),
},
});
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
contactId: contact.id,
unreadCount: fromMe ? 0 : 1,
lastMessageAt: new Date(messageTimestamp * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date(messageTimestamp * 1000),
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
},
});
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
// nativeFlowResponseMessage: resposta ao clique de botão/lista
let nativeFlowBody = null;
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
try {
const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}');
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null;
}
catch { /* ignora JSON inválido */ }
}
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
nativeFlowBody ??
null;
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null;
let replyToId = null;
if (replyToWaId) {
const replyMsg = await prisma_1.prisma.message.findUnique({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
});
replyToId = replyMsg?.id ?? null;
}
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
const saved = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid,
messageId,
fromMe,
type: msgType,
body,
pushName: fromMe ? null : (pushName ?? null),
senderJid: isGroupMessage ? participantJid : null,
replyToId,
status: fromMe ? 'SENT' : 'PENDING',
timestamp: new Date(messageTimestamp * 1000),
},
update: {}, // Se já existe (duplicata), não sobrescreve
});
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
// 6a. Para quem está com o chat aberto (sala chat:{id})
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
const emitMsg = replyToId
? await prisma_1.prisma.message.findUnique({
where: { id: saved.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? saved
: saved;
this.io.to(`chat:${chat.id}`).emit('message:new', {
...emitMsg,
pushName,
senderJid: isGroupMessage ? participantJid : null,
});
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
hook_bus_1.hookBus.emit('ext:message.new', {
instanceId: this.instanceId,
tenantId: this.tenantId,
chatId: chat.id,
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
timestamp: Date.now(),
}).catch(() => { });
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
// Necessário para: novos chats, chats não abertos, badge de unread
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp });
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
if (this.isMediaMessage(contentType)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message };
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType));
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
setImmediate(() => this.chatbotService.handleIncoming({
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
sock: this.sock,
}).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono')));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO LID → TELEFONE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
* (ex: "5511999999999@s.whatsapp.net").
*
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
*
* Fontes de resolução (em ordem de prioridade):
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
* Útil para mensagens RECEBIDAS e contextos de grupo.
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
* Única fonte confiável para mensagens fromMe=true enviadas pelo
* celular pra um contato @lid que ainda não temos mapeado.
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
* Populado por: processamento anterior, chats.phoneNumberShare,
* ou contacts.upsert do history sync.
*
* A persistência do mapeamento LID→phone foi movida para processMessage
* (após o upsert do Contact), para evitar o bug anterior onde o
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
*
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
*/
async resolveLidToPhoneJid(lidJid, key) {
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
// descreve o DONO da sessão, não o destinatário — inútil aqui.
const senderPn = key.senderPn || key.participantPn;
if (senderPn && !key.fromMe) {
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
// mensagens fromMe=true que chegam via sync de outro dispositivo.
if (key.fromMe && key.id) {
const hint = this.peerRecipientHints.get(key.id);
this.peerRecipientHints.delete(key.id); // uso único
if (hint) {
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
select: { jid: true },
});
if (contact?.jid)
return contact.jid;
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// EMISSÃO DE EVENTOS SOCKET.IO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Emite evento `chat:upsert` para todos os clientes do tenant.
*
* Usado para atualizar a lista de chats no frontend em tempo real:
* - Novos chats aparecem instantaneamente
* - Chats existentes atualizam lastMessage, unreadCount, etc.
* - Chats @lid são filtrados (não devem aparecer no frontend)
*
* O frontend usa este evento para reordenar a lista por lastMessageAt
* e exibir o preview da última mensagem.
*/
async emitChatUpsert(chatId, lastMessage) {
try {
const chat = await prisma_1.prisma.chat.findUnique({
where: { id: chatId },
include: {
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
},
});
if (!chat)
return;
// Chats @lid são artefatos internos — nunca expor ao frontend
if (chat.jid.endsWith('@lid'))
return;
const c = chat.contact;
const isGroup = chat.jid.endsWith('@g.us');
// Regra de resolução de nome:
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
// NUNCA usar pushName do remetente como nome de grupo
const displayName = isGroup
? (chat.name ?? null)
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: c
? {
name: displayName,
phone: c.phone,
avatarUrl: c.avatarUrl,
scoreReputacao: c.scoreReputacao,
flagRestricao: c.flagRestricao,
}
: isGroup
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage,
});
}
catch (err) {
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD DE MÍDIA
// ═══════════════════════════════════════════════════════════════════════════
/**
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
*
* Executado via setImmediate() para não bloquear o event loop.
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
* e emite `message:media_ready` para o frontend renderizar o preview.
*
* Organização: /media/{instanceId}/{messageId}.{ext}
*/
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
try {
const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
if (!buffer)
return;
// Para documentos: preserva nome e extensão originais do arquivo
const docMsg = msg.message?.documentMessage;
const originalFileName = docMsg?.fileName?.trim() || null;
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType);
let ext;
if (contentType === 'documentMessage' && originalFileName) {
ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
}
else {
ext = this.extensionForType(contentType);
}
const fileName = `${savedMsgId}.${ext}`;
const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`;
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,
});
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
}
catch (err) {
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
isMediaMessage(contentType) {
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType);
}
/**
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
*/
mapContentType(contentType) {
const map = {
conversation: client_1.MessageType.TEXT,
extendedTextMessage: client_1.MessageType.TEXT,
imageMessage: client_1.MessageType.IMAGE,
videoMessage: client_1.MessageType.VIDEO,
audioMessage: client_1.MessageType.AUDIO,
documentMessage: client_1.MessageType.DOCUMENT,
stickerMessage: client_1.MessageType.STICKER,
locationMessage: client_1.MessageType.LOCATION,
contactMessage: client_1.MessageType.CONTACT,
reactionMessage: client_1.MessageType.REACTION,
pollCreationMessage: client_1.MessageType.POLL,
pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: client_1.MessageType.BUTTONS,
};
return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
extensionForType(contentType) {
const map = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
};
return map[contentType] ?? 'bin';
}
/** Retorna o mimetype adequado para o tipo de mídia */
mimetypeForType(contentType) {
const map = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
};
return map[contentType] ?? 'application/octet-stream';
}
}
exports.MessageHandler = MessageHandler;
//# sourceMappingURL=MessageHandler.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,965 @@
/**
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
*
* Responsabilidades:
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
* 3. Criar/atualizar Contact, Chat e Message no banco
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
* 6. Disparar chatbot para mensagens recebidas com texto
*
* CONTEXTO LID (Linked ID):
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
* - key.participantPn: idem, usado em contextos de grupo
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
*
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
* @see ContactHandler — gerencia contatos e avatares
*/
import path from 'path'
import fs from 'fs/promises'
import {
type WASocket,
type WAMessage,
downloadMediaMessage,
getContentType,
} from '../engine'
import { MessageType } from '@prisma/client'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import { normalizeJid } from '../../../shared/utils/whatsapp'
import { storageProvider } from '../../../core/StorageProvider'
import type { Server as SocketIOServer } from 'socket.io'
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
import { hookBus } from '../../../core/hook-bus'
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path.resolve('./media')
/**
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
*/
const PROTOCOL_CONTENT_TYPES = new Set([
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
'messageContextInfo', // só metadados, sem conteúdo
'appStateSyncKeyShare', // sync de estado do app (protocolo)
'appStateFatalExceptionNotification', // notificação interna (protocolo)
'keepInChatMessage', // manter no chat (protocolo)
])
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg: any): any {
let cur = msg
let depth = 0
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo')
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k))
if (!wrapperKey) return cur
const inner = cur[wrapperKey]?.message
if (!inner || typeof inner !== 'object') return cur
cur = inner
depth++
}
return cur
}
export class MessageHandler {
/**
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
*
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
*
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
* Baileys NÃO expõe em `decode-wa-message.js`.
*
* Por isso, o WhatsAppConnectionManager registra um listener extra em
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
* consome a dica e remove a entrada (mapa efêmero, não cresce).
*/
private peerRecipientHints = new Map<string, string>()
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
private sock: WASocket,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
private instanceId: string,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
private tenantId: string,
/** Socket.IO server — emite eventos em tempo real para o frontend */
private io: SocketIOServer,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
private chatbotService?: ChatbotService
) {}
/**
* Registra uma dica de destinatário extraída da stanza bruta.
*
* Chamado pelo listener `sock.ws.on('CB:message')` no
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
*/
setPeerRecipientHint(msgId: string, peerRecipientPn: string): void {
this.peerRecipientHints.set(msgId, peerRecipientPn)
setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref()
}
/**
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
*
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
*
* Diferença entre os modos:
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
*/
async handle({ messages, type }: { messages: WAMessage[]; type: string }): Promise<void> {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg)
} catch (err) {
logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem')
}
}
} else if (type === 'append') {
await this.handleHistory(messages)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa mensagens históricas (history sync / append).
*
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
* por grupo, minimizando round-trips ao banco.
*
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
* Se não for possível resolver, a mensagem é descartada silenciosamente
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/
async handleHistory(messages: WAMessage[]): Promise<void> {
if (messages.length === 0) return
// 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: WAMessage[] = []
const mensagensAntigas: WAMessage[] = []
// 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.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.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
*/
private processOlderHistoryGradually(messages: WAMessage[]): void {
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.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.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)
*/
private async processHistoryBatch(messages: WAMessage[]): Promise<void> {
if (messages.length === 0) return
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = normalizeJid(this.sock.user?.id ?? '')
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map<string, WAMessage[]>()
for (const msg of messages) {
let jidRaw = msg.key.remoteJid
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) continue
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const normalizedRaw = normalizeJid(jidRaw)
if (ownJid && normalizedRaw === ownJid) continue
// ── Resolução LID → phone JID para mensagens históricas ──────────
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = normalizeJid(remoteJidAlt)
} else {
// 2. senderPn / participantPn
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
if (senderPn) {
jidRaw = normalizeJid(senderPn)
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
} else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
const contact = await prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
})
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid
} else {
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
continue
}
}
}
}
const jid = normalizeJid(jidRaw)
const list = byJid.get(jid) ?? []
list.push(msg)
byJid.set(jid, list)
}
let totalSaved = 0
for (const [jid, msgs] of byJid) {
try {
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
const latestTs = msgs.reduce((max, m) =>
Math.max(max, Number(m.messageTimestamp ?? 0)), 0)
const lastAt = latestTs ? new Date(latestTs * 1000) : null
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
const contact = await prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
},
update: {},
})
// 2. Garantir que o Chat existe e está vinculado ao Contact
const chat = await prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
contactId: contact.id,
lastMessageAt: lastAt,
unreadCount: 0
},
update: {
contactId: contact.id,
...(lastAt && { lastMessageAt: lastAt })
},
})
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
const rows = msgs.flatMap((msg) => {
const { key, messageTimestamp } = msg
let message = msg.message
if (!message || !key.id) return []
message = unwrapInnerMessage(message)
const contentType = getContentType(message as any)
if (!contentType) return []
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
const msgType = this.mapContentType(contentType)
if (msgType === MessageType.UNSUPPORTED) return []
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
const body =
message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
null
const isGroup = jid.endsWith('@g.us')
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid: jid,
messageId: key.id,
fromMe: key.fromMe ?? false,
type: msgType,
body,
// pushName: nome público do remetente (só confiável em msgs recebidas)
pushName: (key.fromMe ? null : msg.pushName) ?? null,
// senderJid: em grupos, identifica quem enviou (JID do participante)
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ' as const, // históricas são consideradas já lidas
timestamp: ts,
}]
})
if (rows.length > 0) {
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
totalSaved += count
}
} catch (err) {
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
}
}
if (totalSaved > 0) {
logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId },
`[History] 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)
*/
private async processMessage(msg: WAMessage): Promise<void> {
const { key, messageTimestamp, pushName } = msg
let message = msg.message
if (!message || !key.remoteJid) return
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message)
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
if (key.remoteJid === 'status@broadcast') return
if (key.remoteJid.endsWith('@newsletter')) return
if (key.remoteJid.startsWith('0@')) return
// ── Resolução LID → telefone ──────────────────────────────────────────
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
//
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
//
// Prioridade de resolução:
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
// 2. key.participantPn — idem, usado em contextos de grupo
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
/**
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
* contatos novos — bug silencioso que fazia o mapeamento ser perdido.
*/
let originalLidJid: string | null = null
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe },
'[MessageHandler] @lid sem resolução de telefone — descartando')
return
}
originalLidJid = key.remoteJid
// Reescreve o remoteJid para que TODO o processamento abaixo use o JID real.
// Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net.
;(key as any).remoteJid = phoneJid
}
const contentType = getContentType(message as any)
if (!contentType) return
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return
const msgType = this.mapContentType(contentType)
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
if (msgType === MessageType.UNSUPPORTED) return
const fromMe = key.fromMe ?? false
const remoteJidRaw = key.remoteJid
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = normalizeJid(this.sock.user?.id ?? '')
if (ownJid && remoteJid === ownJid) return
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
// - Para GRUPOS: pushName é do remetente individual, não do grupo
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
const isGroupMessage = remoteJid.endsWith('@g.us')
const participantJid = key.participant ?? (msg as any).participant ?? null
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null
const contact = await prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
name: safePushName,
notify: safePushName,
phone: isGroupMessage ? null : remoteJid.split('@')[0],
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
...(originalLidJid && { lidJid: originalLidJid }),
},
update: {
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
...(safePushName && { notify: safePushName }),
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
// que ainda não tinham o mapeamento (ex: criados via history sync).
...(originalLidJid && { lidJid: originalLidJid }),
},
})
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
const chat = await prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
contactId: contact.id,
unreadCount: fromMe ? 0 : 1,
lastMessageAt: new Date((messageTimestamp as number) * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date((messageTimestamp as number) * 1000),
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
},
})
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
// nativeFlowResponseMessage: resposta ao clique de botão/lista
let nativeFlowBody: string | null = null
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
try {
const params = JSON.parse(
message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}'
)
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null
} catch { /* ignora JSON inválido */ }
}
const body =
message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
nativeFlowBody ??
null
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null
let replyToId: string | null = null
if (replyToWaId) {
const replyMsg = await prisma.message.findUnique({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
})
replyToId = replyMsg?.id ?? null
}
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
const saved = await prisma.message.upsert({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid,
messageId,
fromMe,
type: msgType,
body,
pushName: fromMe ? null : (pushName ?? null),
senderJid: isGroupMessage ? participantJid : null,
replyToId,
status: fromMe ? 'SENT' : 'PENDING',
timestamp: new Date((messageTimestamp as number) * 1000),
},
update: {}, // Se já existe (duplicata), não sobrescreve
})
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
// 6a. Para quem está com o chat aberto (sala chat:{id})
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
const emitMsg = replyToId
? await prisma.message.findUnique({
where: { id: saved.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? saved
: saved
this.io.to(`chat:${chat.id}`).emit('message:new', {
...emitMsg,
pushName,
senderJid: isGroupMessage ? participantJid : null,
})
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
hookBus.emit('ext:message.new', {
instanceId: this.instanceId,
tenantId: this.tenantId,
chatId: chat.id,
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
timestamp: Date.now(),
}).catch(() => {})
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
// Necessário para: novos chats, chats não abertos, badge de unread
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp })
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
if (this.isMediaMessage(contentType)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message }
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType))
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
setImmediate(() =>
this.chatbotService!.handleIncoming({
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
sock: this.sock,
}).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO LID → TELEFONE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
* (ex: "5511999999999@s.whatsapp.net").
*
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
*
* Fontes de resolução (em ordem de prioridade):
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
* Útil para mensagens RECEBIDAS e contextos de grupo.
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
* Única fonte confiável para mensagens fromMe=true enviadas pelo
* celular pra um contato @lid que ainda não temos mapeado.
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
* Populado por: processamento anterior, chats.phoneNumberShare,
* ou contacts.upsert do history sync.
*
* A persistência do mapeamento LID→phone foi movida para processMessage
* (após o upsert do Contact), para evitar o bug anterior onde o
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
*
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
*/
private async resolveLidToPhoneJid(
lidJid: string,
key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string }
): Promise<string | null> {
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
// descreve o DONO da sessão, não o destinatário — inútil aqui.
const senderPn = key.senderPn || key.participantPn
if (senderPn && !key.fromMe) {
const phoneJid = normalizeJid(senderPn)
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid
}
}
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
// mensagens fromMe=true que chegam via sync de outro dispositivo.
if (key.fromMe && key.id) {
const hint = this.peerRecipientHints.get(key.id)
this.peerRecipientHints.delete(key.id) // uso único
if (hint) {
const phoneJid = normalizeJid(hint)
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
select: { jid: true },
})
if (contact?.jid) return contact.jid
return null
}
// ═══════════════════════════════════════════════════════════════════════════
// EMISSÃO DE EVENTOS SOCKET.IO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Emite evento `chat:upsert` para todos os clientes do tenant.
*
* Usado para atualizar a lista de chats no frontend em tempo real:
* - Novos chats aparecem instantaneamente
* - Chats existentes atualizam lastMessage, unreadCount, etc.
* - Chats @lid são filtrados (não devem aparecer no frontend)
*
* O frontend usa este evento para reordenar a lista por lastMessageAt
* e exibir o preview da última mensagem.
*/
private async emitChatUpsert(
chatId: string,
lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date }
): Promise<void> {
try {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
},
})
if (!chat) return
// Chats @lid são artefatos internos — nunca expor ao frontend
if (chat.jid.endsWith('@lid')) return
const c = chat.contact
const isGroup = chat.jid.endsWith('@g.us')
// Regra de resolução de nome:
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
// NUNCA usar pushName do remetente como nome de grupo
const displayName = isGroup
? (chat.name ?? null)
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null)
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: c
? {
name: displayName,
phone: c.phone,
avatarUrl: c.avatarUrl,
scoreReputacao: c.scoreReputacao,
flagRestricao: c.flagRestricao,
}
: isGroup
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage,
})
} catch (err) {
logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD DE MÍDIA
// ═══════════════════════════════════════════════════════════════════════════
/**
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
*
* Executado via setImmediate() para não bloquear o event loop.
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
* e emite `message:media_ready` para o frontend renderizar o preview.
*
* Organização: /media/{instanceId}/{messageId}.{ext}
*/
private async downloadAndPersistMedia(
msg: WAMessage,
savedMsgId: string,
chatId: string,
contentType: string
): Promise<void> {
try {
const buffer = await downloadMediaMessage(
msg as any,
'buffer',
{},
{ logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any }
)
if (!buffer) return
// Para documentos: preserva nome e extensão originais do arquivo
const docMsg = msg.message?.documentMessage
const originalFileName = docMsg?.fileName?.trim() || null
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType)
let ext: string
if (contentType === 'documentMessage' && originalFileName) {
ext = path.extname(originalFileName).replace('.', '') || 'bin'
} else {
ext = this.extensionForType(contentType)
}
const fileName = `${savedMsgId}.${ext}`
const filePath = path.join(MEDIA_DIR, this.instanceId, fileName)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, buffer as Buffer)
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`
let mediaPath: string | null = filePath
let uploadedToRemote = false
if (storageProvider.isRegistered()) {
try {
const result = await storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: this.tenantId,
sessionJID: this.instanceId,
file: {
originalname: originalFileName ?? fileName,
buffer: buffer as Buffer,
mimetype: originalMimetype,
},
})
mediaUrl = result.path
if (result.provider !== 'local_fallback') {
mediaPath = null
uploadedToRemote = true
}
logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage')
} catch (uploadErr) {
logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local')
}
}
await prisma.message.update({
where: { id: savedMsgId },
data: {
mediaPath,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
})
if (uploadedToRemote) {
fs.unlink(filePath).catch((err) => {
logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto')
})
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
})
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
} catch (err) {
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
private isMediaMessage(contentType: string): boolean {
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType)
}
/**
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
*/
private mapContentType(contentType: string): MessageType {
const map: Record<string, MessageType> = {
conversation: MessageType.TEXT,
extendedTextMessage: MessageType.TEXT,
imageMessage: MessageType.IMAGE,
videoMessage: MessageType.VIDEO,
audioMessage: MessageType.AUDIO,
documentMessage: MessageType.DOCUMENT,
stickerMessage: MessageType.STICKER,
locationMessage: MessageType.LOCATION,
contactMessage: MessageType.CONTACT,
reactionMessage: MessageType.REACTION,
pollCreationMessage: MessageType.POLL,
pollCreationMessageV2: MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: MessageType.BUTTONS,
}
return map[contentType] ?? MessageType.UNSUPPORTED
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
private extensionForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
}
return map[contentType] ?? 'bin'
}
/** Retorna o mimetype adequado para o tipo de mídia */
private mimetypeForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
}
return map[contentType] ?? 'application/octet-stream'
}
}
@@ -0,0 +1,888 @@
/**
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
*
* Responsabilidades:
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
* 3. Criar/atualizar Contact, Chat e Message no banco
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
* 6. Disparar chatbot para mensagens recebidas com texto
*
* CONTEXTO LID (Linked ID):
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
* - key.participantPn: idem, usado em contextos de grupo
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
*
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
* @see ContactHandler — gerencia contatos e avatares
*/
import path from 'path'
import fs from 'fs/promises'
import {
type WASocket,
type WAMessage,
downloadMediaMessage,
getContentType,
} from '../engine'
import { MessageType } from '@prisma/client'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import { normalizeJid } from '../../../shared/utils/whatsapp'
import { storageProvider } from '../../../core/StorageProvider'
import type { Server as SocketIOServer } from 'socket.io'
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
import { hookBus } from '../../../core/hook-bus'
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path.resolve('./media')
/**
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
*/
const PROTOCOL_CONTENT_TYPES = new Set([
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
'messageContextInfo', // só metadados, sem conteúdo
'appStateSyncKeyShare', // sync de estado do app (protocolo)
'appStateFatalExceptionNotification', // notificação interna (protocolo)
'keepInChatMessage', // manter no chat (protocolo)
])
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg: any): any {
let cur = msg
let depth = 0
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo')
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k))
if (!wrapperKey) return cur
const inner = cur[wrapperKey]?.message
if (!inner || typeof inner !== 'object') return cur
cur = inner
depth++
}
return cur
}
export class MessageHandler {
/**
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
*
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
*
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
* Baileys NÃO expõe em `decode-wa-message.js`.
*
* Por isso, o WhatsAppConnectionManager registra um listener extra em
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
* consome a dica e remove a entrada (mapa efêmero, não cresce).
*/
private peerRecipientHints = new Map<string, string>()
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
private sock: WASocket,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
private instanceId: string,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
private tenantId: string,
/** Socket.IO server — emite eventos em tempo real para o frontend */
private io: SocketIOServer,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
private chatbotService?: ChatbotService
) {}
/**
* Registra uma dica de destinatário extraída da stanza bruta.
*
* Chamado pelo listener `sock.ws.on('CB:message')` no
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
*/
setPeerRecipientHint(msgId: string, peerRecipientPn: string): void {
this.peerRecipientHints.set(msgId, peerRecipientPn)
setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref()
}
/**
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
*
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
*
* Diferença entre os modos:
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
*/
async handle({ messages, type }: { messages: WAMessage[]; type: string }): Promise<void> {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg)
} catch (err) {
logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem')
}
}
} else if (type === 'append') {
await this.handleHistory(messages)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa mensagens históricas (history sync / append).
*
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
* por grupo, minimizando round-trips ao banco.
*
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
* Se não for possível resolver, a mensagem é descartada silenciosamente
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/
async handleHistory(messages: WAMessage[]): Promise<void> {
if (messages.length === 0) return
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = normalizeJid(this.sock.user?.id ?? '')
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map<string, WAMessage[]>()
for (const msg of messages) {
let jidRaw = msg.key.remoteJid
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) continue
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const normalizedRaw = normalizeJid(jidRaw)
if (ownJid && normalizedRaw === ownJid) continue
// ── Resolução LID → phone JID para mensagens históricas ──────────
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = normalizeJid(remoteJidAlt)
} else {
// 2. senderPn / participantPn
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
if (senderPn) {
jidRaw = normalizeJid(senderPn)
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
} else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
const contact = await prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
})
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid
} else {
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
continue
}
}
}
}
const jid = normalizeJid(jidRaw)
const list = byJid.get(jid) ?? []
list.push(msg)
byJid.set(jid, list)
}
let totalSaved = 0
for (const [jid, msgs] of byJid) {
try {
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
const latestTs = msgs.reduce((max, m) =>
Math.max(max, Number(m.messageTimestamp ?? 0)), 0)
const lastAt = latestTs ? new Date(latestTs * 1000) : null
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
const contact = await prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
},
update: {},
})
// 2. Garantir que o Chat existe e está vinculado ao Contact
const chat = await prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
contactId: contact.id,
lastMessageAt: lastAt,
unreadCount: 0
},
update: {
contactId: contact.id,
...(lastAt && { lastMessageAt: lastAt })
},
})
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
const rows = msgs.flatMap((msg) => {
const { key, messageTimestamp } = msg
let message = msg.message
if (!message || !key.id) return []
message = unwrapInnerMessage(message)
const contentType = getContentType(message as any)
if (!contentType) return []
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
const msgType = this.mapContentType(contentType)
if (msgType === MessageType.UNSUPPORTED) return []
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
const body =
message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
null
const isGroup = jid.endsWith('@g.us')
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid: jid,
messageId: key.id,
fromMe: key.fromMe ?? false,
type: msgType,
body,
// pushName: nome público do remetente (só confiável em msgs recebidas)
pushName: (key.fromMe ? null : msg.pushName) ?? null,
// senderJid: em grupos, identifica quem enviou (JID do participante)
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ' as const, // históricas são consideradas já lidas
timestamp: ts,
}]
})
if (rows.length > 0) {
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
totalSaved += count
}
} catch (err) {
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
}
}
if (totalSaved > 0) {
logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId },
'[History] Mensagens históricas salvas')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPO REAL — Processa uma única mensagem (notify)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa uma mensagem recebida em tempo real.
*
* Fluxo completo:
* 1. Filtra broadcasts, newsletters
* 2. Resolve LID → telefone (se @lid)
* 3. Cria/atualiza Contact com pushName (se aplicável)
* 4. Cria/atualiza Chat vinculado ao Contact
* 5. Persiste a Message no banco
* 6. Emite socket events (message:new, chat:upsert)
* 7. Agenda download de mídia (assíncrono)
* 8. Dispara chatbot (assíncrono)
*/
private async processMessage(msg: WAMessage): Promise<void> {
const { key, messageTimestamp, pushName } = msg
let message = msg.message
if (!message || !key.remoteJid) return
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message)
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
if (key.remoteJid === 'status@broadcast') return
if (key.remoteJid.endsWith('@newsletter')) return
if (key.remoteJid.startsWith('0@')) return
// ── Resolução LID → telefone ──────────────────────────────────────────
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
//
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
//
// Prioridade de resolução:
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
// 2. key.participantPn — idem, usado em contextos de grupo
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
/**
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
* contatos novos — bug silencioso que fazia o mapeamento ser perdido.
*/
let originalLidJid: string | null = null
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe },
'[MessageHandler] @lid sem resolução de telefone — descartando')
return
}
originalLidJid = key.remoteJid
// Reescreve o remoteJid para que TODO o processamento abaixo use o JID real.
// Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net.
;(key as any).remoteJid = phoneJid
}
const contentType = getContentType(message as any)
if (!contentType) return
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return
const msgType = this.mapContentType(contentType)
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
if (msgType === MessageType.UNSUPPORTED) return
const fromMe = key.fromMe ?? false
const remoteJidRaw = key.remoteJid
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = normalizeJid(this.sock.user?.id ?? '')
if (ownJid && remoteJid === ownJid) return
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
// - Para GRUPOS: pushName é do remetente individual, não do grupo
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
const isGroupMessage = remoteJid.endsWith('@g.us')
const participantJid = key.participant ?? (msg as any).participant ?? null
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null
const contact = await prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
name: safePushName,
notify: safePushName,
phone: isGroupMessage ? null : remoteJid.split('@')[0],
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
...(originalLidJid && { lidJid: originalLidJid }),
},
update: {
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
...(safePushName && { notify: safePushName }),
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
// que ainda não tinham o mapeamento (ex: criados via history sync).
...(originalLidJid && { lidJid: originalLidJid }),
},
})
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
const chat = await prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
contactId: contact.id,
unreadCount: fromMe ? 0 : 1,
lastMessageAt: new Date((messageTimestamp as number) * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date((messageTimestamp as number) * 1000),
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
},
})
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
// nativeFlowResponseMessage: resposta ao clique de botão/lista
let nativeFlowBody: string | null = null
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
try {
const params = JSON.parse(
message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}'
)
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null
} catch { /* ignora JSON inválido */ }
}
const body =
message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
nativeFlowBody ??
null
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null
let replyToId: string | null = null
if (replyToWaId) {
const replyMsg = await prisma.message.findUnique({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
})
replyToId = replyMsg?.id ?? null
}
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
const saved = await prisma.message.upsert({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid,
messageId,
fromMe,
type: msgType,
body,
pushName: fromMe ? null : (pushName ?? null),
senderJid: isGroupMessage ? participantJid : null,
replyToId,
status: fromMe ? 'SENT' : 'PENDING',
timestamp: new Date((messageTimestamp as number) * 1000),
},
update: {}, // Se já existe (duplicata), não sobrescreve
})
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
// 6a. Para quem está com o chat aberto (sala chat:{id})
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
const emitMsg = replyToId
? await prisma.message.findUnique({
where: { id: saved.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? saved
: saved
this.io.to(`chat:${chat.id}`).emit('message:new', {
...emitMsg,
pushName,
senderJid: isGroupMessage ? participantJid : null,
})
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
hookBus.emit('ext:message.new', {
instanceId: this.instanceId,
tenantId: this.tenantId,
chatId: chat.id,
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
timestamp: Date.now(),
}).catch(() => {})
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
// Necessário para: novos chats, chats não abertos, badge de unread
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp })
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
if (this.isMediaMessage(contentType)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message }
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType))
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
setImmediate(() =>
this.chatbotService!.handleIncoming({
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
sock: this.sock,
}).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO LID → TELEFONE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
* (ex: "5511999999999@s.whatsapp.net").
*
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
*
* Fontes de resolução (em ordem de prioridade):
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
* Útil para mensagens RECEBIDAS e contextos de grupo.
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
* Única fonte confiável para mensagens fromMe=true enviadas pelo
* celular pra um contato @lid que ainda não temos mapeado.
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
* Populado por: processamento anterior, chats.phoneNumberShare,
* ou contacts.upsert do history sync.
*
* A persistência do mapeamento LID→phone foi movida para processMessage
* (após o upsert do Contact), para evitar o bug anterior onde o
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
*
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
*/
private async resolveLidToPhoneJid(
lidJid: string,
key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string }
): Promise<string | null> {
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
// descreve o DONO da sessão, não o destinatário — inútil aqui.
const senderPn = key.senderPn || key.participantPn
if (senderPn && !key.fromMe) {
const phoneJid = normalizeJid(senderPn)
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid
}
}
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
// mensagens fromMe=true que chegam via sync de outro dispositivo.
if (key.fromMe && key.id) {
const hint = this.peerRecipientHints.get(key.id)
this.peerRecipientHints.delete(key.id) // uso único
if (hint) {
const phoneJid = normalizeJid(hint)
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
select: { jid: true },
})
if (contact?.jid) return contact.jid
return null
}
// ═══════════════════════════════════════════════════════════════════════════
// EMISSÃO DE EVENTOS SOCKET.IO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Emite evento `chat:upsert` para todos os clientes do tenant.
*
* Usado para atualizar a lista de chats no frontend em tempo real:
* - Novos chats aparecem instantaneamente
* - Chats existentes atualizam lastMessage, unreadCount, etc.
* - Chats @lid são filtrados (não devem aparecer no frontend)
*
* O frontend usa este evento para reordenar a lista por lastMessageAt
* e exibir o preview da última mensagem.
*/
private async emitChatUpsert(
chatId: string,
lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date }
): Promise<void> {
try {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
},
})
if (!chat) return
// Chats @lid são artefatos internos — nunca expor ao frontend
if (chat.jid.endsWith('@lid')) return
const c = chat.contact
const isGroup = chat.jid.endsWith('@g.us')
// Regra de resolução de nome:
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
// NUNCA usar pushName do remetente como nome de grupo
const displayName = isGroup
? (chat.name ?? null)
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null)
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: c
? {
name: displayName,
phone: c.phone,
avatarUrl: c.avatarUrl,
scoreReputacao: c.scoreReputacao,
flagRestricao: c.flagRestricao,
}
: isGroup
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage,
})
} catch (err) {
logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD DE MÍDIA
// ═══════════════════════════════════════════════════════════════════════════
/**
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
*
* Executado via setImmediate() para não bloquear o event loop.
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
* e emite `message:media_ready` para o frontend renderizar o preview.
*
* Organização: /media/{instanceId}/{messageId}.{ext}
*/
private async downloadAndPersistMedia(
msg: WAMessage,
savedMsgId: string,
chatId: string,
contentType: string
): Promise<void> {
try {
const buffer = await downloadMediaMessage(
msg as any,
'buffer',
{},
{ logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any }
)
if (!buffer) return
// Para documentos: preserva nome e extensão originais do arquivo
const docMsg = msg.message?.documentMessage
const originalFileName = docMsg?.fileName?.trim() || null
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType)
let ext: string
if (contentType === 'documentMessage' && originalFileName) {
ext = path.extname(originalFileName).replace('.', '') || 'bin'
} else {
ext = this.extensionForType(contentType)
}
const fileName = `${savedMsgId}.${ext}`
const filePath = path.join(MEDIA_DIR, this.instanceId, fileName)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, buffer as Buffer)
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`
let mediaPath: string | null = filePath
let uploadedToRemote = false
if (storageProvider.isRegistered()) {
try {
const result = await storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: this.tenantId,
sessionJID: this.instanceId,
file: {
originalname: originalFileName ?? fileName,
buffer: buffer as Buffer,
mimetype: originalMimetype,
},
})
mediaUrl = result.path
if (result.provider !== 'local_fallback') {
mediaPath = null
uploadedToRemote = true
}
logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage')
} catch (uploadErr) {
logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local')
}
}
await prisma.message.update({
where: { id: savedMsgId },
data: {
mediaPath,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
})
if (uploadedToRemote) {
fs.unlink(filePath).catch((err) => {
logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto')
})
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
})
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
} catch (err) {
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
private isMediaMessage(contentType: string): boolean {
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType)
}
/**
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
*/
private mapContentType(contentType: string): MessageType {
const map: Record<string, MessageType> = {
conversation: MessageType.TEXT,
extendedTextMessage: MessageType.TEXT,
imageMessage: MessageType.IMAGE,
videoMessage: MessageType.VIDEO,
audioMessage: MessageType.AUDIO,
documentMessage: MessageType.DOCUMENT,
stickerMessage: MessageType.STICKER,
locationMessage: MessageType.LOCATION,
contactMessage: MessageType.CONTACT,
reactionMessage: MessageType.REACTION,
pollCreationMessage: MessageType.POLL,
pollCreationMessageV2: MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: MessageType.BUTTONS,
}
return map[contentType] ?? MessageType.UNSUPPORTED
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
private extensionForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
}
return map[contentType] ?? 'bin'
}
/** Retorna o mimetype adequado para o tipo de mídia */
private mimetypeForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
}
return map[contentType] ?? 'application/octet-stream'
}
}
@@ -0,0 +1,29 @@
import { prisma } from '../../../infra/database/prisma'
import type { InstanceStatus } from '@prisma/client'
export class InstanceRepository {
async findAllByTenant(tenantId: string) {
return prisma.instance.findMany({
where: { tenantId },
orderBy: { createdAt: 'asc' },
})
}
async findById(id: string) {
return prisma.instance.findUnique({ where: { id } })
}
async create(tenantId: string, name: string) {
return prisma.instance.create({
data: { tenantId, name },
})
}
async updateStatus(id: string, status: InstanceStatus) {
return prisma.instance.update({ where: { id }, data: { status } })
}
async delete(id: string) {
return prisma.instance.delete({ where: { id } })
}
}
@@ -0,0 +1,411 @@
/**
* 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. Nó 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. Nó <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 nó biz > list também funciona.
*/
import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine'
import { proto } from './engine'
import type { WASocket, BinaryNode } from './engine'
// ─── Tipos de botão (espelho do formato nativo InfiniteAPI) ──────────────────
type NativeBtn =
| { type: 'reply'; id: string; text: string }
| { type: 'url'; text: string; url: string }
| { type: 'copy'; text: string; copyText: string }
| { type: 'call'; text: string; phoneNumber: string }
// ─── Payload rico de entrada (o que o frontend/ext-api envia) ────────────────
export interface RichPayload {
text?: string
footer?: string
nativeButtons?: NativeBtn[]
nativeList?: {
buttonText: string
sections: Array<{
title: string
rows: Array<{ id: string; title: string; description?: string }>
}>
}
nativeCarousel?: {
cards: Array<{
title?: string
body?: string
footer?: string
image?: { url: string }
buttons: Array<{ type?: string; id?: string; text: string }>
}>
}
poll?: {
name: string
values: string[]
selectableCount?: number
}
}
// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ─────
function formatNativeFlowButton(btn: NativeBtn): { name: string; buttonParamsJson: string } {
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: BinaryNode = {
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: BinaryNode = {
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: BinaryNode = {
tag: 'bot',
attrs: { biz_bot: '1' },
}
// ─── Helper: jid é chat privado (não grupo, não status, não bot) ─────────────
function isPrivateChat(jid: string): boolean {
return (
!isJidGroup(jid) &&
!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: WASocket,
jid: string,
protoMsg: any,
msgId: string,
additionalNodes: BinaryNode[],
quoted?: { key: any; message: any },
): Promise<string> {
const fullMsg = generateWAMessageFromContent(jid, protoMsg, {
userJid: sock.user?.id,
messageId: msgId,
quoted,
} as any)
await sock.relayMessage(jid, fullMsg.message as any, {
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: any): any {
return {
interactiveMessage: interactiveMsg,
}
}
/**
* Envia uma mensagem rica pelo socket Baileys padrão.
*
* - Buttons / CTAs: interactiveMessage direto + nó biz>interactive>native_flow + nó bot
* - Carousel: interactiveMessage direto + nó biz>interactive>native_flow
* - List: interactiveMessage direto + nó biz>interactive>native_flow + nó bot
* - Poll: sock.sendMessage (requer encriptação especial do Baileys)
* - Text: sock.sendMessage normal
*
* Retorna o messageId gerado.
*/
export async function sendRichMessage(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: any; message: any },
engineType?: string,
): Promise<string> {
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,
} as any,
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,
} as any,
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,
} as any,
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 = 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: BinaryNode[] = [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: any[] = cards.map((card) => {
const rawButtons = card.buttons ?? []
const cardButtons = rawButtons.length > 0
? rawButtons.map((btn) => formatNativeFlowButton({
type: (btn.type as any) ?? 'reply',
id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'),
text: btn.text,
} as any))
: [{ 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: BinaryNode[] = [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: any = {
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: BinaryNode[] = [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()}`
}
@@ -0,0 +1,131 @@
/**
* 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.
*/
import { logger } from '../../../config/logger'
import type { WhatsAppConnectionManager } from '../connection/WhatsAppConnectionManager'
interface WarmerConfig {
instanceId: string
targets: string[] // JIDs destino (ex: contatos da agenda)
totalDays: number // Duração do aquecimento (ex: 7 dias)
dailyMessageLimit: number // Máximo de msgs por dia (cresce gradualmente)
}
interface WarmerSession {
config: WarmerConfig
dayCount: number
sentToday: number
timer: ReturnType<typeof setTimeout> | null
}
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: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function randomMessage(): string {
return MESSAGES[Math.floor(Math.random() * MESSAGES.length)]
}
export class WhatsAppWarmer {
private sessions = new Map<string, WarmerSession>()
constructor(private manager: WhatsAppConnectionManager) {}
start(config: WarmerConfig): void {
if (this.sessions.has(config.instanceId)) {
logger.warn({ instanceId: config.instanceId }, 'Warmer já em execução para esta instância')
return
}
const session: WarmerSession = {
config,
dayCount: 0,
sentToday: 0,
timer: null,
}
this.sessions.set(config.instanceId, session)
logger.info({ instanceId: config.instanceId, totalDays: config.totalDays }, '🔥 Warmer iniciado')
this.scheduleNext(config.instanceId)
}
stop(instanceId: string): void {
const session = this.sessions.get(instanceId)
if (!session) return
if (session.timer) clearTimeout(session.timer)
this.sessions.delete(instanceId)
logger.info({ instanceId }, 'Warmer encerrado')
}
private scheduleNext(instanceId: string): void {
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.debug({ instanceId, dayCount, sentToday }, 'Limite diário atingido — aguardando próximo dia')
if (dayCount + 1 >= config.totalDays) {
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)
}
private async sendWarmerMessage(instanceId: string): Promise<void> {
const session = this.sessions.get(instanceId)
if (!session) return
const sock = this.manager.getSocket(instanceId)
if (!sock) {
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.debug({ instanceId, target, sentToday: session.sentToday }, 'Warmer: mensagem enviada')
} catch (err) {
logger.error({ err, instanceId, target }, 'Warmer: falha ao enviar mensagem')
}
this.scheduleNext(instanceId)
}
}
@@ -0,0 +1,282 @@
import path from 'path'
import fs from 'fs/promises'
import { Router, type Request, type Response } from 'express'
import multer from 'multer'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import { storageProvider } from '../../core/StorageProvider'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
// ─── Tipos de mídia suportados ────────────────────────────────────────────────
const MIME_MAP: Record<string, 'image' | 'video' | 'audio' | 'document' | 'sticker'> = {
'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 = multer({
storage: multer.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: string,
messageId: string,
buffer: Buffer,
originalName: string
): Promise<string> {
const ext = path.extname(originalName) || '.bin'
const fileName = `${messageId}${ext}`
const filePath = path.resolve('./media', instanceId, 'sent', fileName)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, buffer)
return `/media/${instanceId}/sent/${fileName}`
}
// ─── Router ───────────────────────────────────────────────────────────────────
export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
const router = 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: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const { jid, caption, replyToMessageId } = req.body as {
jid: string
caption?: string
replyToMessageId?: string
}
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.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: Record<string, unknown>
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: any
if (replyToMessageId) {
const reply = await 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 as any, 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.isRegistered()) {
try {
const result = await storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: tenantId,
sessionJID: instanceId,
file: { originalname, buffer, mimetype },
})
mediaUrl = result.path
} catch (uploadErr) {
logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (mídia enviada), usando path local')
}
}
// ── Garante chat + persiste mensagem ─────────────────────────────────
const chat = await 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() as any
const msg = await prisma.message.create({
data: {
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(),
},
})
logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada')
res.status(201).json(msg)
} catch (err: any) {
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: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const { jid } = req.body as { jid: string }
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.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,
} as any)
const messageId = sent?.key.id ?? `local-${Date.now()}`
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname || 'audio.ogg')
let mediaUrl = localUrl
if (storageProvider.isRegistered()) {
try {
const result = await storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: tenantId,
sessionJID: instanceId,
file: { originalname: originalname || 'audio.ogg', buffer, mimetype: mimetype || 'audio/ogg' },
})
mediaUrl = result.path
} catch (uploadErr) {
logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (áudio enviado), usando path local')
}
}
const chat = await 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.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.info({ instanceId, jid }, 'Áudio PTT enviado')
res.status(201).json(msg)
} catch (err: any) {
logger.error({ err }, 'Erro ao enviar áudio')
res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' })
}
}
)
return router
}
@@ -0,0 +1,205 @@
import { Router, type Request, type Response } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { InstanceRepository } from './repositories/InstanceRepository'
import { dragonfly } from '../../infra/cache/dragonfly'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
import { storageProvider } from '../../core/StorageProvider'
const ENGINE_CHANGE_COOLDOWN_MS = 5 * 60 * 1000 // 5 minutos entre trocas
export function buildWhatsAppRoutes(manager: WhatsAppConnectionManager): Router {
const router = Router()
const repo = new InstanceRepository()
// Listar instâncias do tenant
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instances = await repo.findAllByTenant(tenantId)
res.json(instances)
})
// Criar instância
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const { name } = req.body as { name: string }
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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
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: unknown) => {
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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
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.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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
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.resolve('./media', id)
await fs.rm(localMediaDir, { recursive: true, force: true }).catch((err: any) => {
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.deletePrefix(`whatsapp/${tenantId}/${id}/`).catch((err: any) => {
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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
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.message.count({ where: { instanceId: id, fromMe: false } }),
prisma.message.count({ where: { instanceId: id, fromMe: true } }),
prisma.message.count({
where: {
instanceId: id,
type: { in: ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] },
},
}),
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: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const { engine } = req.body as { engine?: string }
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.get(cooldownKey) as string | null
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.instance.update({ where: { id }, data: { engine } })
// Audit log estruturado
logger.info(
{ tenantId, instanceId: id, fromEngine, toEngine: engine },
'[EngineChange] Troca de engine auditada',
)
// Aplica cooldown (5 min via TTL no Redis)
await 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
}
@@ -0,0 +1,792 @@
/**
* 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)
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
import { sendRichMessage } from './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.
*/
export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
const router = 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: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const {
jid,
text,
footer,
replyToMessageId,
mentions,
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
nativeButtons,
nativeList,
nativeCarousel,
poll,
} = req.body as {
jid: string
text?: string
footer?: string
replyToMessageId?: string
mentions?: string[]
nativeButtons?: Array<{ type: string; id?: string; text: string; url?: string; copyText?: string; phoneNumber?: string }>
nativeList?: { buttonText: string; sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }> }
nativeCarousel?: { cards: Array<{ title?: string; body?: string; footer?: string; image?: { url: string }; buttons: Array<{ type?: string; id?: string; text: string }> }> }
poll?: { name: string; values: string[]; selectableCount?: number }
}
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.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.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: any
if (replyToMessageId) {
const reply = await 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: string
if (hasRich) {
sentMessageId = await sendRichMessage(
sock,
resolvedJid,
{ text, footer, nativeButtons, nativeList, nativeCarousel, poll } as any,
quotedMsg,
instance.engine,
)
} else {
// Texto simples (com suporte a @mentions)
const msgContent: any = { 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.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.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' | 'POLL' | 'BUTTONS' | 'LIST' | 'CAROUSEL' | 'INTERACTIVE' = 'TEXT'
let msgBody = text ?? ''
let richPayloadData: Record<string, unknown> | undefined
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: any) => ['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.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.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: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const groupJid = req.params['groupJid'] as string
if (!groupJid?.endsWith('@g.us')) {
res.status(400).json({ error: 'JID de grupo inválido' })
return
}
const instance = await 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.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.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.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 já 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<string, number>()
const RECONCILE_TTL_MS = 30 * 60 * 1000 // 30 min
router.post('/reconcile-names', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const cacheKey = `reconcile:${tenantId}:${instanceId}`
// Camada 1: Redis (30min TTL)
const cached = await 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.$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.set(cacheKey, String(updated), 1800)
reconcileMemCache.set(cacheKey, Date.now())
if (updated > 0) {
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.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.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: Request, res: Response) => {
const schema = z.object({
numbers: z.array(z.string().min(5)).min(1).max(500),
message: z.string().min(1).max(4096),
delayMs: z.number().int().min(500).max(60000).default(2000),
})
let body: z.infer<typeof schema>
try {
body = schema.parse(req.body)
} catch (err: any) {
res.status(400).json({ error: err.errors ?? err.message }); return
}
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const instance = await 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.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 })
// ── Processamento em background ─────────────────────────────────────
;(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.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
})
sent++
} catch (err) {
logger.warn({ jid, err }, '[Broadcast] Falha ao enviar')
await prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
}).catch(() => {})
failed++
}
// Atualiza contadores em tempo real (permite consulta de progresso)
await 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.broadcast.update({
where: { id: broadcast.id },
data: { status: 'DONE', finishedAt: new Date() },
})
})().catch(err => {
logger.error({ err }, '[Broadcast] Erro no job')
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: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
const limit = Math.min(Number(req.query['limit'] ?? 20), 50)
const rows = await 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: Request, res: Response) => {
const tenantId = req.tenantId!
const broadcastId = req.params['broadcastId'] as string
const row = await 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.
*/
export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
const router = 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: string, instanceId: string, jid: string) =>
`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: string) =>
`"${createHash('md5').update(urlOrMarker).digest('hex')}"`
router.get('/avatar/:jid', async (req: Request, res: Response) => {
const jid = req.params['jid'] as string
const instanceId = req.query['instance'] as string | undefined
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
.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.exists(noAvatarKey(tenantId, instanceId, jid))) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end()
logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached')
return
}
} catch {
// Dragonfly indisponível — segue o fluxo normal sem quebrar
}
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
const contact = await prisma.contact.findFirst({
where: { tenantId, instanceId, jid },
select: { avatarUrl: true },
})
if (contact?.avatarUrl) {
// Revalidação condicional: se o browser mandou If-None-Match e bate,
// responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero.
const etag = etagFor(contact.avatarUrl)
if (req.headers['if-none-match'] === etag) {
res.set('ETag', etag)
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.status(304).end()
logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304')
return
}
try {
// Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h)
const upstream = await fetch(contact.avatarUrl)
if (upstream.ok) {
const buffer = Buffer.from(await upstream.arrayBuffer())
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etag)
res.set('Content-Length', String(buffer.length))
res.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
return
}
// URL da DB expirou — tenta via Baileys abaixo
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
} catch {
// URL da DB expirou ou erro de rede — tenta via 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 }
// Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp
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')
res.set('Cache-Control', HIT_CACHE_CONTROL)
res.set('ETag', etagFor(url))
res.set('Content-Length', String(buffer.length))
res.send(buffer)
logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served')
// Salva URL nova no banco para próximas requisições (fire-and-forget).
// Também invalida o negative cache, caso exista: agora temos foto.
await prisma.contact.updateMany({
where: { tenantId, instanceId, jid },
data: { avatarUrl: url },
}).catch(() => {})
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
} catch {
noAvatar()
}
})
return router
}

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