chore: initial setup with PostgreSQL, DragonflyDB, and Nginx routing configs
This commit is contained in:
@@ -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 ~2–3 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:** ~5–10 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** | **~4–5 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
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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,45 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Server
|
||||
# ─────────────────────────────────────────────
|
||||
PORT=8018
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=scoreodonto_jwt_secret_forte_2026
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PostgreSQL (Prisma)
|
||||
# ─────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://scoreodonto_user:clube67_scoreodonto_pass_9903@10.99.0.3:5432/scoreodonto?schema=public"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DragonflyDB (compatível com Redis)
|
||||
# ─────────────────────────────────────────────
|
||||
DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379/1
|
||||
DRAGONFLY_TTL_SECONDS=86400
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# NATS JetStream
|
||||
# ─────────────────────────────────────────────
|
||||
NATS_URL=nats://10.99.0.3:4222
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Temporal.io
|
||||
# ─────────────────────────────────────────────
|
||||
TEMPORAL_ADDRESS=10.99.0.3:7233
|
||||
TEMPORAL_NAMESPACE=default
|
||||
TEMPORAL_TASK_QUEUE=scoreodonto-queue
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# IA (OpenAI + Google Gemini)
|
||||
# ─────────────────────────────────────────────
|
||||
OPENAI_API_KEY=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
|
||||
GEMINI_API_KEY=AIza...
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# WhatsApp / Baileys
|
||||
# ─────────────────────────────────────────────
|
||||
BAILEYS_SESSIONS_PATH=/home/deploy/scoreodonto-sessions
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Frontend (CORS)
|
||||
# ─────────────────────────────────────────────
|
||||
FRONTEND_URL=http://localhost:3013
|
||||
@@ -0,0 +1,45 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Server
|
||||
# ─────────────────────────────────────────────
|
||||
PORT=8008
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=newwhats_jwt_secret_forte_2026
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PostgreSQL (Prisma)
|
||||
# ─────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://newwhats:newwhats123@localhost:5432/newwhats?schema=public"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DragonflyDB (compatível com Redis)
|
||||
# ─────────────────────────────────────────────
|
||||
DRAGONFLY_URL=redis://localhost:6379
|
||||
DRAGONFLY_TTL_SECONDS=86400
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# NATS JetStream
|
||||
# ─────────────────────────────────────────────
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Temporal.io
|
||||
# ─────────────────────────────────────────────
|
||||
TEMPORAL_ADDRESS=localhost:7233
|
||||
TEMPORAL_NAMESPACE=default
|
||||
TEMPORAL_TASK_QUEUE=newwhats-queue
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# IA (OpenAI + Google Gemini)
|
||||
# ─────────────────────────────────────────────
|
||||
OPENAI_API_KEY=sk-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# WhatsApp / Baileys
|
||||
# ─────────────────────────────────────────────
|
||||
BAILEYS_SESSIONS_PATH=/home/deploy/newwhats-sessions
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Frontend (CORS)
|
||||
# ─────────────────────────────────────────────
|
||||
FRONTEND_URL=http://newwhats.local:3003
|
||||
@@ -0,0 +1,46 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# Server
|
||||
# ─────────────────────────────────────────────
|
||||
PORT=8008
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=troque_por_secret_forte_aqui
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PostgreSQL (Prisma)
|
||||
# ─────────────────────────────────────────────
|
||||
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/newwhats?schema=public"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DragonflyDB (compatível com Redis)
|
||||
# ─────────────────────────────────────────────
|
||||
DRAGONFLY_URL=redis://localhost:6379
|
||||
DRAGONFLY_TTL_SECONDS=86400
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# NATS JetStream
|
||||
# ─────────────────────────────────────────────
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Temporal.io
|
||||
# ─────────────────────────────────────────────
|
||||
TEMPORAL_ADDRESS=localhost:7233
|
||||
TEMPORAL_NAMESPACE=default
|
||||
TEMPORAL_TASK_QUEUE=newwhats-queue
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# IA (OpenAI + Google Gemini)
|
||||
# ─────────────────────────────────────────────
|
||||
OPENAI_API_KEY=sk-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# WhatsApp / Baileys
|
||||
# ─────────────────────────────────────────────
|
||||
# Pasta raiz onde as sessões multi-device são armazenadas (por instância)
|
||||
BAILEYS_SESSIONS_PATH=./sessions
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Frontend (CORS)
|
||||
# ─────────────────────────────────────────────
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json baileys-7.0.0-rc.9.tgz ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY package*.json baileys-7.0.0-rc.9.tgz ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
RUN ln -s /app/plugins /plugins && \
|
||||
ln -s /app /app/backend && \
|
||||
ln -s /app/dist /app/src
|
||||
|
||||
EXPOSE 8008
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/server.js"]
|
||||
Binary file not shown.
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = exports.config = void 0;
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
var env_1 = require("./config/env");
|
||||
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
|
||||
var env_2 = require("./config/env");
|
||||
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = void 0;
|
||||
const zod_1 = require("zod");
|
||||
require("dotenv/config");
|
||||
const envSchema = zod_1.z.object({
|
||||
PORT: zod_1.z.string().default('8008'),
|
||||
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: zod_1.z.string().min(16),
|
||||
DATABASE_URL: zod_1.z.string().url(),
|
||||
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
|
||||
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
|
||||
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
|
||||
});
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:');
|
||||
console.error(parsed.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
exports.env = parsed.data;
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logger = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
const env_1 = require("./env");
|
||||
exports.logger = (0, pino_1.default)({
|
||||
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env_1.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
});
|
||||
Vendored
+88
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.storageProvider = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
provider = null;
|
||||
healthy = false;
|
||||
lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
};
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
return this.provider !== null;
|
||||
}
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
getLastHealthStatus() {
|
||||
return this.lastHealthStatus;
|
||||
}
|
||||
setHealthStatus(status) {
|
||||
this.lastHealthStatus = status;
|
||||
this.healthy = status.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
}
|
||||
return this.provider.upload(payload);
|
||||
}
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await this.provider.checkDetailedHealth();
|
||||
this.healthy = status.healthy;
|
||||
this.lastHealthStatus = status;
|
||||
}
|
||||
catch {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
};
|
||||
}
|
||||
}
|
||||
async getBuffer(path) {
|
||||
if (!this.provider)
|
||||
return null;
|
||||
return this.provider.getBuffer(path);
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
if (!this.provider) {
|
||||
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
|
||||
return { deleted: 0 };
|
||||
}
|
||||
return this.provider.deletePrefix(prefix);
|
||||
}
|
||||
unregister() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
logger_1.logger.info('[StorageProvider] Provider removido');
|
||||
}
|
||||
}
|
||||
exports.storageProvider = new StorageProviderRegistry();
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.db = void 0;
|
||||
exports.ensurePluginTables = ensurePluginTables;
|
||||
const knex_1 = __importDefault(require("knex"));
|
||||
const env_1 = require("../config/env");
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Instância Knex compartilhada entre todos os plugins.
|
||||
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
|
||||
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
|
||||
*/
|
||||
function createKnex() {
|
||||
// DATABASE_URL formato: postgresql://user:pass@host:port/db
|
||||
const connection = env_1.env.DATABASE_URL;
|
||||
const instance = (0, knex_1.default)({
|
||||
client: 'pg',
|
||||
connection,
|
||||
pool: { min: 1, max: 5 },
|
||||
acquireConnectionTimeout: 10000,
|
||||
});
|
||||
instance.on('query-error', (err, query) => {
|
||||
logger_1.logger.error({ err, sql: query.sql }, '[Knex] Query error');
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
exports.db = createKnex();
|
||||
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
|
||||
async function ensurePluginTables(knexInstance) {
|
||||
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs');
|
||||
if (!hasJanitorLogs) {
|
||||
await knexInstance.schema.createTable('janitor_logs', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').notNullable();
|
||||
t.string('category').notNullable();
|
||||
t.string('status').notNullable();
|
||||
t.text('message').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela janitor_logs criada');
|
||||
}
|
||||
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage');
|
||||
if (!hasNanobanaUsage) {
|
||||
await knexInstance.schema.createTable('nanobana_usage', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('partner_id').notNullable();
|
||||
t.string('type').notNullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela nanobana_usage criada');
|
||||
}
|
||||
}
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.hookBus = exports.HookBus = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Event bus entre plugins.
|
||||
* emit() executa todos os handlers registrados e retorna array de resultados.
|
||||
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
|
||||
*/
|
||||
class HookBus {
|
||||
handlers = new Map();
|
||||
register(event, handler) {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, []);
|
||||
}
|
||||
this.handlers.get(event).push(handler);
|
||||
logger_1.logger.debug({ event }, '[HookBus] Handler registrado');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async emit(event, data) {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (!handlers || handlers.length === 0)
|
||||
return [];
|
||||
const results = [];
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const result = await handler(data);
|
||||
results.push(result);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, event }, '[HookBus] Erro em handler');
|
||||
results.push(null);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
|
||||
removeAll(event) {
|
||||
this.handlers.delete(event);
|
||||
}
|
||||
/** Lista eventos registrados — útil para debug */
|
||||
listEvents() {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
}
|
||||
exports.HookBus = HookBus;
|
||||
exports.hookBus = new HookBus();
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginConfig = exports.PluginConfigStore = void 0;
|
||||
const dragonfly_1 = require("../infra/cache/dragonfly");
|
||||
const logger_1 = require("../config/logger");
|
||||
const KEY_PREFIX = 'plugin:config:';
|
||||
// Config de plugins não expira — TTL longo (1 ano)
|
||||
const TTL_SECONDS = 365 * 24 * 3600;
|
||||
/**
|
||||
* Config store para plugins, backed por DragonflyDB.
|
||||
* get() usa cache in-memory para evitar round-trips no hot-path.
|
||||
* set() persiste no Dragonfly e invalida o cache local.
|
||||
*/
|
||||
class PluginConfigStore {
|
||||
cache = new Map();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/** Lê config de um plugin. Retorna {} se não houver config salva. */
|
||||
get(pluginName) {
|
||||
return this.cache.get(pluginName) ?? {};
|
||||
}
|
||||
/** Persiste config no Dragonfly e atualiza cache local. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async set(pluginName, config) {
|
||||
this.cache.set(pluginName, config);
|
||||
await dragonfly_1.dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS);
|
||||
logger_1.logger.debug({ pluginName }, '[PluginConfig] Config salva');
|
||||
}
|
||||
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
|
||||
async loadAll(pluginNames) {
|
||||
await Promise.all(pluginNames.map(async (name) => {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(`${KEY_PREFIX}${name}`);
|
||||
if (stored) {
|
||||
this.cache.set(name, stored);
|
||||
logger_1.logger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly');
|
||||
}
|
||||
}));
|
||||
}
|
||||
/** Retorna config de todos os plugins (para a API admin). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllCached() {
|
||||
const result = {};
|
||||
for (const [name, config] of this.cache.entries()) {
|
||||
result[name] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
exports.PluginConfigStore = PluginConfigStore;
|
||||
exports.pluginConfig = new PluginConfigStore();
|
||||
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadPlugins = loadPlugins;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const logger_1 = require("../config/logger");
|
||||
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/plugins'
|
||||
: path_1.default.resolve(__dirname, '../../../plugins');
|
||||
/**
|
||||
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
|
||||
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
|
||||
*/
|
||||
function topoSort(plugins) {
|
||||
const byName = new Map(plugins.map((p) => [p.manifest.name, p]));
|
||||
const inDegree = new Map();
|
||||
const dependents = new Map();
|
||||
for (const p of plugins) {
|
||||
inDegree.set(p.manifest.name, 0);
|
||||
dependents.set(p.manifest.name, []);
|
||||
}
|
||||
for (const p of plugins) {
|
||||
for (const dep of p.manifest.dependencies) {
|
||||
if (!byName.has(dep)) {
|
||||
logger_1.logger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada');
|
||||
continue;
|
||||
}
|
||||
dependents.get(dep).push(p.manifest.name);
|
||||
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const queue = plugins
|
||||
.filter((p) => inDegree.get(p.manifest.name) === 0)
|
||||
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1));
|
||||
const sorted = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
sorted.push(current);
|
||||
for (const depName of dependents.get(current.manifest.name) ?? []) {
|
||||
const newDegree = (inDegree.get(depName) ?? 1) - 1;
|
||||
inDegree.set(depName, newDegree);
|
||||
if (newDegree === 0) {
|
||||
queue.push(byName.get(depName));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sorted.length !== plugins.length) {
|
||||
logger_1.logger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original');
|
||||
return plugins;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
/**
|
||||
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
|
||||
* Retorna plugins ordenados por dependência.
|
||||
*/
|
||||
async function loadPlugins() {
|
||||
if (!fs_1.default.existsSync(PLUGINS_DIR)) {
|
||||
logger_1.logger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado');
|
||||
return [];
|
||||
}
|
||||
const entries = fs_1.default.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
||||
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
const loaded = [];
|
||||
for (const dirName of pluginDirs) {
|
||||
const pluginDir = path_1.default.join(PLUGINS_DIR, dirName);
|
||||
const manifestPath = path_1.default.join(pluginDir, 'manifest.json');
|
||||
const indexTs = path_1.default.join(pluginDir, 'index.ts');
|
||||
const indexJs = path_1.default.join(pluginDir, 'index.js');
|
||||
if (!fs_1.default.existsSync(manifestPath)) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
|
||||
const entryPoint = fs_1.default.existsSync(indexJs) ? indexJs : fs_1.default.existsSync(indexTs) ? indexTs : null;
|
||||
if (!entryPoint) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
|
||||
const mod = await Promise.resolve(`${entryPoint}`).then(s => __importStar(require(s)));
|
||||
const instance = mod.default ?? mod;
|
||||
if (typeof instance.activate !== 'function') {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando');
|
||||
continue;
|
||||
}
|
||||
loaded.push({ instance, manifest, dir: pluginDir });
|
||||
logger_1.logger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin');
|
||||
}
|
||||
}
|
||||
return topoSort(loaded);
|
||||
}
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginRegistry = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Registry central dos plugins.
|
||||
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
|
||||
*/
|
||||
class PluginRegistry {
|
||||
entries = new Map();
|
||||
ctx;
|
||||
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
|
||||
setContext(ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
/** Registra plugins carregados (sem ativar ainda) */
|
||||
register(plugins) {
|
||||
for (const { instance, manifest, dir } of plugins) {
|
||||
this.entries.set(manifest.name, { instance, manifest, active: false, dir });
|
||||
}
|
||||
}
|
||||
/** Ativa todos os plugins marcados como enabled no manifest.
|
||||
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
|
||||
async activateAll() {
|
||||
for (const [name, entry] of this.entries) {
|
||||
if (!entry.manifest.enabled)
|
||||
continue;
|
||||
try {
|
||||
await this.activate(name);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização');
|
||||
}
|
||||
}
|
||||
}
|
||||
async activate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
if (entry.active)
|
||||
return;
|
||||
// Verifica dependências
|
||||
for (const dep of entry.manifest.dependencies) {
|
||||
const depEntry = this.entries.get(dep);
|
||||
if (!depEntry?.active) {
|
||||
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await entry.instance.activate(this.ctx);
|
||||
entry.active = true;
|
||||
entry.manifest.enabled = true;
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin ativado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async deactivate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
if (!entry.active)
|
||||
return;
|
||||
if (!entry.manifest.canDisable)
|
||||
throw new Error(`Plugin "${name}" não pode ser desativado`);
|
||||
// Verifica se algum plugin ativo depende deste
|
||||
for (const [depName, depEntry] of this.entries) {
|
||||
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
|
||||
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await entry.instance.deactivate(this.ctx);
|
||||
entry.active = false;
|
||||
entry.manifest.enabled = false;
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin desativado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
/** Retorna lista de todos os plugins com status para a API admin */
|
||||
list() {
|
||||
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }));
|
||||
}
|
||||
getEntry(name) {
|
||||
return this.entries.get(name);
|
||||
}
|
||||
/**
|
||||
* Força reativação de um plugin sem passar pelo deactivate.
|
||||
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
|
||||
*/
|
||||
async reactivate(name) {
|
||||
const entry = this.entries.get(name);
|
||||
if (!entry)
|
||||
throw new Error(`Plugin "${name}" não encontrado`);
|
||||
entry.active = false;
|
||||
await this.activate(name);
|
||||
logger_1.logger.info({ name }, '[PluginRegistry] Plugin reativado com nova config');
|
||||
}
|
||||
/** Retorna menu items de todos os plugins ativos */
|
||||
getActiveMenuItems() {
|
||||
return Array.from(this.entries.values())
|
||||
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
|
||||
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }));
|
||||
}
|
||||
}
|
||||
exports.pluginRegistry = new PluginRegistry();
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.dragonfly = void 0;
|
||||
const ioredis_1 = __importDefault(require("ioredis"));
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
class DragonflyClient {
|
||||
client;
|
||||
ttl;
|
||||
constructor() {
|
||||
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
|
||||
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
|
||||
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
// Falha imediata quando desconectado — evita fila infinita em memória
|
||||
enableOfflineQueue: false,
|
||||
// Reconexão exponencial: 200ms → 400ms → ... → 30s
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(200 * 2 ** times, 30_000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
|
||||
this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
|
||||
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
|
||||
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
|
||||
}
|
||||
/** true se o cliente está conectado e pronto */
|
||||
isAlive() {
|
||||
return this.client.status === 'ready';
|
||||
}
|
||||
async connect() {
|
||||
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
|
||||
// retryStrategy cuida da reconexão automática
|
||||
this.client.connect().catch(() => { });
|
||||
}
|
||||
async get(key) {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async getJson(key) {
|
||||
try {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async set(key, value, ttlSeconds) {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, value, 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async setJson(key, value, ttlSeconds) {
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async psetex(key, ttlMs, value) {
|
||||
try {
|
||||
await this.client.psetex(key, ttlMs, value);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async del(key) {
|
||||
try {
|
||||
await this.client.del(key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async exists(key) {
|
||||
try {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel, message) {
|
||||
try {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
raw() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
exports.dragonfly = new DragonflyClient();
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.prisma = void 0;
|
||||
const client_1 = require("@prisma/client");
|
||||
exports.prisma = global.__prisma ??
|
||||
new client_1.PrismaClient({
|
||||
log: process.env.NODE_ENV !== 'production'
|
||||
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
|
||||
: [],
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = exports.prisma;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSocketServer = buildSocketServer;
|
||||
const socket_io_1 = require("socket.io");
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const dragonfly_1 = require("../cache/dragonfly");
|
||||
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
|
||||
const PRESENCE_TTL = 70;
|
||||
function buildSocketServer(httpServer) {
|
||||
const io = new socket_io_1.Server(httpServer, {
|
||||
cors: {
|
||||
origin: env_1.env.FRONTEND_URL,
|
||||
credentials: true,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
// Autenticação por token JWT no handshake
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token;
|
||||
if (!token) {
|
||||
next(new Error('Token não fornecido'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_SECRET);
|
||||
socket.data.userId = payload.sub;
|
||||
socket.data.role = payload.role;
|
||||
next();
|
||||
}
|
||||
catch {
|
||||
next(new Error('Token inválido'));
|
||||
}
|
||||
});
|
||||
io.on('connection', (socket) => {
|
||||
const userId = socket.data.userId;
|
||||
logger_1.logger.debug({ userId, socketId: socket.id }, 'Socket conectado');
|
||||
// Cliente entra na sala do seu tenant automaticamente
|
||||
socket.join(`tenant:${userId}`);
|
||||
// Registra presença do usuário no Dragonfly com TTL
|
||||
dragonfly_1.dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => { });
|
||||
// Cliente pode entrar em salas específicas de instância ou chat
|
||||
socket.on('join:instance', (instanceId) => {
|
||||
socket.join(`instance:${instanceId}`);
|
||||
});
|
||||
socket.on('join:chat', (chatId) => {
|
||||
socket.join(`chat:${chatId}`);
|
||||
});
|
||||
socket.on('leave:chat', (chatId) => {
|
||||
socket.leave(`chat:${chatId}`);
|
||||
});
|
||||
// Heartbeat do frontend a cada 30s — renova TTL de presença
|
||||
socket.on('user:heartbeat', () => {
|
||||
dragonfly_1.dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => { });
|
||||
});
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger_1.logger.debug({ userId, reason }, 'Socket desconectado');
|
||||
// Remove presença imediatamente ao desconectar
|
||||
dragonfly_1.dragonfly.del(`presence:user:${userId}`).catch(() => { });
|
||||
});
|
||||
});
|
||||
return io;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.liberarSlotAgenda = liberarSlotAgenda;
|
||||
exports.notificarConflito = notificarConflito;
|
||||
exports.decrementarScore = decrementarScore;
|
||||
exports.bloquearAgendamentosAutomaticos = bloquearAgendamentosAutomaticos;
|
||||
exports.buscarScoreContato = buscarScoreContato;
|
||||
/**
|
||||
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
|
||||
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
|
||||
*/
|
||||
const prisma_1 = require("../database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
|
||||
async function liberarSlotAgenda(input) {
|
||||
logger_1.logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante');
|
||||
// Aqui você envia mensagem via WhatsApp avisando o solicitante
|
||||
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
|
||||
// Por ora, marca no banco como disponível
|
||||
await prisma_1.prisma.protocol.updateMany({
|
||||
where: {
|
||||
contactId: input.solicitanteId,
|
||||
status: 'WAITING_CLIENT',
|
||||
},
|
||||
data: { status: 'OPEN' },
|
||||
});
|
||||
}
|
||||
async function notificarConflito(input) {
|
||||
logger_1.logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot');
|
||||
// Dispara mensagem via NATS → WhatsApp handler
|
||||
}
|
||||
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
|
||||
async function decrementarScore(input) {
|
||||
const contact = await prisma_1.prisma.contact.findUnique({
|
||||
where: { id: input.contactId },
|
||||
});
|
||||
if (!contact)
|
||||
throw new Error(`Contato ${input.contactId} não encontrado`);
|
||||
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos);
|
||||
const restrito = novoScore <= 20;
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: {
|
||||
scoreReputacao: novoScore,
|
||||
flagRestricao: restrito,
|
||||
},
|
||||
});
|
||||
logger_1.logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo }, '[Temporal] Score de reputação atualizado');
|
||||
return { novoScore, restrito };
|
||||
}
|
||||
async function bloquearAgendamentosAutomaticos(input) {
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: input.contactId },
|
||||
data: { flagRestricao: true },
|
||||
});
|
||||
logger_1.logger.warn({ contactId: input.contactId }, '[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados');
|
||||
}
|
||||
async function buscarScoreContato(contactId) {
|
||||
const c = await prisma_1.prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { scoreReputacao: true, flagRestricao: true },
|
||||
});
|
||||
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false };
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getTemporalClient = getTemporalClient;
|
||||
const client_1 = require("@temporalio/client");
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
let _client = null;
|
||||
async function getTemporalClient() {
|
||||
if (_client)
|
||||
return _client;
|
||||
const connection = await client_1.Connection.connect({ address: env_1.env.TEMPORAL_ADDRESS });
|
||||
_client = new client_1.Client({
|
||||
connection,
|
||||
namespace: env_1.env.TEMPORAL_NAMESPACE,
|
||||
});
|
||||
logger_1.logger.info({ address: env_1.env.TEMPORAL_ADDRESS }, 'Temporal Client conectado');
|
||||
return _client;
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
/**
|
||||
* Worker do Temporal — roda como processo separado.
|
||||
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
|
||||
*/
|
||||
const worker_1 = require("@temporalio/worker");
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const activities = __importStar(require("./activities"));
|
||||
async function runWorker() {
|
||||
const worker = await worker_1.Worker.create({
|
||||
workflowsPath: require.resolve('./workflows'),
|
||||
activities,
|
||||
taskQueue: env_1.env.TEMPORAL_TASK_QUEUE,
|
||||
namespace: env_1.env.TEMPORAL_NAMESPACE,
|
||||
});
|
||||
logger_1.logger.info({ taskQueue: env_1.env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado');
|
||||
await worker.run();
|
||||
}
|
||||
runWorker().catch((err) => {
|
||||
logger_1.logger.error(err, 'Falha fatal no Temporal Worker');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.confirmarSlotSignal = void 0;
|
||||
exports.conflitoAgendaWorkflow = conflitoAgendaWorkflow;
|
||||
/**
|
||||
* Workflow: Conflito de Agenda (1 Hora)
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
|
||||
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
|
||||
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
|
||||
* - Se B não responder em 1h → slot é liberado e transferido para A.
|
||||
*/
|
||||
const workflow_1 = require("@temporalio/workflow");
|
||||
const { liberarSlotAgenda, notificarConflito, } = (0, workflow_1.proxyActivities)({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
});
|
||||
// Signal enviado quando B confirma o slot
|
||||
exports.confirmarSlotSignal = (0, workflow_1.defineSignal)('confirmarSlot');
|
||||
async function conflitoAgendaWorkflow(input) {
|
||||
let bConfirmou = false;
|
||||
// Registra o handler do signal de confirmação de B
|
||||
(0, workflow_1.setHandler)(exports.confirmarSlotSignal, ({ confirmado }) => {
|
||||
bConfirmou = confirmado;
|
||||
workflow_1.log.info('Signal recebido de B', { confirmado });
|
||||
});
|
||||
// Notifica B sobre a disputa
|
||||
await notificarConflito({
|
||||
chatIdB: input.chatIdDetentor,
|
||||
solicitanteNome: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
});
|
||||
workflow_1.log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot });
|
||||
// Aguarda signal de B OU timeout de 1 hora
|
||||
const bRespondeu = await (0, workflow_1.condition)(() => bConfirmou !== false, '1 hour');
|
||||
if (bRespondeu && bConfirmou) {
|
||||
workflow_1.log.info('B confirmou o slot — slot mantido com B');
|
||||
return { resultado: 'MANTIDO_B' };
|
||||
}
|
||||
// B não respondeu ou recusou → libera para A
|
||||
workflow_1.log.info('B não confirmou em 1 hora — liberando slot para A');
|
||||
await liberarSlotAgenda({
|
||||
solicitanteId: input.solicitanteId,
|
||||
tituloSlot: input.tituloSlot,
|
||||
chatIdSolicitante: input.chatIdSolicitante,
|
||||
});
|
||||
return { resultado: 'LIBERADO_A' };
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = exports.reputacaoWorkflow = exports.confirmarSlotSignal = exports.conflitoAgendaWorkflow = void 0;
|
||||
var conflitoAgendaWorkflow_1 = require("./conflitoAgendaWorkflow");
|
||||
Object.defineProperty(exports, "conflitoAgendaWorkflow", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.conflitoAgendaWorkflow; } });
|
||||
Object.defineProperty(exports, "confirmarSlotSignal", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.confirmarSlotSignal; } });
|
||||
var reputacaoWorkflow_1 = require("./reputacaoWorkflow");
|
||||
Object.defineProperty(exports, "reputacaoWorkflow", { enumerable: true, get: function () { return reputacaoWorkflow_1.reputacaoWorkflow; } });
|
||||
Object.defineProperty(exports, "registrarInfracaoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.registrarInfracaoSignal; } });
|
||||
Object.defineProperty(exports, "encerrarMonitoramentoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.encerrarMonitoramentoSignal; } });
|
||||
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = void 0;
|
||||
exports.reputacaoWorkflow = reputacaoWorkflow;
|
||||
/**
|
||||
* Workflow: Gestão de Reputação de Contato
|
||||
*
|
||||
* Regra de negócio:
|
||||
* - Cada falta/infração reduz o score_reputacao do contato.
|
||||
* - Score ≤ 20 → contato entra em flag_restricao = true.
|
||||
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
|
||||
* - Score é recuperável manualmente pelo agente (fora deste workflow).
|
||||
*/
|
||||
const workflow_1 = require("@temporalio/workflow");
|
||||
const { decrementarScore, bloquearAgendamentosAutomaticos, buscarScoreContato, } = (0, workflow_1.proxyActivities)({
|
||||
startToCloseTimeout: '30 seconds',
|
||||
retry: { maximumAttempts: 3 },
|
||||
});
|
||||
// Signal para registrar uma nova infração
|
||||
exports.registrarInfracaoSignal = (0, workflow_1.defineSignal)('registrarInfracao');
|
||||
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
|
||||
exports.encerrarMonitoramentoSignal = (0, workflow_1.defineSignal)('encerrarMonitoramento');
|
||||
async function reputacaoWorkflow(input) {
|
||||
let encerrado = false;
|
||||
const infracoesPendentes = [];
|
||||
(0, workflow_1.setHandler)(exports.registrarInfracaoSignal, (payload) => {
|
||||
workflow_1.log.info('Infração registrada via signal', payload);
|
||||
infracoesPendentes.push(payload);
|
||||
});
|
||||
(0, workflow_1.setHandler)(exports.encerrarMonitoramentoSignal, () => {
|
||||
encerrado = true;
|
||||
});
|
||||
// Loop durável — o Temporal mantém o estado mesmo após reinícios
|
||||
while (!encerrado) {
|
||||
// Aguarda próxima infração ou encerramento
|
||||
await (0, workflow_1.condition)(() => infracoesPendentes.length > 0 || encerrado);
|
||||
if (encerrado)
|
||||
break;
|
||||
const infracao = infracoesPendentes.shift();
|
||||
const { novoScore, restrito } = await decrementarScore({
|
||||
contactId: input.contactId,
|
||||
motivo: infracao.motivo,
|
||||
pontos: infracao.pontos,
|
||||
});
|
||||
workflow_1.log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId });
|
||||
if (restrito) {
|
||||
await bloquearAgendamentosAutomaticos({ contactId: input.contactId });
|
||||
workflow_1.log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
|
||||
contactId: input.contactId,
|
||||
scoreAtual: novoScore,
|
||||
});
|
||||
// Continua monitorando (restrição é revertida manualmente pelo agente)
|
||||
}
|
||||
}
|
||||
workflow_1.log.info('Monitoramento de reputação encerrado', { contactId: input.contactId });
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authMiddleware = exports.authenticate = void 0;
|
||||
// Re-export do middleware de autenticação para compatibilidade com plugins
|
||||
var auth_middleware_1 = require("../shared/middlewares/auth.middleware");
|
||||
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } });
|
||||
var auth_middleware_2 = require("../shared/middlewares/auth.middleware");
|
||||
Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } });
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.requireRole = requireRole;
|
||||
// Stub de RBAC — aceita qualquer role válida
|
||||
function requireRole(..._roles) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildApiKeyRoutes = buildApiKeyRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
function buildApiKeyRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/api-keys
|
||||
router.get('/', async (req, res) => {
|
||||
const keys = await prisma_1.prisma.apiKey.findMany({
|
||||
where: { tenantId: req.tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
// Mask key: show only last 8 chars
|
||||
const masked = keys.map((k) => ({
|
||||
...k,
|
||||
keyPreview: `••••••••${k.key.slice(-8)}`,
|
||||
key: undefined,
|
||||
}));
|
||||
res.json(masked);
|
||||
});
|
||||
// POST /api/api-keys — cria nova chave
|
||||
router.post('/', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
expiresAt: zod_1.z.string().datetime().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const key = `nw_${crypto_1.default.randomBytes(24).toString('hex')}`;
|
||||
const created = await prisma_1.prisma.apiKey.create({
|
||||
data: {
|
||||
tenantId: req.tenantId,
|
||||
name: body.name,
|
||||
key,
|
||||
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
|
||||
},
|
||||
});
|
||||
// Retorna a chave completa apenas uma vez
|
||||
res.status(201).json({
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
key: created.key, // plaintext — única vez
|
||||
isActive: created.isActive,
|
||||
createdAt: created.createdAt,
|
||||
expiresAt: created.expiresAt,
|
||||
});
|
||||
});
|
||||
// PATCH /api/api-keys/:id — ativar/desativar
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({ isActive: zod_1.z.boolean() });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Chave não encontrada' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } });
|
||||
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive });
|
||||
});
|
||||
// DELETE /api/api-keys/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Chave não encontrada' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.apiKey.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.adminRouter = void 0;
|
||||
/**
|
||||
* Rotas exclusivas do ADMIN (dono do SaaS).
|
||||
* Prefixo: /api/admin/
|
||||
*/
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const systeminformation_1 = __importDefault(require("systeminformation"));
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const plugins_routes_1 = require("../plugins/plugins.routes");
|
||||
const SETTINGS_KEY = 'system:settings';
|
||||
const DEFAULT_SETTINGS = {
|
||||
systemName: 'NewWhats',
|
||||
timezone: 'America/Sao_Paulo',
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
accentColor: '#3b82f6',
|
||||
sounds: { newMessage: true, notification: true, connectionStatus: true },
|
||||
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
|
||||
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
|
||||
registration: { mode: 'open', defaultPlanId: null },
|
||||
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
|
||||
webhook: { url: '', secret: '' },
|
||||
};
|
||||
async function getSettings() {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(SETTINGS_KEY);
|
||||
return { ...DEFAULT_SETTINGS, ...stored };
|
||||
}
|
||||
const uploadAsset = (0, multer_1.default)({
|
||||
storage: multer_1.default.memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/'))
|
||||
cb(null, true);
|
||||
else
|
||||
cb(new Error('Apenas imagens são permitidas'));
|
||||
},
|
||||
});
|
||||
exports.adminRouter = (0, express_1.Router)();
|
||||
// ── Plugins ───────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.use('/plugins', plugins_routes_1.pluginsRouter);
|
||||
// ── Plans ─────────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/plans', async (_req, res) => {
|
||||
try {
|
||||
const plans = await prisma_1.prisma.plan.findMany({
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
res.json(plans);
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao listar planos' });
|
||||
}
|
||||
});
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/settings', async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
// Nunca expõe a senha SMTP ao frontend
|
||||
const { smtp, ...rest } = settings;
|
||||
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao buscar configurações' });
|
||||
}
|
||||
});
|
||||
exports.adminRouter.patch('/settings', async (req, res) => {
|
||||
try {
|
||||
const current = await getSettings();
|
||||
const body = req.body;
|
||||
// Se o frontend devolver o placeholder de senha, mantém a senha atual
|
||||
if (body.smtp?.password === '••••••••') {
|
||||
body.smtp = { ...body.smtp, password: current.smtp.password };
|
||||
}
|
||||
const updated = { ...current, ...body };
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, updated, 0); // TTL 0 = sem expiração
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar configurações' });
|
||||
}
|
||||
});
|
||||
// Upload de logo
|
||||
exports.adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'png';
|
||||
const dest = path_1.default.resolve('./media/system/logo.' + ext);
|
||||
await promises_1.default.writeFile(dest, req.file.buffer);
|
||||
const url = `/media/system/logo.${ext}`;
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0);
|
||||
res.json({ url });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar logo' });
|
||||
}
|
||||
});
|
||||
// Upload de favicon
|
||||
exports.adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
const ext = req.file.originalname.split('.').pop() ?? 'ico';
|
||||
const dest = path_1.default.resolve('./media/system/favicon.' + ext);
|
||||
await promises_1.default.writeFile(dest, req.file.buffer);
|
||||
const url = `/media/system/favicon.${ext}`;
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0);
|
||||
res.json({ url });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao salvar favicon' });
|
||||
}
|
||||
});
|
||||
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
|
||||
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
|
||||
exports.adminRouter.get('/settings/public', async (_req, res) => {
|
||||
try {
|
||||
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings();
|
||||
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro' });
|
||||
}
|
||||
});
|
||||
// ── Métricas do sistema ───────────────────────────────────────────────────────
|
||||
exports.adminRouter.get('/metrics', async (_req, res) => {
|
||||
try {
|
||||
const [cpuLoad, mem, disk] = await Promise.all([
|
||||
systeminformation_1.default.currentLoad(),
|
||||
systeminformation_1.default.mem(),
|
||||
systeminformation_1.default.fsSize(),
|
||||
]);
|
||||
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0];
|
||||
const [connectedInstances, totalUsers] = await Promise.all([
|
||||
prisma_1.prisma.instance.count({ where: { status: 'CONNECTED' } }),
|
||||
prisma_1.prisma.user.count(),
|
||||
]);
|
||||
res.json({
|
||||
cpu: { percent: Math.round(cpuLoad.currentLoad) },
|
||||
ram: {
|
||||
total: mem.total,
|
||||
used: mem.used,
|
||||
percent: Math.round((mem.used / mem.total) * 100),
|
||||
},
|
||||
disk: {
|
||||
total: rootDisk?.size ?? 0,
|
||||
used: rootDisk?.used ?? 0,
|
||||
percent: Math.round(rootDisk?.use ?? 0),
|
||||
},
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connectedInstances,
|
||||
totalUsers,
|
||||
});
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro ao coletar métricas' });
|
||||
}
|
||||
});
|
||||
// Listar todos os tenants com presença online
|
||||
exports.adminRouter.get('/users', async (_req, res) => {
|
||||
try {
|
||||
const [users, presenceKeys] = await Promise.all([
|
||||
prisma_1.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { instances: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
dragonfly_1.dragonfly.raw().keys('presence:user:*'),
|
||||
]);
|
||||
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')));
|
||||
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })));
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar usuários' });
|
||||
}
|
||||
});
|
||||
// Detalhes de um tenant
|
||||
exports.adminRouter.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const user = await prisma_1.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
instances: { select: { id: true, name: true, status: true } },
|
||||
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar usuário' });
|
||||
}
|
||||
});
|
||||
// Ativar / desativar tenant
|
||||
exports.adminRouter.patch('/users/:id/status', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { isActive } = zod_1.z.object({ isActive: zod_1.z.boolean() }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
select: { id: true, name: true, email: true, isActive: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'isActive deve ser boolean' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||
}
|
||||
});
|
||||
// Alterar plano do tenant
|
||||
exports.adminRouter.patch('/users/:id/plan', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { planId } = zod_1.z.object({ planId: zod_1.z.string().uuid() }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: { planId },
|
||||
select: { id: true, name: true, planId: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'planId inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao atualizar plano' });
|
||||
}
|
||||
});
|
||||
// Estender trial de um tenant
|
||||
exports.adminRouter.patch('/users/:id/trial', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { days } = zod_1.z.object({ days: zod_1.z.number().int().min(1).max(365) }).parse(req.body);
|
||||
const user = await prisma_1.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
select: { id: true, name: true, trialEndsAt: true },
|
||||
});
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'days inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao estender trial' });
|
||||
}
|
||||
});
|
||||
// Resetar senha de um tenant (admin)
|
||||
exports.adminRouter.patch('/users/:id/reset-password', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { newPassword } = zod_1.z
|
||||
.object({ newPassword: zod_1.z.string().min(8).max(128) })
|
||||
.parse(req.body);
|
||||
const passwordHash = await bcryptjs_1.default.hash(newPassword, 12);
|
||||
await prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
|
||||
res.json({ message: 'Senha redefinida com sucesso' });
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'newPassword inválido' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao redefinir senha' });
|
||||
}
|
||||
});
|
||||
// Obter engine ativa
|
||||
exports.adminRouter.get('/engine', async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const storedEngine = settings.baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
res.json({ engine: storedEngine });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Erro ao obter engine:', err);
|
||||
res.status(500).json({ error: 'Erro ao obter a engine ativa' });
|
||||
}
|
||||
});
|
||||
// Altera a engine e reinicia o backend (via PM2 ou Docker)
|
||||
exports.adminRouter.post('/engine', async (req, res) => {
|
||||
try {
|
||||
const { engine } = zod_1.z.object({ engine: zod_1.z.enum(['infinite', 'official']) }).parse(req.body);
|
||||
// 1. Persiste nas configurações globais
|
||||
const settings = await getSettings();
|
||||
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0);
|
||||
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
|
||||
const envPath = path_1.default.resolve('./.env');
|
||||
try {
|
||||
let content = await promises_1.default.readFile(envPath, 'utf-8');
|
||||
if (content.includes('BAILEYS_ENGINE=')) {
|
||||
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`);
|
||||
}
|
||||
else {
|
||||
content += `\nBAILEYS_ENGINE=${engine}\n`;
|
||||
}
|
||||
await promises_1.default.writeFile(envPath, content, 'utf-8');
|
||||
}
|
||||
catch (envErr) {
|
||||
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr);
|
||||
}
|
||||
// 3. Responde ao frontend antes de desligar/reiniciar
|
||||
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' });
|
||||
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
|
||||
setTimeout(() => {
|
||||
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`);
|
||||
const { exec } = require('child_process');
|
||||
exec('pm2 restart newwhats-backend', (err) => {
|
||||
if (err) {
|
||||
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...');
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Erro ao alternar engine:', err);
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Erro ao salvar a engine' });
|
||||
}
|
||||
});
|
||||
// ── Rota para Disparo Manual do Deploy ──
|
||||
exports.adminRouter.post('/deploys/trigger', async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trigger: 'admin_panel' })
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Status ${response.status}`);
|
||||
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message);
|
||||
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' });
|
||||
}
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class AuthRepository {
|
||||
async findByEmail(email) {
|
||||
return prisma_1.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async create(data) {
|
||||
return prisma_1.prisma.user.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
passwordHash: data.passwordHash,
|
||||
role: data.role ?? 'USER',
|
||||
planId: data.planId,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
planId: true,
|
||||
trialEndsAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async updatePassword(id, passwordHash) {
|
||||
return prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
|
||||
}
|
||||
async setActive(id, isActive) {
|
||||
return prisma_1.prisma.user.update({ where: { id }, data: { isActive } });
|
||||
}
|
||||
}
|
||||
exports.AuthRepository = AuthRepository;
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const auth_service_1 = require("./auth.service");
|
||||
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
|
||||
const service = new auth_service_1.AuthService();
|
||||
// ─── Schemas de validação ─────────────────────────────────────────────────────
|
||||
const registerSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(2).max(100),
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(8).max(128),
|
||||
planId: zod_1.z.string().uuid().optional(),
|
||||
});
|
||||
const loginSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
password: zod_1.z.string().min(1),
|
||||
});
|
||||
const refreshSchema = zod_1.z.object({
|
||||
refreshToken: zod_1.z.string().min(1),
|
||||
});
|
||||
const changePasswordSchema = zod_1.z.object({
|
||||
currentPassword: zod_1.z.string().min(1),
|
||||
newPassword: zod_1.z.string().min(8).max(128),
|
||||
});
|
||||
// ─── Helper para erros de validação / serviço ─────────────────────────────────
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const e = err;
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
|
||||
}
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
exports.authRouter = (0, express_1.Router)();
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Cria novo tenant (usuário com role USER).
|
||||
* Retorna access + refresh token e dados do usuário.
|
||||
*/
|
||||
exports.authRouter.post('/register', async (req, res) => {
|
||||
try {
|
||||
const body = registerSchema.parse(req.body);
|
||||
const result = await service.register(body);
|
||||
res.status(201).json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Autentica usuário. Retorna access + refresh token.
|
||||
*/
|
||||
exports.authRouter.post('/login', async (req, res) => {
|
||||
try {
|
||||
const body = loginSchema.parse(req.body);
|
||||
const result = await service.login(body);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Renova o access token usando o refresh token.
|
||||
*/
|
||||
exports.authRouter.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = refreshSchema.parse(req.body);
|
||||
const result = await service.refresh(refreshToken);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Revoga o refresh token do usuário autenticado.
|
||||
*/
|
||||
exports.authRouter.post('/logout', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.logout(req.tenantId);
|
||||
res.json({ message: 'Sessão encerrada' });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Retorna dados do usuário autenticado + dias restantes do trial.
|
||||
*/
|
||||
exports.authRouter.get('/me', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await service.me(req.tenantId);
|
||||
res.json(user);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* PATCH /api/auth/password
|
||||
* Altera a senha do usuário autenticado.
|
||||
*/
|
||||
exports.authRouter.patch('/password', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const body = changePasswordSchema.parse(req.body);
|
||||
await service.changePassword(req.tenantId, body);
|
||||
res.json({ message: 'Senha alterada com sucesso' });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AuthService = void 0;
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const env_1 = require("../../config/env");
|
||||
const auth_repository_1 = require("./auth.repository");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
const ACCESS_EXPIRES = '8h';
|
||||
const REFRESH_EXPIRES = '30d';
|
||||
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
class AuthService {
|
||||
repo = new auth_repository_1.AuthRepository();
|
||||
// ─── Register ─────────────────────────────────────────────────────────────
|
||||
async register(input) {
|
||||
const existing = await this.repo.findByEmail(input.email);
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 });
|
||||
}
|
||||
const passwordHash = await bcryptjs_1.default.hash(input.password, BCRYPT_ROUNDS);
|
||||
const user = await this.repo.create({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
planId: input.planId,
|
||||
});
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
return { user, ...tokens };
|
||||
}
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
async login(input) {
|
||||
const user = await this.repo.findByEmail(input.email);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 });
|
||||
}
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 });
|
||||
}
|
||||
const valid = await bcryptjs_1.default.compare(input.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 });
|
||||
}
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
return { user: safeUser, ...tokens };
|
||||
}
|
||||
// ─── Refresh ──────────────────────────────────────────────────────────────
|
||||
async refresh(refreshToken) {
|
||||
let payload;
|
||||
try {
|
||||
payload = jsonwebtoken_1.default.verify(refreshToken, env_1.env.JWT_SECRET);
|
||||
}
|
||||
catch {
|
||||
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 });
|
||||
}
|
||||
if (payload.type !== 'refresh') {
|
||||
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 });
|
||||
}
|
||||
// Valida que o token ainda está no cache (não foi revogado)
|
||||
const stored = await dragonfly_1.dragonfly.get(this.refreshKey(payload.sub));
|
||||
if (stored !== refreshToken) {
|
||||
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 });
|
||||
}
|
||||
const user = await this.repo.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 });
|
||||
}
|
||||
const tokens = this.issueTokens(user.id, user.role);
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
return { user, ...tokens };
|
||||
}
|
||||
// ─── Logout ───────────────────────────────────────────────────────────────
|
||||
async logout(userId) {
|
||||
await dragonfly_1.dragonfly.del(this.refreshKey(userId));
|
||||
}
|
||||
// ─── Me ───────────────────────────────────────────────────────────────────
|
||||
async me(userId) {
|
||||
const user = await this.repo.findById(userId);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 });
|
||||
}
|
||||
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null;
|
||||
const daysRemaining = trialEndsAt
|
||||
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000))
|
||||
: null;
|
||||
return { ...user, daysRemaining };
|
||||
}
|
||||
// ─── Change Password ──────────────────────────────────────────────────────
|
||||
async changePassword(userId, input) {
|
||||
const user = await this.repo.findByEmail((await this.repo.findById(userId))?.email ?? '');
|
||||
if (!user)
|
||||
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 });
|
||||
const valid = await bcryptjs_1.default.compare(input.currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 });
|
||||
}
|
||||
const newHash = await bcryptjs_1.default.hash(input.newPassword, BCRYPT_ROUNDS);
|
||||
await this.repo.updatePassword(userId, newHash);
|
||||
await this.logout(userId); // invalida todas as sessões
|
||||
}
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
issueTokens(userId, role) {
|
||||
const base = { sub: userId, role };
|
||||
const accessToken = jsonwebtoken_1.default.sign({ ...base, type: 'access' }, env_1.env.JWT_SECRET, { expiresIn: ACCESS_EXPIRES });
|
||||
const refreshToken = jsonwebtoken_1.default.sign({ ...base, type: 'refresh' }, env_1.env.JWT_SECRET, { expiresIn: REFRESH_EXPIRES });
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
async saveRefreshToken(userId, token) {
|
||||
await dragonfly_1.dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS);
|
||||
}
|
||||
refreshKey(userId) {
|
||||
return `auth:refresh:${userId}`;
|
||||
}
|
||||
}
|
||||
exports.AuthService = AuthService;
|
||||
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
|
||||
/**
|
||||
* chatbot.repository.ts — CRUD para AICredential e AIBot.
|
||||
*/
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||
exports.credentialRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aICredential.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
provider: data.provider ?? 'GEMINI',
|
||||
apiKey: data.apiKey,
|
||||
},
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
}),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Bots ─────────────────────────────────────────────────────────────────────
|
||||
exports.botRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
include: { credential: { select: { id: true, name: true, provider: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { tenantId, instanceId, enabled: true },
|
||||
include: { credential: true },
|
||||
}),
|
||||
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { credential: true },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aIBot.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
credentialId: data.credentialId,
|
||||
name: data.name,
|
||||
systemPrompt: data.systemPrompt,
|
||||
model: data.model ?? 'gemini-1.5-flash',
|
||||
enabled: data.enabled ?? false,
|
||||
triggerMode: data.triggerMode ?? 'ALL',
|
||||
keywords: data.keywords ?? [],
|
||||
},
|
||||
}),
|
||||
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Chat bot state ───────────────────────────────────────────────────────────
|
||||
exports.chatBotState = {
|
||||
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
|
||||
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||
getState: (chatId) => prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { botPaused: true, botSummary: true },
|
||||
}),
|
||||
};
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildChatbotRoutes = buildChatbotRoutes;
|
||||
/**
|
||||
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
|
||||
*
|
||||
* Montado em /api/chatbot (ver server.ts)
|
||||
* Todos os endpoints requerem authMiddleware.
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const chatbot_repository_1 = require("./chatbot.repository");
|
||||
const logger_1 = require("../../config/logger");
|
||||
function buildChatbotRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
const validateInstance = async (instanceId, tenantId) => {
|
||||
return prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
};
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// CREDENTIALS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/chatbot/credentials/:instanceId
|
||||
router.get('/credentials/:instanceId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const creds = await chatbot_repository_1.credentialRepo.findAll(tenantId, instanceId);
|
||||
res.json(creds);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao listar credenciais');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/credentials/:instanceId
|
||||
router.post('/credentials/:instanceId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
apiKey: zod_1.z.string().min(10),
|
||||
provider: zod_1.z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const body = schema.parse(req.body);
|
||||
const cred = await chatbot_repository_1.credentialRepo.create({ tenantId, instanceId, ...body });
|
||||
res.status(201).json(cred);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao criar credencial');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// DELETE /api/chatbot/credentials/:instanceId/:credId
|
||||
router.delete('/credentials/:instanceId/:credId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const credId = req.params['credId'];
|
||||
await chatbot_repository_1.credentialRepo.delete(credId, tenantId);
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao deletar credencial');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOTS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/chatbot/bots/:instanceId
|
||||
router.get('/bots/:instanceId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const bots = await chatbot_repository_1.botRepo.findAll(tenantId, instanceId);
|
||||
res.json(bots);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao listar bots');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/bots/:instanceId
|
||||
router.post('/bots/:instanceId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
credentialId: zod_1.z.string().uuid(),
|
||||
name: zod_1.z.string().min(1).max(80),
|
||||
systemPrompt: zod_1.z.string().min(10),
|
||||
model: zod_1.z.string().default('gemini-1.5-flash'),
|
||||
enabled: zod_1.z.boolean().default(false),
|
||||
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).default('ALL'),
|
||||
keywords: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
if (!await validateInstance(instanceId, tenantId)) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const body = schema.parse(req.body);
|
||||
const existing = await chatbot_repository_1.botRepo.findAll(tenantId, instanceId);
|
||||
if (existing.length > 0) {
|
||||
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' });
|
||||
return;
|
||||
}
|
||||
const bot = await chatbot_repository_1.botRepo.create({ tenantId, instanceId, ...body });
|
||||
res.status(201).json(bot);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao criar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// PUT /api/chatbot/bots/:instanceId/:botId
|
||||
router.put('/bots/:instanceId/:botId', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
credentialId: zod_1.z.string().uuid().optional(),
|
||||
name: zod_1.z.string().min(1).max(80).optional(),
|
||||
systemPrompt: zod_1.z.string().min(10).optional(),
|
||||
model: zod_1.z.string().optional(),
|
||||
enabled: zod_1.z.boolean().optional(),
|
||||
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).optional(),
|
||||
keywords: zod_1.z.array(zod_1.z.string()).optional(),
|
||||
});
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const botId = req.params['botId'];
|
||||
const body = schema.parse(req.body);
|
||||
await chatbot_repository_1.botRepo.update(botId, tenantId, body);
|
||||
const updated = await chatbot_repository_1.botRepo.findById(botId, tenantId);
|
||||
res.json(updated);
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: err.errors });
|
||||
return;
|
||||
}
|
||||
logger_1.logger.error({ err }, 'Erro ao atualizar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// DELETE /api/chatbot/bots/:instanceId/:botId
|
||||
router.delete('/bots/:instanceId/:botId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const botId = req.params['botId'];
|
||||
await chatbot_repository_1.botRepo.delete(botId, tenantId);
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao deletar bot');
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BOT STATE — Human Takeover por chat
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
|
||||
router.post('/chats/:chatId/pause', async (req, res) => {
|
||||
try {
|
||||
await chatbot_repository_1.chatBotState.pauseBot(req.params['chatId']);
|
||||
res.json({ paused: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
|
||||
router.post('/chats/:chatId/resume', async (req, res) => {
|
||||
try {
|
||||
await chatbot_repository_1.chatBotState.resumeBot(req.params['chatId']);
|
||||
res.json({ paused: false });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatbotService = void 0;
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
|
||||
*
|
||||
* Padrão Cérebro (instrucoes.md §5):
|
||||
* - Não envia histórico completo para economizar tokens
|
||||
* - Mantém resumo de 2 frases por chat (botSummary no banco)
|
||||
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
|
||||
*/
|
||||
const generative_ai_1 = require("@google/generative-ai");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const chatbot_repository_1 = require("./chatbot.repository");
|
||||
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
|
||||
const geminiClients = new Map();
|
||||
function getGeminiClient(apiKey) {
|
||||
if (!geminiClients.has(apiKey)) {
|
||||
geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey));
|
||||
}
|
||||
return geminiClients.get(apiKey);
|
||||
}
|
||||
function getModel(apiKey, modelName) {
|
||||
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName });
|
||||
}
|
||||
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
|
||||
async function classifyIntent(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa até agora: ${summary}` : '',
|
||||
`Nova mensagem do cliente: "${userMsg}"`,
|
||||
`Responda APENAS com o número:`,
|
||||
`1 = Consigo responder essa pergunta dentro do meu papel`,
|
||||
`2 = Precisa de um agente humano`,
|
||||
].filter(Boolean).join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 2, temperature: 0 },
|
||||
});
|
||||
const text = result.response.text().trim();
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve';
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve');
|
||||
return 'resolve';
|
||||
}
|
||||
}
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
async function generateResponse(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
systemPrompt,
|
||||
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
|
||||
`\nMensagem do cliente: "${userMsg}"`,
|
||||
`\nResponda de forma natural, breve e direta. Não use markdown.`,
|
||||
].filter(Boolean).join('\n');
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
async function updateSummary(currentSummary, userMsg, botMsg, model) {
|
||||
const prompt = [
|
||||
currentSummary
|
||||
? `Resumo atual da conversa: ${currentSummary}`
|
||||
: 'Esta é a primeira troca da conversa.',
|
||||
`Nova troca:`,
|
||||
`Cliente: "${userMsg}"`,
|
||||
`Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
|
||||
`Responda apenas com o resumo, sem introdução.`,
|
||||
].join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
catch {
|
||||
// Em caso de erro, mantém o resumo anterior
|
||||
return currentSummary ?? '';
|
||||
}
|
||||
}
|
||||
// ─── Interface pública ────────────────────────────────────────────────────────
|
||||
class ChatbotService {
|
||||
io;
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
}
|
||||
/**
|
||||
* Ponto de entrada para cada mensagem recebida.
|
||||
* Chamado pelo MessageHandler após persistir a mensagem.
|
||||
*/
|
||||
async handleIncoming(opts) {
|
||||
const { tenantId, instanceId, chatId, jid, text, sock } = opts;
|
||||
// 1. Verifica se há bot ativo para esta instância
|
||||
const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId);
|
||||
if (!bot)
|
||||
return;
|
||||
// 2. Verifica o estado do chat (human takeover)
|
||||
const chatState = await chatbot_repository_1.chatBotState.getState(chatId);
|
||||
if (!chatState || chatState.botPaused)
|
||||
return;
|
||||
// 3. Aplica modo de gatilho
|
||||
if (bot.triggerMode === 'KEYWORD') {
|
||||
const lowerText = text.toLowerCase();
|
||||
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
|
||||
if (!hasKeyword)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const model = getModel(bot.credential.apiKey, bot.model);
|
||||
const summary = chatState.botSummary ?? null;
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent(text, summary, bot.systemPrompt, model);
|
||||
if (intent === 'escalate') {
|
||||
// Pausa o bot e notifica via Socket.IO
|
||||
await chatbot_repository_1.chatBotState.pauseBot(chatId);
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
message: 'Bot pausado — atendente humano necessário',
|
||||
});
|
||||
logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano');
|
||||
return;
|
||||
}
|
||||
// 5. Gera resposta
|
||||
const responseText = await generateResponse(text, summary, bot.systemPrompt, model);
|
||||
// 6. Envia via Baileys
|
||||
const sent = await sock.sendMessage(jid, { text: responseText });
|
||||
// 7. Persiste mensagem do bot no banco
|
||||
const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (chat) {
|
||||
const botMsg = await prisma_1.prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId,
|
||||
remoteJid: jid,
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: responseText,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
// Notifica frontend
|
||||
this.io.to(`chat:${chatId}`).emit('message:new', botMsg);
|
||||
}
|
||||
// 8. Atualiza Cérebro
|
||||
const newSummary = await updateSummary(summary, text, responseText, model);
|
||||
await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary);
|
||||
logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ChatbotService = ChatbotService;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.invalidateChatListCache = invalidateChatListCache;
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
async function invalidateChatListCache(tenantId, instanceId) {
|
||||
if (!dragonfly_1.dragonfly.isAlive())
|
||||
return;
|
||||
try {
|
||||
const client = dragonfly_1.dragonfly.raw();
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
|
||||
const stream = client.scanStream({ match: pattern, count: 100 });
|
||||
const keys = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (batch) => {
|
||||
for (const k of batch)
|
||||
keys.push(k);
|
||||
});
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class ChatRepository {
|
||||
async findAllByInstance(tenantId, instanceId, opts = {}) {
|
||||
const { limit = 200, search, archived = false } = opts;
|
||||
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
|
||||
const chats = await prisma_1.prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: archived,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
] },
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ jid: { contains: search, mode: 'insensitive' } },
|
||||
{ contact: { name: { contains: search, mode: 'insensitive' } } },
|
||||
{ contact: { notify: { contains: search, mode: 'insensitive' } } },
|
||||
{ contact: { phone: { contains: search, mode: 'insensitive' } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ isPinned: 'desc' },
|
||||
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
});
|
||||
if (chats.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const chatIds = chats.map((c) => c.id);
|
||||
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x) => !!x)));
|
||||
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
|
||||
const [contacts, protocols, lastMsgs] = await Promise.all([
|
||||
contactIds.length > 0
|
||||
? prisma_1.prisma.contact.findMany({
|
||||
where: { id: { in: contactIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
notify: true,
|
||||
verifiedName: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
|
||||
prisma_1.prisma.$queryRaw `
|
||||
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
|
||||
FROM protocols
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
|
||||
ORDER BY "chatId", "createdAt" DESC
|
||||
`,
|
||||
// DISTINCT ON: 1 query batch — pega a última msg por chat
|
||||
prisma_1.prisma.$queryRaw `
|
||||
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
|
||||
FROM messages
|
||||
WHERE "chatId" = ANY(${chatIds}::text[])
|
||||
ORDER BY "chatId", timestamp DESC
|
||||
`,
|
||||
]);
|
||||
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
|
||||
const contactById = new Map(contacts.map((c) => [c.id, c]));
|
||||
const protocolByChat = new Map(protocols.map((p) => [p.chatId, p]));
|
||||
const lastMsgByChat = new Map(lastMsgs.map((m) => [m.chatId, m]));
|
||||
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
|
||||
return chats.map((c) => {
|
||||
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null;
|
||||
const protocol = protocolByChat.get(c.id);
|
||||
const lastMsg = lastMsgByChat.get(c.id);
|
||||
return {
|
||||
...c,
|
||||
contact: contact
|
||||
? {
|
||||
name: contact.name,
|
||||
notify: contact.notify,
|
||||
verifiedName: contact.verifiedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: null,
|
||||
protocols: protocol
|
||||
? [{ number: protocol.number, status: protocol.status }]
|
||||
: [],
|
||||
messages: lastMsg
|
||||
? [{
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
timestamp: lastMsg.timestamp,
|
||||
type: lastMsg.type,
|
||||
pushName: lastMsg.pushName,
|
||||
}]
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
async findById(id, tenantId) {
|
||||
return prisma_1.prisma.chat.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
contact: true,
|
||||
protocols: {
|
||||
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async findByJid(tenantId, instanceId, jid) {
|
||||
return prisma_1.prisma.chat.findUnique({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
include: { contact: true },
|
||||
});
|
||||
}
|
||||
async clearUnread(id, tenantId) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { unreadCount: 0 },
|
||||
});
|
||||
}
|
||||
async setArchived(id, tenantId, archived) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isArchived: archived },
|
||||
});
|
||||
}
|
||||
async setPinned(id, tenantId, pinned) {
|
||||
return prisma_1.prisma.chat.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { isPinned: pinned },
|
||||
});
|
||||
}
|
||||
async countUnreadByInstance(tenantId, instanceId) {
|
||||
const result = await prisma_1.prisma.chat.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { OR: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
] }
|
||||
},
|
||||
_sum: { unreadCount: true },
|
||||
});
|
||||
return result._sum.unreadCount ?? 0;
|
||||
}
|
||||
}
|
||||
exports.ChatRepository = ChatRepository;
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildChatRoutes = buildChatRoutes;
|
||||
/**
|
||||
* Rotas de Chats — API REST para a inbox do frontend.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/chats — Lista chats (com filtros e último msg)
|
||||
* GET /api/chats/search/messages — Busca full-text em mensagens
|
||||
* GET /api/chats/stats/overview — Contagens para o dashboard
|
||||
* GET /api/chats/:id — Detalhe de um chat
|
||||
* POST /api/chats/:id/read — Zera unread_count
|
||||
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
|
||||
* PATCH /api/chats/:id/pin — Fixa/desfixa
|
||||
* DELETE /api/chats/:id — Remove chat e mensagens
|
||||
* GET /api/chats/:id/messages — Histórico paginado por cursor
|
||||
* GET /api/chats/:id/protocol — Protocolos do chat
|
||||
* POST /api/chats/:id/protocol — Abre novo protocolo
|
||||
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
|
||||
*
|
||||
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const zod_1 = require("zod");
|
||||
const chat_repository_1 = require("./chat.repository");
|
||||
const message_repository_1 = require("./message.repository");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const chat_cache_1 = require("./chat.cache");
|
||||
const chatRepo = new chat_repository_1.ChatRepository();
|
||||
const msgRepo = new message_repository_1.MessageRepository();
|
||||
/** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */
|
||||
const CHAT_LIST_CACHE_TTL_S = 86400;
|
||||
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
||||
}
|
||||
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
|
||||
function chatListCacheKey(tenantId, instanceId, archived, limit) {
|
||||
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`;
|
||||
}
|
||||
function buildChatRoutes(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats — Lista chats com snapshot da última mensagem
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { instanceId, search, archived, limit } = zod_1.z.object({
|
||||
instanceId: zod_1.z.string().uuid(),
|
||||
search: zod_1.z.string().optional(),
|
||||
archived: zod_1.z.coerce.boolean().optional(),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(req.query);
|
||||
const archivedFlag = archived ?? false;
|
||||
const limitVal = limit ?? 200;
|
||||
const tenantId = req.tenantId;
|
||||
// ── Cache hit: só quando não houver search (busca tem termos únicos) ──
|
||||
const useCache = !search;
|
||||
let cacheKey = null;
|
||||
if (useCache) {
|
||||
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal);
|
||||
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
|
||||
if (cached) {
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
res.json(cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chats = await chatRepo.findAllByInstance(tenantId, instanceId, {
|
||||
search,
|
||||
archived,
|
||||
limit,
|
||||
});
|
||||
const result = chats.map((c) => {
|
||||
const contact = c.contact;
|
||||
const isGroup = c.jid.endsWith('@g.us');
|
||||
const lastMsg = c.messages[0];
|
||||
const activeProtocol = c.protocols[0] ?? null;
|
||||
const resolvedName = isGroup
|
||||
? (c.name ?? null)
|
||||
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
|
||||
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
|
||||
?? null);
|
||||
return {
|
||||
id: c.id,
|
||||
instanceId: c.instanceId,
|
||||
jid: c.jid,
|
||||
name: c.name ?? null,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
unreadCount: c.unreadCount,
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
createdAt: c.createdAt,
|
||||
contact: contact
|
||||
? {
|
||||
name: resolvedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
|
||||
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
|
||||
avatarVersion: contact.avatarUrl
|
||||
? (0, crypto_1.createHash)('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
|
||||
: null,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage: lastMsg
|
||||
? {
|
||||
body: lastMsg.body,
|
||||
fromMe: lastMsg.fromMe,
|
||||
status: lastMsg.status,
|
||||
type: lastMsg.type,
|
||||
timestamp: lastMsg.timestamp,
|
||||
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
|
||||
}
|
||||
: null,
|
||||
protocol: activeProtocol
|
||||
? { number: activeProtocol.number, status: activeProtocol.status }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
if (useCache && cacheKey) {
|
||||
// fire-and-forget — não bloqueia a resposta
|
||||
dragonfly_1.dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => { });
|
||||
}
|
||||
res.setHeader('X-Cache', 'MISS');
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/search/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/search/messages', async (req, res) => {
|
||||
try {
|
||||
const { instanceId, q, limit } = zod_1.z.object({
|
||||
instanceId: zod_1.z.string().uuid(),
|
||||
q: zod_1.z.string().min(2),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(50).optional(),
|
||||
}).parse(req.query);
|
||||
const results = await msgRepo.search(req.tenantId, instanceId, q, limit);
|
||||
res.json(results);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/stats/overview
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/stats/overview', async (req, res) => {
|
||||
try {
|
||||
const { instanceId } = zod_1.z.object({ instanceId: zod_1.z.string().uuid() }).parse(req.query);
|
||||
const cacheKey = `stats:${req.tenantId}:${instanceId}`;
|
||||
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
|
||||
if (cached) {
|
||||
res.json(cached);
|
||||
return;
|
||||
}
|
||||
const [totalUnread, openProtocols, totalChats] = await Promise.all([
|
||||
chatRepo.countUnreadByInstance(req.tenantId, instanceId),
|
||||
prisma_1.prisma.protocol.count({
|
||||
where: {
|
||||
tenantId: req.tenantId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
}),
|
||||
prisma_1.prisma.chat.count({
|
||||
where: {
|
||||
tenantId: req.tenantId,
|
||||
instanceId,
|
||||
isArchived: false,
|
||||
NOT: { jid: { endsWith: '@lid' } }
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const stats = { totalUnread, openProtocols, totalChats };
|
||||
await dragonfly_1.dragonfly.setJson(cacheKey, stats, 30);
|
||||
res.json(stats);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(chat);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/chats/:id/read
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.post('/:id/read', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.clearUnread(id, tenantId);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/archive
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/archive', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { archived } = zod_1.z.object({ archived: zod_1.z.boolean() }).parse(req.body);
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.setArchived(id, tenantId, archived);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PATCH /api/chats/:id/pin
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.patch('/:id/pin', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { pinned } = zod_1.z.object({ pinned: zod_1.z.boolean() }).parse(req.body);
|
||||
const tenantId = req.tenantId;
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
|
||||
await chatRepo.setPinned(id, tenantId, pinned);
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/chats/:id
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.message.deleteMany({ where: { chatId: id } });
|
||||
await prisma_1.prisma.chat.delete({ where: { id } });
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.status(204).send();
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/chats/:id/messages
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/messages', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { before, after, limit } = zod_1.z.object({
|
||||
before: zod_1.z.string().datetime().optional(),
|
||||
after: zod_1.z.string().datetime().optional(),
|
||||
limit: zod_1.z.coerce.number().int().min(1).max(100).optional(),
|
||||
}).parse(req.query);
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
});
|
||||
res.json({ messages, hasMore });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROTOCOLOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
router.get('/:id/protocol', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
const protocols = await prisma_1.prisma.protocol.findMany({
|
||||
where: { chatId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
sector: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
res.json(protocols);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
router.post('/:id/protocol', async (req, res) => {
|
||||
try {
|
||||
const id = req.params['id'];
|
||||
const { sectorId, teamId } = zod_1.z.object({
|
||||
sectorId: zod_1.z.string().uuid().optional(),
|
||||
teamId: zod_1.z.string().uuid().optional(),
|
||||
}).parse(req.body);
|
||||
const chat = await chatRepo.findById(id, req.tenantId);
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Chat não encontrado' });
|
||||
return;
|
||||
}
|
||||
if (!chat.contactId) {
|
||||
res.status(400).json({ error: 'Chat sem contato associado' });
|
||||
return;
|
||||
}
|
||||
const count = await prisma_1.prisma.protocol.count({ where: { chatId: id } });
|
||||
const year = new Date().getFullYear();
|
||||
const number = `#${String(count + 1).padStart(4, '0')}-${year}`;
|
||||
const protocol = await prisma_1.prisma.protocol.create({
|
||||
data: {
|
||||
tenantId: req.tenantId,
|
||||
number,
|
||||
chatId: id,
|
||||
contactId: chat.contactId,
|
||||
sectorId,
|
||||
teamId,
|
||||
status: 'OPEN',
|
||||
},
|
||||
});
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.status(201).json(protocol);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
router.patch('/:chatId/protocol/:protocolId', async (req, res) => {
|
||||
try {
|
||||
const chatId = req.params['chatId'];
|
||||
const protocolId = req.params['protocolId'];
|
||||
const { status, summary } = zod_1.z.object({
|
||||
status: zod_1.z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
|
||||
summary: zod_1.z.string().max(500).optional(),
|
||||
}).parse(req.body);
|
||||
const protocol = await prisma_1.prisma.protocol.updateMany({
|
||||
where: { id: protocolId, chatId, tenantId: req.tenantId },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(summary && { summary }),
|
||||
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
|
||||
},
|
||||
});
|
||||
const chat = await prisma_1.prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId }, select: { instanceId: true } });
|
||||
if (chat)
|
||||
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
|
||||
res.json({ updated: protocol.count });
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MessageRepository = void 0;
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
class MessageRepository {
|
||||
/**
|
||||
* Histórico paginado por cursor (timestamp).
|
||||
* Retorna `limit` mensagens anteriores ao cursor dado.
|
||||
* Se cursor não for fornecido, retorna as mais recentes.
|
||||
*/
|
||||
async findByChatPaginated(chatId, opts = {}) {
|
||||
const { limit = 40, before, after } = opts;
|
||||
const where = { chatId };
|
||||
if (before)
|
||||
where['timestamp'] = { lt: new Date(before) };
|
||||
if (after)
|
||||
where['timestamp'] = { gt: new Date(after) };
|
||||
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
|
||||
// para retornar as mensagens mais recentes em ordem cronológica.
|
||||
// Scroll-down (after): busca ASC naturalmente.
|
||||
const needsReverse = !after;
|
||||
const messages = await prisma_1.prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
|
||||
take: limit,
|
||||
include: {
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
body: true,
|
||||
fromMe: true,
|
||||
type: true,
|
||||
mediaUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Inverte para ordem cronológica (mais antiga → mais recente)
|
||||
if (needsReverse)
|
||||
messages.reverse();
|
||||
const hasMore = messages.length === limit;
|
||||
return { messages, hasMore };
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.message.findUnique({ where: { id } });
|
||||
}
|
||||
async search(tenantId, instanceId, query, limit = 20) {
|
||||
return prisma_1.prisma.message.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
body: { contains: query, mode: 'insensitive' },
|
||||
},
|
||||
include: {
|
||||
chat: {
|
||||
select: {
|
||||
jid: true,
|
||||
contact: { select: { name: true, notify: true, avatarUrl: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
async updateStatus(messageId, instanceId, status) {
|
||||
return prisma_1.prisma.message.updateMany({
|
||||
where: { messageId, instanceId },
|
||||
data: { status: status },
|
||||
});
|
||||
}
|
||||
async countByChat(chatId) {
|
||||
return prisma_1.prisma.message.count({ where: { chatId } });
|
||||
}
|
||||
}
|
||||
exports.MessageRepository = MessageRepository;
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildContactRoutes = buildContactRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const whatsapp_1 = require("../../shared/utils/whatsapp");
|
||||
const chat_cache_1 = require("../chats/chat.cache");
|
||||
function buildContactRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
|
||||
router.get('/stats/overview', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.query['instanceId'];
|
||||
const where = { tenantId };
|
||||
if (instanceId)
|
||||
where['instanceId'] = instanceId;
|
||||
const [total, blocked, flagged] = await Promise.all([
|
||||
prisma_1.prisma.contact.count({ where }),
|
||||
prisma_1.prisma.contact.count({ where: { ...where, isBlocked: true } }),
|
||||
prisma_1.prisma.contact.count({ where: { ...where, flagRestricao: true } }),
|
||||
]);
|
||||
res.json({ total, blocked, flagged, active: total - blocked });
|
||||
});
|
||||
// ── GET /api/contacts — lista paginada ────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.query['instanceId'];
|
||||
const search = req.query['search'];
|
||||
const blocked = req.query['blocked'];
|
||||
const limit = Math.min(Number(req.query['limit'] ?? 50), 200);
|
||||
const offset = Number(req.query['offset'] ?? 0);
|
||||
const where = { tenantId };
|
||||
if (instanceId)
|
||||
where['instanceId'] = instanceId;
|
||||
if (blocked === 'true')
|
||||
where['isBlocked'] = true;
|
||||
if (blocked === 'false')
|
||||
where['isBlocked'] = false;
|
||||
if (search) {
|
||||
where['OR'] = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ verifiedName: { contains: search, mode: 'insensitive' } },
|
||||
{ notify: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search } },
|
||||
{ jid: { contains: search } },
|
||||
];
|
||||
}
|
||||
const [total, contacts] = await Promise.all([
|
||||
prisma_1.prisma.contact.count({ where }),
|
||||
prisma_1.prisma.contact.findMany({
|
||||
where,
|
||||
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
|
||||
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
|
||||
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
instanceId: true,
|
||||
jid: true,
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
isBlocked: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
total,
|
||||
contacts: contacts.map(c => ({
|
||||
...c,
|
||||
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : (0, whatsapp_1.normalizeJid)(c.jid).split('@')[0])
|
||||
}))
|
||||
});
|
||||
});
|
||||
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
chats: {
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
take: 1,
|
||||
select: { id: true, unreadCount: true, lastMessageAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!contact) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(contact);
|
||||
});
|
||||
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(255).optional(),
|
||||
isBlocked: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.contact.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.contact.update({ where: { id }, data: body });
|
||||
if (body.name && existing.instanceId) {
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.contact.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Contato não encontrado' });
|
||||
return;
|
||||
}
|
||||
// Desvincular chats antes de deletar o contato
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: { contactId: id },
|
||||
data: { contactId: null },
|
||||
});
|
||||
await prisma_1.prisma.contact.delete({ where: { id } });
|
||||
if (existing.instanceId) {
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pairRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const pair_service_1 = require("./pair.service");
|
||||
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
|
||||
const service = new pair_service_1.PairService();
|
||||
function handleError(res, err) {
|
||||
if (err instanceof zod_1.z.ZodError) {
|
||||
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const e = err;
|
||||
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
|
||||
}
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
const requestSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
clientSystem: zod_1.z.string().min(1).max(100),
|
||||
clientName: zod_1.z.string().min(1).max(200),
|
||||
clientUrl: zod_1.z.string().url().nullable().optional(),
|
||||
});
|
||||
const confirmSchema = zod_1.z.object({
|
||||
email: zod_1.z.string().email(),
|
||||
code: zod_1.z.string().length(6),
|
||||
clientSystem: zod_1.z.string().min(1).max(100),
|
||||
clientName: zod_1.z.string().min(1).max(200),
|
||||
clientUrl: zod_1.z.string().url().nullable().optional(),
|
||||
});
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
exports.pairRouter = (0, express_1.Router)();
|
||||
/**
|
||||
* POST /api/pair/request
|
||||
* Inicia pareamento: gera OTP e envia por e-mail.
|
||||
* Público (chamado pelo sistema cliente).
|
||||
*/
|
||||
exports.pairRouter.post('/request', async (req, res) => {
|
||||
try {
|
||||
const body = requestSchema.parse(req.body);
|
||||
const result = await service.request({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* POST /api/pair/confirm
|
||||
* Confirma o OTP e retorna a integration_key.
|
||||
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
|
||||
*/
|
||||
exports.pairRouter.post('/confirm', async (req, res) => {
|
||||
try {
|
||||
const body = confirmSchema.parse(req.body);
|
||||
const result = await service.confirm({
|
||||
...body,
|
||||
clientUrl: body.clientUrl ?? null,
|
||||
});
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/pair/verify
|
||||
* Verifica se uma integration_key é válida.
|
||||
* Público — autenticado pelo header x-nw-key.
|
||||
*/
|
||||
exports.pairRouter.get('/verify', async (req, res) => {
|
||||
try {
|
||||
const raw = req.headers['x-nw-key'];
|
||||
const key = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!key) {
|
||||
res.status(401).json({ error: 'Header x-nw-key ausente' });
|
||||
return;
|
||||
}
|
||||
const result = await service.verify(key);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* GET /api/pair/connections
|
||||
* Lista pares ativos do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
exports.pairRouter.get('/connections', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const pairs = await service.list(req.tenantId);
|
||||
res.json(pairs);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* DELETE /api/pair/connections/:id
|
||||
* Revoga um par do usuário autenticado.
|
||||
* Protegido — requer JWT via authMiddleware.
|
||||
*/
|
||||
exports.pairRouter.delete('/connections/:id', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const result = await service.revoke(req.tenantId, String(req.params.id));
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PairService = void 0;
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const nodemailer_1 = __importDefault(require("nodemailer"));
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const OTP_TTL = 10 * 60; // 10 minutos
|
||||
const KEY_TTL = 90 * 24 * 3600; // 90 dias
|
||||
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
|
||||
const otpKey = (email) => `pair:otp:${email}`;
|
||||
const intKey = (integrationKey) => `pair:key:${integrationKey}`;
|
||||
// ─── Email sender ─────────────────────────────────────────────────────────────
|
||||
async function sendOtpEmail(to, code, clientName) {
|
||||
// Lê config SMTP do admin settings
|
||||
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
|
||||
const smtp = raw?.smtp ?? {};
|
||||
if (!smtp.host || !smtp.user) {
|
||||
logger_1.logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.');
|
||||
logger_1.logger.info(`[pair] OTP para ${to}: ${code}`); // fallback: loga localmente
|
||||
return false;
|
||||
}
|
||||
const transporter = nodemailer_1.default.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port ?? 587,
|
||||
secure: smtp.secure ?? false,
|
||||
auth: { user: smtp.user, pass: smtp.password },
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: smtp.from ?? smtp.user,
|
||||
to,
|
||||
subject: `[NewWhats] Código de pareamento — ${clientName}`,
|
||||
text: [
|
||||
`Olá!`,
|
||||
``,
|
||||
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
|
||||
``,
|
||||
`Seu código de confirmação: ${code}`,
|
||||
``,
|
||||
`Válido por 10 minutos.`,
|
||||
``,
|
||||
`Se você não solicitou isso, ignore este email.`,
|
||||
].join('\n'),
|
||||
html: `
|
||||
<p>Olá!</p>
|
||||
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
|
||||
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
|
||||
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
|
||||
`,
|
||||
});
|
||||
logger_1.logger.info(`[pair] OTP enviado para ${to}`);
|
||||
return true;
|
||||
}
|
||||
// ─── PairService ──────────────────────────────────────────────────────────────
|
||||
class PairService {
|
||||
// Etapa 1 — solicitar pareamento
|
||||
async request(input) {
|
||||
const user = await prisma_1.prisma.user.findUnique({ where: { email: input.email } });
|
||||
if (!user) {
|
||||
// Resposta genérica — não revela se o email existe ou não
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' };
|
||||
}
|
||||
if (!user.isActive) {
|
||||
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 });
|
||||
}
|
||||
// Gera OTP de 6 dígitos
|
||||
const code = Math.floor(100_000 + Math.random() * 900_000).toString();
|
||||
const payload = {
|
||||
code,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(otpKey(input.email), payload, OTP_TTL);
|
||||
const emailSent = await sendOtpEmail(input.email, code, input.clientName);
|
||||
// Quando SMTP não está configurado, devolve o código na resposta
|
||||
// para que o admin possa completar o pareamento manualmente.
|
||||
if (!emailSent) {
|
||||
return { ok: true, message: 'SMTP não configurado.', dev_code: code };
|
||||
}
|
||||
return { ok: true, message: 'Se o email existir, um código será enviado.' };
|
||||
}
|
||||
// Etapa 2 — confirmar com o código recebido
|
||||
async confirm(input) {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(otpKey(input.email));
|
||||
if (!stored) {
|
||||
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 });
|
||||
}
|
||||
if (stored.code !== input.code) {
|
||||
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 });
|
||||
}
|
||||
if (stored.clientSystem !== input.clientSystem) {
|
||||
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 });
|
||||
}
|
||||
const user = await prisma_1.prisma.user.findUnique({ where: { email: input.email } });
|
||||
if (!user || !user.isActive) {
|
||||
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 });
|
||||
}
|
||||
// Revoga par anterior do mesmo sistema (evita duplicatas)
|
||||
await prisma_1.prisma.pluginPair.updateMany({
|
||||
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Gera integration_key exclusiva
|
||||
const integrationKey = `nwk_${crypto_1.default.randomUUID().replace(/-/g, '')}`;
|
||||
const pair = await prisma_1.prisma.pluginPair.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
clientUrl: input.clientUrl,
|
||||
integrationKey,
|
||||
},
|
||||
});
|
||||
// Cache rápido para validação sem bater no banco
|
||||
const keyPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
clientSystem: input.clientSystem,
|
||||
clientName: input.clientName,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
|
||||
// Limpa OTP usado
|
||||
await dragonfly_1.dragonfly.del(otpKey(input.email));
|
||||
logger_1.logger.info(`[pair] ${user.email} ↔ ${input.clientName} pareados (${pair.id})`);
|
||||
return {
|
||||
integration_key: integrationKey,
|
||||
account: { name: user.name, email: user.email },
|
||||
paired_at: pair.createdAt,
|
||||
};
|
||||
}
|
||||
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
|
||||
async verify(integrationKey) {
|
||||
// Fast path — Dragonfly
|
||||
const cached = await dragonfly_1.dragonfly.getJson(intKey(integrationKey));
|
||||
if (cached) {
|
||||
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
|
||||
prisma_1.prisma.pluginPair.updateMany({
|
||||
where: { integrationKey, revokedAt: null },
|
||||
data: { lastSeenAt: new Date() },
|
||||
}).catch(() => { });
|
||||
return { valid: true, account: { email: cached.email }, client: cached.clientSystem };
|
||||
}
|
||||
// Slow path — banco
|
||||
const pair = await prisma_1.prisma.pluginPair.findUnique({
|
||||
where: { integrationKey },
|
||||
include: { user: { select: { name: true, email: true, isActive: true } } },
|
||||
});
|
||||
if (!pair || pair.revokedAt || !pair.user.isActive) {
|
||||
return { valid: false };
|
||||
}
|
||||
// Restaura cache
|
||||
const keyPayload = {
|
||||
userId: pair.userId,
|
||||
email: pair.user.email,
|
||||
clientSystem: pair.clientSystem,
|
||||
clientName: pair.clientName,
|
||||
};
|
||||
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
|
||||
await prisma_1.prisma.pluginPair.update({
|
||||
where: { integrationKey },
|
||||
data: { lastSeenAt: new Date() },
|
||||
});
|
||||
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem };
|
||||
}
|
||||
// Lista pares ativos do usuário autenticado
|
||||
async list(userId) {
|
||||
const pairs = await prisma_1.prisma.pluginPair.findMany({
|
||||
where: { userId, revokedAt: null },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
clientSystem: true,
|
||||
clientName: true,
|
||||
clientUrl: true,
|
||||
lastSeenAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return pairs;
|
||||
}
|
||||
// Revoga um par (usuário autenticado só pode revogar os seus)
|
||||
async revoke(userId, pairId) {
|
||||
const pair = await prisma_1.prisma.pluginPair.findFirst({
|
||||
where: { id: pairId, userId, revokedAt: null },
|
||||
});
|
||||
if (!pair) {
|
||||
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 });
|
||||
}
|
||||
await prisma_1.prisma.pluginPair.update({
|
||||
where: { id: pairId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
// Remove do cache imediatamente
|
||||
await dragonfly_1.dragonfly.del(intKey(pair.integrationKey));
|
||||
logger_1.logger.info(`[pair] Par ${pairId} revogado por ${userId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
exports.PairService = PairService;
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginsRouter = void 0;
|
||||
const express_1 = require("express");
|
||||
const plugin_registry_1 = require("../../core/plugin-registry");
|
||||
const plugin_config_1 = require("../../core/plugin-config");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
/**
|
||||
* Rotas de gerenciamento de plugins — exclusivo admin.
|
||||
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
|
||||
*/
|
||||
exports.pluginsRouter = (0, express_1.Router)();
|
||||
// GET /api/admin/plugins — lista todos os plugins com status e config atual
|
||||
exports.pluginsRouter.get('/', (_req, res) => {
|
||||
const plugins = plugin_registry_1.pluginRegistry.list().map((entry) => ({
|
||||
name: entry.name,
|
||||
displayName: entry.manifest.displayName,
|
||||
version: entry.manifest.version,
|
||||
description: entry.manifest.description,
|
||||
author: entry.manifest.author,
|
||||
category: entry.manifest.category,
|
||||
active: entry.active,
|
||||
canDisable: entry.manifest.canDisable,
|
||||
dependencies: entry.manifest.dependencies,
|
||||
configSchema: entry.manifest.configSchema ?? [],
|
||||
config: plugin_config_1.pluginConfig.get(entry.name),
|
||||
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
|
||||
}));
|
||||
res.json(plugins);
|
||||
});
|
||||
// POST /api/admin/plugins/:name/enable — ativa plugin
|
||||
exports.pluginsRouter.post('/:name/enable', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.activate(name);
|
||||
res.json({ ok: true, message: `Plugin "${name}" ativado` });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
|
||||
exports.pluginsRouter.post('/:name/disable', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.deactivate(name);
|
||||
res.json({ ok: true, message: `Plugin "${name}" desativado` });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// GET /api/admin/plugins/:name/config — retorna config atual
|
||||
exports.pluginsRouter.get('/:name/config', (req, res) => {
|
||||
const { name } = req.params;
|
||||
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
|
||||
if (!entry)
|
||||
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
|
||||
res.json({
|
||||
name,
|
||||
config: plugin_config_1.pluginConfig.get(name),
|
||||
schema: entry.manifest.configSchema ?? [],
|
||||
});
|
||||
});
|
||||
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
|
||||
exports.pluginsRouter.get('/storage/health', async (_req, res) => {
|
||||
if (!StorageProvider_1.storageProvider.isRegistered()) {
|
||||
const cfg = plugin_config_1.pluginConfig.get('UnifiedStorageProvider');
|
||||
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket);
|
||||
return res.json({
|
||||
healthy: false,
|
||||
configured,
|
||||
reason: configured ? 'needs_restart' : 'not_configured',
|
||||
message: configured
|
||||
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
|
||||
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
|
||||
});
|
||||
}
|
||||
await StorageProvider_1.storageProvider.updateHealth();
|
||||
return res.json(StorageProvider_1.storageProvider.getLastHealthStatus());
|
||||
});
|
||||
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
|
||||
exports.pluginsRouter.put('/:name/config', async (req, res) => {
|
||||
const { name } = req.params;
|
||||
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
|
||||
if (!entry)
|
||||
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
|
||||
const newConfig = req.body;
|
||||
await plugin_config_1.pluginConfig.set(name, newConfig);
|
||||
// Reativa o plugin para aplicar a nova config imediatamente
|
||||
if (entry.active) {
|
||||
try {
|
||||
await plugin_registry_1.pluginRegistry.reactivate(name);
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` });
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig });
|
||||
});
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildScheduledRoutes = buildScheduledRoutes;
|
||||
/**
|
||||
* Rotas de Agendamentos de Mensagem
|
||||
*
|
||||
* GET /api/scheduled — Lista agendamentos do tenant
|
||||
* POST /api/scheduled — Cria agendamento
|
||||
* GET /api/scheduled/:id — Detalhe
|
||||
* PUT /api/scheduled/:id — Atualiza
|
||||
* DELETE /api/scheduled/:id — Remove
|
||||
* POST /api/scheduled/:id/pause — Pausa
|
||||
* POST /api/scheduled/:id/resume — Retoma
|
||||
* POST /api/scheduled/:id/run-now — Executa imediatamente
|
||||
* GET /api/scheduled/:id/logs — Histórico de execuções
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const croner_1 = require("croner");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const recipientSchema = zod_1.z.object({
|
||||
jid: zod_1.z.string(),
|
||||
name: zod_1.z.string().optional(),
|
||||
birthday: zod_1.z.string().optional(),
|
||||
anniversary: zod_1.z.string().optional(),
|
||||
signupDate: zod_1.z.string().optional(),
|
||||
});
|
||||
const scheduleSchema = zod_1.z.object({
|
||||
instanceId: zod_1.z.string(),
|
||||
name: zod_1.z.string().min(1).max(100),
|
||||
templateId: zod_1.z.string().optional(),
|
||||
payload: zod_1.z.record(zod_1.z.unknown()),
|
||||
recipientMode: zod_1.z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
|
||||
recipients: zod_1.z.array(recipientSchema).default([]),
|
||||
scheduleType: zod_1.z.enum(['ONCE', 'RECURRING', 'EVENT']),
|
||||
cronExpr: zod_1.z.string().optional(),
|
||||
scheduledAt: zod_1.z.string().datetime().optional(),
|
||||
eventType: zod_1.z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
|
||||
timezone: zod_1.z.string().default('America/Sao_Paulo'),
|
||||
});
|
||||
function getId(req) {
|
||||
const id = req.params['id'];
|
||||
return Array.isArray(id) ? id[0] : id;
|
||||
}
|
||||
function calcNextRun(data) {
|
||||
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
|
||||
return new Date(data.scheduledAt);
|
||||
}
|
||||
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
|
||||
try {
|
||||
const job = new croner_1.Cron(data.cronExpr, { paused: true });
|
||||
return job.nextRun() ?? null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildScheduledRoutes(_scheduler) {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'];
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'];
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(status ? { status: status } : {}),
|
||||
...(type ? { scheduleType: type } : {}),
|
||||
},
|
||||
include: {
|
||||
template: { select: { id: true, name: true, type: true } },
|
||||
_count: { select: { logs: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(schedules);
|
||||
});
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const parse = scheduleSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const data = parse.data;
|
||||
const nextRunAt = calcNextRun(data);
|
||||
const schedule = await prisma_1.prisma.scheduledMessage.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
templateId: data.templateId,
|
||||
payload: data.payload,
|
||||
recipientMode: data.recipientMode,
|
||||
recipients: data.recipients,
|
||||
scheduleType: data.scheduleType,
|
||||
cronExpr: data.cronExpr,
|
||||
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
|
||||
eventType: data.eventType ?? null,
|
||||
timezone: data.timezone,
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
res.status(201).json(schedule);
|
||||
});
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sched = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
include: {
|
||||
template: true,
|
||||
logs: { orderBy: { runAt: 'desc' }, take: 5 },
|
||||
},
|
||||
});
|
||||
if (!sched) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(sched);
|
||||
});
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
const parse = scheduleSchema.partial().safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const data = parse.data;
|
||||
const nextRunAt = calcNextRun({ ...exists, ...data });
|
||||
const { payload, recipients, scheduledAt, eventType, ...rest } = data;
|
||||
const updated = await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(payload !== undefined ? { payload: payload } : {}),
|
||||
...(recipients !== undefined ? { recipients: recipients } : {}),
|
||||
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt) } : {}),
|
||||
...(eventType !== undefined ? { eventType } : {}),
|
||||
status: 'ACTIVE',
|
||||
nextRunAt,
|
||||
},
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// ── PAUSE ─────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/pause', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// ── RESUME ────────────────────────────────────────────────────────────────
|
||||
router.post('/:id/resume', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// ── RUN NOW ───────────────────────────────────────────────────────────────
|
||||
router.post('/:id/run-now', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const sched = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!sched) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id },
|
||||
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
|
||||
});
|
||||
res.json({ ok: true, message: 'Execução agendada para o próximo tick' });
|
||||
});
|
||||
// ── LOGS ──────────────────────────────────────────────────────────────────
|
||||
router.get('/:id/logs', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Agendamento não encontrado' });
|
||||
return;
|
||||
}
|
||||
const logs = await prisma_1.prisma.scheduledMessageLog.findMany({
|
||||
where: { scheduleId: id },
|
||||
orderBy: { runAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
res.json(logs);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SchedulerService = void 0;
|
||||
/**
|
||||
* SchedulerService — executa agendamentos de mensagens ricos.
|
||||
*
|
||||
* Tipos suportados:
|
||||
* ONCE — executa uma vez em scheduledAt
|
||||
* RECURRING — cron expression (cronExpr)
|
||||
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
|
||||
* verificado diariamente às 08:00 contra dados dos contatos
|
||||
*
|
||||
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
|
||||
* Eventos são checados uma vez por dia via job separado.
|
||||
*
|
||||
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
|
||||
* (sem passar pelo HTTP — evita latência e auth overhead).
|
||||
*/
|
||||
const node_cron_1 = require("node-cron");
|
||||
const croner_1 = require("croner");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
class SchedulerService {
|
||||
manager;
|
||||
minuteJob = null;
|
||||
eventJob = null;
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
start() {
|
||||
// Tick a cada minuto — processa ONCE e RECURRING vencidos
|
||||
this.minuteJob = (0, node_cron_1.schedule)('* * * * *', () => {
|
||||
this.processTimeBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de minuto'));
|
||||
});
|
||||
// Tick diário às 08:00 — processa agendamentos por EVENTO
|
||||
this.eventJob = (0, node_cron_1.schedule)('0 8 * * *', () => {
|
||||
this.processEventBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de evento'));
|
||||
});
|
||||
logger_1.logger.info('[Scheduler] Iniciado (minuto + evento diário)');
|
||||
}
|
||||
stop() {
|
||||
this.minuteJob?.stop();
|
||||
this.eventJob?.stop();
|
||||
}
|
||||
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
|
||||
async processTimeBased() {
|
||||
const now = new Date();
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
scheduleType: { in: ['ONCE', 'RECURRING'] },
|
||||
OR: [
|
||||
{ nextRunAt: { lte: now } },
|
||||
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
|
||||
],
|
||||
},
|
||||
});
|
||||
for (const sched of schedules) {
|
||||
await this.executeSchedule(sched);
|
||||
}
|
||||
}
|
||||
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
|
||||
async processEventBased() {
|
||||
const today = new Date();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const todayMmDd = `${mm}-${dd}`;
|
||||
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
|
||||
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
|
||||
});
|
||||
for (const sched of schedules) {
|
||||
const recipients = sched.recipients;
|
||||
const matched = [];
|
||||
for (const r of recipients) {
|
||||
if (!r.jid)
|
||||
continue;
|
||||
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
|
||||
// birthday format: "YYYY-MM-DD" or "MM-DD"
|
||||
const parts = r.birthday.split('-');
|
||||
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday;
|
||||
if (bMmDd === todayMmDd)
|
||||
matched.push(r);
|
||||
}
|
||||
else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
|
||||
const parts = r.anniversary.split('-');
|
||||
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary;
|
||||
if (aMmDd === todayMmDd)
|
||||
matched.push(r);
|
||||
}
|
||||
else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
|
||||
// Dispara N dias após o signup — N fica em cronExpr como "30"
|
||||
const daysAfter = parseInt(sched.cronExpr ?? '0', 10);
|
||||
const signup = new Date(r.signupDate);
|
||||
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000);
|
||||
if (diffDays === daysAfter)
|
||||
matched.push(r);
|
||||
}
|
||||
}
|
||||
if (matched.length > 0) {
|
||||
await this.executeSchedule(sched, matched);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Executa um agendamento ─────────────────────────────────────────────────
|
||||
async executeSchedule(sched, overrideRecipients) {
|
||||
const recipients = overrideRecipients ?? sched.recipients;
|
||||
const sock = this.manager.getSocket(sched.instanceId);
|
||||
if (!sock) {
|
||||
logger_1.logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando');
|
||||
return;
|
||||
}
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors = [];
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.jid)
|
||||
continue;
|
||||
try {
|
||||
const payload = this.buildPayload(sched.payload, recipient);
|
||||
await sock.sendMessage(recipient.jid, payload);
|
||||
sentCount++;
|
||||
// Pequeno delay anti-ban
|
||||
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400));
|
||||
}
|
||||
catch (err) {
|
||||
failedCount++;
|
||||
errors.push(`${recipient.jid}: ${err?.message ?? err}`);
|
||||
logger_1.logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar');
|
||||
}
|
||||
}
|
||||
// Persiste log
|
||||
await prisma_1.prisma.scheduledMessageLog.create({
|
||||
data: {
|
||||
scheduleId: sched.id,
|
||||
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
|
||||
sentCount,
|
||||
failedCount,
|
||||
error: errors.length ? errors.join('\n') : null,
|
||||
},
|
||||
});
|
||||
// Calcula próximo nextRunAt ou marca como DONE
|
||||
if (sched.scheduleType === 'ONCE') {
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
status: 'DONE',
|
||||
lastRunAt: new Date(),
|
||||
runCount: { increment: 1 },
|
||||
nextRunAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
|
||||
const next = this.nextCronDate(sched.cronExpr);
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: {
|
||||
lastRunAt: new Date(),
|
||||
nextRunAt: next,
|
||||
runCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
// EVENT — apenas registra execução
|
||||
await prisma_1.prisma.scheduledMessage.update({
|
||||
where: { id: sched.id },
|
||||
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
logger_1.logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado');
|
||||
}
|
||||
// ── Monta payload Baileys com substituição de variáveis ────────────────────
|
||||
// Variáveis: {{name}}, {{birthday}}, etc.
|
||||
buildPayload(raw, recipient) {
|
||||
const replace = (str) => str
|
||||
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
|
||||
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
|
||||
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '');
|
||||
const recurse = (node) => {
|
||||
if (typeof node === 'string')
|
||||
return replace(node);
|
||||
if (Array.isArray(node))
|
||||
return node.map(recurse);
|
||||
if (node && typeof node === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(node))
|
||||
out[k] = recurse(node[k]);
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return recurse(raw);
|
||||
}
|
||||
// ── Calcula próxima data de um cron ───────────────────────────────────────
|
||||
nextCronDate(expr) {
|
||||
try {
|
||||
const job = new croner_1.Cron(expr, { paused: true });
|
||||
return job.nextRun() ?? null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.SchedulerService = SchedulerService;
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSectorRoutes = buildSectorRoutes;
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
function buildSectorRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// GET /api/sectors
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectors = await prisma_1.prisma.sector.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { teams: true, protocols: true } } },
|
||||
});
|
||||
res.json(sectors);
|
||||
});
|
||||
// POST /api/sectors
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sector = await prisma_1.prisma.sector.create({ data: { tenantId, name: body.name } });
|
||||
res.status(201).json(sector);
|
||||
}
|
||||
catch {
|
||||
res.status(409).json({ error: 'Setor com este nome já existe.' });
|
||||
}
|
||||
});
|
||||
// PATCH /api/sectors/:id
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100).optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.sector.update({ where: { id }, data: body });
|
||||
res.json(updated);
|
||||
});
|
||||
// DELETE /api/sectors/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.team.deleteMany({ where: { sectorId: id } });
|
||||
await prisma_1.prisma.sector.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// GET /api/sectors/:id/teams
|
||||
router.get('/:id/teams', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectorId = req.params['id'];
|
||||
const teams = await prisma_1.prisma.team.findMany({
|
||||
where: { sectorId, tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { protocols: true } } },
|
||||
});
|
||||
res.json(teams);
|
||||
});
|
||||
// POST /api/sectors/:id/teams
|
||||
router.post('/:id/teams', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const sectorId = req.params['id'];
|
||||
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const sector = await prisma_1.prisma.sector.findFirst({ where: { id: sectorId, tenantId } });
|
||||
if (!sector) {
|
||||
res.status(404).json({ error: 'Setor não encontrado' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const team = await prisma_1.prisma.team.create({ data: { tenantId, sectorId, name: body.name } });
|
||||
res.status(201).json(team);
|
||||
}
|
||||
catch {
|
||||
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' });
|
||||
}
|
||||
});
|
||||
// PATCH /api/sectors/:sectorId/teams/:teamId
|
||||
router.patch('/:sectorId/teams/:teamId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const teamId = req.params['teamId'];
|
||||
const schema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100).optional(),
|
||||
isActive: zod_1.z.boolean().optional(),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Equipe não encontrada' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.team.update({ where: { id: teamId }, data: body });
|
||||
res.json(updated);
|
||||
});
|
||||
// DELETE /api/sectors/:sectorId/teams/:teamId
|
||||
router.delete('/:sectorId/teams/:teamId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const teamId = req.params['teamId'];
|
||||
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Equipe não encontrada' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.team.delete({ where: { id: teamId } });
|
||||
res.status(204).send();
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildStickerRoutes = buildStickerRoutes;
|
||||
/**
|
||||
* Rotas de Figurinhas (Stickers)
|
||||
*
|
||||
* GET /api/stickers — Lista stickers do tenant (com listVersion)
|
||||
* GET /api/stickers/recent — Últimos 30 stickers usados
|
||||
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
function buildStickerRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST (com listVersion para cache invalidation) ────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const stickers = await prisma_1.prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
useCount: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
});
|
||||
const versionSource = stickers.length === 0
|
||||
? 'empty'
|
||||
: `${stickers.length}:${stickers[0].sha256}:${stickers[0].lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`;
|
||||
const listVersion = (0, crypto_1.createHash)('md5').update(versionSource).digest('hex').slice(0, 8);
|
||||
res.json({ listVersion, stickers });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers' });
|
||||
}
|
||||
});
|
||||
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const stickers = await prisma_1.prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
});
|
||||
res.json({ stickers });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers recentes' });
|
||||
}
|
||||
});
|
||||
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
|
||||
router.patch('/:id/favorite', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const idParam = req.params['id'];
|
||||
const id = Array.isArray(idParam) ? idParam[0] : idParam;
|
||||
const sticker = await prisma_1.prisma.sticker.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, isFavorite: true },
|
||||
});
|
||||
if (!sticker) {
|
||||
res.status(404).json({ error: 'Sticker não encontrado' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma_1.prisma.sticker.update({
|
||||
where: { id: sticker.id },
|
||||
data: { isFavorite: !sticker.isFavorite },
|
||||
select: { id: true, isFavorite: true },
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar favorito' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildTemplateRoutes = buildTemplateRoutes;
|
||||
/**
|
||||
* Rotas de Templates de Mensagem
|
||||
*
|
||||
* GET /api/templates — Lista templates do tenant
|
||||
* POST /api/templates — Cria template
|
||||
* GET /api/templates/:id — Detalhe do template
|
||||
* PUT /api/templates/:id — Atualiza template
|
||||
* DELETE /api/templates/:id — Remove template
|
||||
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'];
|
||||
const templateSchema = zod_1.z.object({
|
||||
name: zod_1.z.string().min(1).max(100),
|
||||
description: zod_1.z.string().max(500).optional(),
|
||||
type: zod_1.z.enum(templateTypeValues),
|
||||
payload: zod_1.z.record(zod_1.z.unknown()),
|
||||
tags: zod_1.z.array(zod_1.z.string()).default([]),
|
||||
});
|
||||
function getId(req) {
|
||||
const id = req.params['id'];
|
||||
return Array.isArray(id) ? id[0] : id;
|
||||
}
|
||||
function buildTemplateRoutes() {
|
||||
const router = (0, express_1.Router)();
|
||||
// ── LIST ──────────────────────────────────────────────────────────────────
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'];
|
||||
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search'];
|
||||
const templates = await prisma_1.prisma.messageTemplate.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(type ? { type: type } : {}),
|
||||
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
res.json(templates);
|
||||
});
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const parse = templateSchema.safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const { name, description, type, payload, tags } = parse.data;
|
||||
const template = await prisma_1.prisma.messageTemplate.create({
|
||||
data: { tenantId, name, description, type, payload: payload, tags },
|
||||
});
|
||||
res.status(201).json(template);
|
||||
});
|
||||
// ── GET ONE ───────────────────────────────────────────────────────────────
|
||||
router.get('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const template = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id: getId(req), tenantId },
|
||||
});
|
||||
if (!template) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(template);
|
||||
});
|
||||
// ── UPDATE ────────────────────────────────────────────────────────────────
|
||||
router.put('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
const parse = templateSchema.partial().safeParse(req.body);
|
||||
if (!parse.success) {
|
||||
res.status(400).json({ error: parse.error.errors });
|
||||
return;
|
||||
}
|
||||
const { payload, ...rest } = parse.data;
|
||||
const updated = await prisma_1.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { ...rest, ...(payload !== undefined ? { payload: payload } : {}) },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.messageTemplate.delete({ where: { id } });
|
||||
res.status(204).send();
|
||||
});
|
||||
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
|
||||
router.post('/:id/use', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = getId(req);
|
||||
const exists = await prisma_1.prisma.messageTemplate.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
if (!exists) {
|
||||
res.status(404).json({ error: 'Template não encontrado' });
|
||||
return;
|
||||
}
|
||||
await prisma_1.prisma.messageTemplate.update({
|
||||
where: { id },
|
||||
data: { usageCount: { increment: 1 } },
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
|
||||
exports.getEngine = getEngine;
|
||||
const official = __importStar(require("./official"));
|
||||
const infinite = __importStar(require("./infinite"));
|
||||
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
|
||||
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
function resolveEngine(type) {
|
||||
return type === 'infinite' ? infinite : official;
|
||||
}
|
||||
/**
|
||||
* Retorna os exports da engine selecionada para uma instância específica.
|
||||
* Permite que cada instância use uma engine diferente (infinite ou official).
|
||||
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
|
||||
*/
|
||||
function getEngine(engineType) {
|
||||
const e = resolveEngine(engineType ?? defaultEngineType);
|
||||
return {
|
||||
makeWASocket: e.makeWASocket,
|
||||
useMultiFileAuthState: e.useMultiFileAuthState,
|
||||
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage: e.downloadMediaMessage,
|
||||
getContentType: e.getContentType,
|
||||
generateMessageIDV2: e.generateMessageIDV2,
|
||||
generateWAMessageFromContent: e.generateWAMessageFromContent,
|
||||
isJidGroup: e.isJidGroup,
|
||||
isJidStatusBroadcast: e.isJidStatusBroadcast,
|
||||
};
|
||||
}
|
||||
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
|
||||
const selectedEngine = resolveEngine(defaultEngineType);
|
||||
exports.makeWASocket = selectedEngine.makeWASocket;
|
||||
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
|
||||
exports.DisconnectReason = selectedEngine.DisconnectReason;
|
||||
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
|
||||
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
|
||||
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
|
||||
exports.getContentType = selectedEngine.getContentType;
|
||||
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
|
||||
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
|
||||
exports.isJidGroup = selectedEngine.isJidGroup;
|
||||
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
|
||||
var official_1 = require("./official");
|
||||
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("baileys"), exports);
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("@whiskeysockets/baileys"), exports);
|
||||
@@ -0,0 +1,508 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContactHandler = void 0;
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
|
||||
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
|
||||
// forem feitas em curto período. Estas constantes controlam o throttling.
|
||||
/** Quantos avatares buscar em paralelo por lote */
|
||||
const AVATAR_BATCH_SIZE = 5;
|
||||
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
|
||||
const AVATAR_DELAY_MS = 900;
|
||||
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
|
||||
const MAX_MISSING_AVATAR_SYNC = 80;
|
||||
class ContactHandler {
|
||||
instanceId;
|
||||
tenantId;
|
||||
sock;
|
||||
io;
|
||||
/** Fila de JIDs aguardando busca de avatar */
|
||||
avatarQueue = [];
|
||||
/** Flag de mutex simples — evita processamento concorrente da fila */
|
||||
processingAvatars = false;
|
||||
constructor(
|
||||
/** UUID da instância WhatsApp */
|
||||
instanceId,
|
||||
/** UUID do tenant (isolamento multi-tenant) */
|
||||
tenantId,
|
||||
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
|
||||
sock,
|
||||
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
|
||||
io) {
|
||||
this.instanceId = instanceId;
|
||||
this.tenantId = tenantId;
|
||||
this.sock = sock;
|
||||
this.io = io;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESOLUÇÃO DE NOME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Regra de ouro para resolução de nome de contato:
|
||||
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
|
||||
*
|
||||
* Retorna undefined se nenhum campo estiver disponível.
|
||||
*/
|
||||
resolveName(c) {
|
||||
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Cria ou atualiza contatos em massa.
|
||||
*
|
||||
* Dispara em dois cenários:
|
||||
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
|
||||
* 2. Campo contacts[] do messaging-history.set (history sync)
|
||||
*
|
||||
* Para cada contato:
|
||||
* - Cria ou atualiza no banco com nome resolvido
|
||||
* - Vincula chats órfãos (Chat sem contactId) ao contato
|
||||
* - Enfileira busca de avatar se imgUrl não disponível
|
||||
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
|
||||
*
|
||||
* TRATAMENTO DE LID:
|
||||
* O Baileys pode enviar contatos com c.id no formato @lid.
|
||||
* Quando o contato do history sync inclui campo `jid` (telefone real),
|
||||
* usamos esse JID como chave primária e salvamos o LID em lidJid.
|
||||
* Quando não há campo `jid`, o contato é criado com o LID como JID
|
||||
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
|
||||
*/
|
||||
async upsert(contacts) {
|
||||
/** JIDs que precisam buscar avatar via profilePictureUrl */
|
||||
const needFetchAvatar = [];
|
||||
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
|
||||
let directAvatarCount = 0;
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const isLid = c.id.endsWith('@lid');
|
||||
const isBroadcast = c.id.includes('@broadcast');
|
||||
if (isBroadcast)
|
||||
continue;
|
||||
// imgUrl pode vir do Baileys com valores especiais:
|
||||
// - URL string: avatar disponível (salva direto)
|
||||
// - 'changed': avatar foi alterado (precisa buscar novamente)
|
||||
// - null/undefined: não disponível (precisa buscar via API)
|
||||
const imgUrl = c.imgUrl;
|
||||
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
|
||||
// que associa o LID ao contato com JID real (@s.whatsapp.net)
|
||||
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
|
||||
const lidFromContact = c.lid;
|
||||
try {
|
||||
// Para contatos @lid que possuem o JID real do telefone:
|
||||
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
|
||||
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
|
||||
// Ambos são suportados — o primeiro disponível vence.
|
||||
// Isso evita criar contatos duplicados (um com LID, outro com phone).
|
||||
const pnJid = c.jid
|
||||
?? c.phoneNumber;
|
||||
const contactJid = isLid && pnJid ? pnJid : c.id;
|
||||
// Quando o contato É um @lid mas foi resolvido para phoneJid:
|
||||
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
|
||||
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
|
||||
// lidJid = lidFromContact
|
||||
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: {
|
||||
tenantId_instanceId_jid: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
update: {
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
});
|
||||
// Garante vínculo Chat ↔ Contact para chats órfãos
|
||||
// (chats criados antes do contato existir ficam com contactId=null)
|
||||
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
|
||||
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
contactId: null,
|
||||
},
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
// ── Lógica de avatar ──────────────────────────────────────────────
|
||||
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
|
||||
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
|
||||
const resolvedIsLid = contactJid.endsWith('@lid');
|
||||
if (!resolvedIsLid) {
|
||||
if (imgUrl && imgUrl !== 'changed') {
|
||||
// Avatar veio pronto do Baileys — notifica frontend imediatamente
|
||||
directAvatarCount++;
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
avatarUrl: imgUrl,
|
||||
});
|
||||
}
|
||||
else if (imgUrl === 'changed' || imgUrl === undefined) {
|
||||
// Precisa buscar via API — adiciona à fila com rate-limit
|
||||
needFetchAvatar.push(contactJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
|
||||
}
|
||||
}
|
||||
logger_1.logger.debug({
|
||||
instanceId: this.instanceId,
|
||||
direct: directAvatarCount,
|
||||
toFetch: needFetchAvatar.length,
|
||||
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
|
||||
// Enfileira busca de avatares com cap de 50 por batch
|
||||
// (randomiza para priorizar contatos diferentes a cada sync)
|
||||
if (this.sock && needFetchAvatar.length > 0) {
|
||||
const toQueue = needFetchAvatar.length > 50
|
||||
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
|
||||
: needFetchAvatar;
|
||||
this.enqueueAvatars(toQueue);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPDATE PARCIAL (contacts.update)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Atualiza campos parciais de contatos existentes.
|
||||
*
|
||||
* Dispara no evento contacts.update do Baileys — diferente do upsert,
|
||||
* aqui só atualizamos os campos que foram explicitamente alterados.
|
||||
* Não cria contatos novos (se não existir, ignora silenciosamente).
|
||||
*/
|
||||
async update(contacts) {
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const resolvedName = this.resolveName(c);
|
||||
// Se nenhum campo relevante mudou, pula
|
||||
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
|
||||
continue;
|
||||
try {
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
},
|
||||
data: {
|
||||
...(resolvedName !== undefined && { name: resolvedName }),
|
||||
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
|
||||
...(c.notify !== undefined && { notify: c.notify }),
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
|
||||
}
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca e salva a foto de perfil do número WhatsApp da instância.
|
||||
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
|
||||
*/
|
||||
async syncInstanceAvatar(instanceId) {
|
||||
if (!this.sock?.user?.id)
|
||||
return null;
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
|
||||
if (url) {
|
||||
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
|
||||
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Avatar privado ou não disponível — normal para contas com privacidade restrita
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Reconcilia nomes de contatos após sync do histórico.
|
||||
*
|
||||
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
|
||||
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
|
||||
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
|
||||
*
|
||||
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
|
||||
* name=null E notify=null, e preenche usando o pushName da mensagem
|
||||
* recebida mais recente para cada JID.
|
||||
*
|
||||
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
|
||||
*
|
||||
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
|
||||
*/
|
||||
async reconcileNamesFromMessages() {
|
||||
try {
|
||||
// Busca contatos "sem nome" — candidatos à reconciliação
|
||||
const unnamed = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
name: null,
|
||||
notify: null,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
{ jid: { endsWith: '@g.us' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, jid: true },
|
||||
});
|
||||
if (unnamed.length === 0) {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
|
||||
return;
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
|
||||
let updated = 0;
|
||||
for (const contact of unnamed) {
|
||||
// Busca a mensagem recebida mais recente com pushName para este JID
|
||||
const msg = await prisma_1.prisma.message.findFirst({
|
||||
where: {
|
||||
instanceId: this.instanceId,
|
||||
remoteJid: contact.jid,
|
||||
fromMe: false,
|
||||
pushName: { not: null },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
select: { pushName: true },
|
||||
});
|
||||
if (msg?.pushName) {
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { name: msg.pushName, notify: msg.pushName },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
|
||||
// Notifica o frontend para atualizar a lista de chats
|
||||
if (updated > 0) {
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
|
||||
instanceId: this.instanceId,
|
||||
count: updated,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FORCE CONTACT SYNC (resyncAppState)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Força re-sync dos contatos via app-state do Baileys.
|
||||
*
|
||||
* NOTA: Este método existe mas NÃO é chamado automaticamente.
|
||||
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
|
||||
* força o resync das app-state collections que contêm contatos.
|
||||
*
|
||||
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
|
||||
* "tried remove, but no previous op" — erro de estado corrompido
|
||||
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
|
||||
*
|
||||
* Pode ser chamado manualmente via API se necessário.
|
||||
*/
|
||||
async forceContactSync() {
|
||||
if (!this.sock)
|
||||
return;
|
||||
try {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
|
||||
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca avatares ausentes para chats ativos após reconexão.
|
||||
*
|
||||
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
|
||||
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
|
||||
* ou contatos vieram sem imgUrl.
|
||||
*
|
||||
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
|
||||
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
|
||||
*/
|
||||
async syncMissingAvatars() {
|
||||
try {
|
||||
const missing = await prisma_1.prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
isArchived: false,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
contact: { avatarUrl: null },
|
||||
},
|
||||
select: { jid: true },
|
||||
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
take: MAX_MISSING_AVATAR_SYNC,
|
||||
});
|
||||
if (missing.length === 0)
|
||||
return;
|
||||
const jids = missing.map((c) => c.jid);
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
|
||||
this.enqueueAvatars(jids);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
|
||||
* em menos de 48 horas ou já expirou.
|
||||
*
|
||||
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
|
||||
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
|
||||
*/
|
||||
async syncExpiredAvatars() {
|
||||
try {
|
||||
const contacts = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
avatarUrl: { not: null },
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
},
|
||||
select: { jid: true, avatarUrl: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
|
||||
const expiring = contacts
|
||||
.filter(c => {
|
||||
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
|
||||
if (!m)
|
||||
return false;
|
||||
return parseInt(m[1], 16) < threshold;
|
||||
})
|
||||
.map(c => c.jid);
|
||||
if (expiring.length === 0)
|
||||
return;
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
|
||||
// Limpa as URLs expiradas antes de re-buscar, para que o browser
|
||||
// não use URLs inválidas enquanto a fila processa
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: { in: expiring },
|
||||
},
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FILA DE AVATARES COM RATE-LIMIT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
|
||||
*
|
||||
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
|
||||
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
|
||||
*
|
||||
* Se a fila já está sendo processada, os novos JIDs são adicionados
|
||||
* e serão processados na próxima iteração do loop.
|
||||
*/
|
||||
enqueueAvatars(jids) {
|
||||
const inQueue = new Set(this.avatarQueue);
|
||||
for (const jid of jids) {
|
||||
if (!inQueue.has(jid))
|
||||
this.avatarQueue.push(jid);
|
||||
}
|
||||
this.processAvatarQueue();
|
||||
}
|
||||
/**
|
||||
* Processa a fila de avatares com rate-limit.
|
||||
*
|
||||
* Usa Promise.allSettled() para que falhas individuais (foto privada,
|
||||
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
|
||||
* 1. Salvo no campo avatarUrl do contato no banco
|
||||
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
|
||||
*/
|
||||
async processAvatarQueue() {
|
||||
// Mutex simples: apenas uma execução por vez
|
||||
if (this.processingAvatars || this.avatarQueue.length === 0)
|
||||
return;
|
||||
this.processingAvatars = true;
|
||||
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
|
||||
while (this.avatarQueue.length > 0 && this.sock) {
|
||||
// Pega o próximo lote (remove da fila)
|
||||
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
|
||||
await Promise.allSettled(batch.map(async (jid) => {
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(jid, 'image');
|
||||
if (url) {
|
||||
// Persiste no banco
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
// Notifica o frontend para atualizar o avatar na UI
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
avatarUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Foto privada ou JID inválido — ignora silenciosamente
|
||||
// (não loga para evitar spam com milhares de contatos)
|
||||
}
|
||||
}));
|
||||
// Delay entre lotes para respeitar rate-limit do WhatsApp
|
||||
if (this.avatarQueue.length > 0) {
|
||||
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
this.processingAvatars = false;
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
|
||||
}
|
||||
}
|
||||
exports.ContactHandler = ContactHandler;
|
||||
@@ -0,0 +1,969 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MessageHandler = void 0;
|
||||
/**
|
||||
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
|
||||
*
|
||||
* Responsabilidades:
|
||||
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
|
||||
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
|
||||
* 3. Criar/atualizar Contact, Chat e Message no banco
|
||||
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
|
||||
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
|
||||
* 6. Disparar chatbot para mensagens recebidas com texto
|
||||
*
|
||||
* CONTEXTO LID (Linked ID):
|
||||
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
|
||||
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
|
||||
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
|
||||
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
|
||||
* - key.participantPn: idem, usado em contextos de grupo
|
||||
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
|
||||
*
|
||||
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
|
||||
* @see ContactHandler — gerencia contatos e avatares
|
||||
*/
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const engine_1 = require("../engine");
|
||||
const client_1 = require("@prisma/client");
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const whatsapp_1 = require("../../../shared/utils/whatsapp");
|
||||
const StorageProvider_1 = require("../../../core/StorageProvider");
|
||||
const hook_bus_1 = require("../../../core/hook-bus");
|
||||
const chat_cache_1 = require("../../chats/chat.cache");
|
||||
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||
const MEDIA_DIR = path_1.default.resolve('./media');
|
||||
/**
|
||||
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
|
||||
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
|
||||
*/
|
||||
const PROTOCOL_CONTENT_TYPES = new Set([
|
||||
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
|
||||
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
|
||||
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
|
||||
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
|
||||
'messageContextInfo', // só metadados, sem conteúdo
|
||||
'appStateSyncKeyShare', // sync de estado do app (protocolo)
|
||||
'appStateFatalExceptionNotification', // notificação interna (protocolo)
|
||||
'keepInChatMessage', // manter no chat (protocolo)
|
||||
]);
|
||||
/**
|
||||
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
|
||||
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
|
||||
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
|
||||
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
|
||||
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
|
||||
*/
|
||||
const COMPOSITE_WRAPPER_TYPES = new Set([
|
||||
'documentWithCaptionMessage',
|
||||
'viewOnceMessage',
|
||||
'viewOnceMessageV2',
|
||||
'viewOnceMessageV2Extension',
|
||||
'editedMessage',
|
||||
'botInvokeMessage',
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function unwrapInnerMessage(msg) {
|
||||
let cur = msg;
|
||||
let depth = 0;
|
||||
while (cur && depth < 5) {
|
||||
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo');
|
||||
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k));
|
||||
if (!wrapperKey)
|
||||
return cur;
|
||||
const inner = cur[wrapperKey]?.message;
|
||||
if (!inner || typeof inner !== 'object')
|
||||
return cur;
|
||||
cur = inner;
|
||||
depth++;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
class MessageHandler {
|
||||
sock;
|
||||
instanceId;
|
||||
tenantId;
|
||||
io;
|
||||
chatbotService;
|
||||
/**
|
||||
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
|
||||
*
|
||||
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
|
||||
*
|
||||
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
|
||||
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
|
||||
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
|
||||
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
|
||||
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
|
||||
* Baileys NÃO expõe em `decode-wa-message.js`.
|
||||
*
|
||||
* Por isso, o WhatsAppConnectionManager registra um listener extra em
|
||||
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
|
||||
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
|
||||
* consome a dica e remove a entrada (mapa efêmero, não cresce).
|
||||
*/
|
||||
peerRecipientHints = new Map();
|
||||
constructor(
|
||||
/** Socket Baileys ativo — usado para download de mídia e reupload */
|
||||
sock,
|
||||
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
|
||||
instanceId,
|
||||
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
|
||||
tenantId,
|
||||
/** Socket.IO server — emite eventos em tempo real para o frontend */
|
||||
io,
|
||||
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
|
||||
chatbotService) {
|
||||
this.sock = sock;
|
||||
this.instanceId = instanceId;
|
||||
this.tenantId = tenantId;
|
||||
this.io = io;
|
||||
this.chatbotService = chatbotService;
|
||||
}
|
||||
/**
|
||||
* Registra uma dica de destinatário extraída da stanza bruta.
|
||||
*
|
||||
* Chamado pelo listener `sock.ws.on('CB:message')` no
|
||||
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
|
||||
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
|
||||
*/
|
||||
setPeerRecipientHint(msgId, peerRecipientPn) {
|
||||
this.peerRecipientHints.set(msgId, peerRecipientPn);
|
||||
setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref();
|
||||
}
|
||||
/**
|
||||
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
|
||||
*
|
||||
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
|
||||
*
|
||||
* Diferença entre os modos:
|
||||
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
|
||||
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
|
||||
*/
|
||||
async handle({ messages, type }) {
|
||||
if (type === 'notify') {
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await this.processMessage(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (type === 'append') {
|
||||
await this.handleHistory(messages);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Processa mensagens históricas (history sync / append).
|
||||
*
|
||||
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
|
||||
* por grupo, minimizando round-trips ao banco.
|
||||
*
|
||||
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
|
||||
* Se não for possível resolver, a mensagem é descartada silenciosamente
|
||||
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
|
||||
*/
|
||||
async handleHistory(messages) {
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
|
||||
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
|
||||
const mensagensRecentes = [];
|
||||
const mensagensAntigas = [];
|
||||
// Separa as mensagens por idade
|
||||
for (const msg of messages) {
|
||||
const ts = Number(msg.messageTimestamp ?? 0);
|
||||
if (ts >= doisDiasAtrasEmSegundos) {
|
||||
mensagensRecentes.push(msg);
|
||||
}
|
||||
else {
|
||||
mensagensAntigas.push(msg);
|
||||
}
|
||||
}
|
||||
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
|
||||
if (mensagensRecentes.length > 0) {
|
||||
logger_1.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
count: mensagensRecentes.length
|
||||
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
|
||||
await this.processHistoryBatch(mensagensRecentes);
|
||||
}
|
||||
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
|
||||
if (mensagensAntigas.length > 0) {
|
||||
logger_1.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
count: mensagensAntigas.length
|
||||
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
|
||||
this.processOlderHistoryGradually(mensagensAntigas);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
|
||||
*/
|
||||
processOlderHistoryGradually(messages) {
|
||||
const tamanhoDoLote = 100;
|
||||
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
|
||||
let offset = 0;
|
||||
const processarProximoLote = async () => {
|
||||
// Se processou tudo, encerra o ciclo
|
||||
if (offset >= messages.length) {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
|
||||
return;
|
||||
}
|
||||
// Corta o próximo pedaço do histórico
|
||||
const lote = messages.slice(offset, offset + tamanhoDoLote);
|
||||
offset += tamanhoDoLote;
|
||||
try {
|
||||
await this.processHistoryBatch(lote);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
|
||||
}
|
||||
// Agenda o próximo lote de forma não bloqueante
|
||||
setTimeout(processarProximoLote, intervaloMs).unref();
|
||||
};
|
||||
// Inicia o sincronismo em background após 5 segundos, garantindo que
|
||||
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
|
||||
setTimeout(processarProximoLote, 5000).unref();
|
||||
}
|
||||
/**
|
||||
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
|
||||
*/
|
||||
async processHistoryBatch(messages) {
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
|
||||
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
|
||||
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
|
||||
const byJid = new Map();
|
||||
for (const msg of messages) {
|
||||
let jidRaw = msg.key.remoteJid;
|
||||
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message)
|
||||
continue;
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||
if (ownJid && normalizedRaw === ownJid)
|
||||
continue;
|
||||
// ── Resolução LID → phone JID para mensagens históricas ──────────
|
||||
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
|
||||
// Precisamos converter para @s.whatsapp.net antes de persistir,
|
||||
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
|
||||
//
|
||||
// Fontes de resolução (ordem de prioridade):
|
||||
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
|
||||
// 2. key.senderPn / participantPn — campo do protocolo WA
|
||||
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
|
||||
if (jidRaw.endsWith('@lid')) {
|
||||
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
|
||||
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
|
||||
const remoteJidAlt = msg.key.remoteJidAlt;
|
||||
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
|
||||
}
|
||||
else {
|
||||
// 2. senderPn / participantPn
|
||||
const senderPn = msg.key.senderPn || msg.key.participantPn;
|
||||
if (senderPn) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (!jidRaw.endsWith('@s.whatsapp.net'))
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid && !contact.jid.endsWith('@lid')) {
|
||||
jidRaw = contact.jid;
|
||||
}
|
||||
else {
|
||||
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||
const list = byJid.get(jid) ?? [];
|
||||
list.push(msg);
|
||||
byJid.set(jid, list);
|
||||
}
|
||||
let totalSaved = 0;
|
||||
for (const [jid, msgs] of byJid) {
|
||||
try {
|
||||
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
|
||||
const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0);
|
||||
const lastAt = latestTs ? new Date(latestTs * 1000) : null;
|
||||
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
// 2. Garantir que o Chat existe e está vinculado ao Contact
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
contactId: contact.id,
|
||||
lastMessageAt: lastAt,
|
||||
unreadCount: 0
|
||||
},
|
||||
update: {
|
||||
contactId: contact.id,
|
||||
...(lastAt && { lastMessageAt: lastAt })
|
||||
},
|
||||
});
|
||||
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
|
||||
const rows = msgs.flatMap((msg) => {
|
||||
const { key, messageTimestamp } = msg;
|
||||
let message = msg.message;
|
||||
if (!message || !key.id)
|
||||
return [];
|
||||
message = unwrapInnerMessage(message);
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return [];
|
||||
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
||||
if (PROTOCOL_CONTENT_TYPES.has(contentType))
|
||||
return [];
|
||||
const msgType = this.mapContentType(contentType);
|
||||
if (msgType === client_1.MessageType.UNSUPPORTED)
|
||||
return [];
|
||||
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date();
|
||||
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
|
||||
const body = message.conversation ??
|
||||
message.extendedTextMessage?.text ??
|
||||
message.imageMessage?.caption ??
|
||||
message.videoMessage?.caption ??
|
||||
message.documentMessage?.caption ??
|
||||
null;
|
||||
const isGroup = jid.endsWith('@g.us');
|
||||
return [{
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId: key.id,
|
||||
fromMe: key.fromMe ?? false,
|
||||
type: msgType,
|
||||
body,
|
||||
// pushName: nome público do remetente (só confiável em msgs recebidas)
|
||||
pushName: (key.fromMe ? null : msg.pushName) ?? null,
|
||||
// senderJid: em grupos, identifica quem enviou (JID do participante)
|
||||
senderJid: isGroup ? (key.participant ?? null) : null,
|
||||
status: 'READ', // históricas são consideradas já lidas
|
||||
timestamp: ts,
|
||||
}];
|
||||
});
|
||||
if (rows.length > 0) {
|
||||
const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
|
||||
totalSaved += count;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
|
||||
}
|
||||
}
|
||||
if (totalSaved > 0) {
|
||||
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} mensagens históricas salvas`);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TEMPO REAL — Processa uma única mensagem (notify)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Processa uma mensagem recebida em tempo real.
|
||||
*
|
||||
* Fluxo completo:
|
||||
* 1. Filtra broadcasts, newsletters
|
||||
* 2. Resolve LID → telefone (se @lid)
|
||||
* 3. Cria/atualiza Contact com pushName (se aplicável)
|
||||
* 4. Cria/atualiza Chat vinculado ao Contact
|
||||
* 5. Persiste a Message no banco
|
||||
* 6. Emite socket events (message:new, chat:upsert)
|
||||
* 7. Agenda download de mídia (assíncrono)
|
||||
* 8. Dispara chatbot (assíncrono)
|
||||
*/
|
||||
async processMessage(msg) {
|
||||
const { key, messageTimestamp, pushName } = msg;
|
||||
let message = msg.message;
|
||||
if (!message || !key.remoteJid)
|
||||
return;
|
||||
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
|
||||
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
|
||||
message = unwrapInnerMessage(message);
|
||||
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
|
||||
if (key.remoteJid === 'status@broadcast')
|
||||
return;
|
||||
if (key.remoteJid.endsWith('@newsletter'))
|
||||
return;
|
||||
if (key.remoteJid.startsWith('0@'))
|
||||
return;
|
||||
// ── Resolução LID → telefone ──────────────────────────────────────────
|
||||
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
|
||||
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
|
||||
//
|
||||
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
|
||||
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
|
||||
//
|
||||
// Prioridade de resolução:
|
||||
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
|
||||
// 2. key.participantPn — idem, usado em contextos de grupo
|
||||
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
|
||||
/**
|
||||
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
|
||||
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
|
||||
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
|
||||
* contatos novos — bug silencioso que fazia o mapeamento ser perdido.
|
||||
*/
|
||||
let originalLidJid = null;
|
||||
if (key.remoteJid.endsWith('@lid')) {
|
||||
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key);
|
||||
if (!phoneJid) {
|
||||
// Sem resolução: não há como saber o número real do contato.
|
||||
// A mensagem será perdida, mas o mapeamento será capturado no futuro
|
||||
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
|
||||
logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando');
|
||||
return;
|
||||
}
|
||||
originalLidJid = key.remoteJid;
|
||||
key.remoteJid = phoneJid;
|
||||
}
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return;
|
||||
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
|
||||
if (PROTOCOL_CONTENT_TYPES.has(contentType))
|
||||
return;
|
||||
const msgType = this.mapContentType(contentType);
|
||||
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
|
||||
if (msgType === client_1.MessageType.UNSUPPORTED)
|
||||
return;
|
||||
const fromMe = key.fromMe ?? false;
|
||||
const remoteJidRaw = key.remoteJid;
|
||||
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
|
||||
const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
|
||||
const messageId = key.id;
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
|
||||
if (ownJid && remoteJid === ownJid)
|
||||
return;
|
||||
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
|
||||
// REGRA IMPORTANTE sobre pushName:
|
||||
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
|
||||
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
|
||||
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
|
||||
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
|
||||
// - Para GRUPOS: pushName é do remetente individual, não do grupo
|
||||
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
|
||||
const isGroupMessage = remoteJid.endsWith('@g.us');
|
||||
const participantJid = key.participant ?? msg.participant ?? null;
|
||||
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: remoteJid,
|
||||
name: safePushName,
|
||||
notify: safePushName,
|
||||
phone: isGroupMessage ? null : remoteJid.split('@')[0],
|
||||
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
|
||||
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
|
||||
...(originalLidJid && { lidJid: originalLidJid }),
|
||||
},
|
||||
update: {
|
||||
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
|
||||
...(safePushName && { notify: safePushName }),
|
||||
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
|
||||
// que ainda não tinham o mapeamento (ex: criados via history sync).
|
||||
...(originalLidJid && { lidJid: originalLidJid }),
|
||||
},
|
||||
});
|
||||
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: remoteJid,
|
||||
contactId: contact.id,
|
||||
unreadCount: fromMe ? 0 : 1,
|
||||
lastMessageAt: new Date(messageTimestamp * 1000),
|
||||
},
|
||||
update: {
|
||||
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
|
||||
lastMessageAt: new Date(messageTimestamp * 1000),
|
||||
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
|
||||
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
|
||||
},
|
||||
});
|
||||
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
|
||||
// nativeFlowResponseMessage: resposta ao clique de botão/lista
|
||||
let nativeFlowBody = null;
|
||||
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
|
||||
try {
|
||||
const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}');
|
||||
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null;
|
||||
}
|
||||
catch { /* ignora JSON inválido */ }
|
||||
}
|
||||
const body = message.conversation ??
|
||||
message.extendedTextMessage?.text ??
|
||||
message.imageMessage?.caption ??
|
||||
message.videoMessage?.caption ??
|
||||
message.documentMessage?.caption ??
|
||||
nativeFlowBody ??
|
||||
null;
|
||||
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
|
||||
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null;
|
||||
let replyToId = null;
|
||||
if (replyToWaId) {
|
||||
const replyMsg = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
|
||||
});
|
||||
replyToId = replyMsg?.id ?? null;
|
||||
}
|
||||
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
|
||||
const saved = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid,
|
||||
messageId,
|
||||
fromMe,
|
||||
type: msgType,
|
||||
body,
|
||||
pushName: fromMe ? null : (pushName ?? null),
|
||||
senderJid: isGroupMessage ? participantJid : null,
|
||||
replyToId,
|
||||
status: fromMe ? 'SENT' : 'PENDING',
|
||||
timestamp: new Date(messageTimestamp * 1000),
|
||||
// Armazena payload da mídia para permitir re-download posterior
|
||||
...(this.isMediaMessage(contentType) ? {
|
||||
mediaPayload: this.buildMediaPayload(key, message),
|
||||
} : {}),
|
||||
},
|
||||
update: {}, // Se já existe (duplicata), não sobrescreve
|
||||
});
|
||||
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
|
||||
// 6a. Para quem está com o chat aberto (sala chat:{id})
|
||||
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
|
||||
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
|
||||
const emitMsg = replyToId
|
||||
? await prisma_1.prisma.message.findUnique({
|
||||
where: { id: saved.id },
|
||||
include: {
|
||||
replyTo: {
|
||||
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
|
||||
},
|
||||
},
|
||||
}) ?? saved
|
||||
: saved;
|
||||
this.io.to(`chat:${chat.id}`).emit('message:new', {
|
||||
...emitMsg,
|
||||
pushName,
|
||||
senderJid: isGroupMessage ? participantJid : null,
|
||||
});
|
||||
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
|
||||
hook_bus_1.hookBus.emit('ext:message.new', {
|
||||
instanceId: this.instanceId,
|
||||
tenantId: this.tenantId,
|
||||
chatId: chat.id,
|
||||
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
|
||||
timestamp: Date.now(),
|
||||
}).catch(() => { });
|
||||
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
|
||||
// Necessário para: novos chats, chats não abertos, badge de unread
|
||||
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp });
|
||||
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
|
||||
if (this.isMediaMessage(contentType)) {
|
||||
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
|
||||
const msgUnwrapped = { ...msg, message };
|
||||
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType));
|
||||
}
|
||||
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
|
||||
if (!fromMe && body && this.chatbotService) {
|
||||
setImmediate(() => this.chatbotService.handleIncoming({
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
jid: remoteJid,
|
||||
text: body,
|
||||
sock: this.sock,
|
||||
}).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono')));
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESOLUÇÃO LID → TELEFONE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
|
||||
* (ex: "5511999999999@s.whatsapp.net").
|
||||
*
|
||||
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
|
||||
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
|
||||
*
|
||||
* Fontes de resolução (em ordem de prioridade):
|
||||
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
|
||||
* Útil para mensagens RECEBIDAS e contextos de grupo.
|
||||
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
|
||||
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
|
||||
* Única fonte confiável para mensagens fromMe=true enviadas pelo
|
||||
* celular pra um contato @lid que ainda não temos mapeado.
|
||||
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
|
||||
* Populado por: processamento anterior, chats.phoneNumberShare,
|
||||
* ou contacts.upsert do history sync.
|
||||
*
|
||||
* A persistência do mapeamento LID→phone foi movida para processMessage
|
||||
* (após o upsert do Contact), para evitar o bug anterior onde o
|
||||
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
|
||||
*
|
||||
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
|
||||
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
|
||||
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
|
||||
*/
|
||||
async resolveLidToPhoneJid(lidJid, key) {
|
||||
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
|
||||
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
|
||||
// descreve o DONO da sessão, não o destinatário — inútil aqui.
|
||||
const senderPn = key.senderPn || key.participantPn;
|
||||
if (senderPn && !key.fromMe) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
|
||||
// mensagens fromMe=true que chegam via sync de outro dispositivo.
|
||||
if (key.fromMe && key.id) {
|
||||
const hint = this.peerRecipientHints.get(key.id);
|
||||
this.peerRecipientHints.delete(key.id); // uso único
|
||||
if (hint) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Busca no DB por mapeamento LID salvo anteriormente
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid)
|
||||
return contact.jid;
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMISSÃO DE EVENTOS SOCKET.IO
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Emite evento `chat:upsert` para todos os clientes do tenant.
|
||||
*
|
||||
* Usado para atualizar a lista de chats no frontend em tempo real:
|
||||
* - Novos chats aparecem instantaneamente
|
||||
* - Chats existentes atualizam lastMessage, unreadCount, etc.
|
||||
* - Chats @lid são filtrados (não devem aparecer no frontend)
|
||||
*
|
||||
* O frontend usa este evento para reordenar a lista por lastMessageAt
|
||||
* e exibir o preview da última mensagem.
|
||||
*/
|
||||
async emitChatUpsert(chatId, lastMessage) {
|
||||
try {
|
||||
const chat = await prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!chat)
|
||||
return;
|
||||
// Chats @lid são artefatos internos — nunca expor ao frontend
|
||||
if (chat.jid.endsWith('@lid'))
|
||||
return;
|
||||
const c = chat.contact;
|
||||
const isGroup = chat.jid.endsWith('@g.us');
|
||||
// Regra de resolução de nome:
|
||||
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
|
||||
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
|
||||
// NUNCA usar pushName do remetente como nome de grupo
|
||||
const displayName = isGroup
|
||||
? (chat.name ?? null)
|
||||
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
|
||||
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
|
||||
id: chat.id,
|
||||
tenantId: chat.tenantId,
|
||||
instanceId: chat.instanceId,
|
||||
jid: chat.jid,
|
||||
name: chat.name ?? null,
|
||||
unreadCount: chat.unreadCount,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
isPinned: chat.isPinned,
|
||||
isArchived: chat.isArchived,
|
||||
botPaused: chat.botPaused,
|
||||
contact: c
|
||||
? {
|
||||
name: displayName,
|
||||
phone: c.phone,
|
||||
avatarUrl: c.avatarUrl,
|
||||
scoreReputacao: c.scoreReputacao,
|
||||
flagRestricao: c.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage,
|
||||
});
|
||||
// Invalida cache da listagem — força refetch na próxima request da inbox
|
||||
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DOWNLOAD DE MÍDIA
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
|
||||
*
|
||||
* Executado via setImmediate() para não bloquear o event loop.
|
||||
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
|
||||
* e emite `message:media_ready` para o frontend renderizar o preview.
|
||||
*
|
||||
* Organização: /media/{instanceId}/{messageId}.{ext}
|
||||
*/
|
||||
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
|
||||
try {
|
||||
const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
|
||||
if (!buffer)
|
||||
return;
|
||||
// Para documentos: preserva nome e extensão originais do arquivo
|
||||
const docMsg = msg.message?.documentMessage;
|
||||
const originalFileName = docMsg?.fileName?.trim() || null;
|
||||
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType);
|
||||
let ext;
|
||||
if (contentType === 'documentMessage' && originalFileName) {
|
||||
ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
|
||||
}
|
||||
else {
|
||||
ext = this.extensionForType(contentType);
|
||||
}
|
||||
const fileName = `${savedMsgId}.${ext}`;
|
||||
const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
// Tenta upload para Wasabi; se falhar usa path local como fallback
|
||||
let mediaUrl = `/media/${this.instanceId}/${fileName}`;
|
||||
let mediaPath = filePath;
|
||||
let uploadedToRemote = false;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: this.tenantId,
|
||||
sessionJID: this.instanceId,
|
||||
file: {
|
||||
originalname: originalFileName ?? fileName,
|
||||
buffer: buffer,
|
||||
mimetype: originalMimetype,
|
||||
},
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
if (result.provider !== 'local_fallback') {
|
||||
mediaPath = null;
|
||||
uploadedToRemote = true;
|
||||
}
|
||||
logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage');
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local');
|
||||
}
|
||||
}
|
||||
await prisma_1.prisma.message.update({
|
||||
where: { id: savedMsgId },
|
||||
data: {
|
||||
mediaPath,
|
||||
mediaUrl,
|
||||
// Preserva o nome original do documento para exibição no frontend
|
||||
...(contentType === 'documentMessage' && originalFileName
|
||||
? { fileName: originalFileName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (uploadedToRemote) {
|
||||
promises_1.default.unlink(filePath).catch((err) => {
|
||||
logger_1.logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto');
|
||||
});
|
||||
}
|
||||
// Notifica frontend que a mídia está pronta para exibição
|
||||
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
|
||||
messageId: savedMsgId,
|
||||
mediaUrl,
|
||||
});
|
||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||
if (contentType === 'stickerMessage') {
|
||||
this.persistSticker(msg, mediaUrl).catch(() => { });
|
||||
}
|
||||
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
||||
}
|
||||
}
|
||||
async persistSticker(msg, wasabiPath) {
|
||||
const stickerMsg = msg.message?.stickerMessage;
|
||||
if (!stickerMsg)
|
||||
return;
|
||||
const sha256Bytes = stickerMsg.fileSha256;
|
||||
if (!sha256Bytes || sha256Bytes.length === 0)
|
||||
return;
|
||||
const sha256 = Buffer.from(sha256Bytes).toString('hex');
|
||||
const isAnimated = stickerMsg.isAnimated ?? false;
|
||||
await prisma_1.prisma.sticker.upsert({
|
||||
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
sha256,
|
||||
wasabiPath,
|
||||
isAnimated,
|
||||
useCount: 1,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
useCount: { increment: 1 },
|
||||
lastSeenAt: new Date(),
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAYLOAD DE MÍDIA (serialização para re-download)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Constrói o payload serializado da mensagem de mídia para armazenamento.
|
||||
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
|
||||
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
|
||||
*/
|
||||
buildMediaPayload(key, innerMessage) {
|
||||
const stripped = this.stripThumbnails(innerMessage);
|
||||
return {
|
||||
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
|
||||
message: this.serializeForStorage(stripped),
|
||||
};
|
||||
}
|
||||
stripThumbnails(obj) {
|
||||
if (!obj || typeof obj !== 'object')
|
||||
return obj;
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256')
|
||||
continue;
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
serializeForStorage(val) {
|
||||
if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
|
||||
return { _b64: Buffer.from(val).toString('base64') };
|
||||
}
|
||||
if (Array.isArray(val))
|
||||
return val.map(v => this.serializeForStorage(v));
|
||||
if (val !== null && typeof val === 'object') {
|
||||
// Long de protobuf — converte para número
|
||||
if (typeof val.toNumber === 'function')
|
||||
return val.toNumber();
|
||||
return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, this.serializeForStorage(v)]));
|
||||
}
|
||||
return val;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Verifica se o contentType é de mídia (precisa de download) */
|
||||
isMediaMessage(contentType) {
|
||||
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType);
|
||||
}
|
||||
/**
|
||||
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
|
||||
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
|
||||
*/
|
||||
mapContentType(contentType) {
|
||||
const map = {
|
||||
conversation: client_1.MessageType.TEXT,
|
||||
extendedTextMessage: client_1.MessageType.TEXT,
|
||||
imageMessage: client_1.MessageType.IMAGE,
|
||||
videoMessage: client_1.MessageType.VIDEO,
|
||||
audioMessage: client_1.MessageType.AUDIO,
|
||||
documentMessage: client_1.MessageType.DOCUMENT,
|
||||
stickerMessage: client_1.MessageType.STICKER,
|
||||
locationMessage: client_1.MessageType.LOCATION,
|
||||
contactMessage: client_1.MessageType.CONTACT,
|
||||
reactionMessage: client_1.MessageType.REACTION,
|
||||
pollCreationMessage: client_1.MessageType.POLL,
|
||||
pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
|
||||
pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
|
||||
// Resposta ao clique de botão / seleção de item de lista
|
||||
interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
|
||||
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
|
||||
interactiveMessage: client_1.MessageType.BUTTONS,
|
||||
};
|
||||
return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
|
||||
}
|
||||
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
|
||||
extensionForType(contentType) {
|
||||
const map = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
return map[contentType] ?? 'bin';
|
||||
}
|
||||
/** Retorna o mimetype adequado para o tipo de mídia */
|
||||
mimetypeForType(contentType) {
|
||||
const map = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
};
|
||||
return map[contentType] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
exports.MessageHandler = MessageHandler;
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.InstanceRepository = void 0;
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
class InstanceRepository {
|
||||
async findAllByTenant(tenantId) {
|
||||
return prisma_1.prisma.instance.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
async findById(id) {
|
||||
return prisma_1.prisma.instance.findUnique({ where: { id } });
|
||||
}
|
||||
async create(tenantId, name) {
|
||||
return prisma_1.prisma.instance.create({
|
||||
data: { tenantId, name },
|
||||
});
|
||||
}
|
||||
async updateStatus(id, status) {
|
||||
return prisma_1.prisma.instance.update({ where: { id }, data: { status } });
|
||||
}
|
||||
async delete(id) {
|
||||
return prisma_1.prisma.instance.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
exports.InstanceRepository = InstanceRepository;
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.sendRichMessage = sendRichMessage;
|
||||
/**
|
||||
* rich-message.ts — Construção e envio de mensagens ricas via Baileys padrão
|
||||
*
|
||||
* O pacote @whiskeysockets/baileys não suporta os campos `nativeButtons`,
|
||||
* `nativeList` e `nativeCarousel` que são específicos do fork InfiniteAPI.
|
||||
* Este módulo replica a lógica de transformação do InfiniteAPI para que
|
||||
* o motor newwhats possa enviar botões, listas e carrosséis usando o
|
||||
* sock.relayMessage() com o proto correto.
|
||||
*
|
||||
* Referência: github:rsalcara/InfiniteAPI — lib/Socket/messages-send.js
|
||||
*
|
||||
* IMPORTANTE: O WhatsApp Web NÃO suporta viewOnceMessage para mensagens interativas.
|
||||
* Por isso usamos interactiveMessage DIRETO (sem wrapper viewOnce) + additionalNodes.
|
||||
*
|
||||
* O que é necessário para botões/listas/carrosséis funcionarem no WhatsApp
|
||||
* (celular E web):
|
||||
*
|
||||
* 1. interactiveMessage direto (NÃO viewOnceMessage) — viewOnce não carrega no Web
|
||||
*
|
||||
* 2. 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.
|
||||
*/
|
||||
const engine_1 = require("./engine");
|
||||
// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ─────
|
||||
function formatNativeFlowButton(btn) {
|
||||
switch (btn.type) {
|
||||
case 'url':
|
||||
return {
|
||||
name: 'cta_url',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
url: btn.url,
|
||||
merchant_url: btn.url,
|
||||
}),
|
||||
};
|
||||
case 'copy':
|
||||
return {
|
||||
name: 'cta_copy',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
copy_code: btn.copyText,
|
||||
}),
|
||||
};
|
||||
case 'reply':
|
||||
return {
|
||||
name: 'quick_reply',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
id: btn.id,
|
||||
}),
|
||||
};
|
||||
case 'call':
|
||||
return {
|
||||
name: 'cta_call',
|
||||
buttonParamsJson: JSON.stringify({
|
||||
display_text: btn.text,
|
||||
phone_number: btn.phoneNumber,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
// ─── Nó biz > interactive > native_flow (obrigatório para renderizar no Web) ──
|
||||
const BIZ_NATIVE_FLOW_NODE = {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [
|
||||
{
|
||||
tag: 'interactive',
|
||||
attrs: { type: 'native_flow', v: '1' },
|
||||
content: [
|
||||
{
|
||||
tag: 'native_flow',
|
||||
attrs: { v: '9', name: 'mixed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// ─── Nó biz para listMessage legado (formato que renderiza no Web) ───────────
|
||||
// InfiniteAPI usa <biz><list type="product_list" v="2"/></biz> com proto.listMessage
|
||||
// (não interactiveMessage). Comentário do fork: "Modern format causes rejection
|
||||
// (error 479) — Legacy format works on all platforms".
|
||||
const BIZ_LIST_NODE = {
|
||||
tag: 'biz',
|
||||
attrs: {},
|
||||
content: [
|
||||
{
|
||||
tag: 'list',
|
||||
attrs: { type: 'product_list', v: '2' },
|
||||
},
|
||||
],
|
||||
};
|
||||
// ─── Nó bot (necessário para chats 1-a-1 com interactive messages) ────────────
|
||||
const BOT_NODE = {
|
||||
tag: 'bot',
|
||||
attrs: { biz_bot: '1' },
|
||||
};
|
||||
// ─── Helper: jid é chat privado (não grupo, não status, não bot) ─────────────
|
||||
function isPrivateChat(jid) {
|
||||
return (!(0, engine_1.isJidGroup)(jid) &&
|
||||
!(0, engine_1.isJidStatusBroadcast)(jid) &&
|
||||
!jid.includes('newsletter') &&
|
||||
!jid.includes('broadcast'));
|
||||
}
|
||||
// ─── Helper: envia proto pré-construído pelo MESMO caminho do sendMessage ────
|
||||
// Diferença crítica vs `sock.relayMessage` direto:
|
||||
// sendMessage() → generateWAMessage() → WAProto.Message.fromObject() → relayMessage()
|
||||
// ↑ normalização proto que o WA Web exige
|
||||
// Sem esse passo, o Web descarta a mensagem ("Não foi possível carregar").
|
||||
// É o mesmo motivo pelo qual nossas enquetes (via sendMessage) funcionam no Web
|
||||
// e os botões (via relayMessage direto) não funcionavam.
|
||||
async function sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) {
|
||||
const fullMsg = (0, engine_1.generateWAMessageFromContent)(jid, protoMsg, {
|
||||
userJid: sock.user?.id,
|
||||
messageId: msgId,
|
||||
quoted,
|
||||
});
|
||||
await sock.relayMessage(jid, fullMsg.message, {
|
||||
messageId: fullMsg.key.id,
|
||||
additionalNodes,
|
||||
});
|
||||
return fullMsg.key.id;
|
||||
}
|
||||
// ─── Helper: monta interactiveMessage DIRETO (sem viewOnceMessage wrapper) ────
|
||||
// viewOnceMessage wrapper faz o WhatsApp Web exibir "Não foi possível carregar":
|
||||
// o Web trata viewOnce como mensagem efêmera e recusa conteúdo interativo dentro.
|
||||
// Baileys padrão não adiciona messageContextInfo para interactiveMessage
|
||||
// (só faz isso para poll, com messageSecret). Usar interactiveMessage direto
|
||||
// + nó <biz> no additionalNodes é o caminho correto com @whiskeysockets/baileys.
|
||||
function makeInteractiveProto(interactiveMsg) {
|
||||
return {
|
||||
interactiveMessage: interactiveMsg,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Envia uma mensagem rica pelo socket Baileys padrão.
|
||||
*
|
||||
* - Buttons / CTAs: interactiveMessage direto + 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.
|
||||
*/
|
||||
async function sendRichMessage(sock, jid, payload, quoted, engineType) {
|
||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload;
|
||||
const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
// Se a engine ativa for 'infinite', utilizamos o suporte nativo do fork InfiniteAPI
|
||||
if (engine === 'infinite') {
|
||||
// ── Poll: delegado ao sendMessage padrão (requer encriptação especial do Baileys) ──
|
||||
if (poll) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.name,
|
||||
values: poll.values,
|
||||
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
|
||||
},
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `poll-${Date.now()}`;
|
||||
}
|
||||
// ── Botões / CTAs nativos (InfiniteAPI) ──
|
||||
if (nativeButtons) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeButtons,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `btn-${Date.now()}`;
|
||||
}
|
||||
// ── Carrossel nativo (InfiniteAPI) ──
|
||||
if (nativeCarousel) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeCarousel,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `carousel-${Date.now()}`;
|
||||
}
|
||||
// ── Lista nativa (InfiniteAPI) ──
|
||||
if (nativeList) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
text: text ?? '',
|
||||
footer,
|
||||
nativeList,
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `list-${Date.now()}`;
|
||||
}
|
||||
// ── Texto simples (InfiniteAPI) ──
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
// FALLBACK: Lógica de montagem manual de nós binários para o Baileys oficial
|
||||
// ── Poll: delegado ao sendMessage padrão (requer messageSecret) ───────────
|
||||
if (poll) {
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
poll: {
|
||||
name: poll.name,
|
||||
values: poll.values,
|
||||
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
|
||||
},
|
||||
}, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `poll-${Date.now()}`;
|
||||
}
|
||||
// ── Texto simples: delegado ao sendMessage padrão ─────────────────────────
|
||||
if (!nativeButtons && !nativeList && !nativeCarousel) {
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
|
||||
return sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
const msgId = (0, engine_1.generateMessageIDV2)(sock.user?.id);
|
||||
const isPrivate = isPrivateChat(jid);
|
||||
// ── Botões / CTAs: interactiveMessage + nativeFlowMessage ────────────────────
|
||||
if (nativeButtons) {
|
||||
const formattedButtons = nativeButtons.map(formatNativeFlowButton);
|
||||
const protoMsg = makeInteractiveProto({
|
||||
body: { text: text ?? '' },
|
||||
footer: footer ? { text: footer } : undefined,
|
||||
header: { title: '', subtitle: '', hasMediaAttachment: false },
|
||||
nativeFlowMessage: {
|
||||
buttons: formattedButtons,
|
||||
messageParamsJson: JSON.stringify({}),
|
||||
messageVersion: 2,
|
||||
},
|
||||
});
|
||||
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// ── Carrossel: viewOnceMessage > interactiveMessage > carouselMessage ─────
|
||||
if (nativeCarousel) {
|
||||
const cards = nativeCarousel.cards;
|
||||
if (cards.length < 2)
|
||||
throw new Error('Carrossel requer no mínimo 2 cards');
|
||||
if (cards.length > 10)
|
||||
throw new Error('Carrossel suporta no máximo 10 cards');
|
||||
const carouselCards = cards.map((card) => {
|
||||
const rawButtons = card.buttons ?? [];
|
||||
const cardButtons = rawButtons.length > 0
|
||||
? rawButtons.map((btn) => formatNativeFlowButton({
|
||||
type: btn.type ?? 'reply',
|
||||
id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'),
|
||||
text: btn.text,
|
||||
}))
|
||||
: [{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Ver mais', id: 'see_more' }) }];
|
||||
return {
|
||||
header: {
|
||||
title: card.title ?? '',
|
||||
subtitle: '',
|
||||
hasMediaAttachment: !!(card.image?.url),
|
||||
},
|
||||
body: { text: card.body ?? '' },
|
||||
footer: card.footer ? { text: card.footer } : undefined,
|
||||
nativeFlowMessage: {
|
||||
buttons: cardButtons,
|
||||
messageParamsJson: JSON.stringify({}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const protoMsg = makeInteractiveProto({
|
||||
body: { text: text ?? '' },
|
||||
footer: footer ? { text: footer } : undefined,
|
||||
header: { title: '', subtitle: '', hasMediaAttachment: false },
|
||||
carouselMessage: {
|
||||
cards: carouselCards,
|
||||
messageVersion: 1,
|
||||
},
|
||||
});
|
||||
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// ── Lista: proto.listMessage LEGADO + nó <biz><list type="product_list" v="2"/></biz> ──
|
||||
if (nativeList) {
|
||||
const protoMsg = {
|
||||
listMessage: {
|
||||
title: '',
|
||||
description: text ?? '',
|
||||
buttonText: nativeList.buttonText,
|
||||
footerText: footer ?? '',
|
||||
listType: 2,
|
||||
sections: nativeList.sections.map((s) => ({
|
||||
title: s.title,
|
||||
rows: s.rows.map((r) => ({
|
||||
rowId: r.id,
|
||||
title: r.title,
|
||||
description: r.description ?? '',
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const additionalNodes = [BIZ_LIST_NODE];
|
||||
if (isPrivate)
|
||||
additionalNodes.push(BOT_NODE);
|
||||
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
|
||||
}
|
||||
// Fallback (não deve chegar aqui)
|
||||
const sent = await sock.sendMessage(jid, { text: text ?? '' });
|
||||
return sent?.key?.id ?? `fallback-${Date.now()}`;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.WhatsAppWarmer = void 0;
|
||||
/**
|
||||
* WhatsApp Warmer — aquecimento gradual de número novo.
|
||||
*
|
||||
* Envia mensagens em intervalos aleatórios crescentes para simular
|
||||
* uso humano e reduzir risco de ban em números recém-criados.
|
||||
*/
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const MESSAGES = [
|
||||
'Olá! Tudo bem?',
|
||||
'Oi, como vai?',
|
||||
'Boa tarde!',
|
||||
'Só passando para dizer oi 😊',
|
||||
'Tudo certo por aí?',
|
||||
'Bom dia! 🌤',
|
||||
'E aí, tudo bem?',
|
||||
'Boa noite!',
|
||||
];
|
||||
function randomBetween(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
function randomMessage() {
|
||||
return MESSAGES[Math.floor(Math.random() * MESSAGES.length)];
|
||||
}
|
||||
class WhatsAppWarmer {
|
||||
manager;
|
||||
sessions = new Map();
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
start(config) {
|
||||
if (this.sessions.has(config.instanceId)) {
|
||||
logger_1.logger.warn({ instanceId: config.instanceId }, 'Warmer já em execução para esta instância');
|
||||
return;
|
||||
}
|
||||
const session = {
|
||||
config,
|
||||
dayCount: 0,
|
||||
sentToday: 0,
|
||||
timer: null,
|
||||
};
|
||||
this.sessions.set(config.instanceId, session);
|
||||
logger_1.logger.info({ instanceId: config.instanceId, totalDays: config.totalDays }, '🔥 Warmer iniciado');
|
||||
this.scheduleNext(config.instanceId);
|
||||
}
|
||||
stop(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
if (session.timer)
|
||||
clearTimeout(session.timer);
|
||||
this.sessions.delete(instanceId);
|
||||
logger_1.logger.info({ instanceId }, 'Warmer encerrado');
|
||||
}
|
||||
scheduleNext(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
const { config, dayCount, sentToday } = session;
|
||||
// Limite diário cresce linearmente: day 1 = 10%, day N = 100%
|
||||
const progressRatio = Math.min(1, (dayCount + 1) / config.totalDays);
|
||||
const limitHoje = Math.ceil(config.dailyMessageLimit * progressRatio);
|
||||
if (sentToday >= limitHoje) {
|
||||
// Dia completo — aguarda até o próximo dia (24h + jitter)
|
||||
const nextDayMs = 24 * 60 * 60 * 1000 + randomBetween(0, 30 * 60 * 1000);
|
||||
logger_1.logger.debug({ instanceId, dayCount, sentToday }, 'Limite diário atingido — aguardando próximo dia');
|
||||
if (dayCount + 1 >= config.totalDays) {
|
||||
logger_1.logger.info({ instanceId }, '✅ Aquecimento concluído!');
|
||||
this.sessions.delete(instanceId);
|
||||
return;
|
||||
}
|
||||
session.dayCount += 1;
|
||||
session.sentToday = 0;
|
||||
session.timer = setTimeout(() => this.scheduleNext(instanceId), nextDayMs);
|
||||
return;
|
||||
}
|
||||
// Intervalo aleatório entre mensagens (1–5 minutos com jitter)
|
||||
const delayMs = randomBetween(60_000, 5 * 60_000);
|
||||
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), delayMs);
|
||||
}
|
||||
async sendWarmerMessage(instanceId) {
|
||||
const session = this.sessions.get(instanceId);
|
||||
if (!session)
|
||||
return;
|
||||
const sock = this.manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
logger_1.logger.warn({ instanceId }, 'Warmer: socket não encontrado — pausando');
|
||||
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), 60_000);
|
||||
return;
|
||||
}
|
||||
const targets = session.config.targets;
|
||||
const target = targets[Math.floor(Math.random() * targets.length)];
|
||||
const text = randomMessage();
|
||||
try {
|
||||
await sock.sendMessage(target, { text });
|
||||
session.sentToday += 1;
|
||||
logger_1.logger.debug({ instanceId, target, sentToday: session.sentToday }, 'Warmer: mensagem enviada');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId, target }, 'Warmer: falha ao enviar mensagem');
|
||||
}
|
||||
this.scheduleNext(instanceId);
|
||||
}
|
||||
}
|
||||
exports.WhatsAppWarmer = WhatsAppWarmer;
|
||||
@@ -0,0 +1,372 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildMediaRoutes = buildMediaRoutes;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const express_1 = require("express");
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const engine_1 = require("./engine");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
// ─── Tipos de mídia suportados ────────────────────────────────────────────────
|
||||
const MIME_MAP = {
|
||||
'image/jpeg': 'image',
|
||||
'image/png': 'image',
|
||||
'image/webp': 'sticker',
|
||||
'image/gif': 'video', // GIFs são enviados como video no Baileys
|
||||
'video/mp4': 'video',
|
||||
'video/3gpp': 'video',
|
||||
'video/quicktime': 'video',
|
||||
'audio/ogg': 'audio',
|
||||
'audio/mpeg': 'audio',
|
||||
'audio/mp4': 'audio',
|
||||
'audio/wav': 'audio',
|
||||
'audio/webm': 'audio',
|
||||
'application/pdf': 'document',
|
||||
'application/msword': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
|
||||
'application/vnd.ms-excel': 'document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document',
|
||||
'application/zip': 'document',
|
||||
'text/plain': 'document',
|
||||
};
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64 MB
|
||||
// ─── Multer: armazena em memória (buffer) ─────────────────────────────────────
|
||||
// Buffers são passados diretamente ao Baileys sem gravar arquivo temporário
|
||||
const upload = (0, multer_1.default)({
|
||||
storage: multer_1.default.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (MIME_MAP[file.mimetype]) {
|
||||
cb(null, true);
|
||||
}
|
||||
else {
|
||||
cb(new Error(`Tipo de arquivo não suportado: ${file.mimetype}`));
|
||||
}
|
||||
},
|
||||
});
|
||||
// ─── Helper: salva mídia enviada no disco (para histórico local) ──────────────
|
||||
async function saveMediaLocally(instanceId, messageId, buffer, originalName) {
|
||||
const ext = path_1.default.extname(originalName) || '.bin';
|
||||
const fileName = `${messageId}${ext}`;
|
||||
const filePath = path_1.default.resolve('./media', instanceId, 'sent', fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
return `/media/${instanceId}/sent/${fileName}`;
|
||||
}
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
function buildMediaRoutes(manager) {
|
||||
const router = (0, express_1.Router)({ mergeParams: true });
|
||||
// ── POST /api/instances/:instanceId/send-media ─────────────────────────────
|
||||
// Body: multipart/form-data { file, jid, caption?, replyToMessageId? }
|
||||
router.post('/send-media', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid, caption, replyToMessageId } = req.body;
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Arquivo não enviado' });
|
||||
return;
|
||||
}
|
||||
if (!jid) {
|
||||
res.status(400).json({ error: 'jid é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
const { buffer, mimetype, originalname, size } = req.file;
|
||||
const mediaType = MIME_MAP[mimetype] ?? 'document';
|
||||
// ── Monta o content para o Baileys ────────────────────────────────────
|
||||
let content;
|
||||
switch (mediaType) {
|
||||
case 'image':
|
||||
content = { image: buffer, mimetype, caption: caption ?? '' };
|
||||
break;
|
||||
case 'video':
|
||||
content = {
|
||||
video: buffer,
|
||||
mimetype,
|
||||
caption: caption ?? '',
|
||||
gifPlayback: mimetype === 'image/gif',
|
||||
};
|
||||
break;
|
||||
case 'audio':
|
||||
content = { audio: buffer, mimetype, ptt: false };
|
||||
break;
|
||||
case 'sticker':
|
||||
content = { sticker: buffer, mimetype };
|
||||
break;
|
||||
default:
|
||||
content = { document: buffer, mimetype, fileName: originalname, caption: caption ?? '' };
|
||||
}
|
||||
// ── Reply context ─────────────────────────────────────────────────────
|
||||
let quotedMsg;
|
||||
if (replyToMessageId) {
|
||||
const reply = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
|
||||
});
|
||||
if (reply?.body) {
|
||||
quotedMsg = {
|
||||
key: { remoteJid: jid, fromMe: reply.fromMe, id: reply.messageId },
|
||||
message: { conversation: reply.body },
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Envia via Baileys ─────────────────────────────────────────────────
|
||||
const sent = await sock.sendMessage(jid, content, quotedMsg ? { quoted: quotedMsg } : undefined);
|
||||
// ── Salva arquivo e faz upload para Wasabi ────────────────────────────
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname);
|
||||
let mediaUrl = localUrl;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname, buffer, mimetype },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (mídia enviada), usando path local');
|
||||
}
|
||||
}
|
||||
// ── Garante chat + persiste mensagem ─────────────────────────────────
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
|
||||
update: { lastMessageAt: new Date() },
|
||||
});
|
||||
const messageType = mediaType.toUpperCase();
|
||||
// Usa upsert para evitar P2002 — o MessageHandler pode ter inserido antes
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: messageType,
|
||||
body: caption ?? null,
|
||||
mediaUrl,
|
||||
mimeType: mimetype,
|
||||
fileName: mediaType === 'document' ? originalname : null,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {
|
||||
mediaUrl,
|
||||
mimeType: mimetype,
|
||||
status: 'SENT',
|
||||
},
|
||||
});
|
||||
logger_1.logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada');
|
||||
res.status(201).json(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao enviar mídia');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao enviar mídia' });
|
||||
}
|
||||
});
|
||||
// ── POST /api/instances/:instanceId/send-audio ─────────────────────────────
|
||||
// Áudio gravado pelo InputBar (PTT — Push to Talk)
|
||||
// Body: multipart/form-data { audio (blob ogg/webm), jid }
|
||||
router.post('/send-audio', upload.single('audio'), async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid } = req.body;
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Áudio não enviado' });
|
||||
return;
|
||||
}
|
||||
if (!jid) {
|
||||
res.status(400).json({ error: 'jid é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
const { buffer, mimetype, originalname } = req.file;
|
||||
// PTT (push-to-talk) — aparece como nota de voz no WhatsApp
|
||||
const sent = await sock.sendMessage(jid, {
|
||||
audio: buffer,
|
||||
mimetype: mimetype || 'audio/ogg; codecs=opus',
|
||||
ptt: true,
|
||||
});
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname || 'audio.ogg');
|
||||
let mediaUrl = localUrl;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: originalname || 'audio.ogg', buffer, mimetype: mimetype || 'audio/ogg' },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (áudio enviado), usando path local');
|
||||
}
|
||||
}
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
|
||||
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
|
||||
update: { lastMessageAt: new Date() },
|
||||
});
|
||||
// upsert evita colisão com o registro criado pelo MessageHandler via messages.upsert
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: 'AUDIO',
|
||||
mimeType: mimetype || 'audio/ogg',
|
||||
mediaUrl,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: { mediaUrl, status: 'SENT' },
|
||||
});
|
||||
logger_1.logger.info({ instanceId, jid }, 'Áudio PTT enviado');
|
||||
res.status(201).json(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao enviar áudio');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' });
|
||||
}
|
||||
});
|
||||
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
|
||||
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
|
||||
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
|
||||
// a requisição com updateMediaMessage (renova URL expirada se necessário).
|
||||
router.post('/redownload-media/:messageDbId', async (req, res) => {
|
||||
try {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const messageDbId = req.params['messageDbId'];
|
||||
const msg = await prisma_1.prisma.message.findFirst({
|
||||
where: { id: messageDbId, instanceId, tenantId },
|
||||
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
|
||||
});
|
||||
if (!msg) {
|
||||
res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||
return;
|
||||
}
|
||||
if (msg.mediaUrl) {
|
||||
res.json({ mediaUrl: msg.mediaUrl });
|
||||
return;
|
||||
}
|
||||
if (!msg.mediaPayload) {
|
||||
res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
|
||||
const waMsg = deserializePayload(msg.mediaPayload);
|
||||
const buffer = await (0, engine_1.downloadMediaMessage)(waMsg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: sock.updateMediaMessage });
|
||||
if (!buffer) {
|
||||
res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' });
|
||||
return;
|
||||
}
|
||||
const contentType = (0, engine_1.getContentType)(waMsg.message) ?? 'imageMessage';
|
||||
const ext = EXT_MAP[contentType] ?? 'bin';
|
||||
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream';
|
||||
const fileName = `${messageDbId}.${ext}`;
|
||||
let mediaUrl = `/media/${instanceId}/${fileName}`;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: fileName, buffer: buffer, mimetype: mime },
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local');
|
||||
const filePath = path_1.default.resolve('./media', instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const filePath = path_1.default.resolve('./media', instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
}
|
||||
await prisma_1.prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } });
|
||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
|
||||
messageId: messageDbId,
|
||||
mediaUrl,
|
||||
});
|
||||
logger_1.logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso');
|
||||
res.json({ mediaUrl });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, 'Erro ao re-baixar mídia');
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const EXT_MAP = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
const MIME_FOR_TYPE = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
};
|
||||
function deserializePayload(val) {
|
||||
if (Array.isArray(val))
|
||||
return val.map(deserializePayload);
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const obj = val;
|
||||
if ('_b64' in obj && typeof obj._b64 === 'string') {
|
||||
return Buffer.from(obj._b64, 'base64');
|
||||
}
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]));
|
||||
}
|
||||
return val;
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildWhatsAppRoutes = buildWhatsAppRoutes;
|
||||
const express_1 = require("express");
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const InstanceRepository_1 = require("./repositories/InstanceRepository");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
const ENGINE_CHANGE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutos entre trocas
|
||||
function buildWhatsAppRoutes(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
const repo = new InstanceRepository_1.InstanceRepository();
|
||||
// Listar instâncias do tenant
|
||||
router.get('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instances = await repo.findAllByTenant(tenantId);
|
||||
res.json(instances);
|
||||
});
|
||||
// Criar instância
|
||||
router.post('/', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const { name } = req.body;
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'name é obrigatório' });
|
||||
return;
|
||||
}
|
||||
const instance = await repo.create(tenantId, name);
|
||||
res.status(201).json(instance);
|
||||
});
|
||||
// Conectar instância (emite QR via Socket.IO)
|
||||
router.post('/:id/connect', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// Não aguarda — a conexão ocorre em background e o QR chega via Socket.IO
|
||||
manager.connect(id, tenantId).catch((err) => {
|
||||
console.error('[Connect] Falha ao iniciar conexão:', err);
|
||||
});
|
||||
res.json({ message: 'Conectando... aguarde o QR Code via WebSocket' });
|
||||
});
|
||||
// Desconectar instância
|
||||
router.post('/:id/disconnect', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
await manager.disconnect(id);
|
||||
res.json({ message: 'Desconectado' });
|
||||
});
|
||||
// QR Code atual (fallback REST — normalmente via Socket.IO)
|
||||
router.get('/:id/qr', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const qrBase64 = await dragonfly_1.dragonfly.get(`instance:${id}:qr`);
|
||||
if (!qrBase64) {
|
||||
res.status(404).json({ error: 'QR Code não disponível. Inicie a conexão primeiro.' });
|
||||
return;
|
||||
}
|
||||
res.json({ qrBase64 });
|
||||
});
|
||||
// Deletar instância
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// 1. Desconectar e deslogar a sessão do WhatsApp se estiver ativa
|
||||
await manager.disconnect(id).catch(() => { });
|
||||
// 2. Garantir a exclusão física da pasta de credenciais da sessão no servidor
|
||||
await manager.deleteSessionFiles(id).catch(() => { });
|
||||
// 3. Garantir a exclusão física da pasta de mídia local ./media/{instanceId} no servidor
|
||||
const localMediaDir = path_1.default.resolve('./media', id);
|
||||
await promises_1.default.rm(localMediaDir, { recursive: true, force: true }).catch((err) => {
|
||||
console.error(`[deleteInstance] Erro ao deletar pasta local de mídia da instância ${id}:`, err);
|
||||
});
|
||||
// 4. Apagar do banco de dados
|
||||
await repo.delete(id);
|
||||
// 5. Deleta mídias associadas à instância no storage remoto (Wasabi/S3) de forma assíncrona
|
||||
StorageProvider_1.storageProvider.deletePrefix(`whatsapp/${tenantId}/${id}/`).catch((err) => {
|
||||
console.error(`[StorageProvider] Erro ao deletar mídias da instância ${id}:`, err);
|
||||
});
|
||||
res.json({ message: 'Instância removida com sucesso de todos os storages e servidores' });
|
||||
});
|
||||
// Estatísticas de mensagens da instância (contagens do banco)
|
||||
router.get('/:id/stats', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const [received, sent, media, chats] = await Promise.all([
|
||||
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: false } }),
|
||||
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: true } }),
|
||||
prisma_1.prisma.message.count({
|
||||
where: {
|
||||
instanceId: id,
|
||||
type: { in: ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] },
|
||||
},
|
||||
}),
|
||||
prisma_1.prisma.chat.count({ where: { instanceId: id } }),
|
||||
]);
|
||||
res.json({ received, sent, media, chats });
|
||||
});
|
||||
// Trocar engine de uma instância (com cooldown + auditoria)
|
||||
router.patch('/:id/engine', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const id = req.params['id'];
|
||||
const { engine } = req.body;
|
||||
if (engine !== 'infinite' && engine !== 'official') {
|
||||
res.status(400).json({ error: 'engine deve ser "infinite" ou "official"' });
|
||||
return;
|
||||
}
|
||||
const instance = await repo.findById(id);
|
||||
if (!instance || instance.tenantId !== tenantId) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
// Cooldown: bloqueia nova troca por 5 min após a última
|
||||
const cooldownKey = `instance:${id}:engine_change_lock`;
|
||||
const lockedAt = await dragonfly_1.dragonfly.get(cooldownKey);
|
||||
if (lockedAt) {
|
||||
const remaining = Math.ceil((ENGINE_CHANGE_COOLDOWN_MS - (Date.now() - Number(lockedAt))) / 1000);
|
||||
res.status(429).json({ error: `Aguarde ${remaining}s antes de trocar a engine novamente` });
|
||||
return;
|
||||
}
|
||||
const fromEngine = instance.engine ?? 'infinite';
|
||||
if (fromEngine === engine) {
|
||||
res.status(200).json({ message: 'Engine já está definida para este valor', engine });
|
||||
return;
|
||||
}
|
||||
// Persiste a troca
|
||||
await prisma_1.prisma.instance.update({ where: { id }, data: { engine } });
|
||||
// Audit log estruturado
|
||||
logger_1.logger.info({ tenantId, instanceId: id, fromEngine, toEngine: engine }, '[EngineChange] Troca de engine auditada');
|
||||
// Aplica cooldown (5 min via TTL no Redis)
|
||||
await dragonfly_1.dragonfly.psetex(cooldownKey, ENGINE_CHANGE_COOLDOWN_MS, String(Date.now()));
|
||||
// Reconecta a instância com a nova engine
|
||||
const isConnected = manager.getSocket(id) != null;
|
||||
if (isConnected) {
|
||||
await manager.disconnect(id).catch(() => { });
|
||||
manager.connect(id, tenantId).catch(() => { });
|
||||
}
|
||||
res.json({ message: 'Engine atualizada', engine, reconnecting: isConnected });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildSendRoutes = buildSendRoutes;
|
||||
exports.buildAvatarRoute = buildAvatarRoute;
|
||||
/**
|
||||
* Rotas de envio e operações WhatsApp.
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/instances/:instanceId/send — Envia mensagem de texto (com @mentions e reply)
|
||||
* GET /api/instances/:instanceId/groups/:jid/participants — Lista participantes de grupo
|
||||
* POST /api/instances/:instanceId/reconcile-names — Reconcilia nomes via pushName (bulk SQL)
|
||||
* POST /api/instances/:instanceId/broadcast — Disparo em massa (async, até 500 números)
|
||||
* GET /api/instances/:instanceId/broadcast — Lista broadcasts da instância
|
||||
* GET /api/instances/:instanceId/broadcast/:id — Detalhe de um broadcast
|
||||
* GET /api/inbox/avatar/:jid — Proxy de foto de perfil (evita CORS)
|
||||
*
|
||||
* Todas as rotas exigem autenticação (tenantId no req).
|
||||
* O WhatsAppConnectionManager é injetado via buildSendRoutes() para
|
||||
* acessar os sockets Baileys ativos.
|
||||
*
|
||||
* @see WhatsAppConnectionManager — provê getSocket() e getIO()
|
||||
* @see ContactHandler — reconciliação de nomes (mesma lógica, mas via endpoint HTTP)
|
||||
*/
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = require("crypto");
|
||||
const zod_1 = require("zod");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const StorageProvider_1 = require("../../core/StorageProvider");
|
||||
const rich_message_1 = require("./rich-message");
|
||||
/**
|
||||
* Constrói as rotas de envio e operações WhatsApp.
|
||||
*
|
||||
* Montadas em: /api/instances/:instanceId/
|
||||
* Usa mergeParams para herdar :instanceId do router pai.
|
||||
*/
|
||||
function buildSendRoutes(manager) {
|
||||
const router = (0, express_1.Router)({ mergeParams: true });
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/send — Envio de mensagem de texto
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Envia uma mensagem de texto para um JID (contato ou grupo).
|
||||
*
|
||||
* Body:
|
||||
* - jid: string (obrigatório) — JID destino (ex: "5511999999999@s.whatsapp.net")
|
||||
* - text: string (obrigatório) — Texto da mensagem (até 4096 chars)
|
||||
* - replyToMessageId?: string — ID da mensagem sendo respondida (quote/reply)
|
||||
* - mentions?: string[] — Lista de JIDs mencionados com @ (para grupos)
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Valida instância conectada e socket disponível
|
||||
* 2. Monta contexto de reply (se houver replyToMessageId)
|
||||
* 3. Envia via Baileys sock.sendMessage()
|
||||
* 4. Cria/atualiza Chat no banco
|
||||
* 5. Persiste a mensagem enviada
|
||||
* 6. Retorna 201 com o registro da mensagem
|
||||
*/
|
||||
router.post('/send', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const { jid, text, footer, replyToMessageId, mentions,
|
||||
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
|
||||
nativeButtons, nativeList, nativeCarousel, poll, } = req.body;
|
||||
const hasRichContent = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
||||
if (!jid || (!text && !hasRichContent)) {
|
||||
res.status(400).json({ error: 'jid e text (ou tipo rico) são obrigatórios' });
|
||||
return;
|
||||
}
|
||||
// Valida que a instância existe e está conectada
|
||||
const instance = await prisma_1.prisma.instance.findFirst({
|
||||
where: { id: instanceId, tenantId },
|
||||
});
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
// Busca o socket Baileys ativo
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
// ── Resolve o JID real via onWhatsApp (corrige o dígito 9 brasileiro) ────
|
||||
// Números brasileiros migraram de 8 para 9 dígitos. O usuário pode digitar
|
||||
// com ou sem o 9 — o WhatsApp sabe o JID correto. Grupos (@g.us) são
|
||||
// excluídos pois já têm JID definitivo.
|
||||
let resolvedJid = jid;
|
||||
if (!jid.endsWith('@g.us')) {
|
||||
try {
|
||||
const waResults = await sock.onWhatsApp(jid);
|
||||
const waResult = waResults?.[0];
|
||||
if (!waResult?.exists) {
|
||||
res.status(422).json({ error: 'Número não encontrado no WhatsApp' });
|
||||
return;
|
||||
}
|
||||
// Se onWhatsApp retornar @lid (WhatsApp moderno), ignoramos — o Baileys
|
||||
// resolve @lid internamente ao enviar. Armazenar @lid no banco criaria
|
||||
// um chat duplicado conflitando com o @s.whatsapp.net do MessageHandler.
|
||||
if (!waResult.jid.endsWith('@lid')) {
|
||||
resolvedJid = waResult.jid; // JID correto com ou sem o 9
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, jid }, 'onWhatsApp falhou — usando JID original');
|
||||
// Continua com o JID original se a consulta falhar
|
||||
}
|
||||
}
|
||||
// Monta contexto de reply (quoted message) se necessário
|
||||
// O Baileys precisa da mensagem original para exibir o quote no WhatsApp
|
||||
let quotedMsg;
|
||||
if (replyToMessageId) {
|
||||
const reply = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
|
||||
});
|
||||
if (reply?.body) {
|
||||
quotedMsg = {
|
||||
key: { remoteJid: resolvedJid, fromMe: reply.fromMe, id: reply.messageId },
|
||||
message: { conversation: reply.body },
|
||||
};
|
||||
}
|
||||
}
|
||||
// ── Mensagens ricas: delega ao rich-message builder ──────────────────────
|
||||
// O @whiskeysockets/baileys padrão não suporta nativeButtons/nativeList/
|
||||
// nativeCarousel. O builder converte para os protos corretos e usa
|
||||
// relayMessage() para enviar sem passar pelo generateWAMessageContent.
|
||||
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
||||
let sentMessageId;
|
||||
if (hasRich) {
|
||||
sentMessageId = await (0, rich_message_1.sendRichMessage)(sock, resolvedJid, { text, footer, nativeButtons, nativeList, nativeCarousel, poll }, quotedMsg, instance.engine);
|
||||
}
|
||||
else {
|
||||
// Texto simples (com suporte a @mentions)
|
||||
const msgContent = { text: text ?? '' };
|
||||
if (mentions?.length)
|
||||
msgContent.mentions = mentions;
|
||||
const sent = await sock.sendMessage(resolvedJid, msgContent, quotedMsg ? { quoted: quotedMsg } : undefined);
|
||||
sentMessageId = sent?.key?.id ?? `text-${Date.now()}`;
|
||||
}
|
||||
const sent = { key: { id: sentMessageId } };
|
||||
// ── Persiste chat e mensagem imediatamente ────────────────────────────────
|
||||
// O Baileys dispara messages.upsert com type='append' para mensagens enviadas
|
||||
// por nós, o que não emite eventos Socket.IO. Por isso fazemos a criação aqui
|
||||
// e emitimos os eventos manualmente para o frontend atualizar em tempo real.
|
||||
const isGroup = resolvedJid.endsWith('@g.us');
|
||||
// Garantir que o Contato existe no banco
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
jid: resolvedJid,
|
||||
phone: isGroup ? null : resolvedJid.split('@')[0],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
|
||||
create: { tenantId, instanceId, jid: resolvedJid, contactId: contact.id, lastMessageAt: new Date() },
|
||||
update: { contactId: contact.id, lastMessageAt: new Date() },
|
||||
});
|
||||
const messageId = sent?.key.id ?? `local-${Date.now()}`;
|
||||
// Determina tipo, body e richPayload para persistência
|
||||
let msgType = 'TEXT';
|
||||
let msgBody = text ?? '';
|
||||
let richPayloadData;
|
||||
if (poll) {
|
||||
msgType = 'POLL';
|
||||
msgBody = poll.name;
|
||||
richPayloadData = { poll };
|
||||
}
|
||||
else if (nativeButtons) {
|
||||
// Detecta se há botões CTA (url/copy/call) → INTERACTIVE, senão → BUTTONS
|
||||
const hasCta = nativeButtons.some((b) => ['url', 'copy', 'call'].includes(b.type));
|
||||
msgType = hasCta ? 'INTERACTIVE' : 'BUTTONS';
|
||||
msgBody = text ?? '';
|
||||
richPayloadData = { text, footer, nativeButtons };
|
||||
}
|
||||
else if (nativeList) {
|
||||
msgType = 'LIST';
|
||||
msgBody = text ?? nativeList.buttonText;
|
||||
richPayloadData = { text, footer, nativeList };
|
||||
}
|
||||
else if (nativeCarousel) {
|
||||
msgType = 'CAROUSEL';
|
||||
msgBody = text ?? nativeCarousel.cards[0]?.title ?? '[Carrossel]';
|
||||
richPayloadData = { text, footer, nativeCarousel };
|
||||
}
|
||||
const msg = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: resolvedJid,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
type: msgType,
|
||||
body: msgBody,
|
||||
richPayload: richPayloadData ? JSON.parse(JSON.stringify(richPayloadData)) : undefined,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
|
||||
});
|
||||
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
|
||||
const io = manager.getIO();
|
||||
// Inclui replyTo quando é uma resposta — necessário para exibir a caixa
|
||||
// de citação em sessões adicionais do mesmo tenant que tenham o chat aberto.
|
||||
const msgToEmit = replyToMessageId
|
||||
? await prisma_1.prisma.message.findUnique({
|
||||
where: { id: msg.id },
|
||||
include: {
|
||||
replyTo: {
|
||||
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
|
||||
},
|
||||
},
|
||||
}) ?? msg
|
||||
: msg;
|
||||
// Atualiza o chat aberto (se o usuário já estiver nele)
|
||||
io.to(`chat:${chat.id}`).emit('message:new', { ...msgToEmit, pushName: null, senderJid: null });
|
||||
const displayName = isGroup
|
||||
? (chat.name ?? null)
|
||||
: (contact.name ?? contact.verifiedName ?? contact.notify ?? null);
|
||||
// Atualiza a lista de chats do tenant (novo chat ou reordenar pelo lastMessageAt)
|
||||
io.to(`tenant:${tenantId}`).emit('chat:upsert', {
|
||||
id: chat.id,
|
||||
tenantId: chat.tenantId,
|
||||
instanceId: chat.instanceId,
|
||||
jid: chat.jid,
|
||||
name: chat.name ?? null,
|
||||
unreadCount: chat.unreadCount,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
isPinned: chat.isPinned,
|
||||
isArchived: chat.isArchived,
|
||||
botPaused: chat.botPaused,
|
||||
contact: {
|
||||
name: displayName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
},
|
||||
lastMessage: {
|
||||
body: msgBody,
|
||||
fromMe: true,
|
||||
status: 'SENT',
|
||||
type: msgType,
|
||||
timestamp: msg.timestamp,
|
||||
},
|
||||
});
|
||||
res.status(201).json({ id: msg.id, messageId: msg.messageId });
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/groups/:groupJid/participants
|
||||
// Lista participantes de um grupo (para autocomplete de @mentions)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Retorna a lista de participantes de um grupo WhatsApp.
|
||||
*
|
||||
* Usado pelo InputBar do frontend para autocomplete de @mentions.
|
||||
* Busca metadata via Baileys groupMetadata() e enriquece com dados do banco:
|
||||
* - Nome do contato (se existir no banco)
|
||||
* - Para JIDs @lid sem nome: busca pushName da última mensagem no grupo
|
||||
* - Telefone formatado
|
||||
*
|
||||
* Response: { groupJid, subject, participants: [{ jid, admin, name, phone }] }
|
||||
*/
|
||||
router.get('/groups/:groupJid/participants', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const groupJid = req.params['groupJid'];
|
||||
if (!groupJid?.endsWith('@g.us')) {
|
||||
res.status(400).json({ error: 'JID de grupo inválido' });
|
||||
return;
|
||||
}
|
||||
const instance = await prisma_1.prisma.instance.findFirst({
|
||||
where: { id: instanceId, tenantId },
|
||||
});
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Busca metadata do grupo via Baileys (inclui lista de participantes)
|
||||
const metadata = await sock.groupMetadata(groupJid);
|
||||
const participants = await Promise.all(metadata.participants.map(async (p) => {
|
||||
// Enriquece com dados do banco (nome, telefone)
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId, instanceId, jid: p.id },
|
||||
select: { name: true, notify: true, phone: true },
|
||||
});
|
||||
let name = contact?.name || contact?.notify || null;
|
||||
let phone = contact?.phone || null;
|
||||
// Para @lid JIDs sem nome: tenta buscar pushName da última mensagem no grupo
|
||||
// (LID não tem telefone no JID, então o nome só pode vir de mensagens)
|
||||
if (!name && p.id.endsWith('@lid')) {
|
||||
const lastMsg = await prisma_1.prisma.message.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
remoteJid: groupJid,
|
||||
senderJid: p.id,
|
||||
fromMe: false,
|
||||
pushName: { not: null },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
select: { pushName: true },
|
||||
});
|
||||
if (lastMsg?.pushName)
|
||||
name = lastMsg.pushName;
|
||||
}
|
||||
// Extrai telefone do JID (apenas para @s.whatsapp.net, não @lid)
|
||||
if (!phone) {
|
||||
phone = p.id.endsWith('@lid') ? null : p.id.split('@')[0].split(':')[0];
|
||||
}
|
||||
return {
|
||||
jid: p.id,
|
||||
admin: p.admin ?? null, // 'admin' | 'superadmin' | null
|
||||
name,
|
||||
phone,
|
||||
};
|
||||
}));
|
||||
res.json({ groupJid, subject: metadata.subject, participants });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, groupJid }, '[Groups] Erro ao buscar participantes');
|
||||
res.status(500).json({ error: 'Erro ao buscar participantes do grupo' });
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/reconcile-names
|
||||
// Reconciliação bulk de nomes via pushName (endpoint HTTP)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Reconcilia nomes de contatos usando pushName das mensagens recebidas.
|
||||
*
|
||||
* Diferente de ContactHandler.reconcileNamesFromMessages() que roda no sync,
|
||||
* este endpoint pode ser chamado pelo frontend a qualquer momento (ex: page reload).
|
||||
*
|
||||
* Implementação:
|
||||
* - Usa raw SQL com DISTINCT ON para buscar o pushName mais recente por JID
|
||||
* - Uma única query UPDATE...FROM (subquery) atualiza todos os contatos sem nome
|
||||
* - Cache DragonflyDB de 30min para evitar re-execução em loop
|
||||
* - Idempotente: se 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();
|
||||
const RECONCILE_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
router.post('/reconcile-names', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const cacheKey = `reconcile:${tenantId}:${instanceId}`;
|
||||
// Camada 1: Redis (30min TTL)
|
||||
const cached = await dragonfly_1.dragonfly.get(cacheKey);
|
||||
if (cached) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
// Camada 2: in-memory (protege quando Redis está fora)
|
||||
const memTs = reconcileMemCache.get(cacheKey);
|
||||
if (memTs && Date.now() - memTs < RECONCILE_TTL_MS) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Query bulk: encontra contatos sem nome que têm msgs recebidas com pushName.
|
||||
// Usa DISTINCT ON (PostgreSQL) para pegar o pushName mais recente por JID.
|
||||
// Uma única query UPDATE...FROM atualiza todos — O(1) round-trips ao banco.
|
||||
const updated = await prisma_1.prisma.$executeRaw `
|
||||
UPDATE contacts c
|
||||
SET
|
||||
"name" = sub."pushName",
|
||||
"notify" = COALESCE(c."notify", sub."pushName"),
|
||||
"updatedAt" = NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT ON (m."remoteJid")
|
||||
m."remoteJid",
|
||||
m."pushName"
|
||||
FROM messages m
|
||||
WHERE m."instanceId" = ${instanceId}
|
||||
AND m."fromMe" = false
|
||||
AND m."pushName" IS NOT NULL
|
||||
AND m."pushName" != ''
|
||||
ORDER BY m."remoteJid", m."timestamp" DESC
|
||||
) sub
|
||||
WHERE c."instanceId" = ${instanceId}
|
||||
AND c."tenantId" = ${tenantId}
|
||||
AND c."jid" = sub."remoteJid"
|
||||
AND c."name" IS NULL
|
||||
AND c."notify" IS NULL
|
||||
`;
|
||||
// Seta flag no Redis (30min) + in-memory — independente do resultado
|
||||
await dragonfly_1.dragonfly.set(cacheKey, String(updated), 1800);
|
||||
reconcileMemCache.set(cacheKey, Date.now());
|
||||
if (updated > 0) {
|
||||
logger_1.logger.info({ instanceId, updated }, '[Reconcile] Nomes atualizados via pushName');
|
||||
// Notifica frontend para re-buscar a lista de chats
|
||||
const io = manager.getIO();
|
||||
if (io) {
|
||||
io.to(`tenant:${tenantId}`).emit('chats:refresh', { instanceId, reason: 'reconcile', updated });
|
||||
}
|
||||
}
|
||||
res.json({ updated });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId }, '[Reconcile] Erro na reconciliação bulk');
|
||||
// Mesmo com erro, seta cache curto (60s) para não ficar retentando em loop
|
||||
await dragonfly_1.dragonfly.set(cacheKey, '0', 60).catch(() => { });
|
||||
res.status(500).json({ error: 'Erro na reconciliação' });
|
||||
}
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/instances/:instanceId/broadcast — Disparo em massa
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Inicia um disparo em massa (broadcast) para até 500 números.
|
||||
*
|
||||
* Validação:
|
||||
* - numbers: array de strings (mínimo 5 chars cada, 1-500 itens)
|
||||
* - message: texto (1-4096 chars)
|
||||
* - delayMs: delay entre envios (500-60000ms, default 2000ms)
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Valida payload via Zod
|
||||
* 2. Cria registro Broadcast com status 'RUNNING'
|
||||
* 3. Retorna 202 com jobId imediatamente
|
||||
* 4. Processa envios em background (async IIFE)
|
||||
* 5. Atualiza sentCount/failedCount a cada envio
|
||||
* 6. Marca como 'DONE' ao finalizar
|
||||
*
|
||||
* IMPORTANTE: O delay entre envios é obrigatório para evitar ban do WhatsApp.
|
||||
* O default de 2s é conservador — ajuste conforme volume e tolerância ao risco.
|
||||
*/
|
||||
router.post('/broadcast', async (req, res) => {
|
||||
const schema = zod_1.z.object({
|
||||
numbers: zod_1.z.array(zod_1.z.string().min(5)).min(1).max(500),
|
||||
message: zod_1.z.string().min(1).max(4096),
|
||||
delayMs: zod_1.z.number().int().min(500).max(60000).default(2000),
|
||||
});
|
||||
let body;
|
||||
try {
|
||||
body = schema.parse(req.body);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(400).json({ error: err.errors ?? err.message });
|
||||
return;
|
||||
}
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
||||
if (!instance || instance.status !== 'CONNECTED') {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
return;
|
||||
}
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
res.status(503).json({ error: 'Socket não disponível' });
|
||||
return;
|
||||
}
|
||||
// Cria registro do broadcast no banco
|
||||
const broadcast = await prisma_1.prisma.broadcast.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
message: body.message,
|
||||
totalCount: body.numbers.length,
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
status: 'RUNNING',
|
||||
},
|
||||
});
|
||||
// Retorna imediatamente (processamento assíncrono)
|
||||
res.status(202).json({ jobId: broadcast.id, total: body.numbers.length });
|
||||
(async () => {
|
||||
let sent = 0, failed = 0;
|
||||
for (let i = 0; i < body.numbers.length; i++) {
|
||||
const rawNumber = body.numbers[i];
|
||||
// Normaliza número: remove caracteres não-numéricos
|
||||
const number = rawNumber.replace(/\D/g, '');
|
||||
// Se já é um JID completo (contém @), usa como está; senão, adiciona sufixo
|
||||
const jid = number.includes('@') ? number : `${number}@s.whatsapp.net`;
|
||||
try {
|
||||
await sock.sendMessage(jid, { text: body.message });
|
||||
await prisma_1.prisma.broadcastRecipient.create({
|
||||
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ jid, err }, '[Broadcast] Falha ao enviar');
|
||||
await prisma_1.prisma.broadcastRecipient.create({
|
||||
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
|
||||
}).catch(() => { });
|
||||
failed++;
|
||||
}
|
||||
// Atualiza contadores em tempo real (permite consulta de progresso)
|
||||
await prisma_1.prisma.broadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: { sentCount: sent, failedCount: failed },
|
||||
});
|
||||
// Delay entre envios para evitar ban do WhatsApp (pula no último item)
|
||||
if (i < body.numbers.length - 1) {
|
||||
await new Promise(r => setTimeout(r, body.delayMs));
|
||||
}
|
||||
}
|
||||
// Marca como concluído
|
||||
await prisma_1.prisma.broadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: { status: 'DONE', finishedAt: new Date() },
|
||||
});
|
||||
})().catch(err => {
|
||||
logger_1.logger.error({ err }, '[Broadcast] Erro no job');
|
||||
prisma_1.prisma.broadcast.update({ where: { id: broadcast.id }, data: { status: 'FAILED' } }).catch(() => { });
|
||||
});
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/broadcast — Lista broadcasts
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Lista os últimos broadcasts da instância (paginado, max 50) */
|
||||
router.get('/broadcast', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const instanceId = req.params['instanceId'];
|
||||
const limit = Math.min(Number(req.query['limit'] ?? 20), 50);
|
||||
const rows = await prisma_1.prisma.broadcast.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: { _count: { select: { recipients: true } } },
|
||||
});
|
||||
res.json(rows);
|
||||
});
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/instances/:instanceId/broadcast/:id — Detalhe de broadcast
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Retorna detalhe de um broadcast específico, incluindo lista de destinatários */
|
||||
router.get('/broadcast/:broadcastId', async (req, res) => {
|
||||
const tenantId = req.tenantId;
|
||||
const broadcastId = req.params['broadcastId'];
|
||||
const row = await prisma_1.prisma.broadcast.findFirst({
|
||||
where: { id: broadcastId, tenantId },
|
||||
include: { recipients: { orderBy: { createdAt: 'asc' } } },
|
||||
});
|
||||
if (!row) {
|
||||
res.status(404).json({ error: 'Não encontrado' });
|
||||
return;
|
||||
}
|
||||
res.json(row);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ROTA DE AVATAR — Proxy de foto de perfil (evita CORS)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Constrói a rota de proxy de avatar.
|
||||
*
|
||||
* Montada em: /api/inbox/avatar/:jid
|
||||
*
|
||||
* O frontend não pode buscar avatares diretamente do CDN do WhatsApp (CORS).
|
||||
* Esta rota age como proxy:
|
||||
* 1. Busca avatarUrl salvo no banco (cache DB)
|
||||
* 2. Se URL da DB expirou, busca via Baileys profilePictureUrl
|
||||
* 3. Faz proxy do buffer (download → response) para evitar redirect/CORS
|
||||
* 4. Salva URL nova no banco para próximas requisições
|
||||
*
|
||||
* Retorna 204 (sem conteúdo) quando avatar não disponível:
|
||||
* - Foto de perfil privada
|
||||
* - JID @lid (não suporta profilePictureUrl)
|
||||
* - Socket não disponível
|
||||
* O frontend trata 204 mostrando iniciais em vez da foto.
|
||||
*
|
||||
* Cache HTTP: max-age=86400 (24h) + stale-while-revalidate=604800 (7d) para
|
||||
* avatares encontrados; 300s para 204. Valida revalidação via ETag.
|
||||
*
|
||||
* Negative cache: quando decide 204, grava `avatar:none:*` no Dragonfly
|
||||
* com TTL de 1h — próximos hits respondem sem tocar no Prisma nem no Baileys.
|
||||
*/
|
||||
function buildAvatarRoute(manager) {
|
||||
const router = (0, express_1.Router)();
|
||||
// Cabeçalho longo com stale-while-revalidate: o browser serve do cache
|
||||
// local por 24h e, entre 24h e 7d, usa o stale enquanto revalida em
|
||||
// background. Corta requisições repetidas na navegação normal.
|
||||
const HIT_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';
|
||||
const MISS_CACHE_CONTROL = 'public, max-age=300';
|
||||
const NO_AVATAR_TTL_SECONDS = 3600; // 1h
|
||||
const noAvatarKey = (tenantId, instanceId, jid) => `avatar:none:${tenantId}:${instanceId}:${jid}`;
|
||||
// ETag baseado no avatarUrl conhecido. É determinístico (hash da string),
|
||||
// logo quando a URL do banco muda — sinal de que a foto mudou — o ETag
|
||||
// também muda e força o browser a buscar bytes novos.
|
||||
const etagFor = (urlOrMarker) => `"${(0, crypto_1.createHash)('md5').update(urlOrMarker).digest('hex')}"`;
|
||||
router.get('/avatar/:jid', async (req, res) => {
|
||||
const jid = req.params['jid'];
|
||||
const instanceId = req.query['instance'];
|
||||
const t0 = Date.now();
|
||||
const tenantId = req.tenantId;
|
||||
// Helper: retorna 204 No Content (não 404) para que o browser não logue
|
||||
// erro no console. O <img onError> do frontend detecta a falta de imagem
|
||||
// e mostra iniciais. Também grava no negative cache para curto-circuitar
|
||||
// as próximas chamadas ao mesmo JID.
|
||||
const noAvatar = (persist = true) => {
|
||||
if (persist && instanceId) {
|
||||
dragonfly_1.dragonfly
|
||||
.set(noAvatarKey(tenantId, instanceId, jid), '1', NO_AVATAR_TTL_SECONDS)
|
||||
.catch(() => { });
|
||||
}
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
};
|
||||
if (!instanceId) {
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
return;
|
||||
}
|
||||
// 0. Negative cache: se sabemos que não tem avatar há menos de 1h, corta
|
||||
// antes de tocar Prisma/Baileys (o backend está sob pressão de memória).
|
||||
try {
|
||||
if (await dragonfly_1.dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) {
|
||||
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached');
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Dragonfly indisponível — segue o fluxo normal sem quebrar
|
||||
}
|
||||
// ── Helpers para persistência no Wasabi ────────────────────────────────────
|
||||
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
|
||||
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
|
||||
const avatarWasabiPath = (tid, iid, j) => `avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`;
|
||||
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
|
||||
// Chamado após servir os bytes ao browser para não bloquear a resposta.
|
||||
const persistToWasabi = (buffer) => {
|
||||
if (!StorageProvider_1.storageProvider.isRegistered())
|
||||
return;
|
||||
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid);
|
||||
StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
customPath: wasabiPath,
|
||||
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
|
||||
}).then(() => prisma_1.prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })).catch(() => { });
|
||||
};
|
||||
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId, instanceId, jid },
|
||||
select: { avatarUrl: true },
|
||||
});
|
||||
if (contact?.avatarUrl) {
|
||||
const storedUrl = contact.avatarUrl;
|
||||
const isWasabi = !storedUrl.startsWith('http');
|
||||
const etag = etagFor(storedUrl);
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end();
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304');
|
||||
return;
|
||||
}
|
||||
if (isWasabi) {
|
||||
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
|
||||
try {
|
||||
const buffer = await StorageProvider_1.storageProvider.getBuffer(storedUrl);
|
||||
if (buffer) {
|
||||
res.set('Content-Type', 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served');
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
|
||||
}
|
||||
else {
|
||||
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
|
||||
try {
|
||||
const upstream = await fetch(storedUrl);
|
||||
if (upstream.ok) {
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served');
|
||||
persistToWasabi(buffer);
|
||||
return;
|
||||
}
|
||||
// URL expirou — cai no fallback Baileys abaixo
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys');
|
||||
}
|
||||
catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
|
||||
}
|
||||
}
|
||||
// 2. JIDs @lid não suportam profilePictureUrl no Baileys (retorna 404 no WA)
|
||||
if (jid.endsWith('@lid')) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
// 3. Busca URL fresca via Baileys profilePictureUrl
|
||||
const sock = manager.getSocket(instanceId);
|
||||
if (!sock) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = await sock.profilePictureUrl(jid, 'image');
|
||||
if (!url) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
const upstream = await fetch(url);
|
||||
if (!upstream.ok) {
|
||||
noAvatar();
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etagFor(url))
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer);
|
||||
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served');
|
||||
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
|
||||
persistToWasabi(buffer);
|
||||
dragonfly_1.dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => { });
|
||||
}
|
||||
catch {
|
||||
noAvatar();
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
Vendored
+227
@@ -0,0 +1,227 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("dotenv/config");
|
||||
const http_1 = __importDefault(require("http"));
|
||||
const fs_1 = require("fs");
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const helmet_1 = __importDefault(require("helmet"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const env_1 = require("./config/env");
|
||||
const logger_1 = require("./config/logger");
|
||||
const prisma_1 = require("./infra/database/prisma");
|
||||
// prisma re-usado inline abaixo (plan/status route)
|
||||
const dragonfly_1 = require("./infra/cache/dragonfly");
|
||||
const socketServer_1 = require("./infra/socket/socketServer");
|
||||
const hook_bus_1 = require("./core/hook-bus");
|
||||
const plugin_config_1 = require("./core/plugin-config");
|
||||
const db_1 = require("./core/db");
|
||||
const plugin_loader_1 = require("./core/plugin-loader");
|
||||
const plugin_registry_1 = require("./core/plugin-registry");
|
||||
const WhatsAppConnectionManager_1 = require("./modules/whatsapp/connection/WhatsAppConnectionManager");
|
||||
const whatsapp_routes_1 = require("./modules/whatsapp/whatsapp.routes");
|
||||
const chat_routes_1 = require("./modules/chats/chat.routes");
|
||||
const whatsapp_send_routes_1 = require("./modules/whatsapp/whatsapp.send.routes");
|
||||
const whatsapp_media_routes_1 = require("./modules/whatsapp/whatsapp.media.routes");
|
||||
const chatbot_routes_1 = require("./modules/chatbot/chatbot.routes");
|
||||
const contact_routes_1 = require("./modules/contacts/contact.routes");
|
||||
const sector_routes_1 = require("./modules/sectors/sector.routes");
|
||||
const apikey_routes_1 = require("./modules/api-keys/apikey.routes");
|
||||
const auth_routes_1 = require("./modules/auth/auth.routes");
|
||||
const admin_routes_1 = require("./modules/auth/admin.routes");
|
||||
const pair_routes_1 = require("./modules/pair/pair.routes");
|
||||
const template_routes_1 = require("./modules/templates/template.routes");
|
||||
const scheduled_routes_1 = require("./modules/scheduled/scheduled.routes");
|
||||
const sticker_routes_1 = require("./modules/stickers/sticker.routes");
|
||||
const StorageProvider_1 = require("./core/StorageProvider");
|
||||
const scheduler_service_1 = require("./modules/scheduled/scheduler.service");
|
||||
const auth_middleware_1 = require("./shared/middlewares/auth.middleware");
|
||||
const admin_middleware_1 = require("./shared/middlewares/admin.middleware");
|
||||
const error_middleware_1 = require("./shared/middlewares/error.middleware");
|
||||
async function bootstrap() {
|
||||
// ── Express ──────────────────────────────────────────────────────────────
|
||||
const app = (0, express_1.default)();
|
||||
app.use((0, helmet_1.default)({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
app.use((0, cors_1.default)({ origin: env_1.env.FRONTEND_URL, credentials: true }));
|
||||
app.use(express_1.default.json({ limit: '10mb' }));
|
||||
// Servir arquivos de mídia baixados (ambos os prefixos: acesso direto e via proxy nginx)
|
||||
const mediaStatic = express_1.default.static(path_1.default.resolve('./media'), { maxAge: '7d' });
|
||||
app.use('/media', mediaStatic);
|
||||
app.use('/api/media', mediaStatic);
|
||||
// ── HTTP Server + Socket.IO ───────────────────────────────────────────────
|
||||
const httpServer = http_1.default.createServer(app);
|
||||
const io = (0, socketServer_1.buildSocketServer)(httpServer);
|
||||
// ── WhatsApp Connection Manager ───────────────────────────────────────────
|
||||
const whatsAppManager = new WhatsAppConnectionManager_1.WhatsAppConnectionManager(io);
|
||||
globalThis.__whatsAppManager = whatsAppManager;
|
||||
// ── Rotas ─────────────────────────────────────────────────────────────────
|
||||
app.use('/api/auth', auth_routes_1.authRouter);
|
||||
app.use('/api/pair', pair_routes_1.pairRouter);
|
||||
// Configurações públicas de branding (sem autenticação — usadas pelo frontend antes do login)
|
||||
app.get('/api/admin/settings/public', async (_req, res) => {
|
||||
try {
|
||||
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
|
||||
const defaults = { systemName: 'NewWhats', logoUrl: null, faviconUrl: null, accentColor: '#3b82f6', maintenance: { enabled: false, message: '' } };
|
||||
const cfg = { ...defaults, ...(raw ?? {}) };
|
||||
res.json({ systemName: cfg.systemName, logoUrl: cfg.logoUrl, faviconUrl: cfg.faviconUrl, accentColor: cfg.accentColor, maintenance: cfg.maintenance });
|
||||
}
|
||||
catch {
|
||||
res.status(500).json({ error: 'Erro' });
|
||||
}
|
||||
});
|
||||
app.use('/api/admin', auth_middleware_1.authMiddleware, admin_middleware_1.adminMiddleware, admin_routes_1.adminRouter);
|
||||
app.use('/api/chats', auth_middleware_1.authMiddleware, (0, chat_routes_1.buildChatRoutes)(whatsAppManager));
|
||||
app.use('/api/instances', auth_middleware_1.authMiddleware, (0, whatsapp_routes_1.buildWhatsAppRoutes)(whatsAppManager));
|
||||
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildSendRoutes)(whatsAppManager));
|
||||
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_media_routes_1.buildMediaRoutes)(whatsAppManager));
|
||||
app.use('/api/chatbot', auth_middleware_1.authMiddleware, (0, chatbot_routes_1.buildChatbotRoutes)());
|
||||
app.use('/api/contacts', auth_middleware_1.authMiddleware, (0, contact_routes_1.buildContactRoutes)());
|
||||
app.use('/api/sectors', auth_middleware_1.authMiddleware, (0, sector_routes_1.buildSectorRoutes)());
|
||||
app.use('/api/api-keys', auth_middleware_1.authMiddleware, (0, apikey_routes_1.buildApiKeyRoutes)());
|
||||
app.use('/api/inbox', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildAvatarRoute)(whatsAppManager));
|
||||
// ── Templates e Agendamentos ──────────────────────────────────────────────
|
||||
const scheduler = new scheduler_service_1.SchedulerService(whatsAppManager);
|
||||
app.use('/api/templates', auth_middleware_1.authMiddleware, (0, template_routes_1.buildTemplateRoutes)());
|
||||
app.use('/api/scheduled', auth_middleware_1.authMiddleware, (0, scheduled_routes_1.buildScheduledRoutes)(scheduler));
|
||||
app.use('/api/stickers', auth_middleware_1.authMiddleware, (0, sticker_routes_1.buildStickerRoutes)());
|
||||
// Proxy genérico para arquivos no Wasabi — sem authMiddleware porque <img>/<audio>/<video>
|
||||
// do browser não enviam JWT. Paths são UUID-based (obscuros) — mesmo nível de segurança
|
||||
// que a rota /media/ estática que também não requer autenticação.
|
||||
app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buffer = await StorageProvider_1.storageProvider.getBuffer(filePath);
|
||||
if (!buffer) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const mime = ext === 'webp' ? 'image/webp' :
|
||||
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
|
||||
ext === 'png' ? 'image/png' :
|
||||
ext === 'mp4' ? 'video/mp4' :
|
||||
ext === 'ogg' ? 'audio/ogg' :
|
||||
'application/octet-stream';
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.send(buffer);
|
||||
}
|
||||
catch {
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
// Banner do frontend — dias restantes do plano
|
||||
app.get('/api/plan/status', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
const user = await prisma_1.prisma.user.findUnique({
|
||||
where: { id: req.tenantId },
|
||||
select: { trialEndsAt: true, plan: { select: { name: true } } },
|
||||
});
|
||||
const daysRemaining = user?.trialEndsAt
|
||||
? Math.max(0, Math.ceil((new Date(user.trialEndsAt).getTime() - Date.now()) / 86_400_000))
|
||||
: null;
|
||||
res.json({ daysRemaining, planName: user?.plan?.name ?? 'Trial' });
|
||||
});
|
||||
// Presença: retorna se o próprio usuário está online (chave ainda viva no Dragonfly)
|
||||
// Útil para self-check; para listar agentes online em admin, use a mesma lógica com prefixo
|
||||
app.get('/api/presence/me', auth_middleware_1.authMiddleware, async (req, res) => {
|
||||
const online = await dragonfly_1.dragonfly.exists(`presence:user:${req.tenantId}`);
|
||||
res.json({ online });
|
||||
});
|
||||
// Health check (sem autenticação)
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
// Retorna menu items de plugins ativos — usado pelo frontend após login
|
||||
app.get('/api/plugins/menu', auth_middleware_1.authMiddleware, (_req, res) => {
|
||||
const items = plugin_registry_1.pluginRegistry.getActiveMenuItems();
|
||||
res.json(items);
|
||||
});
|
||||
app.use(error_middleware_1.errorMiddleware);
|
||||
// ── Infra: conectar Dragonfly e Prisma ───────────────────────────────────
|
||||
await dragonfly_1.dragonfly.connect();
|
||||
await prisma_1.prisma.$connect();
|
||||
logger_1.logger.info('PostgreSQL conectado via Prisma');
|
||||
// ── Plugin System ─────────────────────────────────────────────────────────
|
||||
await (0, db_1.ensurePluginTables)(db_1.db);
|
||||
const loadedPlugins = await (0, plugin_loader_1.loadPlugins)();
|
||||
const pluginNames = loadedPlugins.map((p) => p.manifest.name);
|
||||
await plugin_config_1.pluginConfig.loadAll(pluginNames);
|
||||
const pluginCtx = {
|
||||
app,
|
||||
db: db_1.db,
|
||||
prisma: prisma_1.prisma,
|
||||
logger: logger_1.logger,
|
||||
hooks: hook_bus_1.hookBus,
|
||||
config: plugin_config_1.pluginConfig,
|
||||
io,
|
||||
httpServer,
|
||||
};
|
||||
plugin_registry_1.pluginRegistry.setContext(pluginCtx);
|
||||
plugin_registry_1.pluginRegistry.register(loadedPlugins);
|
||||
await plugin_registry_1.pluginRegistry.activateAll();
|
||||
logger_1.logger.info({ count: pluginNames.length }, '🔌 Plugins carregados');
|
||||
// ── Auto-reconectar instâncias ativas após reinício ──────────────────────
|
||||
// Reseta CONNECTING/QR_PENDING para DISCONNECTED antes de reconectar:
|
||||
// impede que a API retorne um status "fantasma" de antes do crash.
|
||||
await prisma_1.prisma.instance.updateMany({
|
||||
where: { status: { in: ['CONNECTING', 'QR_PENDING'] } },
|
||||
data: { status: 'DISCONNECTED' },
|
||||
});
|
||||
// Broadcasts travados em RUNNING (processo morreu durante o loop de envio):
|
||||
// marca como FAILED para não aparecerem eternamente como "Enviando…" no frontend.
|
||||
const stuckCount = await prisma_1.prisma.broadcast.updateMany({
|
||||
where: { status: 'RUNNING' },
|
||||
data: { status: 'FAILED', finishedAt: new Date() },
|
||||
});
|
||||
if (stuckCount.count > 0) {
|
||||
logger_1.logger.warn({ count: stuckCount.count }, '[Broadcast] Jobs RUNNING marcados como FAILED após reinício');
|
||||
}
|
||||
// Reconecta toda instância não-banida que tenha credenciais persistidas
|
||||
// no disco (creds.json). O filesystem é a fonte de verdade para "essa
|
||||
// instância já foi pareada" — o status no DB pode estar DISCONNECTED
|
||||
// temporariamente porque o watchdog matou a conexão ANTES do timer de
|
||||
// reconexão rodar (ex: restart do tsx watch durante o backoff de 3s).
|
||||
// Sem esta lógica, a instância fica órfã até alguém chamar connect()
|
||||
// pela API, causando perda de mensagens durante o hiato.
|
||||
const candidates = await prisma_1.prisma.instance.findMany({
|
||||
where: { status: { notIn: ['BANNED'] } },
|
||||
});
|
||||
for (const inst of candidates) {
|
||||
const credsPath = path_1.default.join(env_1.env.BAILEYS_SESSIONS_PATH, inst.id, 'creds.json');
|
||||
if (!(0, fs_1.existsSync)(credsPath)) {
|
||||
// Nunca foi pareada — pular pra não disparar QR indesejado no boot
|
||||
continue;
|
||||
}
|
||||
logger_1.logger.info({ instanceId: inst.id, previousStatus: inst.status }, 'Reconectando instância após reinício (creds persistidas)');
|
||||
whatsAppManager.connect(inst.id, inst.tenantId).catch((err) => logger_1.logger.error({ err, instanceId: inst.id }, 'Falha ao reconectar instância'));
|
||||
}
|
||||
// ── Scheduler de mensagens ────────────────────────────────────────────────
|
||||
scheduler.start();
|
||||
// ── Iniciar servidor ──────────────────────────────────────────────────────
|
||||
const port = parseInt(env_1.env.PORT, 10);
|
||||
httpServer.listen(port, () => {
|
||||
logger_1.logger.info(`🚀 NewWhats backend rodando em http://localhost:${port}`);
|
||||
logger_1.logger.info(` Socket.IO pronto — frontend: ${env_1.env.FRONTEND_URL}`);
|
||||
});
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
||||
const shutdown = async (signal) => {
|
||||
logger_1.logger.info({ signal }, 'Encerrando servidor...');
|
||||
scheduler.stop();
|
||||
httpServer.close();
|
||||
await prisma_1.prisma.$disconnect();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Falha fatal ao iniciar servidor:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.adminMiddleware = adminMiddleware;
|
||||
/**
|
||||
* Garante que apenas usuários com role ADMIN acessem a rota.
|
||||
* Deve ser usado APÓS o authMiddleware.
|
||||
*/
|
||||
function adminMiddleware(req, res, next) {
|
||||
if (req.userRole !== 'ADMIN') {
|
||||
res.status(403).json({ error: 'Acesso restrito a administradores' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.authMiddleware = authMiddleware;
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
const env_1 = require("../../config/env");
|
||||
function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
// Aceita token via query string para uso em <img src="...?token=...">
|
||||
const queryToken = typeof req.query['token'] === 'string' ? req.query['token'] : null;
|
||||
if (!header?.startsWith('Bearer ') && !queryToken) {
|
||||
res.status(401).json({ error: 'Token não fornecido' });
|
||||
return;
|
||||
}
|
||||
const token = header?.startsWith('Bearer ') ? header.slice(7) : queryToken;
|
||||
try {
|
||||
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_SECRET);
|
||||
req.tenantId = payload.sub;
|
||||
req.userRole = payload.role;
|
||||
next();
|
||||
}
|
||||
catch {
|
||||
res.status(401).json({ error: 'Token inválido ou expirado' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.errorMiddleware = errorMiddleware;
|
||||
const logger_1 = require("../../config/logger");
|
||||
function errorMiddleware(err, _req, res, _next) {
|
||||
logger_1.logger.error(err, 'Unhandled error');
|
||||
res.status(500).json({ error: 'Erro interno do servidor' });
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Utilitários para manipulação de JIDs (Jabber IDs) do WhatsApp.
|
||||
*
|
||||
* O WhatsApp usa JIDs como identificadores únicos para contatos, grupos e canais:
|
||||
* - Contatos individuais: "5511999999999@s.whatsapp.net"
|
||||
* - Grupos: "120363012345678@g.us"
|
||||
* - Canais/newsletters: "120363012345678@newsletter"
|
||||
* - LID (Linked ID): "123456789012345@lid" (identificador interno moderno)
|
||||
* - Broadcast: "status@broadcast"
|
||||
*
|
||||
* JIDs de dispositivo incluem sufixo ":N" (ex: "5511999999999:1@s.whatsapp.net")
|
||||
* que identifica o dispositivo específico (multi-device). Para armazenamento
|
||||
* e comparação, normalizamos removendo esse sufixo.
|
||||
*
|
||||
* @see MessageHandler — usa normalizeJid para persistir mensagens
|
||||
* @see ContactHandler — usa para vincular contatos a chats
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.normalizeJid = normalizeJid;
|
||||
exports.extractPhone = extractPhone;
|
||||
/**
|
||||
* Normaliza um JID do WhatsApp removendo sufixos de dispositivo (:1, :2, etc).
|
||||
*
|
||||
* Exemplos:
|
||||
* "556199999999:1@s.whatsapp.net" → "556199999999@s.whatsapp.net"
|
||||
* "556199999999@s.whatsapp.net" → "556199999999@s.whatsapp.net" (sem mudança)
|
||||
* "120363012345@g.us" → "120363012345@g.us" (grupos não têm sufixo)
|
||||
* null / undefined → "" (string vazia como fallback seguro)
|
||||
*
|
||||
* IMPORTANTE: Não confundir com resolução LID. Este método apenas remove
|
||||
* o sufixo de dispositivo — NÃO converte @lid para @s.whatsapp.net.
|
||||
* Para resolução LID, veja MessageHandler.resolveLidToPhoneJid().
|
||||
*
|
||||
* @param jid - JID bruto do Baileys (pode conter sufixo de dispositivo)
|
||||
* @returns JID normalizado sem sufixo de dispositivo
|
||||
*/
|
||||
function normalizeJid(jid) {
|
||||
if (!jid)
|
||||
return '';
|
||||
const [userPart, domain] = jid.split('@');
|
||||
if (!domain)
|
||||
return jid; // Não é um JID válido (sem @), retorna como está
|
||||
// Remove sufixo de dispositivo: "556199999999:1" → "556199999999"
|
||||
const [cleanUser] = userPart.split(':');
|
||||
return `${cleanUser}@${domain}`;
|
||||
}
|
||||
/**
|
||||
* Extrai o número de telefone puro de um JID.
|
||||
*
|
||||
* Exemplos:
|
||||
* "5511999999999@s.whatsapp.net" → "5511999999999"
|
||||
* "5511999999999:1@s.whatsapp.net" → "5511999999999"
|
||||
* "120363012345@g.us" → null (grupos não têm telefone único)
|
||||
* null / undefined → null
|
||||
*
|
||||
* @param jid - JID do WhatsApp
|
||||
* @returns Número de telefone (apenas dígitos + código do país) ou null
|
||||
*/
|
||||
function extractPhone(jid) {
|
||||
if (!jid)
|
||||
return null;
|
||||
if (jid.endsWith('@g.us'))
|
||||
return null; // Grupos não têm "telefone" único no JID
|
||||
const [userPart] = jid.split('@');
|
||||
const [cleanUser] = userPart.split(':');
|
||||
return cleanUser;
|
||||
}
|
||||
Generated
+9610
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,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "stickers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"instanceId" TEXT NOT NULL,
|
||||
"sha256" TEXT NOT NULL,
|
||||
"wasabiPath" TEXT NOT NULL,
|
||||
"isAnimated" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
|
||||
"useCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "stickers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stickers_tenantId_sha256_key" ON "stickers"("tenantId", "sha256");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stickers_tenantId_instanceId_isFavorite_lastSeenAt_idx" ON "stickers"("tenantId", "instanceId", "isFavorite", "lastSeenAt" DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,560 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Prisma Schema — NewWhats SaaS Omnichannel
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── PLANOS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
maxInstances Int @default(1)
|
||||
maxAgents Int @default(5)
|
||||
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
}
|
||||
|
||||
// ─── USUÁRIOS (ADMIN + TENANTS) ───────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
planId String?
|
||||
trialEndsAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
plan Plan? @relation(fields: [planId], references: [id])
|
||||
instances Instance[]
|
||||
sectors Sector[]
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
aiCredentials AICredential[]
|
||||
aiBots AIBot[]
|
||||
broadcasts Broadcast[]
|
||||
pluginPairs PluginPair[]
|
||||
extWebhooks ExtWebhook[]
|
||||
messageTemplates MessageTemplate[]
|
||||
scheduledMessages ScheduledMessage[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
// ─── API KEYS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
key String @unique @default(uuid())
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
// ─── INSTÂNCIAS WHATSAPP ──────────────────────────────────────────────────────
|
||||
|
||||
model Instance {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
phone String?
|
||||
avatar String?
|
||||
status InstanceStatus @default(DISCONNECTED)
|
||||
engine String @default("infinite")
|
||||
sessionPath String?
|
||||
webhookUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
contacts Contact[]
|
||||
chats Chat[]
|
||||
messages Message[]
|
||||
broadcasts Broadcast[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("instances")
|
||||
}
|
||||
|
||||
enum InstanceStatus {
|
||||
DISCONNECTED
|
||||
CONNECTING
|
||||
QR_PENDING
|
||||
CONNECTED
|
||||
BANNED
|
||||
}
|
||||
|
||||
// ─── SETORES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Sector {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
teams Team[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, name])
|
||||
@@map("sectors")
|
||||
}
|
||||
|
||||
// ─── EQUIPES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
sectorId String
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade)
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, sectorId, name])
|
||||
@@map("teams")
|
||||
}
|
||||
|
||||
// ─── CONTATOS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
model Contact {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
jid String
|
||||
lidJid String?
|
||||
// Prioridade: name (agenda) > verifiedName > notify (pushname)
|
||||
name String?
|
||||
verifiedName String?
|
||||
notify String?
|
||||
phone String?
|
||||
avatarUrl String?
|
||||
scoreReputacao Int @default(100)
|
||||
flagRestricao Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
chats Chat[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, instanceId, jid])
|
||||
@@index([tenantId, instanceId, lidJid])
|
||||
@@map("contacts")
|
||||
}
|
||||
|
||||
// ─── CHATS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Chat {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
contactId String?
|
||||
jid String
|
||||
name String? // Subject para grupos (@g.us); null para individuais
|
||||
unreadCount Int @default(0)
|
||||
lastMessageAt DateTime?
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
// IA Chatbot
|
||||
botPaused Boolean @default(false) // human takeover — pausa o bot neste chat
|
||||
botSummary String? // Cérebro: resumo de 2 frases da conversa
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id])
|
||||
messages Message[]
|
||||
protocols Protocol[]
|
||||
|
||||
@@unique([tenantId, instanceId, jid])
|
||||
// Índice composto para a query principal da inbox:
|
||||
// WHERE tenantId + instanceId + isArchived (filtro)
|
||||
// ORDER BY isPinned DESC, lastMessageAt DESC (ordenação)
|
||||
@@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)])
|
||||
@@map("chats")
|
||||
}
|
||||
|
||||
// ─── MENSAGENS ────────────────────────────────────────────────────────────────
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
chatId String
|
||||
remoteJid String
|
||||
messageId String
|
||||
fromMe Boolean @default(false)
|
||||
type MessageType @default(TEXT)
|
||||
body String?
|
||||
caption String?
|
||||
mediaUrl String?
|
||||
mediaPath String?
|
||||
mimeType String?
|
||||
fileName String?
|
||||
duration Int?
|
||||
pushName String? // Nome do remetente no momento do envio (grupos: quem enviou)
|
||||
senderJid String? // JID do remetente real (grupos: key.participant)
|
||||
replyToId String? // ID da mensagem citada (reply)
|
||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||
status MessageStatus @default(PENDING)
|
||||
timestamp DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
|
||||
replies Message[] @relation("MessageReply")
|
||||
|
||||
@@unique([instanceId, messageId])
|
||||
// Índice para "última mensagem por chat" — usado no include da inbox
|
||||
// ORDER BY timestamp DESC LIMIT 1 (por chatId)
|
||||
@@index([chatId, timestamp(sort: Desc)])
|
||||
// Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC
|
||||
// WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL
|
||||
@@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)])
|
||||
// Índice para obter a última mensagem da instância — usado na verificação de gap de sincronização
|
||||
@@index([instanceId, timestamp(sort: Desc)])
|
||||
@@map("messages")
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
TEXT
|
||||
IMAGE
|
||||
VIDEO
|
||||
AUDIO
|
||||
DOCUMENT
|
||||
STICKER
|
||||
LOCATION
|
||||
CONTACT
|
||||
REACTION
|
||||
POLL
|
||||
BUTTONS
|
||||
LIST
|
||||
CAROUSEL
|
||||
INTERACTIVE
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
enum MessageStatus {
|
||||
PENDING
|
||||
SENT
|
||||
DELIVERED
|
||||
READ
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ─── PROTOCOLOS DE ATENDIMENTO ────────────────────────────────────────────────
|
||||
|
||||
model Protocol {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
number String @unique // Número sequencial rastreável: #0001-2024
|
||||
chatId String
|
||||
contactId String
|
||||
sectorId String?
|
||||
teamId String?
|
||||
agentId String?
|
||||
status ProtocolStatus @default(OPEN)
|
||||
summary String? // Resumo de 2 frases gerado pelo Temporal
|
||||
deadline DateTime?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id])
|
||||
contact Contact @relation(fields: [contactId], references: [id])
|
||||
sector Sector? @relation(fields: [sectorId], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
// Índice para filtro de protocolos abertos por chat (usado no include da inbox)
|
||||
// WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC
|
||||
@@index([chatId, status, createdAt(sort: Desc)])
|
||||
@@map("protocols")
|
||||
}
|
||||
|
||||
enum ProtocolStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
WAITING_CLIENT
|
||||
WAITING_AGENT
|
||||
RESOLVED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// ─── IA MULTI-AGENTE ──────────────────────────────────────────────────────────
|
||||
|
||||
model AICredential {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
name String
|
||||
provider LLMProvider @default(GEMINI)
|
||||
apiKey String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
bots AIBot[]
|
||||
|
||||
@@unique([tenantId, instanceId, name])
|
||||
@@map("ai_credentials")
|
||||
}
|
||||
|
||||
model AIBot {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
credentialId String
|
||||
name String
|
||||
systemPrompt String @db.Text
|
||||
model String @default("gemini-1.5-flash")
|
||||
enabled Boolean @default(false)
|
||||
triggerMode TriggerMode @default(ALL)
|
||||
keywords String[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
credential AICredential @relation(fields: [credentialId], references: [id])
|
||||
|
||||
@@unique([tenantId, instanceId])
|
||||
@@map("ai_bots")
|
||||
}
|
||||
|
||||
enum LLMProvider {
|
||||
GEMINI
|
||||
OPENAI
|
||||
}
|
||||
|
||||
enum TriggerMode {
|
||||
ALL
|
||||
KEYWORD
|
||||
}
|
||||
|
||||
// ─── BROADCAST (DISPARO EM MASSA) ─────────────────────────────────────────────
|
||||
|
||||
model Broadcast {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
message String @db.Text
|
||||
totalCount Int @default(0)
|
||||
sentCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
status String @default("RUNNING") // RUNNING | DONE | FAILED
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
recipients BroadcastRecipient[]
|
||||
|
||||
@@map("broadcasts")
|
||||
}
|
||||
|
||||
// ─── PLUGIN PAIRS ─────────────────────────────────────────────────────────────
|
||||
// Registra sistemas externos pareados com uma conta NewWhats.
|
||||
// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*.
|
||||
|
||||
model PluginPair {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
clientSystem String // identificador técnico ex: "alemao-conveniencias"
|
||||
clientName String // nome amigável ex: "Alemão Conveniências"
|
||||
clientUrl String? // URL base do sistema cliente
|
||||
integrationKey String @unique
|
||||
lastSeenAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("plugin_pairs")
|
||||
}
|
||||
|
||||
// ─── EXT WEBHOOKS ─────────────────────────────────────────────────────────────
|
||||
// Endpoints externos registrados por tenant para receber push de eventos.
|
||||
|
||||
model ExtWebhook {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
url String
|
||||
events String[]
|
||||
secret String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("ext_webhooks")
|
||||
}
|
||||
|
||||
model BroadcastRecipient {
|
||||
id String @id @default(uuid())
|
||||
broadcastId String
|
||||
jid String
|
||||
status String @default("PENDING") // PENDING | SENT | FAILED
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("broadcast_recipients")
|
||||
}
|
||||
|
||||
// ─── TEMPLATES DE MENSAGEM ────────────────────────────────────────────────────
|
||||
|
||||
model MessageTemplate {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
name String
|
||||
description String?
|
||||
type TemplateType @default(TEXT)
|
||||
payload Json
|
||||
tags String[]
|
||||
usageCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
schedules ScheduledMessage[]
|
||||
|
||||
@@map("message_templates")
|
||||
}
|
||||
|
||||
enum TemplateType {
|
||||
TEXT
|
||||
BUTTONS
|
||||
INTERACTIVE
|
||||
LIST
|
||||
POLL
|
||||
CAROUSEL
|
||||
}
|
||||
|
||||
// ─── AGENDAMENTOS DE MENSAGEM ─────────────────────────────────────────────────
|
||||
|
||||
model ScheduledMessage {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
name String
|
||||
templateId String?
|
||||
payload Json
|
||||
recipientMode RecipientMode @default(MANUAL)
|
||||
recipients Json
|
||||
scheduleType ScheduleType @default(ONCE)
|
||||
cronExpr String?
|
||||
scheduledAt DateTime?
|
||||
eventType EventType?
|
||||
timezone String @default("America/Sao_Paulo")
|
||||
status ScheduleStatus @default(ACTIVE)
|
||||
lastRunAt DateTime?
|
||||
nextRunAt DateTime?
|
||||
runCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
template MessageTemplate? @relation(fields: [templateId], references: [id])
|
||||
logs ScheduledMessageLog[]
|
||||
|
||||
@@map("scheduled_messages")
|
||||
}
|
||||
|
||||
model ScheduledMessageLog {
|
||||
id String @id @default(uuid())
|
||||
scheduleId String
|
||||
status String
|
||||
sentCount Int @default(0)
|
||||
failedCount Int @default(0)
|
||||
error String?
|
||||
runAt DateTime @default(now())
|
||||
|
||||
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("scheduled_message_logs")
|
||||
}
|
||||
|
||||
enum RecipientMode {
|
||||
MANUAL
|
||||
ALL_CONTACTS
|
||||
TAG_FILTER
|
||||
}
|
||||
|
||||
enum ScheduleType {
|
||||
ONCE
|
||||
RECURRING
|
||||
EVENT
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
BIRTHDAY
|
||||
ANNIVERSARY
|
||||
SIGNUP_DAYS
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
|
||||
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
|
||||
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
|
||||
|
||||
model Sticker {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
sha256 String // fileSha256 hex — identidade permanente do conteúdo
|
||||
wasabiPath String // stickers/{tenantId}/{sha256}.webp
|
||||
isAnimated Boolean @default(false)
|
||||
isFavorite Boolean @default(false)
|
||||
useCount Int @default(1)
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, sha256])
|
||||
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
|
||||
@@map("stickers")
|
||||
}
|
||||
|
||||
// Webhook allowed host fix
|
||||
// Webhook allowed host fix
|
||||
// Webhook query string fix
|
||||
@@ -0,0 +1,45 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const ROUNDS = 10
|
||||
|
||||
const users = [
|
||||
{
|
||||
name: 'Usuário Padrão',
|
||||
email: 'user@user.com',
|
||||
password: '12345678',
|
||||
role: 'USER' as const,
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
email: 'ruibto@gmail.com',
|
||||
password: 'Rc362514',
|
||||
role: 'ADMIN' as const,
|
||||
},
|
||||
]
|
||||
|
||||
for (const u of users) {
|
||||
const passwordHash = await bcrypt.hash(u.password, ROUNDS)
|
||||
const existing = await prisma.user.findUnique({ where: { email: u.email } })
|
||||
|
||||
if (existing) {
|
||||
await prisma.user.update({
|
||||
where: { email: u.email },
|
||||
data: { passwordHash, name: u.name, role: u.role },
|
||||
})
|
||||
console.log(`✅ Atualizado: ${u.email}`)
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: { name: u.name, email: u.email, passwordHash, role: u.role },
|
||||
})
|
||||
console.log(`✅ Criado: ${u.email} (${u.role})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1) })
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Script standalone para testar a conexão Baileys e exibir o QR Code no terminal.
|
||||
* Não requer banco de dados nem DragonflyDB.
|
||||
* Uso: node qr-test.mjs
|
||||
*/
|
||||
|
||||
import {
|
||||
makeWASocket,
|
||||
useMultiFileAuthState,
|
||||
DisconnectReason,
|
||||
fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore,
|
||||
} from '@whiskeysockets/baileys'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import qrcode from 'qrcode-terminal'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const SESSION_DIR = path.join(__dirname, 'sessions', 'qr-test')
|
||||
|
||||
async function start() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR)
|
||||
const { version, isLatest } = await fetchLatestBaileysVersion()
|
||||
|
||||
console.log(`\n📱 Baileys v${version} (latest: ${isLatest})`)
|
||||
console.log('🔄 Iniciando conexão...\n')
|
||||
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
printQRInTerminal: false, // controlamos manualmente
|
||||
qrTimeout: 60_000,
|
||||
browser: ['NewWhats', 'Chrome', '124.0.0'],
|
||||
syncFullHistory: false,
|
||||
})
|
||||
|
||||
sock.ev.on('creds.update', saveCreds)
|
||||
|
||||
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
|
||||
if (qr) {
|
||||
console.clear()
|
||||
console.log('📲 Escaneie o QR Code abaixo com o WhatsApp:\n')
|
||||
qrcode.generate(qr, { small: true })
|
||||
console.log('\n⏳ Aguardando escaneamento...')
|
||||
}
|
||||
|
||||
if (connection === 'open') {
|
||||
const me = sock.user
|
||||
console.log('\n✅ Conectado com sucesso!')
|
||||
console.log(` Número: ${me?.id}`)
|
||||
console.log(` Nome: ${me?.name}`)
|
||||
console.log('\n🎉 Sessão salva em:', SESSION_DIR)
|
||||
console.log(' Agora rode o servidor completo com: npm run dev\n')
|
||||
}
|
||||
|
||||
if (connection === 'close') {
|
||||
const code = (lastDisconnect?.error)?.output?.statusCode
|
||||
const reason = DisconnectReason
|
||||
|
||||
console.log(`\n❌ Conexão encerrada (código: ${code})`)
|
||||
|
||||
const shouldReconnect =
|
||||
code !== reason.loggedOut &&
|
||||
code !== reason.forbidden
|
||||
|
||||
if (shouldReconnect) {
|
||||
console.log('🔄 Reconectando...\n')
|
||||
setTimeout(start, 3000)
|
||||
} else {
|
||||
console.log('🚫 Sessão encerrada definitivamente.')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error('Erro fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = exports.config = void 0;
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
var env_1 = require("./config/env");
|
||||
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
|
||||
var env_2 = require("./config/env");
|
||||
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
|
||||
//# sourceMappingURL=config.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":";;;AAAA,mEAAmE;AACnE,oCAA4C;AAAnC,6FAAA,GAAG,OAAU;AACtB,oCAAkC;AAAzB,0FAAA,GAAG,OAAA"}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
export { env as config } from './config/env'
|
||||
export { env } from './config/env'
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = void 0;
|
||||
const zod_1 = require("zod");
|
||||
require("dotenv/config");
|
||||
const envSchema = zod_1.z.object({
|
||||
PORT: zod_1.z.string().default('8008'),
|
||||
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: zod_1.z.string().min(16),
|
||||
DATABASE_URL: zod_1.z.string().url(),
|
||||
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
|
||||
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
|
||||
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
|
||||
});
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:');
|
||||
console.error(parsed.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
exports.env = parsed.data;
|
||||
//# sourceMappingURL=env.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"env.js","sourceRoot":"","sources":["env.ts"],"names":[],"mappings":";;;AAAA,6BAAuB;AACvB,yBAAsB;AAEtB,MAAM,SAAS,GAAG,OAAC,CAAC,MAAM,CAAC;IACzB,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;IAChC,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;IAC9E,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9B,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC9B,aAAa,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC;IAC3D,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAClD,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACrD,gBAAgB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACtD,kBAAkB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,mBAAmB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACzD,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IACvD,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;CAC1D,CAAC,CAAA;AAEF,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;AAE/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAA;IACnD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAEY,QAAA,GAAG,GAAG,MAAM,CAAC,IAAI,CAAA"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
import 'dotenv/config'
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('8008'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DRAGONFLY_URL: z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: z.string().default('86400'),
|
||||
NATS_URL: z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
|
||||
FRONTEND_URL: z.string().default('http://localhost:3000'),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:')
|
||||
console.error(parsed.error.format())
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logger = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
const env_1 = require("./env");
|
||||
exports.logger = (0, pino_1.default)({
|
||||
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env_1.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
});
|
||||
//# sourceMappingURL=logger.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AACvB,+BAA2B;AAEd,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,SAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;IACvD,SAAS,EACP,SAAG,CAAC,QAAQ,KAAK,YAAY;QAC3B,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE,EAAE;QACvF,CAAC,CAAC,SAAS;CAChB,CAAC,CAAA"}
|
||||
@@ -0,0 +1,10 @@
|
||||
import pino from 'pino'
|
||||
import { env } from './env'
|
||||
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport:
|
||||
env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.storageProvider = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
constructor() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
};
|
||||
}
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
return this.provider !== null;
|
||||
}
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
getLastHealthStatus() {
|
||||
return this.lastHealthStatus;
|
||||
}
|
||||
setHealthStatus(status) {
|
||||
this.lastHealthStatus = status;
|
||||
this.healthy = status.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
}
|
||||
return this.provider.upload(payload);
|
||||
}
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await this.provider.checkDetailedHealth();
|
||||
this.healthy = status.healthy;
|
||||
this.lastHealthStatus = status;
|
||||
}
|
||||
catch {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
};
|
||||
}
|
||||
}
|
||||
async getBuffer(path) {
|
||||
if (!this.provider)
|
||||
return null;
|
||||
return this.provider.getBuffer(path);
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
if (!this.provider) {
|
||||
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
|
||||
return { deleted: 0 };
|
||||
}
|
||||
return this.provider.deletePrefix(prefix);
|
||||
}
|
||||
unregister() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
logger_1.logger.info('[StorageProvider] Provider removido');
|
||||
}
|
||||
}
|
||||
exports.storageProvider = new StorageProviderRegistry();
|
||||
//# sourceMappingURL=StorageProvider.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAoBvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;QACf,qBAAgB,GAAwB;YAC9C,OAAO,EAAE,KAAK;YACd,UAAU,EAAE,KAAK;YACjB,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,2BAA2B;SACrC,CAAA;IA6EH,CAAC;IA3EC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,gBAAgB,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAA;QAC1G,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,mBAAmB;QACjB,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,eAAe,CAAC,MAA2B;QACzC,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,gBAAgB;gBACxB,OAAO,EAAE,2EAA2E;aACrF,CAAA;YACD,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAA;YACxD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;YAC7B,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,IAAI;gBAChB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAA;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,IAAY;QAC1B,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAA;QAC/B,OAAO,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { logger as rootLogger } from '../config/logger'
|
||||
import type { StorageProviderInterface, StorageUploadPayload } from './types'
|
||||
|
||||
export type StorageHealthReason =
|
||||
| 'ok'
|
||||
| 'not_configured'
|
||||
| 'needs_restart'
|
||||
| 'auth_error'
|
||||
| 'billing_error'
|
||||
| 'bucket_not_found'
|
||||
| 'network_error'
|
||||
| 'unknown'
|
||||
|
||||
export interface StorageHealthStatus {
|
||||
healthy: boolean
|
||||
configured: boolean
|
||||
reason: StorageHealthReason
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
private provider: StorageProviderInterface | null = null
|
||||
private healthy = false
|
||||
private lastHealthStatus: StorageHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
}
|
||||
|
||||
register(provider: StorageProviderInterface): void {
|
||||
this.provider = provider
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' }
|
||||
rootLogger.info('[StorageProvider] Provider registrado')
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.provider !== null
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.healthy
|
||||
}
|
||||
|
||||
getLastHealthStatus(): StorageHealthStatus {
|
||||
return this.lastHealthStatus
|
||||
}
|
||||
|
||||
setHealthStatus(status: StorageHealthStatus): void {
|
||||
this.lastHealthStatus = status
|
||||
this.healthy = status.healthy
|
||||
}
|
||||
|
||||
async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.')
|
||||
}
|
||||
return this.provider.upload(payload)
|
||||
}
|
||||
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth(): Promise<void> {
|
||||
if (!this.provider) {
|
||||
this.healthy = false
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const status = await this.provider.checkDetailedHealth()
|
||||
this.healthy = status.healthy
|
||||
this.lastHealthStatus = status
|
||||
} catch {
|
||||
this.healthy = false
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(path: string): Promise<Buffer | null> {
|
||||
if (!this.provider) return null
|
||||
return this.provider.getBuffer(path)
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
if (!this.provider) {
|
||||
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
||||
return { deleted: 0 }
|
||||
}
|
||||
return this.provider.deletePrefix(prefix)
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
this.provider = null
|
||||
this.healthy = false
|
||||
rootLogger.info('[StorageProvider] Provider removido')
|
||||
}
|
||||
}
|
||||
|
||||
export const storageProvider = new StorageProviderRegistry()
|
||||
export type { StorageUploadPayload, StorageProviderInterface }
|
||||
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.db = void 0;
|
||||
exports.ensurePluginTables = ensurePluginTables;
|
||||
const knex_1 = __importDefault(require("knex"));
|
||||
const env_1 = require("../config/env");
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Instância Knex compartilhada entre todos os plugins.
|
||||
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
|
||||
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
|
||||
*/
|
||||
function createKnex() {
|
||||
// DATABASE_URL formato: postgresql://user:pass@host:port/db
|
||||
const connection = env_1.env.DATABASE_URL;
|
||||
const instance = (0, knex_1.default)({
|
||||
client: 'pg',
|
||||
connection,
|
||||
pool: { min: 1, max: 5 },
|
||||
acquireConnectionTimeout: 10000,
|
||||
});
|
||||
instance.on('query-error', (err, query) => {
|
||||
logger_1.logger.error({ err, sql: query.sql }, '[Knex] Query error');
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
exports.db = createKnex();
|
||||
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
|
||||
async function ensurePluginTables(knexInstance) {
|
||||
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs');
|
||||
if (!hasJanitorLogs) {
|
||||
await knexInstance.schema.createTable('janitor_logs', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('filename').notNullable();
|
||||
t.string('category').notNullable();
|
||||
t.string('status').notNullable();
|
||||
t.text('message').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela janitor_logs criada');
|
||||
}
|
||||
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage');
|
||||
if (!hasNanobanaUsage) {
|
||||
await knexInstance.schema.createTable('nanobana_usage', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('partner_id').notNullable();
|
||||
t.string('type').notNullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
logger_1.logger.info('[Knex] Tabela nanobana_usage criada');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=db.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"db.js","sourceRoot":"","sources":["db.ts"],"names":[],"mappings":";;;;;;AA8BA,gDAwBC;AAtDD,gDAAiC;AACjC,uCAAmC;AACnC,6CAAuD;AAEvD;;;;GAIG;AACH,SAAS,UAAU;IACjB,4DAA4D;IAC5D,MAAM,UAAU,GAAG,SAAG,CAAC,YAAY,CAAA;IAEnC,MAAM,QAAQ,GAAG,IAAA,cAAI,EAAC;QACpB,MAAM,EAAE,IAAI;QACZ,UAAU;QACV,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE;QACxB,wBAAwB,EAAE,KAAK;KAChC,CAAC,CAAA;IAEF,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,GAAU,EAAE,KAAsB,EAAE,EAAE;QAChE,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,oBAAoB,CAAC,CAAA;IACjE,CAAC,CAAC,CAAA;IAEF,OAAO,QAAQ,CAAA;AACjB,CAAC;AAEY,QAAA,EAAE,GAAG,UAAU,EAAE,CAAA;AAE9B,uFAAuF;AAChF,KAAK,UAAU,kBAAkB,CAAC,YAAkB;IACzD,MAAM,cAAc,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAA;IACzE,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE;YAC1D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAA;YAChC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAA;YAC5B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAA;IACtD,CAAC;IAED,MAAM,gBAAgB,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAA;IAC7E,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACtB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,gBAAgB,EAAE,CAAC,CAAC,EAAE,EAAE;YAC5D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAA;YACpC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAA;YAC9B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;AACH,CAAC"}
|
||||
@@ -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,133 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.loadPlugins = loadPlugins;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const logger_1 = require("../config/logger");
|
||||
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/plugins'
|
||||
: path_1.default.resolve(__dirname, '../../../plugins');
|
||||
/**
|
||||
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
|
||||
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
|
||||
*/
|
||||
function topoSort(plugins) {
|
||||
const byName = new Map(plugins.map((p) => [p.manifest.name, p]));
|
||||
const inDegree = new Map();
|
||||
const dependents = new Map();
|
||||
for (const p of plugins) {
|
||||
inDegree.set(p.manifest.name, 0);
|
||||
dependents.set(p.manifest.name, []);
|
||||
}
|
||||
for (const p of plugins) {
|
||||
for (const dep of p.manifest.dependencies) {
|
||||
if (!byName.has(dep)) {
|
||||
logger_1.logger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada');
|
||||
continue;
|
||||
}
|
||||
dependents.get(dep).push(p.manifest.name);
|
||||
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const queue = plugins
|
||||
.filter((p) => inDegree.get(p.manifest.name) === 0)
|
||||
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1));
|
||||
const sorted = [];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
sorted.push(current);
|
||||
for (const depName of dependents.get(current.manifest.name) ?? []) {
|
||||
const newDegree = (inDegree.get(depName) ?? 1) - 1;
|
||||
inDegree.set(depName, newDegree);
|
||||
if (newDegree === 0) {
|
||||
queue.push(byName.get(depName));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sorted.length !== plugins.length) {
|
||||
logger_1.logger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original');
|
||||
return plugins;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
/**
|
||||
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
|
||||
* Retorna plugins ordenados por dependência.
|
||||
*/
|
||||
async function loadPlugins() {
|
||||
if (!fs_1.default.existsSync(PLUGINS_DIR)) {
|
||||
logger_1.logger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado');
|
||||
return [];
|
||||
}
|
||||
const entries = fs_1.default.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
||||
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
const loaded = [];
|
||||
for (const dirName of pluginDirs) {
|
||||
const pluginDir = path_1.default.join(PLUGINS_DIR, dirName);
|
||||
const manifestPath = path_1.default.join(pluginDir, 'manifest.json');
|
||||
const indexTs = path_1.default.join(pluginDir, 'index.ts');
|
||||
const indexJs = path_1.default.join(pluginDir, 'index.js');
|
||||
if (!fs_1.default.existsSync(manifestPath)) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
|
||||
const entryPoint = fs_1.default.existsSync(indexJs) ? indexJs : fs_1.default.existsSync(indexTs) ? indexTs : null;
|
||||
if (!entryPoint) {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
|
||||
const mod = await Promise.resolve(`${entryPoint}`).then(s => __importStar(require(s)));
|
||||
const instance = mod.default ?? mod;
|
||||
if (typeof instance.activate !== 'function') {
|
||||
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando');
|
||||
continue;
|
||||
}
|
||||
loaded.push({ instance, manifest, dir: pluginDir });
|
||||
logger_1.logger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin');
|
||||
}
|
||||
}
|
||||
return topoSort(loaded);
|
||||
}
|
||||
//# sourceMappingURL=plugin-loader.js.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user