diff --git a/.gitignore b/.gitignore index 90fcb3c..26282f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,34 @@ gcal_key.json ssh_alerts_config.json *.log .DS_Store + +# Next.js & Node builds +**/node_modules/ +**/.next/ +**/out/ +**/dist/ +**/build/ +.env* +!.env.example + +# Traefik & SSL (Cuidado com segredos!) +**/acme.json +**/traefik_logs.txt + +# Databases / Backups / Dumps +*.sql +*.tar.gz +*.zip + +# Monitoramento e Dados de Estado (SOC) +**/prom-data/ +**/wal/ +**/chunks/ +**/tombstones/ +**/positions.yaml + +# Sessões e Mídia locais +**/sessions/ +**/media/ +**/newwhats-sessions/ +**/newwhats-media/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c2f7eee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "docker.host": "unix:///run/user/1000/docker.sock" +} diff --git a/clube67/arquitetura.md b/clube67/arquitetura.md new file mode 100644 index 0000000..27ffebb --- /dev/null +++ b/clube67/arquitetura.md @@ -0,0 +1,244 @@ +# 📐 Arquitetura de Rede e Topologia de Servidores + +Este documento descreve detalhadamente a arquitetura de servidores e rede privada projetada para garantir **alta performance, isolamento de riscos, segurança de dados e facilidade de escalonamento**. + +--- + +## 🗺️ Visão Geral da Topologia (Rede VPN WireGuard) + +Toda a comunicação crítica entre os servidores trafega por dentro de uma rede privada criptografada baseada no protocolo **WireGuard** (`10.99.0.0/24`). Os servidores possuem seus firewalls públicos trancados, permitindo apenas acessos necessários e isolando os dados vitais. + +```mermaid +graph TD + subgraph "Rede Pública (Internet)" + Internet((Internet Pública)) + Client([Notebook Rui]) + end + + subgraph "Rede Privada VPN (10.99.0.0/24)" + VPS1["🖥️ VPS 1 (Produção)
IP: 10.99.0.1
(Traefik / Apps)"] + VPS3["🗄️ VPS 3 (Banco de Dados)
IP: 10.99.0.3
(PostgreSQL Dedicado)"] + VPS4["🧪 VPS 4 (Desenvolvimento)
IP: 10.99.0.4
(Staging / Builds)"] + VPS5["💬 VPS 5 (CRM WhatsApp)
IP: 10.99.0.5
(Bots / APIs)"] + end + + %% Conexões Públicas + Internet == HTTP/HTTPS (80/443) ==> VPS1 + Client == Conexão VPN Criptografada ==> VPS1 + + %% Conexões Privadas VPN + VPS1 <== Canal Seguro VPN ==> VPS3 + VPS1 <== Canal Seguro VPN ==> VPS4 + VPS1 <== Canal Seguro VPN ==> VPS5 + Client <== Acesso SSH Seguro ==> VPS1 + Client <== Acesso SSH Seguro ==> VPS3 + Client <== Acesso SSH Seguro ==> VPS4 + Client <== Acesso SSH Seguro ==> VPS5 + + %% Conexões de Banco de Dados Internas + VPS1 -- PostgreSQL (Porta 5432) --> VPS3 + VPS4 -- PostgreSQL (Porta 5432) --> VPS3 + VPS5 -- PostgreSQL (Porta 5432) --> VPS3 + + style VPS3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff + style VPS1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff + style VPS4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff + style VPS5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff +``` + +--- + +## 📋 Especificação Detalhada dos Servidores + +### 1. 🗄️ VPS 3: Servidor de Banco de Dados Dedicado (Appliance) +* **Função Principal**: Hospedar exclusivamente o banco de dados **PostgreSQL Bare-Metal (Nativo)** e serviços fundamentais de persistência. +* **IP Privado VPN**: `10.99.0.3` +* **Tecnologias**: PostgreSQL (Instalação Nativa via Host), WireGuard, nftables. (O Docker é **estritamente omitido** para reduzir superfície de ataque e otimizar I/O). +* **Políticas do Firewall (nftables)**: + * **Porta 22 (SSH)**: **Bloqueada publicamente**. Liberada **apenas** para o IP reservado do administrador via VPN (`10.99.0.10`). + * **Porta 5432 (PostgreSQL)**: **Bloqueada publicamente**. Liberada **apenas** para conexões originadas de IPs autorizados na VPN (`10.99.0.1` e `10.99.0.5`). +* **Vantagens de Segurança & Performance**: + * **Zero vazamento de dados**: O banco não pode ser acessado de fora da VPN sob hipótese alguma. + * **Conceito de Appliance**: Sem Docker daemon (sem socket de root adicional) ou compiladores, a superfície de invasão é reduzida ao menor nível matemático possível. + * **Performance Máxima (Tuning de Host)**: Como a máquina é nativa e dedicada, as configurações de cache e buffers de memória do PostgreSQL podem usufruir livremente do Page Cache nativo do Linux e Huge Pages do Kernel, garantindo o máximo rendimento físico do SSD e RAM. + +--- + +### 🚀 2. VPS 1: Servidor de Sistemas em Produção +* **Função Principal**: Gateway de entrada do tráfego público e hospedagem das aplicações em produção. +* **IP Privado VPN**: `10.99.0.1` (Atua como Hub WireGuard) +* **Políticas do Firewall (UFW)**: + * **Portas 80 (HTTP) e 443 (HTTPS)**: **Abertas ao público** para permitir o acesso dos clientes aos sites e sistemas. + * **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN. + * **Porta 3002 (Antigo Dev)**: **Bloqueada publicamente** no Docker (atrelada apenas ao IP privado `10.99.0.1` do container). +* **Vantagens de Segurança & Performance**: + * **Serviços sem Estado (Stateless)**: A VPS 1 apenas processa as requisições web e consome os dados da VPS 3. Caso a VPS 1 sofra um pico extremo de acessos, o banco de dados continua intacto e seguro. + * **Centralização com Traefik**: Proxy reverso centralizado gerenciando SSL e direcionamento interno seguro de containers. + +--- + +### 🧪 3. VPS 4: Servidor de Desenvolvimento e Homologação (Staging) +* **Função Principal**: Testar novos recursos, hospedar containers de staging e realizar builds de desenvolvimento. +* **IP Privado VPN**: `10.99.0.4` +* **Políticas do Firewall (UFW)**: + * **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN. + * **Portas de teste (ex: 3002, 8080)**: **Bloqueadas publicamente** ou atreladas apenas ao IP privado da VPN para acesso exclusivo dos desenvolvedores autorizados. +* **Vantagens de Segurança & Performance**: + * **Isolamento de Riscos**: Compilações de código (`npm run build`, `docker build`) e testes pesados de carga rodam aqui e consomem CPU sem comprometer os servidores de produção e banco de dados. + * **Ambiente Espelho**: Homologação idêntica à produção para validações precisas antes de aplicar modificações em produção. + +--- + +### 💬 4. VPS 5: Servidor de Aplicação Proprietária (newwhats) +* **Função Principal**: Hospedar o backend e workers da plataforma proprietária **newwhats** (Backend Express com Socket.IO, Workers do Temporal e broker NATS). +* **IP Privado VPN**: `10.99.0.5` +* **Políticas do Firewall (UFW)**: + * **Portas de Integração Web**: Liberadas apenas para os Webhooks e requisições públicas de Socket.IO mapeadas via gateway. + * **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN. +* **Vantagens de Segurança & Performance**: + * **Isolamento de Estado**: Executar o backend Node.js (Prisma/Knex), os workers de workflows do Temporal e o broker de mensagens NATS na VPS 5 impede que oscilações no tráfego de mensagens do WhatsApp ou alto uso de CPU nos workers interfiram com o banco de dados principal de produção (VPS 3). + * **Separação de CPU**: O processamento pesado das sessões de WhatsApp e emulação de instâncias roda na VPS 5, consumindo sua CPU e RAM de forma isolada do Gateway (VPS 1) e do Banco (VPS 3). + +--- + +## 🔒 Diretrizes de Segurança Corporativa (Resumo) + +1. **Acesso Administrativo**: Realizado **exclusivamente via chave SSH criptográfica**. Autenticação por senhas desativada em todas as máquinas. +2. **Login de Root Desativado**: Bloqueado login direto de root via SSH. Todos conectam como o usuário `deploy` e elevam privilégios de forma segura via `sudo` após a conexão. +3. **Ponto Único de Falha**: Se a VPS 1 (Hub WireGuard) cair, a comunicação VPN é temporariamente pausada. O console VNC da Contabo é mantido ativo como canal redundante de suporte imediato. +4. **Logs Automatizados**: Logins SSH bem-sucedidos ou suspeitos geram alertas em segundo plano com marcações de 30 minutos na agenda central do Google, permitindo controle visual de acessos em tempo real. + +--- + +## 🛡️ Fase 2: Hardening Avançado e Arquitetura Zero-Trust (Roadmap) + +Esta seção documenta as decisões estratégicas e o plano de ação de segurança de curto e médio prazo projetados para elevar a maturidade da infraestrutura de rede e servidores ao nível **Enterprise / DevSecOps Sênior**. + +### 🗺️ Visão Geral do Fluxo de Segurança na Borda +```mermaid +graph TD + subgraph "Camadas de Defesa (Defense in Depth)" + Internet((Internet Pública)) --> PM[1. Proteção de Borda do Provedor] + PM --> NFT[2. nftables + CrowdSec
Bloqueio Dinâmico / Comportamental] + NFT --> TF[3. Traefik Reverse Proxy
SSL/TLS & Roteamento] + TF --> DK[4. Containers Docker
Rootless / Hardened] + end +``` + +--- + +### 1. 🌐 Desativação Proativa de IPv6 (Curto/Médio Prazo) +Para eliminar vetores de bypass acidentais de firewall e reduzir a complexidade da superfície de ataque, o suporte a IPv6 é **totalmente desativado** em todas as VPSs. + +* **Ações de Configuração**: + Para desativar em tempo de execução e garantir persistência pós-reboot, o arquivo `/etc/sysctl.d/99-disable-ipv6.conf` deve ser criado com as seguintes diretivas: + ```text + net.ipv6.conf.all.disable_ipv6 = 1 + net.ipv6.conf.default.disable_ipv6 = 1 + net.ipv6.conf.lo.disable_ipv6 = 1 + ``` + *Comando para aplicar imediatamente:* `sudo sysctl --system` + +* **Gatilhos para Reativação do IPv6**: + O IPv6 só será reabilitado caso surja uma necessidade real de negócio, tais como: + * Requisitos de conformidade corporativa (*Enterprise Compliance*). + * Integração obrigatória com CDNs IPv6-only ou arquiteturas de borda distribuída (*Anycast*). + * Casos em que a reativação exigirá a implementação completa de **nftables dual-stack, filtros de Router Advertisement (RA), NDP e SLAAC seguros**. + +--- + +### 2. 🐋 Isolamento de Redes Docker (Prevenção de Movimentação Lateral) +O uso da rede de ponte padrão (`bridge`) do Docker é estritamente proibido em produção. Os containers são segmentados em **redes de domínios funcionais isolados**, limitando severamente o raio de colisão (*blast radius*) caso um container de aplicação (especialmente bots de CRM/WhatsApp ou navegadores invisíveis) seja comprometido. + +#### 🔀 Matriz de Conectividade de Dados (newwhats): + +```mermaid +graph TD + subgraph "VPS 1: Gateway de Borda pública" + Traefik[Traefik Router] + end + + subgraph "VPS 5: Camada de Aplicação (newwhats)" + Express[Backend Express & Socket.IO] + TempWorker[Temporal Worker] + NATS[NATS Broker :4222] + + Express <--> NATS + TempWorker <--> Express + end + + subgraph "Túnel VPN Criptografado (wg0)" + WG0((Canal Privado WireGuard)) + end + + subgraph "VPS 3: Appliance de Dados Bare-Metal" + Postgres[(PostgreSQL :5432)] + Dragonfly[(DragonflyDB :6379)] + Temporal[(Temporal Server :7233)] + end + + %% Roteamento Web e Sockets + Traefik == HTTPS Proxy / Sockets ==> WG0 ==> Express + + %% Conexões de Dados da Aplicação via VPN + Express ==> WG0 ==> Postgres + Express ==> WG0 ==> Dragonfly + TempWorker ==> WG0 ==> Temporal + Temporal -. Persistência Interna .-> Postgres + + style Postgres fill:#1E293B,stroke:#3B82F6,stroke-width:2px,color:#fff + style Dragonfly fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff + style Temporal fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff +``` + +* **Diretrizes de Conectividade**: + * **Tráfego de Borda**: O Traefik na VPS 1 encaminha o tráfego HTTP/HTTPS e conexões persistentes do Socket.IO diretamente para a VPS 5 (`10.99.0.5`) pela interface de túnel criptografado, mantendo as portas da VPS 5 públicas totalmente fechadas. + * **Persistência de Negócio (Prisma + Knex)**: Ambas as ORMs/Query Builders centralizadas na VPS 5 apontam diretamente para o IP interno `10.99.0.3:5432` da VPS 3. + * **Cache e Estados Rápidos**: O DragonflyDB na VPS 3 atende instantaneamente às solicitações de sessões e QR codes da VPS 5 via `10.99.0.3:6379`, provendo tempos de resposta na faixa de microssegundos sem concorrência local de CPU. + * **Workflows Assíncronos**: O Temporal Server processa o agendamento de tarefas e status de reputação na VPS 3, enquanto o **Temporal Worker** na VPS 5 consome e executa as atividades pesadas locais da aplicação. + +--- + +### 3. 🔑 Zero-Trust Interno no WireGuard (ACLs no Firewall) +A rede VPN WireGuard (`10.99.0.0/24`) deixa de ser tratada como uma zona de confiança irrestrita. Implementa-se uma política de privilégio mínimo na comunicação entre os servidores da VPN. + +* **O Problema de Movimentação Lateral**: + Se a máquina de desenvolvimento/staging (VPS 4) for comprometida por um script malicioso, o atacante não pode ter permissão para escaneá-la ou conectar-se ao banco de dados de produção (VPS 3). + +* **Regras de Isolamento Lateral (Exemplo no Firewall da VPS 3)**: + ```text + # Bloquear por padrão toda conexão vinda da VPN exceto as estritamente autorizadas: + - Permitir VPS 1 (Produção) -> Porta 5432 (PostgreSQL) [ACEITAR] + - Permitir VPS 5 (CRM) -> Porta 5432 (PostgreSQL) [ACEITAR] + - Permitir VPS 4 (Staging) -> VPS 3 (Produção) [BLOQUEAR] + ``` + +--- + +### 4. 🎛️ Migração de Borda: nftables + CrowdSec +Substitui-se a pilha clássica e reativa do `iptables` / `Fail2Ban` por uma arquitetura ativa, dinâmica e de alta performance na VPS pública 1. + +* **Por que `nftables`?** + A sintaxe nativa do `nftables` permite a criação de conjuntos dinâmicos (*sets*) de IP de forma extremamente eficiente, processados diretamente no kernel com consumo mínimo de CPU durante ataques volumétricos. +* **Por que `CrowdSec`?** + Diferente do Fail2Ban (que analisa logs localmente e de forma reativa por expressões regulares), o CrowdSec utiliza detecção baseada em comportamentos locais de ataque e sincroniza uma base global de reputação de IPs (*inteligência coletiva*). Se um IP atacou outra infraestrutura na internet, ele é bloqueado na sua VPS antes mesmo de fazer a primeira requisição ao seu Traefik. + +--- + +### 5. 💔 Mitigação do Ponto Único de Falha (SPOF) da VPS 1 +Atualmente, a VPS 1 acumula as funções de **Gateway de Borda (Traefik)**, **Hub Central de VPN (WireGuard)** e **Hospedagem de Aplicações**. + +* **Visão de Evolução Futura**: + Para eliminar o risco de interrupção total dos serviços em caso de queda da VPS 1, planeja-se a separação física de responsabilidades em médio prazo: + 1. **VPS Edge Dedicated**: Uma máquina leve rodando exclusivamente o gateway de entrada (`Traefik`), `nftables` e o concentrador `WireGuard`. + 2. **VPS App Dedicated**: Servidores internos que hospedam as aplicações (sem expor portas SSH ou HTTP à internet), consumindo tráfego encaminhado diretamente pelo Gateway da Edge. + +--- + +### 6. 🔒 Hardening Avançado de Containers +Para garantir que a invasão de um container não resulte no comprometimento do sistema operacional host, aplicam-se as seguintes diretrizes de segurança no provisionamento de containers Docker: + +* **No-New-Privileges**: Impede que processos dentro do container ganhem novos privilégios via binários `setuid` ou `setgid`. +* **Read-Only Root Filesystem**: Monta o sistema de arquivos do container como apenas leitura, forçando gravações temporárias apenas em volumes declarados ou `/tmp` na memória (tmpfs). +* **CAP Drop**: Remove capacidades de sistema desnecessárias ao container (ex: `NET_ADMIN`, `SYS_ADMIN`, `SYS_CHROOT`). +* **Rootless Containers**: Sempre que suportado pela aplicação, os containers devem rodar com IDs de usuário não privilegiados (non-root). diff --git a/clube67/correcoes_mds/BAILEYS_ENGINE_SWAP.md b/clube67/correcoes_mds/BAILEYS_ENGINE_SWAP.md new file mode 100644 index 0000000..56c3efb --- /dev/null +++ b/clube67/correcoes_mds/BAILEYS_ENGINE_SWAP.md @@ -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 (``, ``) 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 { + 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 +
+ Engine WhatsApp + + ⚠ Troca reinicia o servidor (~10s) +
+``` + +--- + +## 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 +``` diff --git a/clube67/correcoes_mds/continuar.md b/clube67/correcoes_mds/continuar.md new file mode 100644 index 0000000..ac920ca --- /dev/null +++ b/clube67/correcoes_mds/continuar.md @@ -0,0 +1,86 @@ +# Continuar — NewWhats como Motor Central (SaaS multi-tenant) + +## Instrução inicial para a próxima sessão + +> Faça uma **análise profissional** da ideia descrita abaixo. Antes de escrever qualquer código: +> 1. Gere uma **lista de tarefas detalhada** (fases, dependências, critérios de aceite) e salve em `TAREFAS.md`. +> 2. Mantenha um **relatório vivo** de todas as implementações em `RELATORIO.md` (o que foi feito, decisões técnicas, pontos de atenção, próximos passos). +> 3. **Regra-ouro:** toda funcionalidade nova deve ser pensada e entregue **como plugin** (em `/plugins//` com `manifest.json`, hooks, config_schema). Nada vai direto ao core se couber como plugin. + +--- + +## Contexto do projeto + +- **Projeto motor:** `/home/deploy/projetos/newwhats.local` — já possui Baileys (WhatsApp), inbox, sessões, storage (Wasabi), sistema de plugins estilo WordPress, e API de pareamento OTP (`/api/pair/*`) com `integration_key`. +- **Projetos satélites:** múltiplos sistemas (ex.: Alemão Conveniências em `/home/deploy/projetos/alemaoconveniencias.local`) que **não devem instalar Baileys**. Devem consumir o motor via API autenticada por `integration_key`. +- **Já implementado:** pair system (OTP por e-mail), plugin `newwhats` no Alemão com `x-nw-key` auth, modal de pareamento em 3 passos no dashboard. +- **Falta:** expor inbox, sessões e secretária virtual como serviço consumível pelos satélites. + +## Ideia a analisar + +Transformar o NewWhats em **motor SaaS multi-tenant**, onde cada projeto satélite é um cliente leve que consome: +- **Inbox** (chats, mensagens, envio, mídias) +- **Sessões** (status WA, QR, pareamento de números) +- **Secretária virtual** (IA com contexto — roda 100% no motor, satélite só consome output) + +## Arquitetura-alvo + +``` +NewWhats (motor) + ├─ Baileys + Inbox + Storage + Secretária (IA) + └─ API /api/ext/* (auth: x-nw-key) + │ + ▼ HTTPS + WebSocket + ┌───────┴────────┬──────────┐ + Alemão Projeto B Projeto C + (plugin (plugin (plugin + nw-client) nw-client) nw-client) +``` + +## Decisões propostas (validar antes de implementar) + +1. **Plugin `nw-client` portável** em `/plugins-sdk/newwhats-client/` — reutilizável em todos os satélites, zero dependência de Baileys. +2. **API `/api/ext/*` nova** (além de `/api/pair/*` já existente), autenticada por `x-nw-key`: + - `GET /api/ext/sessions` + - `GET /api/ext/inbox?session=X` + - `GET /api/ext/inbox/:chatId/messages` + - `POST /api/ext/inbox/:chatId/send` + - `POST /api/ext/secretaria/ask` + - `WS /api/ext/stream` (eventos realtime — fallback polling 5s) +3. **Secretária virtual sempre no motor** — centraliza custo LLM e contexto. +4. **Isolamento multi-tenant** — `integration_key` resolve para `user_id` + `allowed_sessions[]`. Satélite nunca vê dados de outro tenant. +5. **Distribuição do client plugin** — opções: + - (a) `npm publish @newwhats/client` (registry privado/público) + - (b) git submodule + - (c) **CLI `nw plugin install`** que baixa tarball assinado do motor ← recomendado +6. **Tudo como plugin:** + - Motor: `plugins/ext-api/` (expõe `/api/ext/*`), `plugins/secretaria-virtual/` (IA), `plugins/realtime-bridge/` (WS). + - Satélite: `plugins/newwhats-client/` (consumidor único, com UI de inbox embutida). + +## Tradeoffs a documentar no relatório + +- **WS vs polling** — WS é melhor para UX, mas adiciona complexidade de reconexão/auth. V1 polling 5s, V2 WS. +- **Proxy de mídias** — satélite baixa mídia via motor (`/api/ext/media/:id`) ou recebe URL assinada Wasabi? URL assinada é mais leve mas expõe storage. +- **Rate limit por integration_key** — necessário desde o v1 para proteger motor. +- **Versionamento da API** — `/api/ext/v1/*` desde o início. + +## Pendências herdadas (do `project_alemao_plugin_session.md`) + +- `aria-hidden` warning no pairModal (Bootstrap 5 — usar `inert` ou Bootstrap 5.3+). +- Inbox no Alemão via plugin — **esta é a primeira entrega desta nova fase**. +- SMTP não configurado no NewWhats — dev_code exposto na resposta até configurar. + +## Entregáveis esperados da próxima sessão + +1. `TAREFAS.md` — backlog fase-a-fase (motor → SDK → satélite → secretária). +2. `RELATORIO.md` — iniciado com análise profissional da ideia, riscos, e decisões arquiteturais. +3. Esqueleto dos plugins (manifests + hooks declarados, sem lógica ainda) para validar a forma antes de implementar. +4. Pergunta ao usuário: **(A)** contrato detalhado da API primeiro, ou **(B)** PoC end-to-end de `/api/ext/inbox` no Alemão para validar fluxo. + +## Referências rápidas + +- Sistema de plugins: `backend/src/core/` (types, hook-bus, plugin-loader, plugin-registry) +- Pair system: `backend/src/modules/pair/pair.routes.ts` + `pair.service.ts` +- Plugin consumidor existente: `/home/deploy/projetos/alemaoconveniencias.local/sistema-nuvem/plugins/newwhats/` +- Build backend: `npm run build` em `/home/deploy/projetos/newwhats.local/backend` +- Serviços: `newwhats-backend.service` (8008), `alemaoconveniencias.service` (4001) diff --git a/clube67/correcoes_mds/correcaoWasabi.md b/clube67/correcoes_mds/correcaoWasabi.md new file mode 100644 index 0000000..02185df --- /dev/null +++ b/clube67/correcoes_mds/correcaoWasabi.md @@ -0,0 +1,201 @@ +# Correção Wasabi Storage — newwhats + +Data: 2026-05-07 +Investigado e corrigido por: Claude Code + +--- + +## Contexto + +O plugin `UnifiedStorageProvider` estava configurado para enviar mídias do WhatsApp ao Wasabi, porém os arquivos continuavam acumulando localmente em `backend/media`, chegando a **7.8 GB** de mídia que deveria estar na nuvem. A causa raiz foi uma combinação de 4 bugs no código e uma inconsistência de path descoberta na revisão final. + +--- + +## Diagnóstico — 4 bugs + 1 inconsistência corrigidos + +### Bug 1 — Arquivo local nunca era deletado após upload Wasabi (crítico) +**Arquivo:** `backend/src/modules/whatsapp/handlers/MessageHandler.ts` + +O fluxo era: +1. Arquivo de mídia chegava → salvo em `backend/media/{instanceId}/{msgId}.ext` ✅ +2. StorageProvider registrado → upload para Wasabi ✅ +3. ❌ **Arquivo local NUNCA era deletado após upload bem-sucedido** + +Resultado: disco enchia indefinidamente mesmo com Wasabi funcionando. + +**Correção:** Após upload bem-sucedido (`provider !== 'local_fallback'`), o arquivo local é removido com `fs.unlink()` assíncrono. + +--- + +### Bug 2 — `mediaPath` apontava para arquivo local inexistente (crítico) +**Arquivo:** `backend/src/modules/whatsapp/handlers/MessageHandler.ts` + +Mesmo após o upload para Wasabi, o campo `mediaPath` no banco continuava sendo salvo com o caminho local completo (`/home/deploy/.../media/...`), que após o Bug 1 corrigido deixa de existir no disco. + +**Correção:** Quando `uploadedToRemote = true`, `mediaPath` é salvo como `null` no banco. O acesso à mídia passa a ser exclusivamente pelo `mediaUrl` que aponta para o Wasabi. + +--- + +### Bug 3 — Rotas HTTP duplicadas ao reativar plugin via admin (médio) +**Arquivo:** `plugins/UnifiedStorageProvider/index.ts` + +O `pluginRegistry.reactivate()` chama `activate()` novamente a cada salvamento de config. O `activate()` registrava as rotas Express (`/api/storage/view/*`, `/api/storage/wasabi/list-buckets`, etc.) a cada chamada, acumulando handlers duplicados em memória. + +**Correção:** Adicionada flag `private routesRegistered = false`. As rotas Express são registradas apenas na primeira chamada de `activate()`. Reativações subsequentes reinicializam apenas o S3 client e o registro do StorageProvider. + +--- + +### Bug 4 — Deleção de instância não limpava mídias no Wasabi (funcionalidade ausente) +**Arquivo:** `backend/src/modules/whatsapp/whatsapp.routes.ts` + +Ao deletar uma instância pelo admin, o fluxo só desconectava o socket Baileys, removia os arquivos de sessão e apagava o registro do banco. As mídias no Wasabi (e no fallback local) ficavam permanentemente órfãs. + +**Implementação:** +- Adicionado `deletePrefix(prefix: string): Promise<{ deleted: number }>` à interface `StorageProviderInterface` em `types.ts` +- Exposto `deletePrefix()` no `StorageProviderRegistry` em `StorageProvider.ts` (silencioso se provider não registrado) +- Implementado no plugin com `ListObjectsV2Command` paginado em batches de 1000 + `DeleteObjectsCommand`, limpando também o fallback local +- Chamado em background na rota `DELETE /:id` após `repo.delete(id)`, sem bloquear a resposta + +--- + +### Inconsistência 5 — Path incorreto no deletePrefix (crítico) +**Arquivo:** `backend/src/modules/whatsapp/whatsapp.routes.ts` + +O plugin grava arquivos no Wasabi com o path: +``` +whatsapp/{tenantId}/{instanceId}/{fileName} +``` + +A implementação inicial do `deletePrefix` usava: +``` +whatsapp/{instanceId}/ ← errado, nunca encontraria os arquivos +``` + +**Correção:** Prefix corrigido para `whatsapp/${tenantId}/${id}/`, que bate exatamente com a estrutura gravada pelo plugin. + +--- + +## Arquivos modificados + +| Arquivo | Mudança | +|---|---| +| `backend/src/core/types.ts` | Adicionado `deletePrefix()` à interface `StorageProviderInterface` | +| `backend/src/core/StorageProvider.ts` | Exposto `deletePrefix()` no registry com fallback silencioso | +| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | `fs.unlink()` após upload + `mediaPath = null` quando remoto | +| `backend/src/modules/whatsapp/whatsapp.routes.ts` | `deletePrefix("whatsapp/{tenantId}/{id}/")` ao deletar instância | +| `plugins/UnifiedStorageProvider/index.ts` | Flag `routesRegistered` + import `DeleteObjectsCommand` + implementação de `deletePrefix()` | + +--- + +## Configuração atual (verificada) + +```json +// DragonflyDB — plugin:config:UnifiedStorageProvider +{ + "wasabiAccessKey": "OFLCYUUZRYJFKHLB81QC", + "wasabiSecretKey": "R4GI2s7W...", + "wasabiBucket": "whatsbasemidia", + "wasabiRegion": "us-east-1" +} +``` + +Endpoint padrão: `https://s3.wasabisys.com` + +--- + +## Situação das mídias antigas (pós-investigação) + +| Situação | Quantidade | Ação | +|---|---|---| +| Migradas para Wasabi pelo script | 231 arquivos | ✅ no Wasabi (path: `whatsapp/{instanceId}/`) | +| Deletadas do servidor após migração | 231 arquivos | ✅ removidas de `backend/media` | +| Banco atualizado (mediaUrl → Wasabi) | 231 registros | ✅ `mediaPath = null`, `mediaUrl = whatsapp/...` | +| Ainda locais em `backend/media` | ~1.430 registros | Serão refeitos ao reconectar sessões | +| Novas mídias (pós-correção) | todas | ✅ vão direto para Wasabi e local é deletado | + +> ⚠️ Os 231 arquivos migrados pelo script têm path `whatsapp/{instanceId}/` (sem tenantId) pois foram lidos direto do disco. O plugin novo grava como `whatsapp/{tenantId}/{instanceId}/`. Isso não quebra o funcionamento mas o `deletePrefix` automático não os apagará quando a instância for deletada — precisarão ser removidos manualmente do bucket se necessário. + +--- + +## Script de migração criado + +**Arquivo:** `migrate-media-wasabi.mjs` (raiz do projeto) + +Script Node.js criado para migrar os arquivos acumulados em `backend/media` para o Wasabi. Suporta `--dry-run` para simulação sem alterações. + +**O que o script faz por arquivo:** +1. Lê `messages` do banco onde `mediaUrl LIKE '/media/%'` +2. Verifica se o arquivo já existe no Wasabi (`HeadObjectCommand`) — pula upload se sim +3. Faz upload para `whatsapp/{instanceId}/{fileName}` no bucket `whatsbasemidia` +4. Atualiza o banco: `mediaUrl = 'whatsapp/...'`, `mediaPath = NULL` +5. Remove o arquivo local com `fs.unlinkSync()` +6. Ao final, remove diretórios vazios de `backend/media` + +**Execução:** +```bash +# Simulação (sem alterar nada): +node migrate-media-wasabi.mjs --dry-run + +# Migração real: +node migrate-media-wasabi.mjs +``` + +**Status da execução em 2026-05-07:** +- Migração iniciada e interrompida manualmente aos 231/1.661 arquivos +- Motivo da parada: decisão de deletar sessões manualmente e reconectar para que as mídias sejam baixadas novamente do WhatsApp já com o Wasabi funcionando + +--- + +## Backup gerado antes das operações + +**Arquivo:** `/home/deploy/projetos/newwhats_export/newwhats_completo.tar.gz` — **7.7 GB** +**Dump do banco:** `/home/deploy/projetos/newwhats_export/newwhats_dump.sql` — **18 MB** + +O tar.gz contém o projeto completo (incluindo `backend/media` com todas as mídias locais no estado anterior à migração) e o dump PostgreSQL. Gerado em 2026-05-07 como snapshot de segurança antes de qualquer alteração. + +--- + +## Fluxo completo após as correções + +### Upload de nova mídia: +``` +WhatsApp recebe mídia + → salva buffer temporariamente em backend/media/{instanceId}/{msgId}.ext + → storageProvider.uploadFile() → Wasabi: whatsapp/{tenantId}/{instanceId}/{fileName} + → fs.unlink(localFile) ← arquivo local removido + → banco: mediaPath = null, mediaUrl = whatsapp/{tenantId}/{instanceId}/{fileName} + → frontend acessa via /api/storage/view/whatsapp/... (proxy → Wasabi) +``` + +### Se Wasabi falhar: +``` + → arquivo local mantido em backend/media + → banco: mediaPath = /caminho/local, mediaUrl = /media/{instanceId}/{fileName} + → SyncWorker tenta reenviar a cada 2 minutos + → quando enviado: arquivo local removido automaticamente +``` + +### Ao deletar instância: +``` +DELETE /api/whatsapp/{id} + → manager.disconnect() → desconecta Baileys + → repo.delete() → remove do banco (cascade: chats, mensagens, contatos) + → responde 200 ← não bloqueia + → [background] storageProvider.deletePrefix("whatsapp/{tenantId}/{id}/") + → ListObjectsV2 paginado (batches de 1000) + → DeleteObjects em cada batch + → Remove fallback local se existir + → Loga total removidos +--- + +## Status da Implementação Física em Produção (VPS 1) — 2026-05-07 + +Todas as correções descritas neste documento foram fisicamente implementadas, compiladas, testadas e implantadas com sucesso na VPS 1 de Produção. + +### Ações Executadas: +1. **Ajuste de Interfaces e Código Core:** Modificados `types.ts`, `StorageProvider.ts`, `MessageHandler.ts`, e `whatsapp.routes.ts`. +2. **Implementação de Limpeza Automática:** `UnifiedStorageProvider/index.ts` agora conta com `deletePrefix` S3 e deleção local robusta. +3. **Correção de Arquitetura de Container (Crítica):** Foi detectado que nenhum plugin era carregado em produção por falta de mapeamento. Montamos o volume de plugins e criamos os links simbólicos necessários (`/plugins`, `/app/backend`, `/app/src`) no Dockerfile para que os imports relativos funcionem na perfeição. +4. **Re-deploy Completo:** O container `newwhats-backend` foi totalmente rebuildado e reiniciado com `docker compose up -d --build`. + +**Status Final:** ✅ **CORRIGIDO E IMPLEMENTADO EM PRODUÇÃO** diff --git a/clube67/correcoes_mds/plugins/secretaria/PLUGIN_EMPRESA.md b/clube67/correcoes_mds/plugins/secretaria/PLUGIN_EMPRESA.md new file mode 100644 index 0000000..0a211bd --- /dev/null +++ b/clube67/correcoes_mds/plugins/secretaria/PLUGIN_EMPRESA.md @@ -0,0 +1,240 @@ +# Plugin Empresa — Arquitetura de Integração + +## Visão Geral + +O sistema NewWhats roda em servidor próprio e nunca acessa diretamente o banco da empresa cliente. +A comunicação acontece por dois plugins que se reconhecem via chave compartilhada: + +``` +[ Servidor NewWhats ] [ Servidor da Empresa ] + Plugin: secretaria ←→ Plugin: newwhats-client + - Secretária IA - CRUD nos bancos + - WhatsApp / Baileys - Agente de consulta + - Inbox / Sessions - Frontend embarcado +``` + +--- + +## Plugin da Empresa (`newwhats-client`) + +### Responsabilidades + +- Conecta ao banco de dados interno da empresa (leitura e escrita) +- Expõe endpoints controlados para o NewWhats consumir +- Recebe webhooks do NewWhats (eventos, notificações) +- Serve o frontend embarcado (inbox, sessions, secretária) +- Gerencia a chave de integração e autenticação cruzada + +### Instalação + +O plugin é distribuído como pacote independente. +A empresa instala no próprio servidor — Node.js, PHP, Python ou via Docker. +Após instalação, gera-se uma `integration_key` que é inserida nos dois lados. + +``` +1. Empresa instala o plugin +2. Plugin gera integration_key + registra URL do servidor NewWhats +3. Admin copia a key e cola nas configurações do NewWhats +4. Os dois sistemas se reconhecem e começam a conversar +``` + +--- + +## Comunicação entre os Plugins + +### Direções + +| Direção | Quando | Exemplo | +|---|---|---| +| NewWhats → Empresa (pull) | Secretária precisa de dados em tempo real | Consultar agenda do Dr. | +| Empresa → NewWhats (push) | Evento proativo no sistema da empresa | Dr. cancelou consulta, avisar paciente | + +### Autenticação + +- Chave JWT gerada no NewWhats, compartilhada com o plugin da empresa +- Rotacionável pelo admin a qualquer momento +- Cada requisição carrega o JWT no header `Authorization: Bearer ` +- Expiração configurável (padrão: 90 dias) + +--- + +## Contrato de API (Endpoints Mínimos) + +O plugin da empresa **obrigatoriamente** expõe estes endpoints para a secretária funcionar: + +### Agenda +``` +GET /nw/agenda?data=YYYY-MM-DD&profissional_id= → horários disponíveis +POST /nw/agenda/agendar → confirmar agendamento +PUT /nw/agenda/:id/cancelar → cancelar agendamento +GET /nw/agenda/:id → detalhes de um agendamento +``` + +### Profissionais / Responsáveis +``` +GET /nw/profissionais → lista com nome, área, número WhatsApp +GET /nw/profissionais/:id → dados de um profissional +``` + +### Clientes / Pacientes / Contatos +``` +GET /nw/cliente?telefone=5511999999999 → dados pelo número de WhatsApp +GET /nw/cliente/:id → dados por ID +POST /nw/cliente → cadastrar novo cliente +``` + +### Webhook +``` +POST /nw/webhook/registrar → registrar URL de callback no NewWhats +``` + +### Endpoints opcionais (dependem do ramo) +``` +GET /nw/produtos → catálogo +GET /nw/pedidos?cliente_id= → histórico de pedidos +GET /nw/financeiro?cliente_id= → situação financeira +GET /nw/historico?cliente_id= → histórico de atendimentos +``` + +--- + +## Agente de Consulta (dentro do plugin da empresa) + +O plugin não expõe o banco diretamente — ele tem um **agente interno** que: + +1. Recebe a query da secretária (ex: `agenda?data=2026-04-15&profissional=Dr. Carlos`) +2. Traduz para uma query SQL ou chamada interna do sistema da empresa +3. Filtra apenas os campos permitidos (sem dados sensíveis) +4. Devolve JSON padronizado + +``` +Secretária IA (NewWhats) + ↓ GET /nw/agenda?data=15/04 +Plugin da Empresa + ↓ SELECT * FROM agenda WHERE data = '2026-04-15' AND ativo = true +Banco da Empresa + ↓ rows +Plugin filtra e formata + ↓ JSON limpo +Secretária injeta no contexto e responde o cliente +``` + +### Permissões por endpoint + +Cada endpoint tem uma flag de permissão configurável pelo admin da empresa: + +``` +agenda.read = true ✓ +agenda.write = true ✓ +cliente.read = true ✓ +cliente.write = false ✗ (somente leitura por enquanto) +financeiro.read = false ✗ (dado sensível, bloqueado) +``` + +--- + +## Frontend Embarcado + +### O que é + +As três telas principais do NewWhats servidas **dentro do sistema da empresa**, +sem precisar abrir outro sistema ou fazer login separado. + +``` +Sistema da Empresa (ERP / CRM) + └── Menu + ├── [módulos da empresa] + └── NewWhats ← plugin embarcado + ├── Inbox (conversas WhatsApp em tempo real) + ├── Sessions (instâncias Baileys, status de conexão) + └── Secretária IA (configuração + chat de teste) +``` + +### Opções de implementação + +**Opção 1 — iFrame com token SSO** (mais simples) +- Plugin gera um token de sessão temporário (15min, renovável) +- Abre `https://newwhats-server/embed?token=` em iFrame +- NewWhats valida o token e serve a interface sem tela de login + +**Opção 2 — Micro-frontend (componentes standalone)** (mais integrado) +- Plugin distribui componentes React/Vue que consomem a API do NewWhats +- Renderizam dentro do layout da empresa sem iFrame +- Requerem que a empresa use React/Vue no frontend + +**Recomendação:** começar com iFrame SSO — funciona em qualquer stack, +entrega valor rápido. Migrar para micro-frontend quando houver demanda. + +### Autenticação cruzada (SSO) + +``` +1. Usuário logado no sistema da empresa clica em "NewWhats" +2. Sistema da empresa chama POST /nw/auth/sso no NewWhats com a integration_key +3. NewWhats retorna um token de sessão temporário +4. Plugin abre o iFrame com esse token +5. Usuário entra direto, sem segunda tela de login +``` + +--- + +## Números e Papéis + +O plugin da empresa cadastra os números WhatsApp relevantes e seus papéis: + +| Papel | Descrição | Exemplo | +|---|---|---| +| `secretary_virtual` | Número principal da secretária IA | Atende clientes | +| `clinic` | Número geral da empresa | Recebe notificações | +| `doctor` / `specialist` | Profissional responsável por área | Recebe dúvidas específicas | +| `manager` | Fallback universal | Recebe quando ninguém mais responde | +| `reserve` | Backup da secretária | Entra quando principal cai ou é banido | +| `human_secretary` | Secretária humana | Pode assumir conversa da IA | + +O NewWhats consulta esses papéis via `GET /nw/profissionais` e usa para roteamento interno. + +--- + +## Fluxo Completo — Exemplo Real + +**Cenário:** Paciente pergunta se pode agendar com o Dr. Carlos na quinta-feira. + +``` +1. Paciente envia mensagem no WhatsApp +2. Baileys recebe → NewWhats aciona Secretária IA +3. Nó de intenção classifica: "agendamento" +4. Nó de calendário chama GET /nw/agenda?data=2026-04-16&profissional=Dr.Carlos +5. Plugin da empresa consulta banco → retorna horários livres +6. Secretária oferece opções ao paciente +7. Paciente confirma horário das 14h +8. Secretária chama POST /nw/agenda/agendar com os dados +9. Plugin grava no banco da empresa +10. Secretária confirma para o paciente via WhatsApp +11. Sistema da empresa exibe o agendamento normalmente no próprio sistema +``` + +**Tudo isso sem o paciente saber que são dois sistemas.** + +--- + +## Roadmap de Implementação + +### Fase 1 — Conexão básica +- [ ] Geração e troca de `integration_key` entre os dois plugins +- [ ] Endpoints mínimos no plugin da empresa (agenda, profissionais, cliente) +- [ ] Nó do tipo `api_query` no cérebro da secretária +- [ ] Teste end-to-end: secretária consulta agenda real + +### Fase 2 — Agente de consulta +- [ ] Agente interno no plugin da empresa com mapeamento de permissões +- [ ] Push de eventos (webhook): cancelamentos, atualizações +- [ ] Roteamento por papel: secretária sabe para qual número perguntar + +### Fase 3 — Frontend embarcado +- [ ] SSO por token temporário +- [ ] iFrame com Inbox + Sessions + Secretária IA +- [ ] Personalização básica (logo, cores da empresa) + +### Fase 4 — Expansão +- [ ] SDK para empresas implementarem o plugin em qualquer stack +- [ ] Marketplace de conectores prontos (ex: conector para sistemas populares do setor) +- [ ] Painel de auditoria cruzada (o que a secretária consultou e quando) diff --git a/clube67/dev.sh b/clube67/dev.sh new file mode 100755 index 0000000..982d55a --- /dev/null +++ b/clube67/dev.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# ─── dev.sh — Alterna entre modo dev e produção ─────────────────────────────── +# +# ./dev.sh up → sobe em modo DEV (tsx watch + next dev, sem build) +# ./dev.sh down → derruba tudo +# ./dev.sh prod → sobe em modo PROD (imagens buildadas) +# ./dev.sh logs → segue logs de backend e frontend +# +# ───────────────────────────────────────────────────────────────────────────── + +DOCKER="DOCKER_HOST=unix:///run/user/1000/docker.sock /home/deploy/bin/docker" +DC="$DOCKER compose" +DC_DEV="$DC -f docker-compose.yml -f docker-compose.dev.yml" + +cd "$(dirname "$0")" + +case "$1" in + up) + echo "▶ Subindo em modo DEV (tsx watch + next dev)..." + eval "$DC_DEV up -d newwhats-backend newwhats-frontend" + echo "" + echo " Backend : tsx watch — edite ./newwhats.local/backend/src/**/*.ts" + echo " Frontend: next dev — edite ./newwhats.local/frontend/**/*.tsx" + echo "" + echo " Logs: ./dev.sh logs" + ;; + down) + echo "■ Derrubando containers..." + eval "$DC down" + ;; + prod) + echo "▶ Subindo em modo PROD (imagens buildadas)..." + eval "$DC up -d" + ;; + logs) + eval "$DC logs -f --tail=60 newwhats-backend newwhats-frontend" + ;; + *) + echo "Uso: ./dev.sh [up|down|prod|logs]" + exit 1 + ;; +esac diff --git a/clube67/docker-compose.dev.yml b/clube67/docker-compose.dev.yml new file mode 100644 index 0000000..b692f4f --- /dev/null +++ b/clube67/docker-compose.dev.yml @@ -0,0 +1,39 @@ +# ─── Dev override — NÃO requer docker compose build ────────────────────────── +# +# Uso: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d +# +# O que muda em relação ao docker-compose.yml de produção: +# - Backend: tsx watch (interpreta TypeScript direto — sem tsc) +# Volume monta ./newwhats.local/backend/src no container. +# Altere qualquer .ts → container reinicia automaticamente. +# +# - Frontend: next dev (HMR — sem next build) +# Volume monta ./newwhats.local/frontend no container. +# Altere qualquer componente → browser atualiza em <2s. +# +# Para voltar à produção: +# docker compose up -d (sem o -f docker-compose.dev.yml) +# ───────────────────────────────────────────────────────────────────────────── + +services: + newwhats-backend: + command: ["node", "node_modules/.bin/tsx", "watch", "src/server.ts"] + environment: + - NODE_ENV=development + volumes: + - "/home/deploy/newwhats-sessions:/app/sessions" + - "./newwhats.local/plugins:/app/plugins" + - "./newwhats.local/backend/src:/app/src" + - "./newwhats.local/backend/prisma:/app/prisma" + + newwhats-frontend: + command: ["node", "node_modules/.bin/next", "dev", "-p", "3003"] + environment: + - PORT=3003 + - NODE_ENV=development + - NEXT_PUBLIC_API_URL=https://clube67.com/api + volumes: + - "./newwhats.local/frontend:/app" + - /app/node_modules + - /app/.next diff --git a/clube67/docker-compose.yml b/clube67/docker-compose.yml new file mode 100644 index 0000000..030561a --- /dev/null +++ b/clube67/docker-compose.yml @@ -0,0 +1,65 @@ +services: + nginx: + image: nginx:alpine + restart: always + volumes: + - "./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro" + labels: + - "traefik.enable=true" + # Roteador de Produção (clube67.com) + - "traefik.http.routers.clube67-prod.rule=Host(`clube67.com`) || Host(`www.clube67.com`)" + - "traefik.http.routers.clube67-prod.entrypoints=websecure" + - "traefik.http.routers.clube67-prod.tls.certresolver=le" + - "traefik.http.services.clube67-prod.loadbalancer.server.port=80" + - "traefik.docker.network=web" + networks: + - soc + - web + depends_on: + - newwhats-backend + - newwhats-frontend + + newwhats-backend: + build: + context: "./newwhats.local/backend" + dockerfile: Dockerfile + restart: always + env_file: + - "./newwhats.local/backend/.env" + environment: + - BAILEYS_ENGINE=infinite + - PORT=8008 + - NODE_ENV=production + - DATABASE_URL=postgresql://newwhats:clube67_newwhats_pass_9903@10.99.0.3:5432/newwhats?schema=public + - DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379 + - NATS_URL=nats://10.99.0.3:4222 + - TEMPORAL_ADDRESS=10.99.0.3:7233 + - BAILEYS_SESSIONS_PATH=/app/sessions + volumes: + - "/home/deploy/newwhats-sessions:/app/sessions" + - "/home/deploy/newwhats-media:/app/media" + - "./newwhats.local/plugins:/app/plugins" + networks: + - soc + - web + + newwhats-frontend: + build: + context: "./newwhats.local/frontend" + dockerfile: Dockerfile + restart: always + env_file: + - "./newwhats.local/frontend/.env.production.local" + environment: + - PORT=3003 + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=https://clube67.com/api + networks: + - soc + - web + +networks: + soc: + external: true + web: + external: true diff --git a/clube67/newwhats.local/.gitignore b/clube67/newwhats.local/.gitignore new file mode 100644 index 0000000..90fcb3c --- /dev/null +++ b/clube67/newwhats.local/.gitignore @@ -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 diff --git a/clube67/newwhats.local/BAILEYS_ENGINE_SWAP.md b/clube67/newwhats.local/BAILEYS_ENGINE_SWAP.md new file mode 100644 index 0000000..56c3efb --- /dev/null +++ b/clube67/newwhats.local/BAILEYS_ENGINE_SWAP.md @@ -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 (``, ``) 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 { + 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 +
+ Engine WhatsApp + + ⚠ Troca reinicia o servidor (~10s) +
+``` + +--- + +## 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 +``` diff --git a/clube67/newwhats.local/DEVELOPMENT.md b/clube67/newwhats.local/DEVELOPMENT.md new file mode 100644 index 0000000..9ddc69c --- /dev/null +++ b/clube67/newwhats.local/DEVELOPMENT.md @@ -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 +``` + +### 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) diff --git a/clube67/newwhats.local/RELATORIO.md b/clube67/newwhats.local/RELATORIO.md new file mode 100644 index 0000000..95a248b --- /dev/null +++ b/clube67/newwhats.local/RELATORIO.md @@ -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! diff --git a/clube67/newwhats.local/TAREFAS.md b/clube67/newwhats.local/TAREFAS.md new file mode 100644 index 0000000..5e06e4d --- /dev/null +++ b/clube67/newwhats.local/TAREFAS.md @@ -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. | diff --git a/clube67/newwhats.local/arquitetura.md b/clube67/newwhats.local/arquitetura.md new file mode 100644 index 0000000..c2114ba --- /dev/null +++ b/clube67/newwhats.local/arquitetura.md @@ -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)
IP: 10.99.0.1
(Traefik / Apps)"] + VPS3["🗄️ VPS 3 (Banco de Dados)
IP: 10.99.0.3
(PostgreSQL Dedicado)"] + VPS4["🧪 VPS 4 (Desenvolvimento)
IP: 10.99.0.4
(Staging / Builds)"] + VPS5["💬 VPS 5 (CRM WhatsApp)
IP: 10.99.0.5
(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
Bloqueio Dinâmico / Comportamental] + NFT --> TF[3. Traefik Reverse Proxy
SSL/TLS & Roteamento] + TF --> DK[4. Containers Docker
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). diff --git a/clube67/newwhats.local/backend/.dockerignore b/clube67/newwhats.local/backend/.dockerignore new file mode 100644 index 0000000..2140257 --- /dev/null +++ b/clube67/newwhats.local/backend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +*.log +.env +sessions +media +storage +uploads diff --git a/clube67/newwhats.local/backend/.env.example b/clube67/newwhats.local/backend/.env.example new file mode 100644 index 0000000..7449427 --- /dev/null +++ b/clube67/newwhats.local/backend/.env.example @@ -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 diff --git a/clube67/newwhats.local/backend/Dockerfile b/clube67/newwhats.local/backend/Dockerfile new file mode 100644 index 0000000..22112d5 --- /dev/null +++ b/clube67/newwhats.local/backend/Dockerfile @@ -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"] diff --git a/clube67/newwhats.local/backend/baileys-7.0.0-rc.9.tgz b/clube67/newwhats.local/backend/baileys-7.0.0-rc.9.tgz new file mode 100644 index 0000000..68adbaa Binary files /dev/null and b/clube67/newwhats.local/backend/baileys-7.0.0-rc.9.tgz differ diff --git a/clube67/newwhats.local/backend/package-lock.json b/clube67/newwhats.local/backend/package-lock.json new file mode 100644 index 0000000..375208d --- /dev/null +++ b/clube67/newwhats.local/backend/package-lock.json @@ -0,0 +1,9641 @@ +{ + "name": "newwhats-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "newwhats-backend", + "version": "1.0.0", + "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", + "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", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1029.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1029.0.tgz", + "integrity": "sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/credential-provider-node": "^3.972.30", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", + "@aws-sdk/middleware-expect-continue": "^3.972.9", + "@aws-sdk/middleware-flexible-checksums": "^3.974.7", + "@aws-sdk/middleware-host-header": "^3.972.9", + "@aws-sdk/middleware-location-constraint": "^3.972.9", + "@aws-sdk/middleware-logger": "^3.972.9", + "@aws-sdk/middleware-recursion-detection": "^3.972.10", + "@aws-sdk/middleware-sdk-s3": "^3.972.28", + "@aws-sdk/middleware-ssec": "^3.972.9", + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/region-config-resolver": "^3.972.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.16", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@aws-sdk/util-user-agent-browser": "^3.972.9", + "@aws-sdk/util-user-agent-node": "^3.973.15", + "@smithy/config-resolver": "^4.4.14", + "@smithy/core": "^3.23.14", + "@smithy/eventstream-serde-browser": "^4.2.13", + "@smithy/eventstream-serde-config-resolver": "^4.3.13", + "@smithy/eventstream-serde-node": "^4.2.13", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/hash-blob-browser": "^4.2.14", + "@smithy/hash-node": "^4.2.13", + "@smithy/hash-stream-node": "^4.2.13", + "@smithy/invalid-dependency": "^4.2.13", + "@smithy/md5-js": "^4.2.13", + "@smithy/middleware-content-length": "^4.2.13", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-retry": "^4.5.0", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.45", + "@smithy/util-defaults-mode-node": "^4.2.49", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", + "@smithy/util-stream": "^4.5.22", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.15", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", + "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/xml-builder": "^3.972.17", + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/signature-v4": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.6.tgz", + "integrity": "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", + "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", + "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", + "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/credential-provider-env": "^3.972.25", + "@aws-sdk/credential-provider-http": "^3.972.27", + "@aws-sdk/credential-provider-login": "^3.972.29", + "@aws-sdk/credential-provider-process": "^3.972.25", + "@aws-sdk/credential-provider-sso": "^3.972.29", + "@aws-sdk/credential-provider-web-identity": "^3.972.29", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", + "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", + "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.25", + "@aws-sdk/credential-provider-http": "^3.972.27", + "@aws-sdk/credential-provider-ini": "^3.972.29", + "@aws-sdk/credential-provider-process": "^3.972.25", + "@aws-sdk/credential-provider-sso": "^3.972.29", + "@aws-sdk/credential-provider-web-identity": "^3.972.29", + "@aws-sdk/types": "^3.973.7", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", + "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", + "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/token-providers": "3.1026.0", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", + "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.1029.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1029.0.tgz", + "integrity": "sha512-Un9PaYUUHKCbO+A2w4Hse7Fahg9siRSilR7MR5Eu9wbgzG79fCc/Jgm8x7q35KhRMW2z5Iy4tF5NjSw4nxnksA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.1029.0" + } + }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.9.tgz", + "integrity": "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.9.tgz", + "integrity": "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.7.tgz", + "integrity": "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/crc64-nvme": "^3.972.6", + "@aws-sdk/types": "^3.973.7", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", + "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.9.tgz", + "integrity": "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", + "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", + "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.28.tgz", + "integrity": "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/signature-v4": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.9.tgz", + "integrity": "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", + "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-retry": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", + "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/middleware-host-header": "^3.972.9", + "@aws-sdk/middleware-logger": "^3.972.9", + "@aws-sdk/middleware-recursion-detection": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/region-config-resolver": "^3.972.11", + "@aws-sdk/types": "^3.973.7", + "@aws-sdk/util-endpoints": "^3.996.6", + "@aws-sdk/util-user-agent-browser": "^3.972.9", + "@aws-sdk/util-user-agent-node": "^3.973.15", + "@smithy/config-resolver": "^4.4.14", + "@smithy/core": "^3.23.14", + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/hash-node": "^4.2.13", + "@smithy/invalid-dependency": "^4.2.13", + "@smithy/middleware-content-length": "^4.2.13", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-retry": "^4.5.0", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.45", + "@smithy/util-defaults-mode-node": "^4.2.49", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", + "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/config-resolver": "^4.4.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", + "integrity": "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.28", + "@aws-sdk/types": "^3.973.7", + "@smithy/protocol-http": "^5.3.13", + "@smithy/signature-v4": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1026.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", + "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.27", + "@aws-sdk/nested-clients": "^3.996.19", + "@aws-sdk/types": "^3.973.7", + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", + "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", + "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-endpoints": "^3.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", + "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.7", + "@smithy/types": "^4.14.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", + "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.29", + "@aws-sdk/types": "^3.973.7", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", + "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "fast-xml-parser": "5.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@jimp/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", + "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^16.0.0", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", + "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", + "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", + "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", + "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", + "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", + "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", + "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", + "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", + "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", + "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", + "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", + "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", + "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", + "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", + "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", + "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", + "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", + "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", + "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", + "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", + "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/types": "1.6.0", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", + "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", + "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", + "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", + "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", + "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", + "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", + "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", + "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", + "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", + "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", + "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", + "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.1", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", + "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", + "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.4", + "@smithy/util-middleware": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.14", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", + "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-stream": "^4.5.22", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", + "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.13.tgz", + "integrity": "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.13.tgz", + "integrity": "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.13.tgz", + "integrity": "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.13.tgz", + "integrity": "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.13.tgz", + "integrity": "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", + "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.14.tgz", + "integrity": "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", + "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.13.tgz", + "integrity": "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", + "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.13.tgz", + "integrity": "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", + "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.29", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", + "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/middleware-serde": "^4.2.17", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "@smithy/url-parser": "^4.2.13", + "@smithy/util-middleware": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", + "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/service-error-classification": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-retry": "^4.3.1", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", + "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", + "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", + "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.13", + "@smithy/shared-ini-file-loader": "^4.4.8", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", + "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.13", + "@smithy/querystring-builder": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", + "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", + "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", + "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", + "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", + "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", + "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", + "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.13", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", + "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.14", + "@smithy/middleware-endpoint": "^4.4.29", + "@smithy/middleware-stack": "^4.2.13", + "@smithy/protocol-http": "^5.3.13", + "@smithy/types": "^4.14.0", + "@smithy/util-stream": "^4.5.22", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", + "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", + "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.45", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", + "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", + "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.14", + "@smithy/credential-provider-imds": "^4.2.13", + "@smithy/node-config-provider": "^4.3.13", + "@smithy/property-provider": "^4.2.13", + "@smithy/smithy-client": "^4.12.9", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", + "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", + "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", + "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.13", + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.22", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", + "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.16", + "@smithy/node-http-handler": "^4.5.2", + "@smithy/types": "^4.14.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.15.tgz", + "integrity": "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@temporalio/activity": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/activity/-/activity-1.15.0.tgz", + "integrity": "sha512-kKEIrHMTsANiEpDVZd+v3OT6kU20UtcvAK7iibJNzQnoZUGC7XnqdKQlBuGYBxgpJzD9ppGgskXRczW+XU5Kng==", + "license": "MIT", + "dependencies": { + "@temporalio/client": "1.15.0", + "@temporalio/common": "1.15.0", + "abort-controller": "^3.0.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/client": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/client/-/client-1.15.0.tgz", + "integrity": "sha512-SxTGqRIa2+Vy4P9+06ZpUf4u7ZZmOXfx/kr9XvNqAApLxTMKjTQIg5OH5Wt4JLUtIR7dFkuHIyhewdRyG+hSsQ==", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.12.4", + "@temporalio/common": "1.15.0", + "@temporalio/proto": "1.15.0", + "abort-controller": "^3.0.0", + "long": "^5.2.3", + "uuid": "^11.1.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/common": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/common/-/common-1.15.0.tgz", + "integrity": "sha512-tBfC3fdOExsNoS5krkMUXnaMtCRKj05Jts4+TH+cgHpbys68nslFvUQLqwPIw2x6155Divb9MF219a/75itbTg==", + "license": "MIT", + "dependencies": { + "@temporalio/proto": "1.15.0", + "long": "^5.2.3", + "ms": "3.0.0-canary.1", + "nexus-rpc": "^0.0.1", + "proto3-json-serializer": "^2.0.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/common/node_modules/ms": { + "version": "3.0.0-canary.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.1.tgz", + "integrity": "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==", + "license": "MIT", + "engines": { + "node": ">=12.13" + } + }, + "node_modules/@temporalio/core-bridge": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/core-bridge/-/core-bridge-1.15.0.tgz", + "integrity": "sha512-Qdrs5zju5MiOwmERCWzQ6uHZwn1JaQk/ppS2UHbxqZndjbAEFPDU9KQqFLkxWAicHMy7+LGPnA4DpVOANGlTZA==", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.12.4", + "@temporalio/common": "1.15.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/nexus": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/nexus/-/nexus-1.15.0.tgz", + "integrity": "sha512-E6CdIjskkbK2aObxcb76Z4V3o1D3QDxEtsxmuHX5D7HEABuYGdV+oeOzDyxMlfeY9GyIM9Nvky4XCiSz2h2XRA==", + "license": "MIT", + "dependencies": { + "@temporalio/client": "1.15.0", + "@temporalio/common": "1.15.0", + "@temporalio/proto": "1.15.0", + "long": "^5.2.3", + "nexus-rpc": "^0.0.1" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/proto": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/proto/-/proto-1.15.0.tgz", + "integrity": "sha512-Awy4Fjzyba7Pg/CVZjjQ3x2CWkDL1qELyTZWcLlyjXq8bX694JVfBsmiMmF6tHn5/ySOIxDTcc0MSScZ0oKX5A==", + "license": "MIT", + "dependencies": { + "long": "^5.2.3", + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/worker": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/worker/-/worker-1.15.0.tgz", + "integrity": "sha512-9e0AWP2OxYFgeztMdkoWYbDVqmNubreRkG7/frVKFT3xHdIOrFQ2W6Yomv61q3oKMXTIrpvjClHtiTjAUr70uA==", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.12.4", + "@swc/core": "^1.3.102", + "@temporalio/activity": "1.15.0", + "@temporalio/client": "1.15.0", + "@temporalio/common": "1.15.0", + "@temporalio/core-bridge": "1.15.0", + "@temporalio/nexus": "1.15.0", + "@temporalio/proto": "1.15.0", + "@temporalio/workflow": "1.15.0", + "abort-controller": "^3.0.0", + "heap-js": "^2.6.0", + "memfs": "^4.6.0", + "nexus-rpc": "^0.0.1", + "proto3-json-serializer": "^2.0.0", + "protobufjs": "^7.2.5", + "rxjs": "^7.8.1", + "source-map": "^0.7.4", + "source-map-loader": "^4.0.2", + "supports-color": "^8.1.1", + "swc-loader": "^0.2.3", + "unionfs": "^4.5.1", + "webpack": "^5.104.1" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@temporalio/workflow": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@temporalio/workflow/-/workflow-1.15.0.tgz", + "integrity": "sha512-VaMhVtlA0hLLM/pna26vFSdn5W1Arq2+ccgItdbdRdZUa0X8eW2B7sl/PcUYQOWc4aMUGnvPATKUGUoFKyCxSg==", + "license": "MIT", + "dependencies": { + "@temporalio/common": "1.15.0", + "@temporalio/proto": "1.15.0", + "nexus-rpc": "^0.0.1" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate/node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@whiskeysockets/baileys": { + "version": "6.7.21", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.21.tgz", + "integrity": "sha512-xx9OHd6jlPiu5yZVuUdwEgFNAOXiEG8sULHxC6XfzNwssnwxnA9Lp44pR05H621GQcKyCfsH33TGy+Na6ygX4w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "axios": "^1.6.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "music-metadata": "^11.7.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baileys": { + "version": "7.0.0-rc.9", + "resolved": "file:baileys-7.0.0-rc.9.tgz", + "integrity": "sha512-0fvUV1FdzQOcu4XCHrY9ZXlSG5F/D70ydZYe0X+SU4lg/y3KR/pdYwgL/jSAMVTGnwiuImFDU3nV4FSWfyqmIg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^2.0.1", + "@hapi/boom": "^10.0.1", + "async-mutex": "^0.5.0", + "fflate": "^0.8.2", + "libsignal": "github:whiskeysockets/libsignal-node", + "lru-cache": "^11.2.6", + "music-metadata": "^11.12.3", + "p-queue": "^9.0.0", + "pino": "^10.3.1", + "prom-client": "^15.1.3", + "protobufjs": "^8.0.0", + "whatsapp-rust-bridge": "0.5.3", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.2.3", + "jimp": "^1.6.0", + "link-preview-js": "^4.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/baileys/node_modules/@cacheable/node-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-2.0.2.tgz", + "integrity": "sha512-j0iLckDroiSTpCFhGk0X0hcyN37ZYzLFJdRL+bTNG5EbnylHhYM/+Y/Qn8PIf8wSxzP2DkL2DfISEEHm31g18w==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/baileys/node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/baileys/node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/baileys/node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/baileys/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/baileys/node_modules/protobufjs": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.3.tgz", + "integrity": "sha512-LBYnMWkKLB8fE/ljROPDbCl7mgLSlI+oBe1fAAr5MTqFg4TIi0tYrVVurJvQggOjnUYMQtEZBjrej59ojMNTHQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/baileys/node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "license": "MIT" + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/heap-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.7.1.tgz", + "integrity": "sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jimp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", + "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/diff": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-blur": "1.6.0", + "@jimp/plugin-circle": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-contain": "1.6.0", + "@jimp/plugin-cover": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-displace": "1.6.0", + "@jimp/plugin-dither": "1.6.0", + "@jimp/plugin-fisheye": "1.6.0", + "@jimp/plugin-flip": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/plugin-mask": "1.6.0", + "@jimp/plugin-print": "1.6.0", + "@jimp/plugin-quantize": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/plugin-rotate": "1.6.0", + "@jimp/plugin-threshold": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/knex": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.2.9.tgz", + "integrity": "sha512-dtAILTjBMaG8YloP5oBxohDIKyIsdQ/TkcVvSjhsksvsjeH63Y0PADyuMDfNZKbVT3Rlx3vEYVBlecbPT/KerA==", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "pg-query-stream": "^4.14.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "license": "MIT" + }, + "node_modules/knex/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/libsignal": { + "name": "@whiskeysockets/libsignal-node", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "6.8.8" + } + }, + "node_modules/libsignal/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/libsignal/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/libsignal/node_modules/protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", + "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-to-fsa": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/music-metadata": { + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", + "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "file-type": "^21.3.1", + "media-typer": "^1.1.0", + "strtok3": "^10.3.4", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/music-metadata/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/music-metadata/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/music-metadata/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/music-metadata/node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/music-metadata/node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/nats": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", + "integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==", + "license": "Apache-2.0", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/nexus-rpc": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/nexus-rpc/-/nexus-rpc-0.0.1.tgz", + "integrity": "sha512-hAWn8Hh2eewpB5McXR5EW81R3pR/ziuGhKCF3wFyUVCklanPqrIgMNr7jKCbzXeNVad0nUDfWpFRqh2u+zxQtw==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", + "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-queue": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", + "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qified": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "dev": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-xml-to-json": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.5.tgz", + "integrity": "sha512-ZyRpOE39EtJcTA3eL7qkHXKtZSNrDm3PkttECKBfaFTDuEvnXQA/Sq0mPMxdNfMLrCkMcJT4nEgGStceP73Ihg==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-4.0.2.tgz", + "integrity": "sha512-oYwAqCuL0OZhBoSgmdrLa7mv9MjommVMiQIWgcztf+eS4+8BfcUee6nenFnDhKOhzAVnk5gpZdfnz1iiBv+5sg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swc-loader": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/swc-loader/-/swc-loader-0.2.7.tgz", + "integrity": "sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==", + "license": "MIT", + "dependencies": { + "@swc/counter": "^0.1.3" + }, + "peerDependencies": { + "@swc/core": "^1.2.147", + "webpack": ">=2" + } + }, + "node_modules/systeminformation": { + "version": "5.31.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", + "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unionfs": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/unionfs/-/unionfs-4.6.0.tgz", + "integrity": "sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==", + "dependencies": { + "fs-monkey": "^1.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/whatsapp-rust-bridge": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/whatsapp-rust-bridge/-/whatsapp-rust-bridge-0.5.3.tgz", + "integrity": "sha512-Xb3GAgtWQQJ30oI4a4pjM4+YUeli9CMLTwTIewUrb+AJMFElIkiT5uo+j1Zhc+amiV0Jj+LfX76c/EEZirJbGA==", + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/clube67/newwhats.local/backend/package.json b/clube67/newwhats.local/backend/package.json new file mode 100644 index 0000000..d148b8b --- /dev/null +++ b/clube67/newwhats.local/backend/package.json @@ -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" + } +} diff --git a/clube67/newwhats.local/backend/prisma/schema.prisma b/clube67/newwhats.local/backend/prisma/schema.prisma new file mode 100644 index 0000000..42d0d40 --- /dev/null +++ b/clube67/newwhats.local/backend/prisma/schema.prisma @@ -0,0 +1,533 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Prisma Schema — NewWhats SaaS Omnichannel +// ───────────────────────────────────────────────────────────────────────────── + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ─── PLANOS ────────────────────────────────────────────────────────────────── + +model Plan { + id String @id @default(uuid()) + name String + maxInstances Int @default(1) + maxAgents Int @default(5) + priceMonthly Decimal @default(0) @db.Decimal(10, 2) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] +} + +// ─── USUÁRIOS (ADMIN + TENANTS) ─────────────────────────────────────────────── + +model User { + id String @id @default(uuid()) + name String + email String @unique + passwordHash String + role UserRole @default(USER) + isActive Boolean @default(true) + planId String? + trialEndsAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + plan Plan? @relation(fields: [planId], references: [id]) + instances Instance[] + sectors Sector[] + teams Team[] + apiKeys ApiKey[] + aiCredentials AICredential[] + aiBots AIBot[] + broadcasts Broadcast[] + pluginPairs PluginPair[] + extWebhooks ExtWebhook[] + messageTemplates MessageTemplate[] + scheduledMessages ScheduledMessage[] + + @@map("users") +} + + +enum UserRole { + ADMIN + USER +} + +// ─── API KEYS ───────────────────────────────────────────────────────────────── + +model ApiKey { + id String @id @default(uuid()) + tenantId String + key String @unique @default(uuid()) + name String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + expiresAt DateTime? + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@map("api_keys") +} + +// ─── INSTÂNCIAS WHATSAPP ────────────────────────────────────────────────────── + +model Instance { + id String @id @default(uuid()) + tenantId String + name String + phone String? + avatar String? + status InstanceStatus @default(DISCONNECTED) + engine String @default("infinite") + sessionPath String? + webhookUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + contacts Contact[] + chats Chat[] + messages Message[] + broadcasts Broadcast[] + + @@unique([tenantId, name]) + @@map("instances") +} + +enum InstanceStatus { + DISCONNECTED + CONNECTING + QR_PENDING + CONNECTED + BANNED +} + +// ─── SETORES ───────────────────────────────────────────────────────────────── + +model Sector { + id String @id @default(uuid()) + tenantId String + name String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + teams Team[] + protocols Protocol[] + + @@unique([tenantId, name]) + @@map("sectors") +} + +// ─── EQUIPES ───────────────────────────────────────────────────────────────── + +model Team { + id String @id @default(uuid()) + tenantId String + sectorId String + name String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade) + protocols Protocol[] + + @@unique([tenantId, sectorId, name]) + @@map("teams") +} + +// ─── CONTATOS ───────────────────────────────────────────────────────────────── + +model Contact { + id String @id @default(uuid()) + tenantId String + instanceId String + jid String + lidJid String? + // Prioridade: name (agenda) > verifiedName > notify (pushname) + name String? + verifiedName String? + notify String? + phone String? + avatarUrl String? + scoreReputacao Int @default(100) + flagRestricao Boolean @default(false) + isBlocked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + chats Chat[] + protocols Protocol[] + + @@unique([tenantId, instanceId, jid]) + @@index([tenantId, instanceId, lidJid]) + @@map("contacts") +} + +// ─── CHATS ──────────────────────────────────────────────────────────────────── + +model Chat { + id String @id @default(uuid()) + tenantId String + instanceId String + contactId String? + jid String + name String? // Subject para grupos (@g.us); null para individuais + unreadCount Int @default(0) + lastMessageAt DateTime? + isPinned Boolean @default(false) + isArchived Boolean @default(false) + // IA Chatbot + botPaused Boolean @default(false) // human takeover — pausa o bot neste chat + botSummary String? // Cérebro: resumo de 2 frases da conversa + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + contact Contact? @relation(fields: [contactId], references: [id]) + messages Message[] + protocols Protocol[] + + @@unique([tenantId, instanceId, jid]) + // Índice composto para a query principal da inbox: + // WHERE tenantId + instanceId + isArchived (filtro) + // ORDER BY isPinned DESC, lastMessageAt DESC (ordenação) + @@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)]) + @@map("chats") +} + +// ─── MENSAGENS ──────────────────────────────────────────────────────────────── + +model Message { + id String @id @default(uuid()) + tenantId String + instanceId String + chatId String + remoteJid String + messageId String + fromMe Boolean @default(false) + type MessageType @default(TEXT) + body String? + caption String? + mediaUrl String? + mediaPath String? + mimeType String? + fileName String? + duration Int? + pushName String? // Nome do remetente no momento do envio (grupos: quem enviou) + senderJid String? // JID do remetente real (grupos: key.participant) + replyToId String? // ID da mensagem citada (reply) + richPayload Json? // Payload rico original (buttons/list/carousel/poll) + status MessageStatus @default(PENDING) + timestamp DateTime + createdAt DateTime @default(now()) + + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id]) + replies Message[] @relation("MessageReply") + + @@unique([instanceId, messageId]) + // Índice para "última mensagem por chat" — usado no include da inbox + // ORDER BY timestamp DESC LIMIT 1 (por chatId) + @@index([chatId, timestamp(sort: Desc)]) + // Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC + // WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL + @@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)]) + @@map("messages") +} + +enum MessageType { + TEXT + IMAGE + VIDEO + AUDIO + DOCUMENT + STICKER + LOCATION + CONTACT + REACTION + POLL + BUTTONS + LIST + CAROUSEL + INTERACTIVE + UNSUPPORTED +} + +enum MessageStatus { + PENDING + SENT + DELIVERED + READ + FAILED +} + +// ─── PROTOCOLOS DE ATENDIMENTO ──────────────────────────────────────────────── + +model Protocol { + id String @id @default(uuid()) + tenantId String + number String @unique // Número sequencial rastreável: #0001-2024 + chatId String + contactId String + sectorId String? + teamId String? + agentId String? + status ProtocolStatus @default(OPEN) + summary String? // Resumo de 2 frases gerado pelo Temporal + deadline DateTime? + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + chat Chat @relation(fields: [chatId], references: [id]) + contact Contact @relation(fields: [contactId], references: [id]) + sector Sector? @relation(fields: [sectorId], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) + + // Índice para filtro de protocolos abertos por chat (usado no include da inbox) + // WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC + @@index([chatId, status, createdAt(sort: Desc)]) + @@map("protocols") +} + +enum ProtocolStatus { + OPEN + IN_PROGRESS + WAITING_CLIENT + WAITING_AGENT + RESOLVED + CANCELLED +} + +// ─── IA MULTI-AGENTE ────────────────────────────────────────────────────────── + +model AICredential { + id String @id @default(uuid()) + tenantId String + instanceId String + name String + provider LLMProvider @default(GEMINI) + apiKey String + createdAt DateTime @default(now()) + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + bots AIBot[] + + @@unique([tenantId, instanceId, name]) + @@map("ai_credentials") +} + +model AIBot { + id String @id @default(uuid()) + tenantId String + instanceId String + credentialId String + name String + systemPrompt String @db.Text + model String @default("gemini-1.5-flash") + enabled Boolean @default(false) + triggerMode TriggerMode @default(ALL) + keywords String[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + credential AICredential @relation(fields: [credentialId], references: [id]) + + @@unique([tenantId, instanceId]) + @@map("ai_bots") +} + +enum LLMProvider { + GEMINI + OPENAI +} + +enum TriggerMode { + ALL + KEYWORD +} + +// ─── BROADCAST (DISPARO EM MASSA) ───────────────────────────────────────────── + +model Broadcast { + id String @id @default(uuid()) + tenantId String + instanceId String + message String @db.Text + totalCount Int @default(0) + sentCount Int @default(0) + failedCount Int @default(0) + status String @default("RUNNING") // RUNNING | DONE | FAILED + createdAt DateTime @default(now()) + finishedAt DateTime? + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + recipients BroadcastRecipient[] + + @@map("broadcasts") +} + +// ─── PLUGIN PAIRS ───────────────────────────────────────────────────────────── +// Registra sistemas externos pareados com uma conta NewWhats. +// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*. + +model PluginPair { + id String @id @default(uuid()) + userId String + clientSystem String // identificador técnico ex: "alemao-conveniencias" + clientName String // nome amigável ex: "Alemão Conveniências" + clientUrl String? // URL base do sistema cliente + integrationKey String @unique + lastSeenAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("plugin_pairs") +} + +// ─── EXT WEBHOOKS ───────────────────────────────────────────────────────────── +// Endpoints externos registrados por tenant para receber push de eventos. + +model ExtWebhook { + id String @id @default(uuid()) + tenantId String + url String + events String[] + secret String + active Boolean @default(true) + createdAt DateTime @default(now()) + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@map("ext_webhooks") +} + +model BroadcastRecipient { + id String @id @default(uuid()) + broadcastId String + jid String + status String @default("PENDING") // PENDING | SENT | FAILED + createdAt DateTime @default(now()) + + broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade) + + @@map("broadcast_recipients") +} + +// ─── TEMPLATES DE MENSAGEM ──────────────────────────────────────────────────── + +model MessageTemplate { + id String @id @default(uuid()) + tenantId String + name String + description String? + type TemplateType @default(TEXT) + payload Json + tags String[] + usageCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + schedules ScheduledMessage[] + + @@map("message_templates") +} + +enum TemplateType { + TEXT + BUTTONS + INTERACTIVE + LIST + POLL + CAROUSEL +} + +// ─── AGENDAMENTOS DE MENSAGEM ───────────────────────────────────────────────── + +model ScheduledMessage { + id String @id @default(uuid()) + tenantId String + instanceId String + name String + templateId String? + payload Json + recipientMode RecipientMode @default(MANUAL) + recipients Json + scheduleType ScheduleType @default(ONCE) + cronExpr String? + scheduledAt DateTime? + eventType EventType? + timezone String @default("America/Sao_Paulo") + status ScheduleStatus @default(ACTIVE) + lastRunAt DateTime? + nextRunAt DateTime? + runCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade) + template MessageTemplate? @relation(fields: [templateId], references: [id]) + logs ScheduledMessageLog[] + + @@map("scheduled_messages") +} + +model ScheduledMessageLog { + id String @id @default(uuid()) + scheduleId String + status String + sentCount Int @default(0) + failedCount Int @default(0) + error String? + runAt DateTime @default(now()) + + schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@map("scheduled_message_logs") +} + +enum RecipientMode { + MANUAL + ALL_CONTACTS + TAG_FILTER +} + +enum ScheduleType { + ONCE + RECURRING + EVENT +} + +enum EventType { + BIRTHDAY + ANNIVERSARY + SIGNUP_DAYS +} + +enum ScheduleStatus { + ACTIVE + PAUSED + DONE + CANCELLED +} diff --git a/clube67/newwhats.local/backend/prisma/seed.ts b/clube67/newwhats.local/backend/prisma/seed.ts new file mode 100644 index 0000000..4082d9c --- /dev/null +++ b/clube67/newwhats.local/backend/prisma/seed.ts @@ -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()) diff --git a/clube67/newwhats.local/backend/qr-test.mjs b/clube67/newwhats.local/backend/qr-test.mjs new file mode 100644 index 0000000..78d358e --- /dev/null +++ b/clube67/newwhats.local/backend/qr-test.mjs @@ -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) +}) diff --git a/clube67/newwhats.local/backend/src/config.js b/clube67/newwhats.local/backend/src/config.js new file mode 100644 index 0000000..5b02c86 --- /dev/null +++ b/clube67/newwhats.local/backend/src/config.js @@ -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 \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config.js.map b/clube67/newwhats.local/backend/src/config.js.map new file mode 100644 index 0000000..5317bad --- /dev/null +++ b/clube67/newwhats.local/backend/src/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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config.ts b/clube67/newwhats.local/backend/src/config.ts new file mode 100644 index 0000000..0c37a0b --- /dev/null +++ b/clube67/newwhats.local/backend/src/config.ts @@ -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' diff --git a/clube67/newwhats.local/backend/src/config/env.js b/clube67/newwhats.local/backend/src/config/env.js new file mode 100644 index 0000000..8a72f80 --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/env.js @@ -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 \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config/env.js.map b/clube67/newwhats.local/backend/src/config/env.js.map new file mode 100644 index 0000000..9a90199 --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config/env.ts b/clube67/newwhats.local/backend/src/config/env.ts new file mode 100644 index 0000000..880a383 --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/env.ts @@ -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 diff --git a/clube67/newwhats.local/backend/src/config/logger.js b/clube67/newwhats.local/backend/src/config/logger.js new file mode 100644 index 0000000..5c6c444 --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/logger.js @@ -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 \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config/logger.js.map b/clube67/newwhats.local/backend/src/config/logger.js.map new file mode 100644 index 0000000..b00ee0a --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/config/logger.ts b/clube67/newwhats.local/backend/src/config/logger.ts new file mode 100644 index 0000000..dbf0d9c --- /dev/null +++ b/clube67/newwhats.local/backend/src/config/logger.ts @@ -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, +}) diff --git a/clube67/newwhats.local/backend/src/core/StorageProvider.js b/clube67/newwhats.local/backend/src/core/StorageProvider.js new file mode 100644 index 0000000..cb436fd --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/StorageProvider.js @@ -0,0 +1,58 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.storageProvider = void 0; +const logger_1 = require("../config/logger"); +/** + * Singleton registry para o storage provider ativo. + * O plugin UnifiedStorageProvider se registra aqui durante o activate(). + * O plugin uploads chama uploadFile() para enviar ao storage registrado. + */ +class StorageProviderRegistry { + constructor() { + this.provider = null; + this.healthy = false; + } + register(provider) { + this.provider = provider; + logger_1.logger.info('[StorageProvider] Provider registrado'); + } + isRegistered() { + return this.provider !== null; + } + isHealthy() { + return this.healthy; + } + async uploadFile(payload) { + if (!this.provider) { + throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.'); + } + return this.provider.upload(payload); + } + /** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */ + async updateHealth() { + if (!this.provider) { + this.healthy = false; + return; + } + try { + this.healthy = await this.provider.checkHealth(); + } + catch { + this.healthy = false; + } + } + async deletePrefix(prefix) { + if (!this.provider) { + logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado'); + return { deleted: 0 }; + } + return this.provider.deletePrefix(prefix); + } + unregister() { + this.provider = null; + this.healthy = false; + logger_1.logger.info('[StorageProvider] Provider removido'); + } +} +exports.storageProvider = new StorageProviderRegistry(); +//# sourceMappingURL=StorageProvider.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/StorageProvider.js.map b/clube67/newwhats.local/backend/src/core/StorageProvider.js.map new file mode 100644 index 0000000..f1d2110 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/StorageProvider.js.map @@ -0,0 +1 @@ +{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAGvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;IAgDzB,CAAC;IA9CC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACtB,CAAC;IACH,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/StorageProvider.ts b/clube67/newwhats.local/backend/src/core/StorageProvider.ts new file mode 100644 index 0000000..20584c9 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/StorageProvider.ts @@ -0,0 +1,109 @@ +import { logger as rootLogger } from '../config/logger' +import type { StorageProviderInterface, StorageUploadPayload } from './types' + +export type StorageHealthReason = + | 'ok' + | 'not_configured' + | 'needs_restart' + | 'auth_error' + | 'billing_error' + | 'bucket_not_found' + | 'network_error' + | 'unknown' + +export interface StorageHealthStatus { + healthy: boolean + configured: boolean + reason: StorageHealthReason + message: string +} + +/** + * Singleton registry para o storage provider ativo. + * O plugin UnifiedStorageProvider se registra aqui durante o activate(). + * O plugin uploads chama uploadFile() para enviar ao storage registrado. + */ +class StorageProviderRegistry { + private provider: StorageProviderInterface | null = null + private healthy = false + private lastHealthStatus: StorageHealthStatus = { + healthy: false, + configured: false, + reason: 'not_configured', + message: 'Storage não inicializado.', + } + + register(provider: StorageProviderInterface): void { + this.provider = provider + this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' } + rootLogger.info('[StorageProvider] Provider registrado') + } + + isRegistered(): boolean { + return this.provider !== null + } + + isHealthy(): boolean { + return this.healthy + } + + getLastHealthStatus(): StorageHealthStatus { + return this.lastHealthStatus + } + + setHealthStatus(status: StorageHealthStatus): void { + this.lastHealthStatus = status + this.healthy = status.healthy + } + + async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> { + if (!this.provider) { + throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.') + } + return this.provider.upload(payload) + } + + /** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */ + async updateHealth(): Promise { + if (!this.provider) { + this.healthy = false + this.lastHealthStatus = { + healthy: false, + configured: false, + reason: 'not_configured', + message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.', + } + return + } + try { + const status = await this.provider.checkDetailedHealth() + this.healthy = status.healthy + this.lastHealthStatus = status + } catch { + this.healthy = false + this.lastHealthStatus = { + healthy: false, + configured: true, + reason: 'unknown', + message: 'Erro desconhecido ao verificar o storage.', + } + } + } + + async deletePrefix(prefix: string): Promise<{ deleted: number }> { + if (!this.provider) { + rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado') + return { deleted: 0 } + } + return this.provider.deletePrefix(prefix) + } + + unregister(): void { + this.provider = null + this.healthy = false + rootLogger.info('[StorageProvider] Provider removido') + } +} + +export const storageProvider = new StorageProviderRegistry() +export type { StorageUploadPayload, StorageProviderInterface } diff --git a/clube67/newwhats.local/backend/src/core/db.ts b/clube67/newwhats.local/backend/src/core/db.ts new file mode 100644 index 0000000..bc6f30d --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/db.ts @@ -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 { + 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') + } +} diff --git a/clube67/newwhats.local/backend/src/core/hook-bus.js b/clube67/newwhats.local/backend/src/core/hook-bus.js new file mode 100644 index 0000000..6215fba --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/hook-bus.js @@ -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 \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/hook-bus.js.map b/clube67/newwhats.local/backend/src/core/hook-bus.js.map new file mode 100644 index 0000000..9022079 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/hook-bus.ts b/clube67/newwhats.local/backend/src/core/hook-bus.ts new file mode 100644 index 0000000..0f2929e --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/hook-bus.ts @@ -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 + +/** + * 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() + + 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 { + 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() diff --git a/clube67/newwhats.local/backend/src/core/plugin-config.js b/clube67/newwhats.local/backend/src/core/plugin-config.js new file mode 100644 index 0000000..223ef41 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/plugin-config.js @@ -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 \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/plugin-config.js.map b/clube67/newwhats.local/backend/src/core/plugin-config.js.map new file mode 100644 index 0000000..a4c0964 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/plugin-config.ts b/clube67/newwhats.local/backend/src/core/plugin-config.ts new file mode 100644 index 0000000..54448ad --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/plugin-config.ts @@ -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>() + + // 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 { + 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): Promise { + 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 { + await Promise.all( + pluginNames.map(async (name) => { + const stored = await dragonfly.getJson>(`${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> { + const result: Record> = {} + for (const [name, config] of this.cache.entries()) { + result[name] = config + } + return result + } +} + +export const pluginConfig = new PluginConfigStore() diff --git a/clube67/newwhats.local/backend/src/core/plugin-loader.ts b/clube67/newwhats.local/backend/src/core/plugin-loader.ts new file mode 100644 index 0000000..b5938bc --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/plugin-loader.ts @@ -0,0 +1,119 @@ +import path from 'path' +import fs from 'fs' +import { logger as rootLogger } from '../config/logger' +import type { PluginInstance, PluginManifest } from './types' + +export interface LoadedPlugin { + instance: PluginInstance + manifest: PluginManifest + dir: string +} + +// Em produção: __dirname = backend/dist/core → 3 níveis acima = raiz do projeto +// Em dev com tsx: __dirname = backend/src/core → 3 níveis acima = raiz do projeto +const PLUGINS_DIR = path.resolve(__dirname, '../../../plugins') + +/** + * Ordena plugins por dependências usando topological sort (Kahn's algorithm). + * Plugins sem dependências vêm primeiro; core plugins antes dos business plugins. + */ +function topoSort(plugins: LoadedPlugin[]): LoadedPlugin[] { + const byName = new Map(plugins.map((p) => [p.manifest.name, p])) + const inDegree = new Map() + 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)) { + rootLogger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada') + continue + } + dependents.get(dep)!.push(p.manifest.name) + inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1) + } + } + + const queue = plugins + .filter((p) => inDegree.get(p.manifest.name) === 0) + .sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1)) + + const sorted: LoadedPlugin[] = [] + + while (queue.length > 0) { + const current = queue.shift()! + sorted.push(current) + + for (const depName of dependents.get(current.manifest.name) ?? []) { + const newDegree = (inDegree.get(depName) ?? 1) - 1 + inDegree.set(depName, newDegree) + if (newDegree === 0) { + queue.push(byName.get(depName)!) + } + } + } + + if (sorted.length !== plugins.length) { + rootLogger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original') + return plugins + } + + return sorted +} + +/** + * Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js. + * Retorna plugins ordenados por dependência. + */ +export async function loadPlugins(): Promise { + if (!fs.existsSync(PLUGINS_DIR)) { + rootLogger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado') + return [] + } + + const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }) + const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name) + + const loaded: LoadedPlugin[] = [] + + for (const dirName of pluginDirs) { + const pluginDir = path.join(PLUGINS_DIR, dirName) + const manifestPath = path.join(pluginDir, 'manifest.json') + const indexTs = path.join(pluginDir, 'index.ts') + const indexJs = path.join(pluginDir, 'index.js') + + if (!fs.existsSync(manifestPath)) { + rootLogger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando') + continue + } + + // Prefere .js (produção compilada); fallback para .ts (tsx watch em dev) + const entryPoint = fs.existsSync(indexJs) ? indexJs : fs.existsSync(indexTs) ? indexTs : null + if (!entryPoint) { + rootLogger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando') + continue + } + + try { + const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) + const mod = await import(entryPoint) + const instance: PluginInstance = mod.default ?? mod + + if (typeof instance.activate !== 'function') { + rootLogger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando') + continue + } + + loaded.push({ instance, manifest, dir: pluginDir }) + rootLogger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado') + } catch (err) { + rootLogger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin') + } + } + + return topoSort(loaded) +} diff --git a/clube67/newwhats.local/backend/src/core/plugin-registry.ts b/clube67/newwhats.local/backend/src/core/plugin-registry.ts new file mode 100644 index 0000000..b4bfa5b --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/plugin-registry.ts @@ -0,0 +1,122 @@ +import { logger as rootLogger } from '../config/logger' +import type { PluginContext, PluginInstance, PluginManifest } from './types' +import type { LoadedPlugin } from './plugin-loader' + +export interface RegistryEntry { + instance: PluginInstance + manifest: PluginManifest + active: boolean + dir: string +} + +/** + * Registry central dos plugins. + * Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin. + */ +class PluginRegistry { + private entries = new Map() + private ctx!: PluginContext + + /** Deve ser chamado uma vez no bootstrap com o contexto completo */ + setContext(ctx: PluginContext): void { + this.ctx = ctx + } + + /** Registra plugins carregados (sem ativar ainda) */ + register(plugins: LoadedPlugin[]): void { + for (const { instance, manifest, dir } of plugins) { + this.entries.set(manifest.name, { instance, manifest, active: false, dir }) + } + } + + /** Ativa todos os plugins marcados como enabled no manifest. + * Plugins com dependências faltando são pulados com warning (não crasham o servidor). */ + async activateAll(): Promise { + for (const [name, entry] of this.entries) { + if (!entry.manifest.enabled) continue + try { + await this.activate(name) + } catch (err: any) { + rootLogger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização') + } + } + } + + async activate(name: string): Promise { + const entry = this.entries.get(name) + if (!entry) throw new Error(`Plugin "${name}" não encontrado`) + if (entry.active) return + + // Verifica dependências + for (const dep of entry.manifest.dependencies) { + const depEntry = this.entries.get(dep) + if (!depEntry?.active) { + throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`) + } + } + + try { + await entry.instance.activate(this.ctx) + entry.active = true + entry.manifest.enabled = true + rootLogger.info({ name }, '[PluginRegistry] Plugin ativado') + } catch (err) { + rootLogger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin') + throw err + } + } + + async deactivate(name: string): Promise { + const entry = this.entries.get(name) + if (!entry) throw new Error(`Plugin "${name}" não encontrado`) + if (!entry.active) return + if (!entry.manifest.canDisable) throw new Error(`Plugin "${name}" não pode ser desativado`) + + // Verifica se algum plugin ativo depende deste + for (const [depName, depEntry] of this.entries) { + if (depEntry.active && depEntry.manifest.dependencies.includes(name)) { + throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`) + } + } + + try { + await entry.instance.deactivate(this.ctx) + entry.active = false + entry.manifest.enabled = false + rootLogger.info({ name }, '[PluginRegistry] Plugin desativado') + } catch (err) { + rootLogger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin') + throw err + } + } + + /** Retorna lista de todos os plugins com status para a API admin */ + list(): Array { + return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry })) + } + + getEntry(name: string): RegistryEntry | undefined { + return this.entries.get(name) + } + + /** + * Força reativação de um plugin sem passar pelo deactivate. + * Útil quando canDisable=false mas a config foi atualizada e precisa ser relida. + */ + async reactivate(name: string): Promise { + const entry = this.entries.get(name) + if (!entry) throw new Error(`Plugin "${name}" não encontrado`) + entry.active = false + await this.activate(name) + rootLogger.info({ name }, '[PluginRegistry] Plugin reativado com nova config') + } + + /** Retorna menu items de todos os plugins ativos */ + getActiveMenuItems(): Array<{ pluginName: string; items: PluginManifest['frontend'] }> { + return Array.from(this.entries.values()) + .filter((e) => e.active && e.manifest.frontend?.menuItems?.length) + .map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend })) + } +} + +export const pluginRegistry = new PluginRegistry() diff --git a/clube67/newwhats.local/backend/src/core/types.js b/clube67/newwhats.local/backend/src/core/types.js new file mode 100644 index 0000000..11e638d --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/types.js @@ -0,0 +1,3 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/types.js.map b/clube67/newwhats.local/backend/src/core/types.js.map new file mode 100644 index 0000000..8da0887 --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/types.js.map @@ -0,0 +1 @@ +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/core/types.ts b/clube67/newwhats.local/backend/src/core/types.ts new file mode 100644 index 0000000..65cb2cd --- /dev/null +++ b/clube67/newwhats.local/backend/src/core/types.ts @@ -0,0 +1,134 @@ +import type { Express } from 'express' +import type { Server as HttpServer } from 'http' +import type { Server as SocketServer } from 'socket.io' +import type { Knex } from 'knex' +import type { PrismaClient } from '@prisma/client' +import type { Logger } from 'pino' +import type { HookBus } from './hook-bus' +import type { PluginConfigStore } from './plugin-config' + +// ── Manifest ───────────────────────────────────────────────────────────────── + +export interface MenuItem { + id: string + label: string + icon: string + href: string + roles: string[] + order: number +} + +export interface Widget { + id: string + component: string + span?: number + roles: string[] + order: number +} + +export interface PluginManifest { + name: string + displayName: string + version: string + description: string + author: string + category: 'core' | 'business' | 'integration' | 'utility' + enabled: boolean + canDisable: boolean + dependencies: string[] + backend?: { + routePrefix: string + hasMigrations: boolean + globalMiddleware?: string[] + } + frontend?: { + menuItems: MenuItem[] + widgets?: Widget[] + pages?: Array<{ path: string; component: string; roles: string[] }> + } + hooks?: { + subscribes: string[] + emits: string[] + } + /** Schema de configuração para gerar o formulário no admin */ + configSchema?: PluginConfigField[] +} + +export interface PluginConfigField { + key: string + label: string + type: 'text' | 'password' | 'select' | 'toggle' + placeholder?: string + description?: string + tooltip?: string + options?: Array<{ label: string; value: string }> + required?: boolean + group?: string + default?: string +} + +// ── Context ─────────────────────────────────────────────────────────────────── + +export interface PluginContext { + /** Express app — para registrar rotas */ + app: Express + /** Knex — acesso às tabelas dinâmicas dos plugins */ + db: Knex + /** Prisma — acesso ao core do sistema */ + prisma: PrismaClient + /** Logger (pino) */ + logger: Logger + /** Event bus entre plugins */ + hooks: HookBus + /** Config store backed por DragonflyDB */ + config: PluginConfigStore + /** Socket.IO — para eventos em tempo real */ + io: SocketServer + /** HTTP server nativo — para plugins que precisam interceptar upgrade (WS raw) */ + httpServer: HttpServer +} + +// ── Instance ────────────────────────────────────────────────────────────────── + +export interface PluginInstance { + manifest: PluginManifest + activate(ctx: PluginContext): Promise + deactivate(ctx: PluginContext): Promise +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +export type StorageCategory = + | 'cdi' + | 'xrays' + | 'documents' + | 'exports' + | 'posts' + | 'cards' + | 'partners' + | 'whatsapp' + | 'media' + +export interface StorageUploadPayload { + category: StorageCategory + partnerId?: string + benefitId?: string + postId?: string + whatsappId?: string + /** Identificador do usuário/tenant no sistema — usado para compor o path de mídia WhatsApp */ + usuarioSis?: string + /** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */ + sessionJID?: string + file: { + originalname: string + buffer: Buffer + mimetype: string + } +} + +export interface StorageProviderInterface { + checkHealth(): Promise + checkDetailedHealth(): Promise + upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> + deletePrefix(prefix: string): Promise<{ deleted: number }> +} diff --git a/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js new file mode 100644 index 0000000..f9e1279 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js @@ -0,0 +1,55 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dragonfly = void 0; +const ioredis_1 = __importDefault(require("ioredis")); +const env_1 = require("../../config/env"); +const logger_1 = require("../../config/logger"); +class DragonflyClient { + constructor() { + this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10); + this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: true, + }); + this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado')); + this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro')); + } + async connect() { + await this.client.connect(); + } + async get(key) { + return this.client.get(key); + } + async getJson(key) { + const raw = await this.client.get(key); + if (!raw) + return null; + return JSON.parse(raw); + } + async set(key, value, ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl); + } + async setJson(key, value, ttlSeconds) { + await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl); + } + async del(key) { + await this.client.del(key); + } + async exists(key) { + const count = await this.client.exists(key); + return count > 0; + } + /** Publica em um canal pub/sub (usado para broadcasting de QR/status) */ + async publish(channel, message) { + await this.client.publish(channel, message); + } + raw() { + return this.client; + } +} +exports.dragonfly = new DragonflyClient(); +//# sourceMappingURL=dragonfly.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js.map b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js.map new file mode 100644 index 0000000..74203e4 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.js.map @@ -0,0 +1 @@ +{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,oBAAoB,EAAE,CAAC;YACvB,gBAAgB,EAAE,IAAI;YACtB,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,CAAA;IACzE,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;IAClB,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IAC7C,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/infra/cache/dragonfly.ts b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.ts new file mode 100644 index 0000000..ec5c37c --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/cache/dragonfly.ts @@ -0,0 +1,102 @@ +import Redis from 'ioredis' +import { env } from '../../config/env' +import { logger } from '../../config/logger' + +class DragonflyClient { + private client: Redis + private readonly ttl: number + + constructor() { + this.ttl = parseInt(env.DRAGONFLY_TTL_SECONDS, 10) + + this.client = new Redis(env.DRAGONFLY_URL, { + // null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy + maxRetriesPerRequest: null, + enableReadyCheck: false, + lazyConnect: true, + // Falha imediata quando desconectado — evita fila infinita em memória + enableOfflineQueue: false, + // Reconexão exponencial: 200ms → 400ms → ... → 30s + retryStrategy(times: number) { + const delay = Math.min(200 * 2 ** times, 30_000) + return delay + }, + }) + + this.client.on('connect', () => logger.info('DragonflyDB conectado')) + this.client.on('ready', () => logger.info('DragonflyDB pronto')) + this.client.on('error', (err) => logger.error({ msg: 'DragonflyDB erro', code: (err as any).code })) + this.client.on('close', () => logger.warn('DragonflyDB conexão fechada — reconectando...')) + } + + /** true se o cliente está conectado e pronto */ + isAlive(): boolean { + return this.client.status === 'ready' + } + + async connect(): Promise { + // Inicia conexão em background — não bloqueia o startup se Redis estiver fora + // retryStrategy cuida da reconexão automática + this.client.connect().catch(() => { /* reconexão em background via retryStrategy */ }) + } + + async get(key: string): Promise { + try { return await this.client.get(key) } catch { return null } + } + + async getJson(key: string): Promise { + try { + const raw = await this.client.get(key) + if (!raw) return null + return JSON.parse(raw) as T + } catch { return null } + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + try { + const ttl = ttlSeconds ?? this.ttl + if (ttl > 0) { + await this.client.set(key, value, 'EX', ttl) + } else { + await this.client.set(key, value) + } + } catch { /* Redis indisponível — ignora silenciosamente */ } + } + + async setJson(key: string, value: unknown, ttlSeconds?: number): Promise { + try { + const ttl = ttlSeconds ?? this.ttl + if (ttl > 0) { + await this.client.set(key, JSON.stringify(value), 'EX', ttl) + } else { + await this.client.set(key, JSON.stringify(value)) + } + } catch { /* Redis indisponível — ignora silenciosamente */ } + } + + async psetex(key: string, ttlMs: number, value: string): Promise { + try { await this.client.psetex(key, ttlMs, value) } catch { } + } + + async del(key: string): Promise { + try { await this.client.del(key) } catch { } + } + + async exists(key: string): Promise { + try { + const count = await this.client.exists(key) + return count > 0 + } catch { return false } + } + + /** Publica em um canal pub/sub (usado para broadcasting de QR/status) */ + async publish(channel: string, message: string): Promise { + try { await this.client.publish(channel, message) } catch { } + } + + raw(): Redis { + return this.client + } +} + +export const dragonfly = new DragonflyClient() diff --git a/clube67/newwhats.local/backend/src/infra/database/prisma.js b/clube67/newwhats.local/backend/src/infra/database/prisma.js new file mode 100644 index 0000000..2346eac --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/database/prisma.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.prisma = void 0; +const client_1 = require("@prisma/client"); +exports.prisma = global.__prisma ?? + new client_1.PrismaClient({ + log: process.env.NODE_ENV !== 'production' + ? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }] + : [], + }); +if (process.env.NODE_ENV !== 'production') { + global.__prisma = exports.prisma; +} +//# sourceMappingURL=prisma.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/infra/database/prisma.js.map b/clube67/newwhats.local/backend/src/infra/database/prisma.js.map new file mode 100644 index 0000000..c4b6678 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/database/prisma.js.map @@ -0,0 +1 @@ +{"version":3,"file":"prisma.js","sourceRoot":"","sources":["prisma.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAQhC,QAAA,MAAM,GACjB,MAAM,CAAC,QAAQ;IACf,IAAI,qBAAY,CAAC;QACf,GAAG,EACD,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YACnC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YACzE,CAAC,CAAC,EAAE;KACT,CAAC,CAAA;AAEJ,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,MAAM,CAAC,QAAQ,GAAG,cAAM,CAAA;AAC1B,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/infra/database/prisma.ts b/clube67/newwhats.local/backend/src/infra/database/prisma.ts new file mode 100644 index 0000000..7d847ec --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/database/prisma.ts @@ -0,0 +1,20 @@ +import { PrismaClient } from '@prisma/client' +import { logger } from '../../config/logger' + +declare global { + // eslint-disable-next-line no-var + var __prisma: PrismaClient | undefined +} + +export const prisma = + global.__prisma ?? + new PrismaClient({ + log: + process.env.NODE_ENV !== 'production' + ? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }] + : [], + }) + +if (process.env.NODE_ENV !== 'production') { + global.__prisma = prisma +} diff --git a/clube67/newwhats.local/backend/src/infra/socket/socketServer.ts b/clube67/newwhats.local/backend/src/infra/socket/socketServer.ts new file mode 100644 index 0000000..33575f6 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/socket/socketServer.ts @@ -0,0 +1,78 @@ +import { Server as SocketIOServer } from 'socket.io' +import type { Server as HttpServer } from 'http' +import jwt from 'jsonwebtoken' +import { env } from '../../config/env' +import { logger } from '../../config/logger' +import { dragonfly } from '../cache/dragonfly' + +// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem +const PRESENCE_TTL = 70 + +interface SocketUser { + sub: string // userId / tenantId (matches JWT payload) + role: string +} + +export function buildSocketServer(httpServer: HttpServer): SocketIOServer { + const io = new SocketIOServer(httpServer, { + cors: { + origin: env.FRONTEND_URL, + credentials: true, + }, + transports: ['websocket', 'polling'], + }) + + // Autenticação por token JWT no handshake + io.use((socket, next) => { + const token = socket.handshake.auth?.token as string | undefined + if (!token) { + next(new Error('Token não fornecido')) + return + } + try { + const payload = jwt.verify(token, env.JWT_SECRET) as SocketUser + socket.data.userId = payload.sub + socket.data.role = payload.role + next() + } catch { + next(new Error('Token inválido')) + } + }) + + io.on('connection', (socket) => { + const userId: string = socket.data.userId + logger.debug({ userId, socketId: socket.id }, 'Socket conectado') + + // Cliente entra na sala do seu tenant automaticamente + socket.join(`tenant:${userId}`) + + // Registra presença do usuário no Dragonfly com TTL + dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {}) + + // Cliente pode entrar em salas específicas de instância ou chat + socket.on('join:instance', (instanceId: string) => { + socket.join(`instance:${instanceId}`) + }) + + socket.on('join:chat', (chatId: string) => { + socket.join(`chat:${chatId}`) + }) + + socket.on('leave:chat', (chatId: string) => { + socket.leave(`chat:${chatId}`) + }) + + // Heartbeat do frontend a cada 30s — renova TTL de presença + socket.on('user:heartbeat', () => { + dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {}) + }) + + socket.on('disconnect', (reason) => { + logger.debug({ userId, reason }, 'Socket desconectado') + // Remove presença imediatamente ao desconectar + dragonfly.del(`presence:user:${userId}`).catch(() => {}) + }) + }) + + return io +} diff --git a/clube67/newwhats.local/backend/src/infra/temporal/activities.ts b/clube67/newwhats.local/backend/src/infra/temporal/activities.ts new file mode 100644 index 0000000..cf81d96 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/activities.ts @@ -0,0 +1,87 @@ +/** + * Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O). + * Activities são executadas pelo Worker e podem ser re-tentadas automaticamente. + */ +import { prisma } from '../database/prisma' +import { logger } from '../../config/logger' + +// ─── Conflito de Agenda: liberar slot após timeout ──────────────────────────── + +export async function liberarSlotAgenda(input: { + solicitanteId: string + tituloSlot: string + chatIdSolicitante: string +}): Promise { + logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante') + + // Aqui você envia mensagem via WhatsApp avisando o solicitante + // Implementação real acoplada ao WhatsAppConnectionManager via NATS + // Por ora, marca no banco como disponível + await prisma.protocol.updateMany({ + where: { + contactId: input.solicitanteId, + status: 'WAITING_CLIENT', + }, + data: { status: 'OPEN' }, + }) +} + +export async function notificarConflito(input: { + chatIdB: string + solicitanteNome: string + tituloSlot: string +}): Promise { + logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot') + // Dispara mensagem via NATS → WhatsApp handler +} + +// ─── Gestão de Reputação ────────────────────────────────────────────────────── + +export async function decrementarScore(input: { + contactId: string + motivo: string + pontos: number +}): Promise<{ novoScore: number; restrito: boolean }> { + const contact = await prisma.contact.findUnique({ + where: { id: input.contactId }, + }) + if (!contact) throw new Error(`Contato ${input.contactId} não encontrado`) + + const novoScore = Math.max(0, contact.scoreReputacao - input.pontos) + const restrito = novoScore <= 20 + + await prisma.contact.update({ + where: { id: input.contactId }, + data: { + scoreReputacao: novoScore, + flagRestricao: restrito, + }, + }) + + logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo }, + '[Temporal] Score de reputação atualizado') + + return { novoScore, restrito } +} + +export async function bloquearAgendamentosAutomaticos(input: { + contactId: string +}): Promise { + await prisma.contact.update({ + where: { id: input.contactId }, + data: { flagRestricao: true }, + }) + logger.warn({ contactId: input.contactId }, + '[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados') +} + +export async function buscarScoreContato(contactId: string): Promise<{ + score: number + restrito: boolean +}> { + const c = await prisma.contact.findUnique({ + where: { id: contactId }, + select: { scoreReputacao: true, flagRestricao: true }, + }) + return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false } +} diff --git a/clube67/newwhats.local/backend/src/infra/temporal/temporalClient.ts b/clube67/newwhats.local/backend/src/infra/temporal/temporalClient.ts new file mode 100644 index 0000000..fd5dc43 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/temporalClient.ts @@ -0,0 +1,19 @@ +import { Client, Connection } from '@temporalio/client' +import { env } from '../../config/env' +import { logger } from '../../config/logger' + +let _client: Client | null = null + +export async function getTemporalClient(): Promise { + if (_client) return _client + + const connection = await Connection.connect({ address: env.TEMPORAL_ADDRESS }) + + _client = new Client({ + connection, + namespace: env.TEMPORAL_NAMESPACE, + }) + + logger.info({ address: env.TEMPORAL_ADDRESS }, 'Temporal Client conectado') + return _client +} diff --git a/clube67/newwhats.local/backend/src/infra/temporal/temporalWorker.ts b/clube67/newwhats.local/backend/src/infra/temporal/temporalWorker.ts new file mode 100644 index 0000000..3af3bf5 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/temporalWorker.ts @@ -0,0 +1,25 @@ +/** + * Worker do Temporal — roda como processo separado. + * Uso: npx tsx src/infra/temporal/temporalWorker.ts + */ +import { Worker } from '@temporalio/worker' +import { env } from '../../config/env' +import { logger } from '../../config/logger' +import * as activities from './activities' + +async function runWorker() { + const worker = await Worker.create({ + workflowsPath: require.resolve('./workflows'), + activities, + taskQueue: env.TEMPORAL_TASK_QUEUE, + namespace: env.TEMPORAL_NAMESPACE, + }) + + logger.info({ taskQueue: env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado') + await worker.run() +} + +runWorker().catch((err) => { + logger.error(err, 'Falha fatal no Temporal Worker') + process.exit(1) +}) diff --git a/clube67/newwhats.local/backend/src/infra/temporal/workflows/conflitoAgendaWorkflow.ts b/clube67/newwhats.local/backend/src/infra/temporal/workflows/conflitoAgendaWorkflow.ts new file mode 100644 index 0000000..3e7ee60 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/workflows/conflitoAgendaWorkflow.ts @@ -0,0 +1,76 @@ +/** + * Workflow: Conflito de Agenda (1 Hora) + * + * Regra de negócio: + * - Pessoa A solicita o slot que Pessoa B já tem PENDENTE. + * - Sistema marca o conflito como WAITING_B e aguarda 1 hora. + * - Se B confirmar dentro de 1h → slot permanece com B, A é notificada. + * - Se B não responder em 1h → slot é liberado e transferido para A. + */ +import { + proxyActivities, + sleep, + defineSignal, + setHandler, + condition, + log, +} from '@temporalio/workflow' +import type * as acts from '../activities' + +const { + liberarSlotAgenda, + notificarConflito, +} = proxyActivities({ + startToCloseTimeout: '30 seconds', + retry: { maximumAttempts: 3 }, +}) + +// Signal enviado quando B confirma o slot +export const confirmarSlotSignal = defineSignal<[{ confirmado: boolean }]>('confirmarSlot') + +export interface ConflitoAgendaInput { + solicitanteId: string // Pessoa A + detentorId: string // Pessoa B + chatIdSolicitante: string + chatIdDetentor: string + tituloSlot: string // ex: "Consulta 14h do dia 10/04" +} + +export async function conflitoAgendaWorkflow(input: ConflitoAgendaInput): Promise<{ + resultado: 'MANTIDO_B' | 'LIBERADO_A' +}> { + let bConfirmou = false + + // Registra o handler do signal de confirmação de B + setHandler(confirmarSlotSignal, ({ confirmado }) => { + bConfirmou = confirmado + log.info('Signal recebido de B', { confirmado }) + }) + + // Notifica B sobre a disputa + await notificarConflito({ + chatIdB: input.chatIdDetentor, + solicitanteNome: input.solicitanteId, + tituloSlot: input.tituloSlot, + }) + + log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot }) + + // Aguarda signal de B OU timeout de 1 hora + const bRespondeu = await condition(() => bConfirmou !== false, '1 hour') + + if (bRespondeu && bConfirmou) { + log.info('B confirmou o slot — slot mantido com B') + return { resultado: 'MANTIDO_B' } + } + + // B não respondeu ou recusou → libera para A + log.info('B não confirmou em 1 hora — liberando slot para A') + await liberarSlotAgenda({ + solicitanteId: input.solicitanteId, + tituloSlot: input.tituloSlot, + chatIdSolicitante: input.chatIdSolicitante, + }) + + return { resultado: 'LIBERADO_A' } +} diff --git a/clube67/newwhats.local/backend/src/infra/temporal/workflows/index.ts b/clube67/newwhats.local/backend/src/infra/temporal/workflows/index.ts new file mode 100644 index 0000000..99c731b --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/workflows/index.ts @@ -0,0 +1,2 @@ +export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow' +export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow' diff --git a/clube67/newwhats.local/backend/src/infra/temporal/workflows/reputacaoWorkflow.ts b/clube67/newwhats.local/backend/src/infra/temporal/workflows/reputacaoWorkflow.ts new file mode 100644 index 0000000..adc4730 --- /dev/null +++ b/clube67/newwhats.local/backend/src/infra/temporal/workflows/reputacaoWorkflow.ts @@ -0,0 +1,83 @@ +/** + * Workflow: Gestão de Reputação de Contato + * + * Regra de negócio: + * - Cada falta/infração reduz o score_reputacao do contato. + * - Score ≤ 20 → contato entra em flag_restricao = true. + * - Agendamentos automáticos são bloqueados para contatos RESTRITOS. + * - Score é recuperável manualmente pelo agente (fora deste workflow). + */ +import { + proxyActivities, + defineSignal, + setHandler, + condition, + log, +} from '@temporalio/workflow' +import type * as acts from '../activities' + +const { + decrementarScore, + bloquearAgendamentosAutomaticos, + buscarScoreContato, +} = proxyActivities({ + startToCloseTimeout: '30 seconds', + retry: { maximumAttempts: 3 }, +}) + +// Signal para registrar uma nova infração +export const registrarInfracaoSignal = defineSignal<[{ + motivo: string + pontos: number +}]>('registrarInfracao') + +// Signal para encerrar o monitoramento (contato reabilitado manualmente) +export const encerrarMonitoramentoSignal = defineSignal('encerrarMonitoramento') + +export interface ReputacaoInput { + contactId: string + tenantId: string +} + +export async function reputacaoWorkflow(input: ReputacaoInput): Promise { + let encerrado = false + const infracoesPendentes: Array<{ motivo: string; pontos: number }> = [] + + setHandler(registrarInfracaoSignal, (payload) => { + log.info('Infração registrada via signal', payload) + infracoesPendentes.push(payload) + }) + + setHandler(encerrarMonitoramentoSignal, () => { + encerrado = true + }) + + // Loop durável — o Temporal mantém o estado mesmo após reinícios + while (!encerrado) { + // Aguarda próxima infração ou encerramento + await condition(() => infracoesPendentes.length > 0 || encerrado) + + if (encerrado) break + + const infracao = infracoesPendentes.shift()! + + const { novoScore, restrito } = await decrementarScore({ + contactId: input.contactId, + motivo: infracao.motivo, + pontos: infracao.pontos, + }) + + log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId }) + + if (restrito) { + await bloquearAgendamentosAutomaticos({ contactId: input.contactId }) + log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', { + contactId: input.contactId, + scoreAtual: novoScore, + }) + // Continua monitorando (restrição é revertida manualmente pelo agente) + } + } + + log.info('Monitoramento de reputação encerrado', { contactId: input.contactId }) +} diff --git a/clube67/newwhats.local/backend/src/middleware/auth.js b/clube67/newwhats.local/backend/src/middleware/auth.js new file mode 100644 index 0000000..df56894 --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/auth.js @@ -0,0 +1,9 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.authMiddleware = exports.authenticate = void 0; +// Re-export do middleware de autenticação para compatibilidade com plugins +var auth_middleware_1 = require("../shared/middlewares/auth.middleware"); +Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } }); +var auth_middleware_2 = require("../shared/middlewares/auth.middleware"); +Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } }); +//# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/middleware/auth.js.map b/clube67/newwhats.local/backend/src/middleware/auth.js.map new file mode 100644 index 0000000..dcf69c8 --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/auth.js.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":";;;AAAA,2EAA2E;AAC3E,yEAAsF;AAA7E,+GAAA,cAAc,OAAgB;AACvC,yEAAsE;AAA7D,iHAAA,cAAc,OAAA"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/middleware/auth.ts b/clube67/newwhats.local/backend/src/middleware/auth.ts new file mode 100644 index 0000000..aa754e5 --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/auth.ts @@ -0,0 +1,3 @@ +// Re-export do middleware de autenticação para compatibilidade com plugins +export { authMiddleware as authenticate } from '../shared/middlewares/auth.middleware' +export { authMiddleware } from '../shared/middlewares/auth.middleware' diff --git a/clube67/newwhats.local/backend/src/middleware/rbac.js b/clube67/newwhats.local/backend/src/middleware/rbac.js new file mode 100644 index 0000000..ef6533c --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/rbac.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.requireRole = requireRole; +// Stub de RBAC — aceita qualquer role válida +function requireRole(..._roles) { + return (_req, _res, next) => next(); +} +//# sourceMappingURL=rbac.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/middleware/rbac.js.map b/clube67/newwhats.local/backend/src/middleware/rbac.js.map new file mode 100644 index 0000000..feec35f --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/rbac.js.map @@ -0,0 +1 @@ +{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,kCAEC;AAHD,6CAA6C;AAC7C,SAAgB,WAAW,CAAC,GAAG,MAAgB;IAC7C,OAAO,CAAC,IAAa,EAAE,IAAc,EAAE,IAAkB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;AACtE,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/middleware/rbac.ts b/clube67/newwhats.local/backend/src/middleware/rbac.ts new file mode 100644 index 0000000..1631f08 --- /dev/null +++ b/clube67/newwhats.local/backend/src/middleware/rbac.ts @@ -0,0 +1,6 @@ +import type { Request, Response, NextFunction } from 'express' + +// Stub de RBAC — aceita qualquer role válida +export function requireRole(..._roles: string[]) { + return (_req: Request, _res: Response, next: NextFunction) => next() +} diff --git a/clube67/newwhats.local/backend/src/modules/api-keys/apikey.routes.ts b/clube67/newwhats.local/backend/src/modules/api-keys/apikey.routes.ts new file mode 100644 index 0000000..c3c3139 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/api-keys/apikey.routes.ts @@ -0,0 +1,93 @@ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' +import crypto from 'crypto' + +export function buildApiKeyRoutes(): Router { + const router = Router() + + // GET /api/api-keys + router.get('/', async (req: Request, res: Response) => { + const keys = await prisma.apiKey.findMany({ + where: { tenantId: req.tenantId! }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + key: true, + isActive: true, + createdAt: true, + expiresAt: true, + }, + }) + // Mask key: show only last 8 chars + const masked = keys.map((k) => ({ + ...k, + keyPreview: `••••••••${k.key.slice(-8)}`, + key: undefined, + })) + res.json(masked) + }) + + // POST /api/api-keys — cria nova chave + router.post('/', async (req: Request, res: Response) => { + const schema = z.object({ + name: z.string().min(1).max(80), + expiresAt: z.string().datetime().optional(), + }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + const key = `nw_${crypto.randomBytes(24).toString('hex')}` + + const created = await prisma.apiKey.create({ + data: { + tenantId: req.tenantId!, + name: body.name, + key, + expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined, + }, + }) + + // Retorna a chave completa apenas uma vez + res.status(201).json({ + id: created.id, + name: created.name, + key: created.key, // plaintext — única vez + isActive: created.isActive, + createdAt: created.createdAt, + expiresAt: created.expiresAt, + }) + }) + + // PATCH /api/api-keys/:id — ativar/desativar + router.patch('/:id', async (req: Request, res: Response) => { + const id = req.params['id'] as string + const schema = z.object({ isActive: z.boolean() }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } }) + if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return } + + const updated = await prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } }) + res.json({ id: updated.id, name: updated.name, isActive: updated.isActive }) + }) + + // DELETE /api/api-keys/:id + router.delete('/:id', async (req: Request, res: Response) => { + const id = req.params['id'] as string + + const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } }) + if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return } + + await prisma.apiKey.delete({ where: { id } }) + res.status(204).send() + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/auth/admin.routes.ts b/clube67/newwhats.local/backend/src/modules/auth/admin.routes.ts new file mode 100644 index 0000000..b98b9a1 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/auth/admin.routes.ts @@ -0,0 +1,375 @@ +/** + * Rotas exclusivas do ADMIN (dono do SaaS). + * Prefixo: /api/admin/ + */ +import path from 'path' +import fs from 'fs/promises' +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import bcrypt from 'bcryptjs' +import multer from 'multer' +import si from 'systeminformation' +import { prisma } from '../../infra/database/prisma' +import { dragonfly } from '../../infra/cache/dragonfly' +import { pluginsRouter } from '../plugins/plugins.routes' + +const SETTINGS_KEY = 'system:settings' + +const DEFAULT_SETTINGS = { + systemName: 'NewWhats', + timezone: 'America/Sao_Paulo', + logoUrl: null as string | null, + faviconUrl: null as string | null, + accentColor: '#3b82f6', + sounds: { newMessage: true, notification: true, connectionStatus: true }, + smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false }, + emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' }, + registration: { mode: 'open' as 'open' | 'closed' | 'invite', defaultPlanId: null as string | null }, + maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' }, + webhook: { url: '', secret: '' }, +} + +async function getSettings() { + const stored = await dragonfly.getJson(SETTINGS_KEY) + return { ...DEFAULT_SETTINGS, ...stored } +} + +const uploadAsset = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith('image/')) cb(null, true) + else cb(new Error('Apenas imagens são permitidas')) + }, +}) + +export const adminRouter = Router() + +// ── Plugins ─────────────────────────────────────────────────────────────────── +adminRouter.use('/plugins', pluginsRouter) + +// ── Plans ───────────────────────────────────────────────────────────────────── +adminRouter.get('/plans', async (_req: Request, res: Response) => { + try { + const plans = await prisma.plan.findMany({ + select: { id: true, name: true } + }) + res.json(plans) + } catch { + res.status(500).json({ error: 'Erro ao listar planos' }) + } +}) + +// ── Settings ────────────────────────────────────────────────────────────────── +adminRouter.get('/settings', async (_req: Request, res: Response) => { + try { + const settings = await getSettings() + // Nunca expõe a senha SMTP ao frontend + const { smtp, ...rest } = settings + res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } }) + } catch { + res.status(500).json({ error: 'Erro ao buscar configurações' }) + } +}) + +adminRouter.patch('/settings', async (req: Request, res: Response) => { + try { + const current = await getSettings() + const body = req.body as Partial + + // Se o frontend devolver o placeholder de senha, mantém a senha atual + if (body.smtp?.password === '••••••••') { + body.smtp = { ...body.smtp, password: current.smtp.password } + } + + const updated = { ...current, ...body } + await dragonfly.setJson(SETTINGS_KEY, updated, 0) // TTL 0 = sem expiração + res.json({ ok: true }) + } catch { + res.status(500).json({ error: 'Erro ao salvar configurações' }) + } +}) + +// Upload de logo +adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req: Request, res: Response) => { + try { + if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return } + const ext = req.file.originalname.split('.').pop() ?? 'png' + const dest = path.resolve('./media/system/logo.' + ext) + await fs.writeFile(dest, req.file.buffer) + const url = `/media/system/logo.${ext}` + const settings = await getSettings() + await dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0) + res.json({ url }) + } catch { + res.status(500).json({ error: 'Erro ao salvar logo' }) + } +}) + +// Upload de favicon +adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req: Request, res: Response) => { + try { + if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return } + const ext = req.file.originalname.split('.').pop() ?? 'ico' + const dest = path.resolve('./media/system/favicon.' + ext) + await fs.writeFile(dest, req.file.buffer) + const url = `/media/system/favicon.${ext}` + const settings = await getSettings() + await dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0) + res.json({ url }) + } catch { + res.status(500).json({ error: 'Erro ao salvar favicon' }) + } +}) + +// Endpoint público para o frontend ler as settings de branding (sem autenticação) +// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token +adminRouter.get('/settings/public', async (_req: Request, res: Response) => { + try { + const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings() + res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance }) + } catch { + res.status(500).json({ error: 'Erro' }) + } +}) + +// ── Métricas do sistema ─────────────────────────────────────────────────────── +adminRouter.get('/metrics', async (_req: Request, res: Response) => { + try { + const [cpuLoad, mem, disk] = await Promise.all([ + si.currentLoad(), + si.mem(), + si.fsSize(), + ]) + + const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0] + + const [connectedInstances, totalUsers] = await Promise.all([ + prisma.instance.count({ where: { status: 'CONNECTED' } }), + prisma.user.count(), + ]) + + res.json({ + cpu: { percent: Math.round(cpuLoad.currentLoad) }, + ram: { + total: mem.total, + used: mem.used, + percent: Math.round((mem.used / mem.total) * 100), + }, + disk: { + total: rootDisk?.size ?? 0, + used: rootDisk?.used ?? 0, + percent: Math.round(rootDisk?.use ?? 0), + }, + uptime: Math.floor(process.uptime()), + connectedInstances, + totalUsers, + }) + } catch { + res.status(500).json({ error: 'Erro ao coletar métricas' }) + } +}) + +// Listar todos os tenants com presença online +adminRouter.get('/users', async (_req: Request, res: Response) => { + try { + const [users, presenceKeys] = await Promise.all([ + prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + planId: true, + trialEndsAt: true, + createdAt: true, + _count: { select: { instances: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + dragonfly.raw().keys('presence:user:*'), + ]) + + const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', ''))) + + res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) }))) + } catch (err) { + res.status(500).json({ error: 'Erro ao listar usuários' }) + } +}) + +// Detalhes de um tenant +adminRouter.get('/users/:id', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + planId: true, + trialEndsAt: true, + createdAt: true, + instances: { select: { id: true, name: true, status: true } }, + apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } }, + }, + }) + if (!user) { + res.status(404).json({ error: 'Usuário não encontrado' }) + return + } + res.json(user) + } catch (err) { + res.status(500).json({ error: 'Erro ao buscar usuário' }) + } +}) + +// Ativar / desativar tenant +adminRouter.patch('/users/:id/status', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { isActive } = z.object({ isActive: z.boolean() }).parse(req.body) + + const user = await prisma.user.update({ + where: { id }, + data: { isActive }, + select: { id: true, name: true, email: true, isActive: true }, + }) + res.json(user) + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'isActive deve ser boolean' }) + return + } + res.status(500).json({ error: 'Erro ao atualizar status' }) + } +}) + +// Alterar plano do tenant +adminRouter.patch('/users/:id/plan', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { planId } = z.object({ planId: z.string().uuid() }).parse(req.body) + + const user = await prisma.user.update({ + where: { id }, + data: { planId }, + select: { id: true, name: true, planId: true }, + }) + res.json(user) + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'planId inválido' }) + return + } + res.status(500).json({ error: 'Erro ao atualizar plano' }) + } +}) + +// Estender trial de um tenant +adminRouter.patch('/users/:id/trial', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { days } = z.object({ days: z.number().int().min(1).max(365) }).parse(req.body) + + const user = await prisma.user.update({ + where: { id }, + data: { + trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000), + }, + select: { id: true, name: true, trialEndsAt: true }, + }) + res.json(user) + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'days inválido' }) + return + } + res.status(500).json({ error: 'Erro ao estender trial' }) + } +}) + +// Resetar senha de um tenant (admin) +adminRouter.patch('/users/:id/reset-password', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { newPassword } = z + .object({ newPassword: z.string().min(8).max(128) }) + .parse(req.body) + + const passwordHash = await bcrypt.hash(newPassword, 12) + await prisma.user.update({ where: { id }, data: { passwordHash } }) + + res.json({ message: 'Senha redefinida com sucesso' }) + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'newPassword inválido' }) + return + } + res.status(500).json({ error: 'Erro ao redefinir senha' }) + } +}) + +// Obter engine ativa +adminRouter.get('/engine', async (_req: Request, res: Response) => { + try { + const settings = await getSettings() + const storedEngine = (settings as any).baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite' + res.json({ engine: storedEngine }) + } catch (err) { + console.error('[Admin] Erro ao obter engine:', err) + res.status(500).json({ error: 'Erro ao obter a engine ativa' }) + } +}) + +// Altera a engine e reinicia o backend (via PM2 ou Docker) +adminRouter.post('/engine', async (req: Request, res: Response) => { + try { + const { engine } = z.object({ engine: z.enum(['infinite', 'official']) }).parse(req.body) + + // 1. Persiste nas configurações globais + const settings = await getSettings() + await dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0) + + // 2. Escreve a alteração no arquivo .env para que persista no próximo boot + const envPath = path.resolve('./.env') + try { + let content = await fs.readFile(envPath, 'utf-8') + if (content.includes('BAILEYS_ENGINE=')) { + content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`) + } else { + content += `\nBAILEYS_ENGINE=${engine}\n` + } + await fs.writeFile(envPath, content, 'utf-8') + } catch (envErr) { + console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr) + } + + // 3. Responde ao frontend antes de desligar/reiniciar + res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' }) + + // 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK + setTimeout(() => { + console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`) + const { exec } = require('child_process') + exec('pm2 restart newwhats-backend', (err: any) => { + if (err) { + console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...') + process.exit(0) + } + }) + }, 1500) + + } catch (err) { + console.error('[Admin] Erro ao alternar engine:', err) + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' }) + return + } + res.status(500).json({ error: 'Erro ao salvar a engine' }) + } +}) diff --git a/clube67/newwhats.local/backend/src/modules/auth/auth.repository.ts b/clube67/newwhats.local/backend/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..a59c781 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/auth/auth.repository.ts @@ -0,0 +1,60 @@ +import { prisma } from '../../infra/database/prisma' +import type { UserRole } from '@prisma/client' + +export class AuthRepository { + async findByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }) + } + + async findById(id: string) { + return prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + planId: true, + trialEndsAt: true, + createdAt: true, + }, + }) + } + + async create(data: { + name: string + email: string + passwordHash: string + role?: UserRole + planId?: string + }) { + return prisma.user.create({ + data: { + name: data.name, + email: data.email, + passwordHash: data.passwordHash, + role: data.role ?? 'USER', + planId: data.planId, + trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial + }, + select: { + id: true, + name: true, + email: true, + role: true, + planId: true, + trialEndsAt: true, + createdAt: true, + }, + }) + } + + async updatePassword(id: string, passwordHash: string) { + return prisma.user.update({ where: { id }, data: { passwordHash } }) + } + + async setActive(id: string, isActive: boolean) { + return prisma.user.update({ where: { id }, data: { isActive } }) + } +} diff --git a/clube67/newwhats.local/backend/src/modules/auth/auth.routes.ts b/clube67/newwhats.local/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..6665dbd --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,127 @@ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { AuthService } from './auth.service' +import { authMiddleware } from '../../shared/middlewares/auth.middleware' + +const service = new AuthService() + +// ─── Schemas de validação ───────────────────────────────────────────────────── + +const registerSchema = z.object({ + name: z.string().min(2).max(100), + email: z.string().email(), + password: z.string().min(8).max(128), + planId: z.string().uuid().optional(), +}) + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}) + +const refreshSchema = z.object({ + refreshToken: z.string().min(1), +}) + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8).max(128), +}) + +// ─── Helper para erros de validação / serviço ───────────────────────────────── + +function handleError(res: Response, err: unknown) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors }) + return + } + const e = err as Error & { statusCode?: number } + res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' }) +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export const authRouter = Router() + +/** + * POST /api/auth/register + * Cria novo tenant (usuário com role USER). + * Retorna access + refresh token e dados do usuário. + */ +authRouter.post('/register', async (req: Request, res: Response) => { + try { + const body = registerSchema.parse(req.body) as any + const result = await service.register(body) + res.status(201).json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * POST /api/auth/login + * Autentica usuário. Retorna access + refresh token. + */ +authRouter.post('/login', async (req: Request, res: Response) => { + try { + const body = loginSchema.parse(req.body) as any + const result = await service.login(body) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * POST /api/auth/refresh + * Renova o access token usando o refresh token. + */ +authRouter.post('/refresh', async (req: Request, res: Response) => { + try { + const { refreshToken } = refreshSchema.parse(req.body) + const result = await service.refresh(refreshToken) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * POST /api/auth/logout + * Revoga o refresh token do usuário autenticado. + */ +authRouter.post('/logout', authMiddleware, async (req: Request, res: Response) => { + try { + await service.logout(req.tenantId!) + res.json({ message: 'Sessão encerrada' }) + } catch (err) { + handleError(res, err) + } +}) + +/** + * GET /api/auth/me + * Retorna dados do usuário autenticado + dias restantes do trial. + */ +authRouter.get('/me', authMiddleware, async (req: Request, res: Response) => { + try { + const user = await service.me(req.tenantId!) + res.json(user) + } catch (err) { + handleError(res, err) + } +}) + +/** + * PATCH /api/auth/password + * Altera a senha do usuário autenticado. + */ +authRouter.patch('/password', authMiddleware, async (req: Request, res: Response) => { + try { + const body = changePasswordSchema.parse(req.body) as any + await service.changePassword(req.tenantId!, body) + res.json({ message: 'Senha alterada com sucesso' }) + } catch (err) { + handleError(res, err) + } +}) diff --git a/clube67/newwhats.local/backend/src/modules/auth/auth.service.ts b/clube67/newwhats.local/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..9283af7 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,176 @@ +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { env } from '../../config/env' +import { AuthRepository } from './auth.repository' +import { dragonfly } from '../../infra/cache/dragonfly' + +const BCRYPT_ROUNDS = 12 +const ACCESS_EXPIRES = '8h' +const REFRESH_EXPIRES = '30d' +const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 + +interface TokenPair { + accessToken: string + refreshToken: string +} + +interface JwtPayload { + sub: string + role: string + type: 'access' | 'refresh' +} + +export class AuthService { + private repo = new AuthRepository() + + // ─── Register ───────────────────────────────────────────────────────────── + + async register(input: { + name: string + email: string + password: string + planId?: string + }) { + const existing = await this.repo.findByEmail(input.email) + if (existing) { + throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 }) + } + + const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS) + + const user = await this.repo.create({ + name: input.name, + email: input.email, + passwordHash, + planId: input.planId, + }) + + const tokens = this.issueTokens(user.id, user.role) + await this.saveRefreshToken(user.id, tokens.refreshToken) + + return { user, ...tokens } + } + + // ─── Login ──────────────────────────────────────────────────────────────── + + async login(input: { email: string; password: string }) { + const user = await this.repo.findByEmail(input.email) + if (!user) { + throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 }) + } + + if (!user.isActive) { + throw Object.assign(new Error('Conta desativada'), { statusCode: 403 }) + } + + const valid = await bcrypt.compare(input.password, user.passwordHash) + if (!valid) { + throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 }) + } + + const tokens = this.issueTokens(user.id, user.role) + await this.saveRefreshToken(user.id, tokens.refreshToken) + + const { passwordHash: _, ...safeUser } = user + return { user: safeUser, ...tokens } + } + + // ─── Refresh ────────────────────────────────────────────────────────────── + + async refresh(refreshToken: string) { + let payload: JwtPayload + try { + payload = jwt.verify(refreshToken, env.JWT_SECRET) as JwtPayload + } catch { + throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 }) + } + + if (payload.type !== 'refresh') { + throw Object.assign(new Error('Token incorreto'), { statusCode: 401 }) + } + + // Valida que o token ainda está no cache (não foi revogado) + const stored = await dragonfly.get(this.refreshKey(payload.sub)) + if (stored !== refreshToken) { + throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 }) + } + + const user = await this.repo.findById(payload.sub) + if (!user || !user.isActive) { + throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 }) + } + + const tokens = this.issueTokens(user.id, user.role) + await this.saveRefreshToken(user.id, tokens.refreshToken) + + return { user, ...tokens } + } + + // ─── Logout ─────────────────────────────────────────────────────────────── + + async logout(userId: string) { + await dragonfly.del(this.refreshKey(userId)) + } + + // ─── Me ─────────────────────────────────────────────────────────────────── + + async me(userId: string) { + const user = await this.repo.findById(userId) + if (!user) { + throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 }) + } + + const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null + const daysRemaining = trialEndsAt + ? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000)) + : null + + return { ...user, daysRemaining } + } + + // ─── Change Password ────────────────────────────────────────────────────── + + async changePassword(userId: string, input: { currentPassword: string; newPassword: string }) { + const user = await this.repo.findByEmail( + (await this.repo.findById(userId))?.email ?? '' + ) + if (!user) throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 }) + + const valid = await bcrypt.compare(input.currentPassword, user.passwordHash) + if (!valid) { + throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 }) + } + + const newHash = await bcrypt.hash(input.newPassword, BCRYPT_ROUNDS) + await this.repo.updatePassword(userId, newHash) + await this.logout(userId) // invalida todas as sessões + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private issueTokens(userId: string, role: string): TokenPair { + const base = { sub: userId, role } + + const accessToken = jwt.sign( + { ...base, type: 'access' }, + env.JWT_SECRET, + { expiresIn: ACCESS_EXPIRES } + ) + + const refreshToken = jwt.sign( + { ...base, type: 'refresh' }, + env.JWT_SECRET, + { expiresIn: REFRESH_EXPIRES } + ) + + return { accessToken, refreshToken } + } + + private async saveRefreshToken(userId: string, token: string) { + await dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS) + } + + private refreshKey(userId: string): string { + return `auth:refresh:${userId}` + } +} diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js new file mode 100644 index 0000000..241326e --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js @@ -0,0 +1,68 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0; +/** + * chatbot.repository.ts — CRUD para AICredential e AIBot. + */ +const prisma_1 = require("../../infra/database/prisma"); +// ─── Credentials ───────────────────────────────────────────────────────────── +exports.credentialRepo = { + findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({ + where: { tenantId, instanceId }, + select: { id: true, name: true, provider: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }), + create: (data) => prisma_1.prisma.aICredential.create({ + data: { + tenantId: data.tenantId, + instanceId: data.instanceId, + name: data.name, + provider: data.provider ?? 'GEMINI', + apiKey: data.apiKey, + }, + select: { id: true, name: true, provider: true, createdAt: true }, + }), + delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }), +}; +// ─── Bots ───────────────────────────────────────────────────────────────────── +exports.botRepo = { + findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({ + where: { tenantId, instanceId }, + include: { credential: { select: { id: true, name: true, provider: true } } }, + orderBy: { createdAt: 'desc' }, + }), + findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({ + where: { tenantId, instanceId, enabled: true }, + include: { credential: true }, + }), + findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({ + where: { id, tenantId }, + include: { credential: true }, + }), + create: (data) => prisma_1.prisma.aIBot.create({ + data: { + tenantId: data.tenantId, + instanceId: data.instanceId, + credentialId: data.credentialId, + name: data.name, + systemPrompt: data.systemPrompt, + model: data.model ?? 'gemini-1.5-flash', + enabled: data.enabled ?? false, + triggerMode: data.triggerMode ?? 'ALL', + keywords: data.keywords ?? [], + }, + }), + update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }), + delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }), +}; +// ─── Chat bot state ─────────────────────────────────────────────────────────── +exports.chatBotState = { + pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }), + resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }), + updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }), + getState: (chatId) => prisma_1.prisma.chat.findUnique({ + where: { id: chatId }, + select: { botPaused: true, botSummary: true }, + }), +}; +//# sourceMappingURL=chatbot.repository.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js.map b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js.map new file mode 100644 index 0000000..4946725 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.js.map @@ -0,0 +1 @@ +{"version":3,"file":"chatbot.repository.js","sourceRoot":"","sources":["chatbot.repository.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts new file mode 100644 index 0000000..c60a3a0 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts @@ -0,0 +1,121 @@ +/** + * chatbot.repository.ts — CRUD para AICredential e AIBot. + */ +import { prisma } from '../../infra/database/prisma' + +// ─── Credentials ───────────────────────────────────────────────────────────── + +export const credentialRepo = { + findAll: (tenantId: string, instanceId: string) => + prisma.aICredential.findMany({ + where: { tenantId, instanceId }, + select: { id: true, name: true, provider: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }), + + create: (data: { + tenantId: string + instanceId: string + name: string + provider?: 'GEMINI' | 'OPENAI' + apiKey: string + }) => + prisma.aICredential.create({ + data: { + tenantId: data.tenantId, + instanceId: data.instanceId, + name: data.name, + provider: data.provider ?? 'GEMINI', + apiKey: data.apiKey, + }, + select: { id: true, name: true, provider: true, createdAt: true }, + }), + + delete: (id: string, tenantId: string) => + prisma.aICredential.deleteMany({ where: { id, tenantId } }), +} + +// ─── Bots ───────────────────────────────────────────────────────────────────── + +export const botRepo = { + findAll: (tenantId: string, instanceId: string) => + prisma.aIBot.findMany({ + where: { tenantId, instanceId }, + include: { credential: { select: { id: true, name: true, provider: true } } }, + orderBy: { createdAt: 'desc' }, + }), + + findEnabled: (tenantId: string, instanceId: string) => + prisma.aIBot.findFirst({ + where: { tenantId, instanceId, enabled: true }, + include: { credential: true }, + }), + + findById: (id: string, tenantId: string) => + prisma.aIBot.findFirst({ + where: { id, tenantId }, + include: { credential: true }, + }), + + create: (data: { + tenantId: string + instanceId: string + credentialId: string + name: string + systemPrompt: string + model?: string + enabled?: boolean + triggerMode?: 'ALL' | 'KEYWORD' + keywords?: string[] + }) => + prisma.aIBot.create({ + data: { + tenantId: data.tenantId, + instanceId: data.instanceId, + credentialId: data.credentialId, + name: data.name, + systemPrompt: data.systemPrompt, + model: data.model ?? 'gemini-1.5-flash', + enabled: data.enabled ?? false, + triggerMode: data.triggerMode ?? 'ALL', + keywords: data.keywords ?? [], + }, + }), + + update: ( + id: string, + tenantId: string, + data: Partial<{ + name: string + systemPrompt: string + model: string + enabled: boolean + triggerMode: 'ALL' | 'KEYWORD' + keywords: string[] + credentialId: string + }> + ) => + prisma.aIBot.updateMany({ where: { id, tenantId }, data }), + + delete: (id: string, tenantId: string) => + prisma.aIBot.deleteMany({ where: { id, tenantId } }), +} + +// ─── Chat bot state ─────────────────────────────────────────────────────────── + +export const chatBotState = { + pauseBot: (chatId: string) => + prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }), + + resumeBot: (chatId: string) => + prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }), + + updateSummary: (chatId: string, botSummary: string) => + prisma.chat.update({ where: { id: chatId }, data: { botSummary } }), + + getState: (chatId: string) => + prisma.chat.findUnique({ + where: { id: chatId }, + select: { botPaused: true, botSummary: true }, + }), +} diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts new file mode 100644 index 0000000..eb96e87 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts @@ -0,0 +1,193 @@ +/** + * chatbot.routes.ts — CRUD de credenciais e bots IA por instância. + * + * Montado em /api/chatbot (ver server.ts) + * Todos os endpoints requerem authMiddleware. + */ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' +import { credentialRepo, botRepo, chatBotState } from './chatbot.repository' +import { logger } from '../../config/logger' + +export function buildChatbotRoutes(): Router { + const router = Router() + + // ── Helpers ────────────────────────────────────────────────────────────── + + const validateInstance = async (instanceId: string, tenantId: string) => { + return prisma.instance.findFirst({ where: { id: instanceId, tenantId } }) + } + + // ───────────────────────────────────────────────────────────────────────── + // CREDENTIALS + // ───────────────────────────────────────────────────────────────────────── + + // GET /api/chatbot/credentials/:instanceId + router.get('/credentials/:instanceId', async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + if (!await validateInstance(instanceId, tenantId)) { + res.status(404).json({ error: 'Instância não encontrada' }); return + } + const creds = await credentialRepo.findAll(tenantId, instanceId) + res.json(creds) + } catch (err: any) { + logger.error({ err }, 'Erro ao listar credenciais') + res.status(500).json({ error: err.message }) + } + }) + + // POST /api/chatbot/credentials/:instanceId + router.post('/credentials/:instanceId', async (req: Request, res: Response) => { + const schema = z.object({ + name: z.string().min(1).max(80), + apiKey: z.string().min(10), + provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'), + }) + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + if (!await validateInstance(instanceId, tenantId)) { + res.status(404).json({ error: 'Instância não encontrada' }); return + } + const body = schema.parse(req.body) + const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any) + res.status(201).json(cred) + } catch (err: any) { + if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return } + logger.error({ err }, 'Erro ao criar credencial') + res.status(500).json({ error: err.message }) + } + }) + + // DELETE /api/chatbot/credentials/:instanceId/:credId + router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const credId = req.params['credId'] as string + await credentialRepo.delete(credId, tenantId) + res.status(204).send() + } catch (err: any) { + logger.error({ err }, 'Erro ao deletar credencial') + res.status(500).json({ error: err.message }) + } + }) + + // ───────────────────────────────────────────────────────────────────────── + // BOTS + // ───────────────────────────────────────────────────────────────────────── + + // GET /api/chatbot/bots/:instanceId + router.get('/bots/:instanceId', async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + if (!await validateInstance(instanceId, tenantId)) { + res.status(404).json({ error: 'Instância não encontrada' }); return + } + const bots = await botRepo.findAll(tenantId, instanceId) + res.json(bots) + } catch (err: any) { + logger.error({ err }, 'Erro ao listar bots') + res.status(500).json({ error: err.message }) + } + }) + + // POST /api/chatbot/bots/:instanceId + router.post('/bots/:instanceId', async (req: Request, res: Response) => { + const schema = z.object({ + credentialId: z.string().uuid(), + name: z.string().min(1).max(80), + systemPrompt: z.string().min(10), + model: z.string().default('gemini-1.5-flash'), + enabled: z.boolean().default(false), + triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'), + keywords: z.array(z.string()).default([]), + }) + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + if (!await validateInstance(instanceId, tenantId)) { + res.status(404).json({ error: 'Instância não encontrada' }); return + } + const body = schema.parse(req.body) + const existing = await botRepo.findAll(tenantId, instanceId) + if (existing.length > 0) { + res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' }) + return + } + const bot = await botRepo.create({ tenantId, instanceId, ...body } as any) + res.status(201).json(bot) + } catch (err: any) { + if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return } + logger.error({ err }, 'Erro ao criar bot') + res.status(500).json({ error: err.message }) + } + }) + + // PUT /api/chatbot/bots/:instanceId/:botId + router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => { + const schema = z.object({ + credentialId: z.string().uuid().optional(), + name: z.string().min(1).max(80).optional(), + systemPrompt: z.string().min(10).optional(), + model: z.string().optional(), + enabled: z.boolean().optional(), + triggerMode: z.enum(['ALL', 'KEYWORD']).optional(), + keywords: z.array(z.string()).optional(), + }) + try { + const tenantId = req.tenantId! + const botId = req.params['botId'] as string + const body = schema.parse(req.body) + await botRepo.update(botId, tenantId, body as any) + const updated = await botRepo.findById(botId, tenantId) + res.json(updated) + } catch (err: any) { + if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return } + logger.error({ err }, 'Erro ao atualizar bot') + res.status(500).json({ error: err.message }) + } + }) + + // DELETE /api/chatbot/bots/:instanceId/:botId + router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const botId = req.params['botId'] as string + await botRepo.delete(botId, tenantId) + res.status(204).send() + } catch (err: any) { + logger.error({ err }, 'Erro ao deletar bot') + res.status(500).json({ error: err.message }) + } + }) + + // ───────────────────────────────────────────────────────────────────────── + // BOT STATE — Human Takeover por chat + // ───────────────────────────────────────────────────────────────────────── + + // POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover) + router.post('/chats/:chatId/pause', async (req: Request, res: Response) => { + try { + await chatBotState.pauseBot(req.params['chatId'] as string) + res.json({ paused: true }) + } catch (err: any) { + res.status(500).json({ error: err.message }) + } + }) + + // POST /api/chatbot/chats/:chatId/resume — retoma o bot + router.post('/chats/:chatId/resume', async (req: Request, res: Response) => { + try { + await chatBotState.resumeBot(req.params['chatId'] as string) + res.json({ paused: false }) + } catch (err: any) { + res.status(500).json({ error: err.message }) + } + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js new file mode 100644 index 0000000..70fba77 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js @@ -0,0 +1,165 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ChatbotService = void 0; +/** + * chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash. + * + * Padrão Cérebro (instrucoes.md §5): + * - Não envia histórico completo para economizar tokens + * - Mantém resumo de 2 frases por chat (botSummary no banco) + * - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar + */ +const generative_ai_1 = require("@google/generative-ai"); +const prisma_1 = require("../../infra/database/prisma"); +const logger_1 = require("../../config/logger"); +const chatbot_repository_1 = require("./chatbot.repository"); +// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem) +const geminiClients = new Map(); +function getGeminiClient(apiKey) { + if (!geminiClients.has(apiKey)) { + geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey)); + } + return geminiClients.get(apiKey); +} +function getModel(apiKey, modelName) { + return getGeminiClient(apiKey).getGenerativeModel({ model: modelName }); +} +// ─── Micro-prompt: classificação de intenção (1 token) ─────────────────────── +async function classifyIntent(userMsg, summary, systemPrompt, model) { + const prompt = [ + `Você é um classificador de intenção para um chatbot de atendimento.`, + `Prompt do atendente: ${systemPrompt}`, + summary ? `Contexto da conversa até agora: ${summary}` : '', + `Nova mensagem do cliente: "${userMsg}"`, + `Responda APENAS com o número:`, + `1 = Consigo responder essa pergunta dentro do meu papel`, + `2 = Precisa de um agente humano`, + ].filter(Boolean).join('\n'); + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 2, temperature: 0 }, + }); + const text = result.response.text().trim(); + return text.startsWith('2') ? 'escalate' : 'resolve'; + } + catch (err) { + logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve'); + return 'resolve'; + } +} +// ─── Geração de resposta ────────────────────────────────────────────────────── +async function generateResponse(userMsg, summary, systemPrompt, model) { + const prompt = [ + systemPrompt, + summary ? `\nContexto da conversa até agora:\n${summary}` : '', + `\nMensagem do cliente: "${userMsg}"`, + `\nResponda de forma natural, breve e direta. Não use markdown.`, + ].filter(Boolean).join('\n'); + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 300, temperature: 0.7 }, + }); + return result.response.text().trim(); +} +// ─── Atualização do Cérebro (resumo de 2 frases) ───────────────────────────── +async function updateSummary(currentSummary, userMsg, botMsg, model) { + const prompt = [ + currentSummary + ? `Resumo atual da conversa: ${currentSummary}` + : 'Esta é a primeira troca da conversa.', + `Nova troca:`, + `Cliente: "${userMsg}"`, + `Assistente: "${botMsg}"`, + `Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`, + `Responda apenas com o resumo, sem introdução.`, + ].join('\n'); + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 80, temperature: 0.3 }, + }); + return result.response.text().trim(); + } + catch { + // Em caso de erro, mantém o resumo anterior + return currentSummary ?? ''; + } +} +// ─── Interface pública ──────────────────────────────────────────────────────── +class ChatbotService { + constructor(io) { + this.io = io; + } + /** + * Ponto de entrada para cada mensagem recebida. + * Chamado pelo MessageHandler após persistir a mensagem. + */ + async handleIncoming(opts) { + const { tenantId, instanceId, chatId, jid, text, sock } = opts; + // 1. Verifica se há bot ativo para esta instância + const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId); + if (!bot) + return; + // 2. Verifica o estado do chat (human takeover) + const chatState = await chatbot_repository_1.chatBotState.getState(chatId); + if (!chatState || chatState.botPaused) + return; + // 3. Aplica modo de gatilho + if (bot.triggerMode === 'KEYWORD') { + const lowerText = text.toLowerCase(); + const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase())); + if (!hasKeyword) + return; + } + try { + const model = getModel(bot.credential.apiKey, bot.model); + const summary = chatState.botSummary ?? null; + // 4. Micro-prompt: intenção + const intent = await classifyIntent(text, summary, bot.systemPrompt, model); + if (intent === 'escalate') { + // Pausa o bot e notifica via Socket.IO + await chatbot_repository_1.chatBotState.pauseBot(chatId); + this.io.to(`chat:${chatId}`).emit('bot:escalated', { + chatId, + message: 'Bot pausado — atendente humano necessário', + }); + logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano'); + return; + } + // 5. Gera resposta + const responseText = await generateResponse(text, summary, bot.systemPrompt, model); + // 6. Envia via Baileys + const sent = await sock.sendMessage(jid, { text: responseText }); + // 7. Persiste mensagem do bot no banco + const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } }); + if (chat) { + const botMsg = await prisma_1.prisma.message.create({ + data: { + tenantId, + instanceId, + chatId, + remoteJid: jid, + messageId: sent?.key.id ?? `bot-${Date.now()}`, + fromMe: true, + type: 'TEXT', + body: responseText, + status: 'SENT', + timestamp: new Date(), + }, + }); + // Notifica frontend + this.io.to(`chat:${chatId}`).emit('message:new', botMsg); + } + // 8. Atualiza Cérebro + const newSummary = await updateSummary(summary, text, responseText, model); + await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary); + logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada'); + } + catch (err) { + logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem'); + } + } +} +exports.ChatbotService = ChatbotService; +//# sourceMappingURL=chatbot.service.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js.map b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js.map new file mode 100644 index 0000000..04b3ce1 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.js.map @@ -0,0 +1 @@ +{"version":3,"file":"chatbot.service.js","sourceRoot":"","sources":["chatbot.service.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts new file mode 100644 index 0000000..6c7cf90 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.service.ts @@ -0,0 +1,203 @@ +/** + * chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash. + * + * Padrão Cérebro (instrucoes.md §5): + * - Não envia histórico completo para economizar tokens + * - Mantém resumo de 2 frases por chat (botSummary no banco) + * - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar + */ +import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai' +import type { WASocket } from '../whatsapp/engine' +import { prisma } from '../../infra/database/prisma' +import { logger } from '../../config/logger' +import { botRepo, chatBotState } from './chatbot.repository' +import type { Server as SocketIOServer } from 'socket.io' + +// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem) +const geminiClients = new Map() + +function getGeminiClient(apiKey: string): GoogleGenerativeAI { + if (!geminiClients.has(apiKey)) { + geminiClients.set(apiKey, new GoogleGenerativeAI(apiKey)) + } + return geminiClients.get(apiKey)! +} + +function getModel(apiKey: string, modelName: string): GenerativeModel { + return getGeminiClient(apiKey).getGenerativeModel({ model: modelName }) +} + +// ─── Micro-prompt: classificação de intenção (1 token) ─────────────────────── + +async function classifyIntent( + userMsg: string, + summary: string | null, + systemPrompt: string, + model: GenerativeModel +): Promise<'resolve' | 'escalate'> { + const prompt = [ + `Você é um classificador de intenção para um chatbot de atendimento.`, + `Prompt do atendente: ${systemPrompt}`, + summary ? `Contexto da conversa até agora: ${summary}` : '', + `Nova mensagem do cliente: "${userMsg}"`, + `Responda APENAS com o número:`, + `1 = Consigo responder essa pergunta dentro do meu papel`, + `2 = Precisa de um agente humano`, + ].filter(Boolean).join('\n') + + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 2, temperature: 0 }, + }) + const text = result.response.text().trim() + return text.startsWith('2') ? 'escalate' : 'resolve' + } catch (err) { + logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve') + return 'resolve' + } +} + +// ─── Geração de resposta ────────────────────────────────────────────────────── + +async function generateResponse( + userMsg: string, + summary: string | null, + systemPrompt: string, + model: GenerativeModel +): Promise { + const prompt = [ + systemPrompt, + summary ? `\nContexto da conversa até agora:\n${summary}` : '', + `\nMensagem do cliente: "${userMsg}"`, + `\nResponda de forma natural, breve e direta. Não use markdown.`, + ].filter(Boolean).join('\n') + + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 300, temperature: 0.7 }, + }) + return result.response.text().trim() +} + +// ─── Atualização do Cérebro (resumo de 2 frases) ───────────────────────────── + +async function updateSummary( + currentSummary: string | null, + userMsg: string, + botMsg: string, + model: GenerativeModel +): Promise { + const prompt = [ + currentSummary + ? `Resumo atual da conversa: ${currentSummary}` + : 'Esta é a primeira troca da conversa.', + `Nova troca:`, + `Cliente: "${userMsg}"`, + `Assistente: "${botMsg}"`, + `Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`, + `Responda apenas com o resumo, sem introdução.`, + ].join('\n') + + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 80, temperature: 0.3 }, + }) + return result.response.text().trim() + } catch { + // Em caso de erro, mantém o resumo anterior + return currentSummary ?? '' + } +} + +// ─── Interface pública ──────────────────────────────────────────────────────── + +export class ChatbotService { + constructor(private io: SocketIOServer) {} + + /** + * Ponto de entrada para cada mensagem recebida. + * Chamado pelo MessageHandler após persistir a mensagem. + */ + async handleIncoming(opts: { + tenantId: string + instanceId: string + chatId: string + jid: string + text: string + sock: WASocket + }): Promise { + const { tenantId, instanceId, chatId, jid, text, sock } = opts + + // 1. Verifica se há bot ativo para esta instância + const bot = await botRepo.findEnabled(tenantId, instanceId) + if (!bot) return + + // 2. Verifica o estado do chat (human takeover) + const chatState = await chatBotState.getState(chatId) + if (!chatState || chatState.botPaused) return + + // 3. Aplica modo de gatilho + if (bot.triggerMode === 'KEYWORD') { + const lowerText = text.toLowerCase() + const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase())) + if (!hasKeyword) return + } + + try { + const model = getModel(bot.credential.apiKey, bot.model) + const summary = chatState.botSummary ?? null + + // 4. Micro-prompt: intenção + const intent = await classifyIntent(text, summary, bot.systemPrompt, model) + + if (intent === 'escalate') { + // Pausa o bot e notifica via Socket.IO + await chatBotState.pauseBot(chatId) + this.io.to(`chat:${chatId}`).emit('bot:escalated', { + chatId, + message: 'Bot pausado — atendente humano necessário', + }) + logger.info({ chatId, jid }, '[Chatbot] Escalação para humano') + return + } + + // 5. Gera resposta + const responseText = await generateResponse(text, summary, bot.systemPrompt, model) + + // 6. Envia via Baileys + const sent = await sock.sendMessage(jid, { text: responseText }) + + // 7. Persiste mensagem do bot no banco + const chat = await prisma.chat.findUnique({ where: { id: chatId } }) + if (chat) { + const botMsg = await prisma.message.create({ + data: { + tenantId, + instanceId, + chatId, + remoteJid: jid, + messageId: sent?.key.id ?? `bot-${Date.now()}`, + fromMe: true, + type: 'TEXT', + body: responseText, + status: 'SENT', + timestamp: new Date(), + }, + }) + + // Notifica frontend + this.io.to(`chat:${chatId}`).emit('message:new', botMsg) + } + + // 8. Atualiza Cérebro + const newSummary = await updateSummary(summary, text, responseText, model) + await chatBotState.updateSummary(chatId, newSummary) + + logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada') + } catch (err) { + logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem') + } + } +} diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts new file mode 100644 index 0000000..d942ea3 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.repository.ts @@ -0,0 +1,133 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '../../infra/database/prisma' + +export class ChatRepository { + async findAllByInstance(tenantId: string, instanceId: string, opts: { + limit?: number + search?: string + archived?: boolean + } = {}) { + const { limit = 200, search, archived = false } = opts + + return prisma.chat.findMany({ + where: { + tenantId, + instanceId, + isArchived: archived, + NOT: { OR: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + { jid: { endsWith: '@newsletter' } }, + ]}, + ...(search + ? { + OR: [ + { jid: { contains: search, mode: 'insensitive' as const } }, + { contact: { name: { contains: search, mode: 'insensitive' as const } } }, + { contact: { notify: { contains: search, mode: 'insensitive' as const } } }, + { contact: { phone: { contains: search, mode: 'insensitive' as const } } }, + ], + } + : {}), + }, + include: { + contact: { + select: { + name: true, + notify: true, + verifiedName: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + }, + }, + protocols: { + where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } }, + select: { number: true, status: true }, + take: 1, + orderBy: { createdAt: 'desc' }, + }, + messages: { + select: { + body: true, + fromMe: true, + status: true, + timestamp: true, + type: true, + pushName: true, + }, + orderBy: { timestamp: 'desc' }, + take: 1, + }, + }, + orderBy: [ + { isPinned: 'desc' }, + // COALESCE: usa lastMessageAt se existir, senão createdAt (chats novos sem msg) + // Garante que chats recém-criados não caiam para o fundo da lista + { lastMessageAt: { sort: 'desc', nulls: 'last' } }, + { createdAt: 'desc' }, + ], + take: limit, + }) + } + + async findById(id: string, tenantId: string) { + return prisma.chat.findFirst({ + where: { id, tenantId }, + include: { + contact: true, + protocols: { + where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } }, + take: 1, + orderBy: { createdAt: 'desc' }, + }, + }, + }) + } + + async findByJid(tenantId: string, instanceId: string, jid: string) { + return prisma.chat.findUnique({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + include: { contact: true }, + }) + } + + async clearUnread(id: string, tenantId: string) { + return prisma.chat.updateMany({ + where: { id, tenantId }, + data: { unreadCount: 0 }, + }) + } + + async setArchived(id: string, tenantId: string, archived: boolean) { + return prisma.chat.updateMany({ + where: { id, tenantId }, + data: { isArchived: archived }, + }) + } + + async setPinned(id: string, tenantId: string, pinned: boolean) { + return prisma.chat.updateMany({ + where: { id, tenantId }, + data: { isPinned: pinned }, + }) + } + + async countUnreadByInstance(tenantId: string, instanceId: string): Promise { + const result = await prisma.chat.aggregate({ + where: { + tenantId, + instanceId, + isArchived: false, + NOT: { OR: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + { jid: { endsWith: '@newsletter' } }, + ]} + }, + _sum: { unreadCount: true }, + }) + return result._sum.unreadCount ?? 0 + } +} diff --git a/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts new file mode 100644 index 0000000..482a533 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chats/chat.routes.ts @@ -0,0 +1,480 @@ +/** + * Rotas de Chats — API REST para a inbox do frontend. + * + * Endpoints: + * GET /api/chats — Lista chats (com filtros e último msg) + * GET /api/chats/search/messages — Busca full-text em mensagens + * GET /api/chats/stats/overview — Contagens para o dashboard + * GET /api/chats/:id — Detalhe de um chat + * POST /api/chats/:id/read — Zera unread_count + * PATCH /api/chats/:id/archive — Arquiva/desarquiva + * PATCH /api/chats/:id/pin — Fixa/desfixa + * DELETE /api/chats/:id — Remove chat e mensagens + * GET /api/chats/:id/messages — Histórico paginado por cursor + * GET /api/chats/:id/protocol — Protocolos do chat + * POST /api/chats/:id/protocol — Abre novo protocolo + * PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo + * + * RESOLUÇÃO DE NOME (displayName): + * - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert) + * - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName + * - Se nenhum nome disponível: retorna null → frontend formata o telefone + * + * @see ChatRepository — queries otimizadas com JOINs + * @see MessageRepository — busca e paginação de mensagens + */ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { ChatRepository } from './chat.repository' +import { MessageRepository } from './message.repository' +import { prisma } from '../../infra/database/prisma' +import { dragonfly } from '../../infra/cache/dragonfly' +import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager' + +const chatRepo = new ChatRepository() +const msgRepo = new MessageRepository() + +/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */ +function handleError(res: Response, err: unknown) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors }) + return + } + res.status(500).json({ error: (err as Error).message ?? 'Erro interno' }) +} + +/** + * Constrói as rotas de chat. + * + * Montadas em: /api/chats + * O WhatsAppConnectionManager é injetado mas não usado diretamente aqui + * (reservado para futuras features como typing indicator). + */ +export function buildChatRoutes(manager: WhatsAppConnectionManager): Router { + const router = Router() + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/chats — Lista chats com snapshot da última mensagem + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Lista chats do tenant para uma instância, com: + * - Último mensagem (preview para a lista de chats) + * - Protocolo ativo (se houver) + * - Dados do contato (nome resolvido, avatar, reputação) + * + * Query params: + * - instanceId: UUID (obrigatório) + * - search?: string (busca por nome/telefone) + * - archived?: boolean (filtrar por arquivados) + * - limit?: number (1-200) + * + * REGRA DE RESOLUÇÃO DE NOME (resolvedName): + * Grupos: Chat.name (subject do grupo) + * Individuais: + * 1. Contact.name (agenda do telefone — via contacts.upsert) + * 2. Contact.verifiedName (conta business verificada) + * 3. Contact.notify (pushname — via contacts.upsert ou mensagem) + * 4. lastMsg.pushName (fallback: pushName da última mensagem recebida) + * 5. null (frontend formata o telefone como "55 11 99999-9999") + */ + router.get('/', async (req: Request, res: Response) => { + try { + const { instanceId, search, archived, limit } = z.object({ + instanceId: z.string().uuid(), + search: z.string().optional(), + archived: z.coerce.boolean().optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }).parse(req.query) + + const chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, { + search, + archived, + limit, + }) + + // Serializa cada chat com nome resolvido e dados formatados para o frontend + const result = chats.map((c) => { + const contact = c.contact + const isGroup = c.jid.endsWith('@g.us') + + // Última mensagem (já vem do JOIN no repository, limitado a 1) + const lastMsg = c.messages[0] + // Protocolo ativo mais recente (OPEN ou IN_PROGRESS) + const activeProtocol = c.protocols[0] ?? null + + // ── Resolução de nome ───────────────────────────────────────────── + // Regra: nunca retorna o JID bruto como nome — retorna null e deixa + // o frontend fazer o fallback (telefone formatado ou "Grupo"). + // + // Para individuais: usa hierarchia Contact > lastMsg.pushName > null + // Para grupos: usa Chat.name (subject) ou null + // + // O fallback lastMsg.pushName cobre contatos que vieram do history + // sync sem nome (a maioria — só ~22 de ~3000 têm verifiedName). + const resolvedName = isGroup + ? (c.name ?? null) + : (contact?.name ?? contact?.verifiedName ?? contact?.notify + ?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null) + ?? null) + + return { + id: c.id, + instanceId: c.instanceId, + jid: c.jid, + name: c.name ?? null, + isPinned: c.isPinned, + isArchived: c.isArchived, + unreadCount: c.unreadCount, + lastMessageAt: c.lastMessageAt, + createdAt: c.createdAt, + contact: contact + ? { + name: resolvedName, // null quando não há nome real + phone: contact.phone, + avatarUrl: contact.avatarUrl, + scoreReputacao: contact.scoreReputacao, + flagRestricao: contact.flagRestricao, + } + : isGroup + ? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + : null, + lastMessage: lastMsg + ? { + body: lastMsg.body, + fromMe: lastMsg.fromMe, + status: lastMsg.status, + type: lastMsg.type, + timestamp: lastMsg.timestamp, + pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null), + } + : null, + protocol: activeProtocol + ? { number: activeProtocol.number, status: activeProtocol.status } + : null, + } + }) + + res.json(result) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/chats/search/messages — Busca full-text em mensagens + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Busca full-text no corpo das mensagens. + * MUST ser definido ANTES de /:id para evitar captura de "search" como param. + * + * Query params: + * - instanceId: UUID + * - q: string (mínimo 2 chars) + * - limit?: number (1-50) + */ + router.get('/search/messages', async (req: Request, res: Response) => { + try { + const { instanceId, q, limit } = z.object({ + instanceId: z.string().uuid(), + q: z.string().min(2), + limit: z.coerce.number().int().min(1).max(50).optional(), + }).parse(req.query) + + const results = await msgRepo.search(req.tenantId!, instanceId, q, limit) + res.json(results) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/chats/stats/overview — Contagens para o dashboard + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Retorna métricas resumidas para o dashboard do frontend. + * MUST ser definido ANTES de /:id para evitar captura de "stats" como param. + * + * Response: { totalUnread, openProtocols, totalChats } + * + * Cache DragonflyDB de 30s para não sobrecarregar o banco em dashboards + * com refresh automático. + */ + router.get('/stats/overview', async (req: Request, res: Response) => { + try { + const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query) + + const cacheKey = `stats:${req.tenantId}:${instanceId}` + const cached = await dragonfly.getJson(cacheKey) + if (cached) { res.json(cached); return } + + // Executa as 3 queries em paralelo para minimizar latência + const [totalUnread, openProtocols, totalChats] = await Promise.all([ + chatRepo.countUnreadByInstance(req.tenantId!, instanceId), + prisma.protocol.count({ + where: { + tenantId: req.tenantId!, + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + }), + prisma.chat.count({ + where: { + tenantId: req.tenantId!, + instanceId, + isArchived: false, + // Exclui chats @lid (artefatos internos do WhatsApp) + NOT: { jid: { endsWith: '@lid' } } + }, + }), + ]) + + const stats = { totalUnread, openProtocols, totalChats } + await dragonfly.setJson(cacheKey, stats, 30) // cache 30s + + res.json(stats) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/chats/:id — Detalhe de um chat + // ═══════════════════════════════════════════════════════════════════════════ + + router.get('/:id', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const chat = await chatRepo.findById(id, req.tenantId!) + if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } + res.json(chat) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // POST /api/chats/:id/read — Zera contador de não-lidas + // ═══════════════════════════════════════════════════════════════════════════ + + /** Marca todas as mensagens do chat como lidas (zera unreadCount) */ + router.post('/:id/read', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + await chatRepo.clearUnread(id, req.tenantId!) + res.json({ ok: true }) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // PATCH /api/chats/:id/archive — Arquivar/desarquivar + // ═══════════════════════════════════════════════════════════════════════════ + + /** Body: { archived: boolean } */ + router.patch('/:id/archive', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { archived } = z.object({ archived: z.boolean() }).parse(req.body) + await chatRepo.setArchived(id, req.tenantId!, archived) + res.json({ ok: true }) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // PATCH /api/chats/:id/pin — Fixar/desfixar + // ═══════════════════════════════════════════════════════════════════════════ + + /** Body: { pinned: boolean } */ + router.patch('/:id/pin', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body) + await chatRepo.setPinned(id, req.tenantId!, pinned) + res.json({ ok: true }) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // DELETE /api/chats/:id — Remove chat e mensagens + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Remove um chat e TODAS as suas mensagens permanentemente. + * Operação irreversível — use com cuidado. + * Remove mensagens primeiro (FK constraint) e depois o chat. + */ + router.delete('/:id', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const chat = await chatRepo.findById(id, req.tenantId!) + if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } + await prisma.message.deleteMany({ where: { chatId: id } }) + await prisma.chat.delete({ where: { id } }) + res.status(204).send() + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/chats/:id/messages — Histórico paginado + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Retorna mensagens do chat com paginação por cursor (timestamp ISO). + * + * Query params: + * - before?: ISO datetime (mensagens anteriores a este timestamp) + * - after?: ISO datetime (mensagens posteriores a este timestamp) + * - limit?: number (1-100, default definido no repository) + * + * Response: { messages: Message[], hasMore: boolean } + * + * O frontend usa infinite scroll: carrega as mais recentes primeiro, + * depois pede mais antigas passando `before` do timestamp mais antigo. + */ + router.get('/:id/messages', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { before, after, limit } = z.object({ + before: z.string().datetime().optional(), + after: z.string().datetime().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }).parse(req.query) + + // Valida que o chat pertence ao tenant (segurança multi-tenant) + const chat = await chatRepo.findById(id, req.tenantId!) + if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } + + const { messages, hasMore } = await msgRepo.findByChatPaginated(id, { + before, + after, + limit, + }) + + res.json({ messages, hasMore }) + } catch (err) { + handleError(res, err) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // PROTOCOLOS DE ATENDIMENTO + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * GET /api/chats/:id/protocol — Últimos 10 protocolos do chat. + * Inclui setor e equipe associados. + */ + router.get('/:id/protocol', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const chat = await chatRepo.findById(id, req.tenantId!) + if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } + + const protocols = await prisma.protocol.findMany({ + where: { chatId: id }, + orderBy: { createdAt: 'desc' }, + take: 10, + include: { + sector: { select: { name: true } }, + team: { select: { name: true } }, + }, + }) + + res.json(protocols) + } catch (err) { + handleError(res, err) + } + }) + + /** + * POST /api/chats/:id/protocol — Abre novo protocolo de atendimento. + * + * Body (opcional): + * - sectorId?: UUID — setor responsável + * - teamId?: UUID — equipe responsável + * + * O número do protocolo é gerado sequencialmente: #0001-2026, #0002-2026, etc. + * Exige que o chat tenha um contactId vinculado (não funciona para chats órfãos). + */ + router.post('/:id/protocol', async (req: Request, res: Response) => { + try { + const id = req.params['id'] as string + const { sectorId, teamId } = z.object({ + sectorId: z.string().uuid().optional(), + teamId: z.string().uuid().optional(), + }).parse(req.body) + + const chat = await chatRepo.findById(id, req.tenantId!) + if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return } + if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return } + + // Gera número de protocolo sequencial com ano + const count = await prisma.protocol.count({ where: { chatId: id } }) + const year = new Date().getFullYear() + const number = `#${String(count + 1).padStart(4, '0')}-${year}` + + const protocol = await prisma.protocol.create({ + data: { + tenantId: req.tenantId!, + number, + chatId: id, + contactId: chat.contactId, + sectorId, + teamId, + status: 'OPEN', + }, + }) + + res.status(201).json(protocol) + } catch (err) { + handleError(res, err) + } + }) + + /** + * PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo. + * + * Body: + * - status?: enum — novo status do protocolo + * - summary?: string (max 500) — resumo do atendimento + * + * Se status='RESOLVED', preenche resolvedAt automaticamente. + * + * Status possíveis: + * OPEN → IN_PROGRESS → WAITING_CLIENT / WAITING_AGENT → RESOLVED / CANCELLED + */ + router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => { + try { + const chatId = req.params['chatId'] as string + const protocolId = req.params['protocolId'] as string + const { status, summary } = z.object({ + status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(), + summary: z.string().max(500).optional(), + }).parse(req.body) + + const protocol = await prisma.protocol.updateMany({ + where: { id: protocolId, chatId, tenantId: req.tenantId! }, + data: { + ...(status && { status }), + ...(summary && { summary }), + ...(status === 'RESOLVED' && { resolvedAt: new Date() }), + }, + }) + + res.json({ updated: protocol.count }) + } catch (err) { + handleError(res, err) + } + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/chats/message.repository.ts b/clube67/newwhats.local/backend/src/modules/chats/message.repository.ts new file mode 100644 index 0000000..786548b --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/chats/message.repository.ts @@ -0,0 +1,86 @@ +import { prisma } from '../../infra/database/prisma' + +export class MessageRepository { + /** + * Histórico paginado por cursor (timestamp). + * Retorna `limit` mensagens anteriores ao cursor dado. + * Se cursor não for fornecido, retorna as mais recentes. + */ + async findByChatPaginated(chatId: string, opts: { + limit?: number + before?: string // timestamp ISO — cursor de paginação + after?: string // timestamp ISO — buscar mensagens mais novas + } = {}) { + const { limit = 40, before, after } = opts + + const where: Record = { chatId } + + if (before) where['timestamp'] = { lt: new Date(before) } + if (after) where['timestamp'] = { gt: new Date(after) } + + // Initial load (sem cursor) e scroll-up (before): busca DESC e inverte + // para retornar as mensagens mais recentes em ordem cronológica. + // Scroll-down (after): busca ASC naturalmente. + const needsReverse = !after + + const messages = await prisma.message.findMany({ + where, + orderBy: { timestamp: needsReverse ? 'desc' : 'asc' }, + take: limit, + include: { + replyTo: { + select: { + id: true, + messageId: true, + body: true, + fromMe: true, + type: true, + mediaUrl: true, + }, + }, + }, + }) + + // Inverte para ordem cronológica (mais antiga → mais recente) + if (needsReverse) messages.reverse() + + const hasMore = messages.length === limit + + return { messages, hasMore } + } + + async findById(id: string) { + return prisma.message.findUnique({ where: { id } }) + } + + async search(tenantId: string, instanceId: string, query: string, limit = 20) { + return prisma.message.findMany({ + where: { + tenantId, + instanceId, + body: { contains: query, mode: 'insensitive' }, + }, + include: { + chat: { + select: { + jid: true, + contact: { select: { name: true, notify: true, avatarUrl: true } }, + }, + }, + }, + orderBy: { timestamp: 'desc' }, + take: limit, + }) + } + + async updateStatus(messageId: string, instanceId: string, status: string) { + return prisma.message.updateMany({ + where: { messageId, instanceId }, + data: { status: status as any }, + }) + } + + async countByChat(chatId: string): Promise { + return prisma.message.count({ where: { chatId } }) + } +} diff --git a/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts b/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts new file mode 100644 index 0000000..1283141 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/contacts/contact.routes.ts @@ -0,0 +1,160 @@ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' +import { normalizeJid } from '../../shared/utils/whatsapp' + +export function buildContactRoutes(): Router { + const router = Router() + + // ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ───────────── + router.get('/stats/overview', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.query['instanceId'] as string | undefined + + const where: Record = { tenantId } + if (instanceId) where['instanceId'] = instanceId + + const [total, blocked, flagged] = await Promise.all([ + prisma.contact.count({ where }), + prisma.contact.count({ where: { ...where, isBlocked: true } }), + prisma.contact.count({ where: { ...where, flagRestricao: true } }), + ]) + + res.json({ total, blocked, flagged, active: total - blocked }) + }) + + // ── GET /api/contacts — lista paginada ──────────────────────────────────── + router.get('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.query['instanceId'] as string | undefined + const search = req.query['search'] as string | undefined + const blocked = req.query['blocked'] as string | undefined + const limit = Math.min(Number(req.query['limit'] ?? 50), 200) + const offset = Number(req.query['offset'] ?? 0) + + const where: Record = { tenantId } + if (instanceId) where['instanceId'] = instanceId + if (blocked === 'true') where['isBlocked'] = true + if (blocked === 'false') where['isBlocked'] = false + if (search) { + where['OR'] = [ + { name: { contains: search, mode: 'insensitive' } }, + { verifiedName: { contains: search, mode: 'insensitive' } }, + { notify: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search } }, + { jid: { contains: search } }, + ] + } + + const [total, contacts] = await Promise.all([ + prisma.contact.count({ where }), + prisma.contact.findMany({ + where, + // Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo + // updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET + // devolve linhas sobrepostas entre páginas — quebra "carregar mais". + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + take: limit, + skip: offset, + select: { + id: true, + instanceId: true, + jid: true, + name: true, + verifiedName: true, + notify: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + isBlocked: true, + createdAt: true, + updatedAt: true, + }, + }), + ]) + + res.json({ + total, + contacts: contacts.map(c => ({ + ...c, + displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : normalizeJid(c.jid).split('@')[0]) + })) + }) + }) + + // ── GET /api/contacts/:id — detalhe ────────────────────────────────────── + router.get('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const contact = await prisma.contact.findFirst({ + where: { id, tenantId }, + include: { + chats: { + orderBy: { lastMessageAt: 'desc' }, + take: 1, + select: { id: true, unreadCount: true, lastMessageAt: true }, + }, + }, + }) + + if (!contact) { + res.status(404).json({ error: 'Contato não encontrado' }) + return + } + + res.json(contact) + }) + + // ── PATCH /api/contacts/:id — editar nome / bloquear ───────────────────── + router.patch('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const schema = z.object({ + name: z.string().min(1).max(255).optional(), + isBlocked: z.boolean().optional(), + }) + + let body: z.infer + try { + body = schema.parse(req.body) + } catch (err: any) { + res.status(400).json({ error: err.errors ?? err.message }) + return + } + + const existing = await prisma.contact.findFirst({ where: { id, tenantId } }) + if (!existing) { + res.status(404).json({ error: 'Contato não encontrado' }) + return + } + + const updated = await prisma.contact.update({ where: { id }, data: body }) + res.json(updated) + }) + + // ── DELETE /api/contacts/:id ────────────────────────────────────────────── + router.delete('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const existing = await prisma.contact.findFirst({ where: { id, tenantId } }) + if (!existing) { + res.status(404).json({ error: 'Contato não encontrado' }) + return + } + + // Desvincular chats antes de deletar o contato + await prisma.chat.updateMany({ + where: { contactId: id }, + data: { contactId: null }, + }) + + await prisma.contact.delete({ where: { id } }) + res.status(204).send() + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/pair/pair.routes.ts b/clube67/newwhats.local/backend/src/modules/pair/pair.routes.ts new file mode 100644 index 0000000..c4a2d60 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/pair/pair.routes.ts @@ -0,0 +1,120 @@ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { PairService } from './pair.service' +import { authMiddleware } from '../../shared/middlewares/auth.middleware' + +const service = new PairService() + +function handleError(res: Response, err: unknown) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors }) + return + } + const e = err as Error & { statusCode?: number } + res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' }) +} + +// ─── Schemas ────────────────────────────────────────────────────────────────── + +const requestSchema = z.object({ + email: z.string().email(), + clientSystem: z.string().min(1).max(100), + clientName: z.string().min(1).max(200), + clientUrl: z.string().url().nullable().optional(), +}) + +const confirmSchema = z.object({ + email: z.string().email(), + code: z.string().length(6), + clientSystem: z.string().min(1).max(100), + clientName: z.string().min(1).max(200), + clientUrl: z.string().url().nullable().optional(), +}) + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export const pairRouter = Router() + +/** + * POST /api/pair/request + * Inicia pareamento: gera OTP e envia por e-mail. + * Público (chamado pelo sistema cliente). + */ +pairRouter.post('/request', async (req: Request, res: Response) => { + try { + const body = requestSchema.parse(req.body) as any + const result = await service.request({ + ...body, + clientUrl: body.clientUrl ?? null, + }) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * POST /api/pair/confirm + * Confirma o OTP e retorna a integration_key. + * Público (chamado pelo sistema cliente com o código que o usuário digitou). + */ +pairRouter.post('/confirm', async (req: Request, res: Response) => { + try { + const body = confirmSchema.parse(req.body) as any + const result = await service.confirm({ + ...body, + clientUrl: body.clientUrl ?? null, + }) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * GET /api/pair/verify + * Verifica se uma integration_key é válida. + * Público — autenticado pelo header x-nw-key. + */ +pairRouter.get('/verify', async (req: Request, res: Response) => { + try { + const raw = req.headers['x-nw-key'] + const key = Array.isArray(raw) ? raw[0] : raw + if (!key) { + res.status(401).json({ error: 'Header x-nw-key ausente' }) + return + } + const result = await service.verify(key) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) + +/** + * GET /api/pair/connections + * Lista pares ativos do usuário autenticado. + * Protegido — requer JWT via authMiddleware. + */ +pairRouter.get('/connections', authMiddleware, async (req: Request, res: Response) => { + try { + const pairs = await service.list(req.tenantId!) + res.json(pairs) + } catch (err) { + handleError(res, err) + } +}) + +/** + * DELETE /api/pair/connections/:id + * Revoga um par do usuário autenticado. + * Protegido — requer JWT via authMiddleware. + */ +pairRouter.delete('/connections/:id', authMiddleware, async (req: Request, res: Response) => { + try { + const result = await service.revoke(req.tenantId!, String(req.params.id)) + res.json(result) + } catch (err) { + handleError(res, err) + } +}) diff --git a/clube67/newwhats.local/backend/src/modules/pair/pair.service.ts b/clube67/newwhats.local/backend/src/modules/pair/pair.service.ts new file mode 100644 index 0000000..5357c6c --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/pair/pair.service.ts @@ -0,0 +1,265 @@ +import crypto from 'crypto' +import nodemailer from 'nodemailer' +import { prisma } from '../../infra/database/prisma' +import { dragonfly } from '../../infra/cache/dragonfly' +import { logger } from '../../config/logger' + +const OTP_TTL = 10 * 60 // 10 minutos +const KEY_TTL = 90 * 24 * 3600 // 90 dias + +interface OtpPayload { + code: string + clientSystem: string + clientName: string + clientUrl: string | null +} + +interface KeyPayload { + userId: string + email: string + clientSystem: string + clientName: string +} + +// ─── Keys do Dragonfly ──────────────────────────────────────────────────────── + +const otpKey = (email: string) => `pair:otp:${email}` +const intKey = (integrationKey: string) => `pair:key:${integrationKey}` + +// ─── Email sender ───────────────────────────────────────────────────────────── + +async function sendOtpEmail(to: string, code: string, clientName: string): Promise { + // Lê config SMTP do admin settings + const raw = await dragonfly.getJson('system:settings') + const smtp = raw?.smtp ?? {} + + if (!smtp.host || !smtp.user) { + logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.') + logger.info(`[pair] OTP para ${to}: ${code}`) // fallback: loga localmente + return false + } + + const transporter = nodemailer.createTransport({ + host: smtp.host, + port: smtp.port ?? 587, + secure: smtp.secure ?? false, + auth: { user: smtp.user, pass: smtp.password }, + }) + + await transporter.sendMail({ + from: smtp.from ?? smtp.user, + to, + subject: `[NewWhats] Código de pareamento — ${clientName}`, + text: [ + `Olá!`, + ``, + `O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`, + ``, + `Seu código de confirmação: ${code}`, + ``, + `Válido por 10 minutos.`, + ``, + `Se você não solicitou isso, ignore este email.`, + ].join('\n'), + html: ` +

Olá!

+

O sistema ${clientName} solicitou conexão com sua conta NewWhats.

+

${code}

+

Válido por 10 minutos. Se não foi você, ignore este email.

+ `, + }) + + logger.info(`[pair] OTP enviado para ${to}`) + return true +} + +// ─── PairService ────────────────────────────────────────────────────────────── + +export class PairService { + + // Etapa 1 — solicitar pareamento + async request(input: { + email: string + clientSystem: string + clientName: string + clientUrl: string | null + }) { + const user = await prisma.user.findUnique({ where: { email: input.email } }) + if (!user) { + // Resposta genérica — não revela se o email existe ou não + return { ok: true, message: 'Se o email existir, um código será enviado.' } + } + + if (!user.isActive) { + throw Object.assign(new Error('Conta desativada'), { statusCode: 403 }) + } + + // Gera OTP de 6 dígitos + const code = Math.floor(100_000 + Math.random() * 900_000).toString() + + const payload: OtpPayload = { + code, + clientSystem: input.clientSystem, + clientName: input.clientName, + clientUrl: input.clientUrl, + } + + await dragonfly.setJson(otpKey(input.email), payload, OTP_TTL) + const emailSent = await sendOtpEmail(input.email, code, input.clientName) + + // Quando SMTP não está configurado, devolve o código na resposta + // para que o admin possa completar o pareamento manualmente. + if (!emailSent) { + return { ok: true, message: 'SMTP não configurado.', dev_code: code } + } + + return { ok: true, message: 'Se o email existir, um código será enviado.' } + } + + // Etapa 2 — confirmar com o código recebido + async confirm(input: { + email: string + code: string + clientSystem: string + clientName: string + clientUrl: string | null + }) { + const stored = await dragonfly.getJson(otpKey(input.email)) + + if (!stored) { + throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 }) + } + + if (stored.code !== input.code) { + throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 }) + } + + if (stored.clientSystem !== input.clientSystem) { + throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 }) + } + + const user = await prisma.user.findUnique({ where: { email: input.email } }) + if (!user || !user.isActive) { + throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 }) + } + + // Revoga par anterior do mesmo sistema (evita duplicatas) + await prisma.pluginPair.updateMany({ + where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null }, + data: { revokedAt: new Date() }, + }) + + // Gera integration_key exclusiva + const integrationKey = `nwk_${crypto.randomUUID().replace(/-/g, '')}` + + const pair = await prisma.pluginPair.create({ + data: { + userId: user.id, + clientSystem: input.clientSystem, + clientName: input.clientName, + clientUrl: input.clientUrl, + integrationKey, + }, + }) + + // Cache rápido para validação sem bater no banco + const keyPayload: KeyPayload = { + userId: user.id, + email: user.email, + clientSystem: input.clientSystem, + clientName: input.clientName, + } + await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL) + + // Limpa OTP usado + await dragonfly.del(otpKey(input.email)) + + logger.info(`[pair] ${user.email} ↔ ${input.clientName} pareados (${pair.id})`) + + return { + integration_key: integrationKey, + account: { name: user.name, email: user.email }, + paired_at: pair.createdAt, + } + } + + // Verifica se uma integration_key é válida (chamada pelo sistema cliente) + async verify(integrationKey: string) { + // Fast path — Dragonfly + const cached = await dragonfly.getJson(intKey(integrationKey)) + if (cached) { + // Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta) + prisma.pluginPair.updateMany({ + where: { integrationKey, revokedAt: null }, + data: { lastSeenAt: new Date() }, + }).catch(() => {}) + + return { valid: true, account: { email: cached.email }, client: cached.clientSystem } + } + + // Slow path — banco + const pair = await prisma.pluginPair.findUnique({ + where: { integrationKey }, + include: { user: { select: { name: true, email: true, isActive: true } } }, + }) + + if (!pair || pair.revokedAt || !pair.user.isActive) { + return { valid: false } + } + + // Restaura cache + const keyPayload: KeyPayload = { + userId: pair.userId, + email: pair.user.email, + clientSystem: pair.clientSystem, + clientName: pair.clientName, + } + await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL) + + await prisma.pluginPair.update({ + where: { integrationKey }, + data: { lastSeenAt: new Date() }, + }) + + return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem } + } + + // Lista pares ativos do usuário autenticado + async list(userId: string) { + const pairs = await prisma.pluginPair.findMany({ + where: { userId, revokedAt: null }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + clientSystem: true, + clientName: true, + clientUrl: true, + lastSeenAt: true, + createdAt: true, + }, + }) + return pairs + } + + // Revoga um par (usuário autenticado só pode revogar os seus) + async revoke(userId: string, pairId: string) { + const pair = await prisma.pluginPair.findFirst({ + where: { id: pairId, userId, revokedAt: null }, + }) + + if (!pair) { + throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 }) + } + + await prisma.pluginPair.update({ + where: { id: pairId }, + data: { revokedAt: new Date() }, + }) + + // Remove do cache imediatamente + await dragonfly.del(intKey(pair.integrationKey)) + + logger.info(`[pair] Par ${pairId} revogado por ${userId}`) + return { ok: true } + } +} diff --git a/clube67/newwhats.local/backend/src/modules/plugins/plugins.routes.ts b/clube67/newwhats.local/backend/src/modules/plugins/plugins.routes.ts new file mode 100644 index 0000000..56d7a7d --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/plugins/plugins.routes.ts @@ -0,0 +1,105 @@ +import { Router } from 'express' +import { pluginRegistry } from '../../core/plugin-registry' +import { pluginConfig } from '../../core/plugin-config' +import { storageProvider } from '../../core/StorageProvider' + +/** + * Rotas de gerenciamento de plugins — exclusivo admin. + * Montado em /api/admin/plugins pelo server.ts via adminRouter. + */ +export const pluginsRouter = Router() + +// GET /api/admin/plugins — lista todos os plugins com status e config atual +pluginsRouter.get('/', (_req, res) => { + const plugins = pluginRegistry.list().map((entry) => ({ + name: entry.name, + displayName: entry.manifest.displayName, + version: entry.manifest.version, + description: entry.manifest.description, + author: entry.manifest.author, + category: entry.manifest.category, + active: entry.active, + canDisable: entry.manifest.canDisable, + dependencies: entry.manifest.dependencies, + configSchema: entry.manifest.configSchema ?? [], + config: pluginConfig.get(entry.name), + hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] }, + })) + + res.json(plugins) +}) + +// POST /api/admin/plugins/:name/enable — ativa plugin +pluginsRouter.post('/:name/enable', async (req, res) => { + const { name } = req.params + try { + await pluginRegistry.activate(name) + res.json({ ok: true, message: `Plugin "${name}" ativado` }) + } catch (err: any) { + res.status(400).json({ error: err.message }) + } +}) + +// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true) +pluginsRouter.post('/:name/disable', async (req, res) => { + const { name } = req.params + try { + await pluginRegistry.deactivate(name) + res.json({ ok: true, message: `Plugin "${name}" desativado` }) + } catch (err: any) { + res.status(400).json({ error: err.message }) + } +}) + +// GET /api/admin/plugins/:name/config — retorna config atual +pluginsRouter.get('/:name/config', (req, res) => { + const { name } = req.params + const entry = pluginRegistry.getEntry(name) + if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` }) + + res.json({ + name, + config: pluginConfig.get(name), + schema: entry.manifest.configSchema ?? [], + }) +}) + +// GET /api/admin/plugins/storage/health — retorna status detalhado do storage +pluginsRouter.get('/storage/health', async (_req, res) => { + if (!storageProvider.isRegistered()) { + const cfg = pluginConfig.get('UnifiedStorageProvider') + const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket) + return res.json({ + healthy: false, + configured, + reason: configured ? 'needs_restart' : 'not_configured', + message: configured + ? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.' + : 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.', + }) + } + await storageProvider.updateHealth() + return res.json(storageProvider.getLastHealthStatus()) +}) + +// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário +pluginsRouter.put('/:name/config', async (req, res) => { + const { name } = req.params + const entry = pluginRegistry.getEntry(name) + if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` }) + + const newConfig = req.body as Record + + await pluginConfig.set(name, newConfig) + + // Reativa o plugin para aplicar a nova config imediatamente + if (entry.active) { + try { + await pluginRegistry.reactivate(name) + } catch (err: any) { + return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` }) + } + } + + res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig }) +}) diff --git a/clube67/newwhats.local/backend/src/modules/scheduled/scheduled.routes.ts b/clube67/newwhats.local/backend/src/modules/scheduled/scheduled.routes.ts new file mode 100644 index 0000000..4e6cc1d --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/scheduled/scheduled.routes.ts @@ -0,0 +1,232 @@ +/** + * Rotas de Agendamentos de Mensagem + * + * GET /api/scheduled — Lista agendamentos do tenant + * POST /api/scheduled — Cria agendamento + * GET /api/scheduled/:id — Detalhe + * PUT /api/scheduled/:id — Atualiza + * DELETE /api/scheduled/:id — Remove + * POST /api/scheduled/:id/pause — Pausa + * POST /api/scheduled/:id/resume — Retoma + * POST /api/scheduled/:id/run-now — Executa imediatamente + * GET /api/scheduled/:id/logs — Histórico de execuções + */ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { Cron } from 'croner' +import { prisma } from '../../infra/database/prisma' +import type { SchedulerService } from './scheduler.service' + +const recipientSchema = z.object({ + jid: z.string(), + name: z.string().optional(), + birthday: z.string().optional(), + anniversary: z.string().optional(), + signupDate: z.string().optional(), +}) + +const scheduleSchema = z.object({ + instanceId: z.string(), + name: z.string().min(1).max(100), + templateId: z.string().optional(), + payload: z.record(z.unknown()), + recipientMode: z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'), + recipients: z.array(recipientSchema).default([]), + scheduleType: z.enum(['ONCE', 'RECURRING', 'EVENT']), + cronExpr: z.string().optional(), + scheduledAt: z.string().datetime().optional(), + eventType: z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(), + timezone: z.string().default('America/Sao_Paulo'), +}) + +function getId(req: Request): string { + const id = req.params['id'] + return Array.isArray(id) ? id[0] : id +} + +function calcNextRun(data: Partial>): Date | null { + if (data.scheduleType === 'ONCE' && data.scheduledAt) { + return new Date(data.scheduledAt) + } + if (data.scheduleType === 'RECURRING' && data.cronExpr) { + try { + const job = new Cron(data.cronExpr, { paused: true }) + return job.nextRun() ?? null + } catch { return null } + } + return null +} + +export function buildScheduledRoutes(_scheduler: SchedulerService): Router { + const router = Router() + + // ── LIST ────────────────────────────────────────────────────────────────── + router.get('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'] as string | undefined + const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] as string | undefined + + const schedules = await prisma.scheduledMessage.findMany({ + where: { + tenantId, + ...(status ? { status: status as any } : {}), + ...(type ? { scheduleType: type as any } : {}), + }, + include: { + template: { select: { id: true, name: true, type: true } }, + _count: { select: { logs: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + + res.json(schedules) + }) + + // ── CREATE ──────────────────────────────────────────────────────────────── + router.post('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const parse = scheduleSchema.safeParse(req.body) + if (!parse.success) { + res.status(400).json({ error: parse.error.errors }) + return + } + + const data = parse.data + const nextRunAt = calcNextRun(data) + + const schedule = await prisma.scheduledMessage.create({ + data: { + tenantId, + instanceId: data.instanceId, + name: data.name, + templateId: data.templateId, + payload: data.payload as any, + recipientMode: data.recipientMode, + recipients: data.recipients as any, + scheduleType: data.scheduleType, + cronExpr: data.cronExpr, + scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null, + eventType: data.eventType ?? null, + timezone: data.timezone, + nextRunAt, + }, + }) + + res.status(201).json(schedule) + }) + + // ── GET ONE ─────────────────────────────────────────────────────────────── + router.get('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const sched = await prisma.scheduledMessage.findFirst({ + where: { id: getId(req), tenantId }, + include: { + template: true, + logs: { orderBy: { runAt: 'desc' }, take: 5 }, + }, + }) + if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + res.json(sched) + }) + + // ── UPDATE ──────────────────────────────────────────────────────────────── + router.put('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.scheduledMessage.findFirst({ + where: { id, tenantId }, + }) + if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + + const parse = scheduleSchema.partial().safeParse(req.body) + if (!parse.success) { + res.status(400).json({ error: parse.error.errors }) + return + } + + const data = parse.data + const nextRunAt = calcNextRun({ ...exists, ...data } as any) + + const { payload, recipients, scheduledAt, eventType, ...rest } = data + const updated = await prisma.scheduledMessage.update({ + where: { id }, + data: { + ...rest, + ...(payload !== undefined ? { payload: payload as any } : {}), + ...(recipients !== undefined ? { recipients: recipients as any } : {}), + ...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt as string) } : {}), + ...(eventType !== undefined ? { eventType } : {}), + status: 'ACTIVE', + nextRunAt, + }, + }) + + res.json(updated) + }) + + // ── DELETE ──────────────────────────────────────────────────────────────── + router.delete('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.scheduledMessage.findFirst({ + where: { id, tenantId }, + }) + if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + + await prisma.scheduledMessage.delete({ where: { id } }) + res.status(204).send() + }) + + // ── PAUSE ───────────────────────────────────────────────────────────────── + router.post('/:id/pause', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } }) + if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } }) + res.json({ ok: true }) + }) + + // ── RESUME ──────────────────────────────────────────────────────────────── + router.post('/:id/resume', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } }) + if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } }) + res.json({ ok: true }) + }) + + // ── RUN NOW ─────────────────────────────────────────────────────────────── + router.post('/:id/run-now', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const sched = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } }) + if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + + // Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto + await prisma.scheduledMessage.update({ + where: { id }, + data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' }, + }) + res.json({ ok: true, message: 'Execução agendada para o próximo tick' }) + }) + + // ── LOGS ────────────────────────────────────────────────────────────────── + router.get('/:id/logs', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } }) + if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return } + + const logs = await prisma.scheduledMessageLog.findMany({ + where: { scheduleId: id }, + orderBy: { runAt: 'desc' }, + take: 50, + }) + + res.json(logs) + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/scheduled/scheduler.service.ts b/clube67/newwhats.local/backend/src/modules/scheduled/scheduler.service.ts new file mode 100644 index 0000000..502496f --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/scheduled/scheduler.service.ts @@ -0,0 +1,227 @@ +/** + * SchedulerService — executa agendamentos de mensagens ricos. + * + * Tipos suportados: + * ONCE — executa uma vez em scheduledAt + * RECURRING — cron expression (cronExpr) + * EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS) + * verificado diariamente às 08:00 contra dados dos contatos + * + * O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos. + * Eventos são checados uma vez por dia via job separado. + * + * Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager + * (sem passar pelo HTTP — evita latência e auth overhead). + */ +import { schedule as cronSchedule } from 'node-cron' +import { Cron } from 'croner' +import { prisma } from '../../infra/database/prisma' +import { logger } from '../../config/logger' +import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager' + +type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string } + +export class SchedulerService { + private manager: WhatsAppConnectionManager + private minuteJob: ReturnType | null = null + private eventJob: ReturnType | null = null + + constructor(manager: WhatsAppConnectionManager) { + this.manager = manager + } + + start() { + // Tick a cada minuto — processa ONCE e RECURRING vencidos + this.minuteJob = cronSchedule('* * * * *', () => { + this.processTimeBased().catch((err) => + logger.error({ err }, '[Scheduler] Erro no tick de minuto') + ) + }) + + // Tick diário às 08:00 — processa agendamentos por EVENTO + this.eventJob = cronSchedule('0 8 * * *', () => { + this.processEventBased().catch((err) => + logger.error({ err }, '[Scheduler] Erro no tick de evento') + ) + }) + + logger.info('[Scheduler] Iniciado (minuto + evento diário)') + } + + stop() { + this.minuteJob?.stop() + this.eventJob?.stop() + } + + // ── Processa ONCE e RECURRING ────────────────────────────────────────────── + + private async processTimeBased() { + const now = new Date() + + const schedules = await prisma.scheduledMessage.findMany({ + where: { + status: 'ACTIVE', + scheduleType: { in: ['ONCE', 'RECURRING'] }, + OR: [ + { nextRunAt: { lte: now } }, + { nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' }, + ], + }, + }) + + for (const sched of schedules) { + await this.executeSchedule(sched) + } + } + + // ── Processa EVENT (birthday, anniversary, signup) ───────────────────────── + + private async processEventBased() { + const today = new Date() + const mm = String(today.getMonth() + 1).padStart(2, '0') + const dd = String(today.getDate()).padStart(2, '0') + const todayMmDd = `${mm}-${dd}` + + const schedules = await prisma.scheduledMessage.findMany({ + where: { status: 'ACTIVE', scheduleType: 'EVENT' }, + }) + + for (const sched of schedules) { + const recipients = sched.recipients as Recipient[] + const matched: Recipient[] = [] + + for (const r of recipients) { + if (!r.jid) continue + + if (sched.eventType === 'BIRTHDAY' && r.birthday) { + // birthday format: "YYYY-MM-DD" or "MM-DD" + const parts = r.birthday.split('-') + const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday + if (bMmDd === todayMmDd) matched.push(r) + } else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) { + const parts = r.anniversary.split('-') + const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary + if (aMmDd === todayMmDd) matched.push(r) + } else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) { + // Dispara N dias após o signup — N fica em cronExpr como "30" + const daysAfter = parseInt(sched.cronExpr ?? '0', 10) + const signup = new Date(r.signupDate) + const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000) + if (diffDays === daysAfter) matched.push(r) + } + } + + if (matched.length > 0) { + await this.executeSchedule(sched, matched) + } + } + } + + // ── Executa um agendamento ───────────────────────────────────────────────── + + private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) { + const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[]) + const sock = this.manager.getSocket(sched.instanceId) + + if (!sock) { + logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando') + return + } + + let sentCount = 0 + let failedCount = 0 + const errors: string[] = [] + + for (const recipient of recipients) { + if (!recipient.jid) continue + try { + const payload = this.buildPayload(sched.payload as any, recipient) + await sock.sendMessage(recipient.jid, payload) + sentCount++ + // Pequeno delay anti-ban + await new Promise((r) => setTimeout(r, 800 + Math.random() * 400)) + } catch (err: any) { + failedCount++ + errors.push(`${recipient.jid}: ${err?.message ?? err}`) + logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar') + } + } + + // Persiste log + await prisma.scheduledMessageLog.create({ + data: { + scheduleId: sched.id, + status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL', + sentCount, + failedCount, + error: errors.length ? errors.join('\n') : null, + }, + }) + + // Calcula próximo nextRunAt ou marca como DONE + if (sched.scheduleType === 'ONCE') { + await prisma.scheduledMessage.update({ + where: { id: sched.id }, + data: { + status: 'DONE', + lastRunAt: new Date(), + runCount: { increment: 1 }, + nextRunAt: null, + }, + }) + } else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) { + const next = this.nextCronDate(sched.cronExpr) + await prisma.scheduledMessage.update({ + where: { id: sched.id }, + data: { + lastRunAt: new Date(), + nextRunAt: next, + runCount: { increment: 1 }, + }, + }) + } else { + // EVENT — apenas registra execução + await prisma.scheduledMessage.update({ + where: { id: sched.id }, + data: { lastRunAt: new Date(), runCount: { increment: 1 } }, + }) + } + + logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado') + } + + // ── Monta payload Baileys com substituição de variáveis ──────────────────── + // Variáveis: {{name}}, {{birthday}}, etc. + + private buildPayload(raw: any, recipient: Recipient): any { + const replace = (str: string) => + str + .replace(/\{\{name\}\}/gi, recipient.name ?? '') + .replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '') + .replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '') + + const recurse = (node: any): any => { + if (typeof node === 'string') return replace(node) + if (Array.isArray(node)) return node.map(recurse) + if (node && typeof node === 'object') { + const out: any = {} + for (const k of Object.keys(node)) out[k] = recurse(node[k]) + return out + } + return node + } + + return recurse(raw) + } + + // ── Calcula próxima data de um cron ─────────────────────────────────────── + + private nextCronDate(expr: string): Date | null { + try { + const job = new Cron(expr, { paused: true }) + return job.nextRun() ?? null + } catch { + return null + } + } +} diff --git a/clube67/newwhats.local/backend/src/modules/sectors/sector.routes.ts b/clube67/newwhats.local/backend/src/modules/sectors/sector.routes.ts new file mode 100644 index 0000000..ed3f21e --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/sectors/sector.routes.ts @@ -0,0 +1,136 @@ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' + +export function buildSectorRoutes(): Router { + const router = Router() + + // GET /api/sectors + router.get('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const sectors = await prisma.sector.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + include: { _count: { select: { teams: true, protocols: true } } }, + }) + res.json(sectors) + }) + + // POST /api/sectors + router.post('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const schema = z.object({ name: z.string().min(1).max(100) }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + try { + const sector = await prisma.sector.create({ data: { tenantId, name: body.name } }) + res.status(201).json(sector) + } catch { + res.status(409).json({ error: 'Setor com este nome já existe.' }) + } + }) + + // PATCH /api/sectors/:id + router.patch('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + const schema = z.object({ + name: z.string().min(1).max(100).optional(), + isActive: z.boolean().optional(), + }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + const existing = await prisma.sector.findFirst({ where: { id, tenantId } }) + if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return } + + const updated = await prisma.sector.update({ where: { id }, data: body }) + res.json(updated) + }) + + // DELETE /api/sectors/:id + router.delete('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const existing = await prisma.sector.findFirst({ where: { id, tenantId } }) + if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return } + + await prisma.team.deleteMany({ where: { sectorId: id } }) + await prisma.sector.delete({ where: { id } }) + res.status(204).send() + }) + + // GET /api/sectors/:id/teams + router.get('/:id/teams', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const sectorId = req.params['id'] as string + + const teams = await prisma.team.findMany({ + where: { sectorId, tenantId }, + orderBy: { name: 'asc' }, + include: { _count: { select: { protocols: true } } }, + }) + res.json(teams) + }) + + // POST /api/sectors/:id/teams + router.post('/:id/teams', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const sectorId = req.params['id'] as string + const schema = z.object({ name: z.string().min(1).max(100) }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + const sector = await prisma.sector.findFirst({ where: { id: sectorId, tenantId } }) + if (!sector) { res.status(404).json({ error: 'Setor não encontrado' }); return } + + try { + const team = await prisma.team.create({ data: { tenantId, sectorId, name: body.name } }) + res.status(201).json(team) + } catch { + res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' }) + } + }) + + // PATCH /api/sectors/:sectorId/teams/:teamId + router.patch('/:sectorId/teams/:teamId', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const teamId = req.params['teamId'] as string + const schema = z.object({ + name: z.string().min(1).max(100).optional(), + isActive: z.boolean().optional(), + }) + + let body: z.infer + try { body = schema.parse(req.body) } + catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return } + + const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } }) + if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return } + + const updated = await prisma.team.update({ where: { id: teamId }, data: body }) + res.json(updated) + }) + + // DELETE /api/sectors/:sectorId/teams/:teamId + router.delete('/:sectorId/teams/:teamId', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const teamId = req.params['teamId'] as string + + const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } }) + if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return } + + await prisma.team.delete({ where: { id: teamId } }) + res.status(204).send() + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/templates/template.routes.ts b/clube67/newwhats.local/backend/src/modules/templates/template.routes.ts new file mode 100644 index 0000000..4dd59a4 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/templates/template.routes.ts @@ -0,0 +1,134 @@ +/** + * Rotas de Templates de Mensagem + * + * GET /api/templates — Lista templates do tenant + * POST /api/templates — Cria template + * GET /api/templates/:id — Detalhe do template + * PUT /api/templates/:id — Atualiza template + * DELETE /api/templates/:id — Remove template + * POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar) + */ +import { Router, type Request, type Response } from 'express' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' + +const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'] as const + +const templateSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + type: z.enum(templateTypeValues), + payload: z.record(z.unknown()), + tags: z.array(z.string()).default([]), +}) + +function getId(req: Request): string { + const id = req.params['id'] + return Array.isArray(id) ? id[0] : id +} + +export function buildTemplateRoutes(): Router { + const router = Router() + + // ── LIST ────────────────────────────────────────────────────────────────── + router.get('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] + const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search'] + + const templates = await prisma.messageTemplate.findMany({ + where: { + tenantId, + ...(type ? { type: type as any } : {}), + ...(search ? { name: { contains: search as string, mode: 'insensitive' } } : {}), + }, + orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }], + }) + + res.json(templates) + }) + + // ── CREATE ──────────────────────────────────────────────────────────────── + router.post('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const parse = templateSchema.safeParse(req.body) + if (!parse.success) { + res.status(400).json({ error: parse.error.errors }) + return + } + + const { name, description, type, payload, tags } = parse.data + + const template = await prisma.messageTemplate.create({ + data: { tenantId, name, description, type, payload: payload as any, tags }, + }) + + res.status(201).json(template) + }) + + // ── GET ONE ─────────────────────────────────────────────────────────────── + router.get('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const template = await prisma.messageTemplate.findFirst({ + where: { id: getId(req), tenantId }, + }) + if (!template) { res.status(404).json({ error: 'Template não encontrado' }); return } + res.json(template) + }) + + // ── UPDATE ──────────────────────────────────────────────────────────────── + router.put('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.messageTemplate.findFirst({ + where: { id, tenantId }, + }) + if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return } + + const parse = templateSchema.partial().safeParse(req.body) + if (!parse.success) { + res.status(400).json({ error: parse.error.errors }) + return + } + + const { payload, ...rest } = parse.data + const updated = await prisma.messageTemplate.update({ + where: { id }, + data: { ...rest, ...(payload !== undefined ? { payload: payload as any } : {}) }, + }) + + res.json(updated) + }) + + // ── DELETE ──────────────────────────────────────────────────────────────── + router.delete('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.messageTemplate.findFirst({ + where: { id, tenantId }, + }) + if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return } + + await prisma.messageTemplate.delete({ where: { id } }) + res.status(204).send() + }) + + // ── USE (IA incrementa uso) ──────────────────────────────────────────────── + router.post('/:id/use', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = getId(req) + const exists = await prisma.messageTemplate.findFirst({ + where: { id, tenantId }, + }) + if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return } + + await prisma.messageTemplate.update({ + where: { id }, + data: { usageCount: { increment: 1 } }, + }) + + res.json({ ok: true }) + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js new file mode 100644 index 0000000..08f84cd --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js @@ -0,0 +1,1125 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WhatsAppConnectionManager = void 0; +/** + * WhatsAppConnectionManager — Orquestra conexões Baileys para todas as instâncias. + * + * Esta é a classe central do módulo WhatsApp. Ela gerencia: + * 1. Ciclo de vida das conexões (connect, disconnect, reconnect) + * 2. Registro de TODOS os event listeners do Baileys + * 3. Reconexão automática com backoff exponencial + * 4. Watchdog para detectar conexões mortas silenciosamente + * 5. Sincronização de chats, contatos e histórico + * 6. Mapeamento LID → phone JID (via chats.phoneNumberShare) + * 7. Emissão de eventos Socket.IO para o frontend + * + * ARQUITETURA: + * - Uma instância de WhatsAppConnectionManager por servidor (singleton prático) + * - Cada instância WhatsApp (número conectado) tem seu próprio WASocket + * - Os Maps internos indexam por instanceId para acesso O(1) + * - Multi-tenant: tenantByInstance mapeia instância → tenant para isolamento + * + * EVENTOS BAILEYS REGISTRADOS: + * - creds.update → salva credenciais (auth state) + * - connection.update → gerencia QR, conexão, desconexão, reconexão + * - messages.upsert → delega para MessageHandler (tempo real + histórico) + * - messages.update → atualiza status (sent→delivered→read) + * - contacts.upsert/update → delega para ContactHandler + * - chats.upsert/update → sincroniza lista de chats no banco + * - chats.phoneNumberShare → captura mapeamento LID → phone JID + * - groups.upsert/update → sincroniza grupos (subject, metadata) + * - messaging-history.set → history sync (contatos, chats, mensagens em lote) + * + * @see MessageHandler — processa mensagens individuais + * @see ContactHandler — gerencia contatos e avatares + */ +const path_1 = __importDefault(require("path")); +const promises_1 = __importDefault(require("fs/promises")); +const promises_2 = __importDefault(require("dns/promises")); +const baileys_1 = require("@whiskeysockets/baileys"); +const qrcode_1 = __importDefault(require("qrcode")); +const env_1 = require("../../../config/env"); +const logger_1 = require("../../../config/logger"); +const dragonfly_1 = require("../../../infra/cache/dragonfly"); +const prisma_1 = require("../../../infra/database/prisma"); +const client_1 = require("@prisma/client"); +const MessageHandler_1 = require("../handlers/MessageHandler"); +const ContactHandler_1 = require("../handlers/ContactHandler"); +const chatbot_service_1 = require("../../chatbot/chatbot.service"); +const hook_bus_1 = require("../../../core/hook-bus"); +// ─── Chaves de cache no DragonflyDB (Redis-compatible) ────────────────────── +const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`; +const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`; +// ─── Configuração de reconexão automática ─────────────────────────────────── +// Backoff exponencial: 3s → 6s → 12s → 24s → 48s → ... → max 5min +// Nunca desiste, exceto loggedOut (usuário deslogou) ou forbidden (ban) +const RECONNECT_BASE_DELAY_MS = 3000; // delay inicial +const RECONNECT_MAX_DELAY_MS = 300000; // teto geral: 5 minutos +// Teto MENOR para erros transientes de rede (DNS, ECONN, ETIMEDOUT). +// Esses erros costumam resolver em segundos; um teto de 5min deixa a +// sessão "dormindo" num setTimeout longo enquanto a rede já voltou. +const RECONNECT_NETWORK_MAX_MS = 60000; // teto rede: 60s +const RECONNECT_MAX_ATTEMPTS = Infinity; +// ─── Network probe — acorda reconexões pendentes quando a rede volta ──────── +// Enquanto uma instância está aguardando backoff, um DNS lookup leve é +// disparado em intervalo fixo. Se o lookup funciona E existe um retry +// agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o timer +// e dispara retry imediato. Zero tráfego para o WhatsApp — só DNS. +const NETWORK_PROBE_INTERVAL_MS = 30000; // a cada 30s +const PROBE_ADVANCE_THRESHOLD_MS = 15000; // só adianta se faltar > 15s +const PROBE_DNS_HOST = 'web.whatsapp.com'; +// ─── Watchdog: detecta conexão morta silenciosamente ──────────────────────── +// Conservador por design: reconexões frequentes sinalizam "cliente instável" +// para o WhatsApp e aumentam risco de banimento do número. Por isso: +// - Timeout alto (10 min): conversas calmas são normais, não disparam +// - Intervalo baixo de verificação (3 min): menos overhead +// - Ping leve antes de matar: tenta reviver sem derrubar o socket +// - Cool-down entre kills: evita loop de reconexão se a rede oscila +const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min +const WATCHDOG_TIMEOUT_MS = 600000; // 10 min sem atividade antes de agir +const WATCHDOG_PING_GRACE_MS = 30000; // aguarda 30s após ping antes de matar +const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos +class WhatsAppConnectionManager { + constructor(io) { + // ─── Maps indexados por instanceId (UUID) ────────────────────────────── + /** Socket Baileys ativo por instância */ + this.sockets = new Map(); + /** Handler de contatos por instância */ + this.contactHandlers = new Map(); + /** Handler de mensagens por instância */ + this.msgHandlers = new Map(); + /** Contador de tentativas de reconexão (reseta ao conectar com sucesso) */ + this.reconnectAttempts = new Map(); + /** Timer de reconexão pendente (clearTimeout para cancelar) */ + this.reconnectTimers = new Map(); + /** + * Timestamp absoluto (ms) em que o retry pendente vai disparar. + * Usado pelo network probe para decidir se vale adiantar um retry + * que ainda está dormindo num backoff longo. + */ + this.reconnectScheduledAt = new Map(); + /** Timer global do network probe (singleton) */ + this.networkProbeTimer = null; + /** Timer do watchdog (clearInterval para parar) */ + this.watchdogTimers = new Map(); + /** Timestamp da última atividade (qualquer evento Baileys) */ + this.lastActivity = new Map(); + /** Timestamp do último force-kill do watchdog (cool-down anti-loop) */ + this.lastWatchdogKill = new Map(); + /** Timestamp do último ping do watchdog (aguarda resposta antes de matar) */ + this.lastWatchdogPing = new Map(); + /** Mapeamento instanceId → tenantId (necessário para reconexões) */ + this.tenantByInstance = new Map(); + this.io = io; + this.chatbotService = new chatbot_service_1.ChatbotService(io); + this.startNetworkProbe(); + } + // ═══════════════════════════════════════════════════════════════════════════ + // CONEXÃO PRINCIPAL + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Conecta (ou reconecta) uma instância WhatsApp. + * + * Fluxo: + * 1. Carrega auth state do disco (useMultiFileAuthState) + * 2. Cria socket Baileys com configuração padrão + * 3. Registra TODOS os event listeners + * 4. Baileys inicia handshake com WhatsApp Web + * 5. Se precisar de QR: emite evento 'qr' via Socket.IO + * 6. Se já autenticado: connection 'open' dispara sync + * + * IMPORTANTE: Este método é idempotente — se a instância já está + * conectada, retorna sem fazer nada (evita sockets duplicados). + */ + async connect(instanceId, tenantId) { + if (this.sockets.has(instanceId)) { + // Socket já existe — re-emite o status atual para sincronizar o frontend + // (acontece quando o backend reconectou em background antes do frontend abrir) + const cachedStatus = await dragonfly_1.dragonfly.get(CACHE_KEY_STATUS(instanceId)); + const currentStatus = cachedStatus ?? client_1.InstanceStatus.CONNECTED; + logger_1.logger.info({ instanceId, currentStatus }, 'Instância já conectada — re-emitindo status para o frontend'); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: currentStatus, + }); + return; + } + // Limpa timers pendentes de reconexão anterior + this.clearReconnectTimer(instanceId); + this.tenantByInstance.set(instanceId, tenantId); + // ── Auth state: credenciais persistidas em disco ────────────────────── + // Cada instância tem seu diretório em BAILEYS_SESSIONS_PATH/{instanceId}/ + // Contém: creds.json, app-state-sync-key-*, pre-key-*, etc. + const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId); + try { + await promises_1.default.mkdir(sessionDir, { recursive: true }); + } + catch (err) { + logger_1.logger.error({ err, instanceId, sessionDir }, 'Falha ao criar diretório de sessão'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED }); + return; + } + const { state, saveCreds } = await (0, baileys_1.useMultiFileAuthState)(sessionDir); + // fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar + // se não houver internet. Usa versão embutida como fallback seguro. + let version = [2, 3000, 1023504162]; + try { + const fetched = await (0, baileys_1.fetchLatestBaileysVersion)(); + version = fetched.version; + } + catch (err) { + logger_1.logger.warn({ instanceId }, 'fetchLatestBaileysVersion falhou — usando versão embutida como fallback'); + } + logger_1.logger.info({ instanceId, version }, 'Iniciando socket Baileys'); + // ── Cria socket Baileys ────────────────────────────────────────────── + // DEBUG: logger trace só para capturar XML de mensagens enviadas + const debugLogger = { + level: 'trace', + child: () => debugLogger, + trace: (obj) => { + if (obj?.xml && typeof obj.xml === 'string' && obj.xml.startsWith(' { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { }, + }; + const sock = (0, baileys_1.makeWASocket)({ + version, + auth: { + creds: state.creds, + // Cache de chaves Signal para performance (evita leituras de disco) + keys: (0, baileys_1.makeCacheableSignalKeyStore)(state.keys, logger_1.logger), + }, + printQRInTerminal: false, + qrTimeout: 60000, + logger: debugLogger, + browser: ['NewWhats', 'Chrome', '124.0.0'], + syncFullHistory: true, // Solicita histórico completo ao telefone + markOnlineOnConnect: true, // Marca como "online" ao conectar + // getMessage: callback para reenvio de mensagens (retry) + // O Baileys chama quando precisa reenviar uma mensagem que falhou + getMessage: async (key) => { + if (!key.id) + return undefined; + const msg = await prisma_1.prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId, messageId: key.id } }, + }); + if (!msg?.body) + return undefined; + return { conversation: msg.body }; + }, + }); + this.sockets.set(instanceId, sock); + this.touchActivity(instanceId); + // ═════════════════════════════════════════════════════════════════════ + // REGISTRO DE EVENT LISTENERS DO BAILEYS + // ═════════════════════════════════════════════════════════════════════ + // ─── Credenciais: salvar ao atualizar ──────────────────────────────── + // O Baileys atualiza credenciais periodicamente (rotação de chaves Signal) + sock.ev.on('creds.update', saveCreds); + // ─── Estado de conexão ─────────────────────────────────────────────── + // Eventos: qr (novo QR gerado), open (conectado), close (desconectado), + // connecting (tentando conectar) + sock.ev.on('connection.update', (update) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'connection.update', { + connection: update.connection, + qr: update.qr ? '(qr gerado)' : undefined, + lastDisconnect: update.lastDisconnect + ? { statusCode: update.lastDisconnect.error?.output?.statusCode } + : undefined, + }); + this.handleConnectionUpdate(instanceId, tenantId, update, sock); + }); + // ─── Mensagens recebidas (tempo real + histórico) ──────────────────── + // type 'notify': mensagem em tempo real → processMessage completo + // type 'append': histórico sync → handleHistory (bulk, sem socket) + const msgHandler = new MessageHandler_1.MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService); + this.msgHandlers.set(instanceId, msgHandler); + sock.ws.on('CB:message', (node) => { + const msgId = node?.attrs?.id; + const peerRecipientPn = node?.attrs?.peer_recipient_pn; + if (msgId && peerRecipientPn) { + msgHandler.setPeerRecipientHint(msgId, peerRecipientPn); + } + }); + sock.ev.on('messages.upsert', (m) => { + this.touchActivity(instanceId); + // Log de debug para monitoramento via página /baileys + for (const msg of m.messages ?? []) { + const text = msg.message?.conversation ?? + msg.message?.extendedTextMessage?.text ?? + msg.message?.imageMessage?.caption ?? + msg.message?.videoMessage?.caption ?? + null; + this.baileysLog(tenantId, instanceId, 'message', { + fromMe: msg.key?.fromMe, + remoteJid: msg.key?.remoteJid, + body: text, + type: Object.keys(msg.message ?? {}).find(k => k !== 'messageContextInfo') ?? 'unknown', + status: msg.status, + }); + // Log de monitoramento para mensagens LID + // Ajuda a verificar se key.senderPn está presente para resolução + if (msg.key?.remoteJid?.endsWith('@lid')) { + logger_1.logger.debug({ + instanceId, + remoteJid: msg.key.remoteJid, + senderPn: msg.key.senderPn, + pushName: msg.pushName, + }, '[LID] Mensagem @lid recebida — será resolvida pelo MessageHandler'); + } + } + // A persistência do mapeamento LID→phone vive dentro do + // MessageHandler.processMessage (no upsert do Contact), porque + // updateMany aqui afetava 0 rows em contatos inexistentes — bug + // silencioso que descartava o mapeamento na primeira mensagem LID. + // Delega processamento completo para o MessageHandler + msgHandler.handle(m); + }); + // ─── Status de mensagens (sent → delivered → read) ─────────────────── + // O WhatsApp envia atualizações de status conforme a mensagem é entregue + // e lida. Mapeamos os códigos numéricos para strings legíveis. + sock.ev.on('messages.update', (updates) => { + this.touchActivity(instanceId); + for (const { key, update } of updates) { + if (!key.id || !update.status) + continue; + const statusMap = { + 0: 'PENDING', 1: 'PENDING', 2: 'SENT', 3: 'DELIVERED', 4: 'READ', 5: 'READ', + }; + const status = statusMap[update.status]; + if (!status) + continue; + // Atualiza no banco e emite para o frontend (ticks azuis/cinza) + prisma_1.prisma.message.updateMany({ + where: { instanceId, messageId: key.id }, + data: { status: status }, + }).then((r) => { + if (r.count === 0) + return; + this.io.to(`tenant:${tenantId}`).emit('message:status', { + messageId: key.id, + status, + }); + hook_bus_1.hookBus.emit('ext:message.update', { + instanceId, + tenantId, + messageId: key.id, + status, + timestamp: Date.now(), + }).catch(() => { }); + }).catch(() => { }); + } + }); + // ─── Contatos ──────────────────────────────────────────────────────── + // contacts.upsert: lista completa de contatos (conexão inicial/sync) + // contacts.update: atualizações parciais (nome alterado, etc) + const contactHandler = new ContactHandler_1.ContactHandler(instanceId, tenantId, sock, this.io); + this.contactHandlers.set(instanceId, contactHandler); + sock.ev.on('contacts.upsert', (contacts) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'contacts.upsert', { count: contacts.length }); + logger_1.logger.info({ instanceId, count: contacts.length }, '[contacts.upsert] Recebido'); + contactHandler.upsert(contacts); + }); + sock.ev.on('contacts.update', (contacts) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length }); + contactHandler.update(contacts); + }); + // ─── LID → Phone mapping (chats.phoneNumberShare) ──────────────────── + // Evento emitido quando um contato envia uma mensagem de protocolo + // SHARE_PHONE_NUMBER, revelando o telefone real por trás do LID. + // + // Este é um dos mecanismos para construir o mapeamento LID → phone. + // Outros mecanismos: + // - key.senderPn nas mensagens (MessageHandler.resolveLidToPhoneJid) + // - campo `lid` nos contatos do history sync (ContactHandler.upsert) + // + // @see MessageHandler.resolveLidToPhoneJid() — consome este mapeamento + sock.ev.on('chats.phoneNumberShare', ({ lid, jid: phoneJid }) => { + this.touchActivity(instanceId); + logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido'); + if (lid && phoneJid) { + // Salva o mapeamento LID no contato que já existe com o phone JID + prisma_1.prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: phoneJid }, + data: { lidJid: lid }, + }).catch(() => { }); + // Tenta corrigir contatos que foram criados com JID = LID + // (pode acontecer se o contato chegou antes do phoneNumberShare) + prisma_1.prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: lid }, + data: { jid: phoneJid, phone: phoneJid.split('@')[0], lidJid: lid }, + }).catch(() => { }); + } + }); + // ─── Chats ─────────────────────────────────────────────────────────── + // chats.upsert: novos chats (ou chats que voltaram da lista) + // chats.update: atualizações (unread count, pin, archive, etc) + sock.ev.on('chats.upsert', (chats) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length }); + this.syncChats(instanceId, tenantId, chats).catch(() => { }); + }); + sock.ev.on('chats.update', (chats) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length }); + this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => { }); + }); + // ─── Grupos ────────────────────────────────────────────────────────── + // groups.upsert: grupo criado ou entrou em novo grupo + // groups.update: subject (nome) ou metadata alterados + // Convertemos para o formato de chat e sincronizamos via syncChats() + sock.ev.on('groups.upsert', (groups) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length }); + const chatsFromGroups = groups.map((g) => ({ + id: g.id, + name: g.subject, + conversationTimestamp: g.creation, + unreadCount: 0, + pinned: false, + archived: false, + })); + this.syncChats(instanceId, tenantId, chatsFromGroups).catch(() => { }); + }); + sock.ev.on('groups.update', (updates) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'groups.update', { count: updates.length }); + const chatsFromUpdates = updates + .filter((g) => g.id && g.subject) + .map((g) => ({ id: g.id, name: g.subject })); + this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => { }); + }); + // ─── Histórico inicial (messaging-history.set) ─────────────────────── + // Evento principal de sincronização: o telefone envia chats, contatos + // e mensagens em lotes. Pode disparar múltiplas vezes. + // Quando isLatest=true, o sync terminou → dispara reconciliação de nomes. + sock.ev.on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }) => { + this.touchActivity(instanceId); + logger_1.logger.info({ + instanceId, + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }, '[History] messaging-history.set recebido'); + this.baileysLog(tenantId, instanceId, 'messaging-history.set', { + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }); + // Processa na ordem: contatos → chats → mensagens + // (contatos primeiro para que o vínculo Chat→Contact funcione) + if (histContacts.length > 0) { + await contactHandler.upsert(histContacts).catch(() => { }); + } + if (histChats.length > 0) { + await this.syncChats(instanceId, tenantId, histChats).catch(() => { }); + } + if (histMessages.length > 0) { + await msgHandler.handleHistory(histMessages).catch(() => { }); + } + // Quando isLatest=true, o sync do histórico terminou completamente. + // Inicia reconciliação: busca contatos sem nome e preenche via pushName + // das mensagens recebidas (o history sync não traz pushName nos contatos). + if (isLatest) { + logger_1.logger.info({ instanceId }, '[History] Sync completo (isLatest=true) — iniciando reconciliação de nomes'); + contactHandler.reconcileNamesFromMessages().catch(() => { }); + } + }); + // ── Guard: emite DISCONNECTED se o Baileys lançar erro síncrono ───────── + // makeWASocket pode lançar em casos raros (OpenSSL, auth corrompida, etc.) + // Não há como capturar erros async do Baileys aqui além dos event listeners, + // mas garantimos que o socket foi registrado antes de prosseguir. + if (!this.sockets.has(instanceId)) { + logger_1.logger.error({ instanceId }, 'Socket Baileys não foi registrado após inicialização — marcando como DISCONNECTED'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED }); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÃO DE CONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa eventos de mudança de estado da conexão Baileys. + * + * Estados possíveis: + * - qr: QR Code gerado → emite para o frontend escanear + * - open: conectado com sucesso → inicia sync, watchdog, etc. + * - close: desconectado → decide se reconecta ou para + * - connecting: tentando conectar → atualiza status + * + * DECISÃO DE RECONEXÃO: + * - loggedOut (401) → sessão expirada, limpa arquivos, exige novo QR + * - forbidden (403) → conta banida, para completamente + * - Qualquer outro código → reconecta com backoff exponencial + */ + async handleConnectionUpdate(instanceId, tenantId, update, sock) { + const { connection, lastDisconnect, qr } = update; + // ── QR Code emitido ─────────────────────────────────────────────────── + if (qr) { + logger_1.logger.info({ instanceId }, 'QR Code gerado — aguardando escaneamento'); + const qrBase64 = await qrcode_1.default.toDataURL(qr); + // Salva no cache com TTL de 60s (QR expira rapidamente) + await dragonfly_1.dragonfly.set(CACHE_KEY_QR(instanceId), qrBase64, 60); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.QR_PENDING); + // Emite para a sala da instância (página de conexão) E para o tenant (dashboard) + this.io.to(`instance:${instanceId}`).emit('qr', { instanceId, qrBase64 }); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.QR_PENDING, + qrBase64, + }); + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hook_bus_1.hookBus.emit('ext:session.qr', { instanceId, tenantId, qrBase64, timestamp: Date.now() }).catch(() => { }); + } + // ── Conectado com sucesso ───────────────────────────────────────────── + if (connection === 'open') { + logger_1.logger.info({ instanceId }, 'WhatsApp conectado com sucesso'); + // Reseta contadores de reconexão + this.reconnectAttempts.set(instanceId, 0); + this.clearReconnectTimer(instanceId); + // Inicia watchdog para detectar conexão morta + this.startWatchdog(instanceId); + await dragonfly_1.dragonfly.del(CACHE_KEY_QR(instanceId)); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTED); + // Salva o número de telefone da sessão no banco + // (sock.user.id vem no formato "5511999999999:1@s.whatsapp.net") + const phoneJid = sock.user?.id; + if (phoneJid) { + const phone = phoneJid.split(':')[0].split('@')[0]; + await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { phone } }) + .catch(() => { }); + } + // Pós-conexão: sincroniza avatar da instância e busca avatares ausentes + const handler = this.contactHandlers.get(instanceId); + if (handler) { + // Avatar da própria instância (foto de perfil do número) + handler.syncInstanceAvatar(instanceId) + .then((avatar) => { + if (avatar) { + this.io.to(`tenant:${tenantId}`).emit('instance:avatar', { instanceId, avatar }); + } + }) + .catch(() => { }); + // Busca avatares ausentes após 5s (dá tempo para o sync inicial) + setTimeout(() => handler.syncMissingAvatars().catch(() => { }), 5000); + // Renova avatares próximos de expirar após 30s (não compete com syncMissing) + setTimeout(() => handler.syncExpiredAvatars().catch(() => { }), 30000); + } + // Sync incremental: verifica gap de mensagens durante downtime + this.syncMissedMessages(instanceId, tenantId).catch(() => { }); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTED, + }); + hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'connected', timestamp: Date.now() }).catch(() => { }); + } + // ── Desconectado ────────────────────────────────────────────────────── + if (connection === 'close') { + const rawErr = lastDisconnect?.error; + const error = rawErr?.output; + const statusCode = error?.statusCode; + const isNetworkErr = this.isTransientNetworkError(rawErr); + logger_1.logger.warn({ instanceId, statusCode, isNetworkErr }, 'Conexao encerrada'); + // Limpa referências da instância desconectada + this.sockets.delete(instanceId); + this.contactHandlers.delete(instanceId); + this.msgHandlers.delete(instanceId); + this.stopWatchdog(instanceId); + // Decide se deve reconectar baseado no código de erro + const shouldReconnect = statusCode !== baileys_1.DisconnectReason.loggedOut && + statusCode !== baileys_1.DisconnectReason.forbidden; + if (shouldReconnect) { + const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1; + this.reconnectAttempts.set(instanceId, attempts); + // Backoff exponencial: 3s, 6s, 12s, 24s, 48s… + // Teto menor (60s) para erros de rede transientes: DNS/ECONN se + // resolvem em segundos, não faz sentido dormir 5min. Teto geral + // (5min) fica para erros de servidor/protocolo do WhatsApp, onde + // paciência é melhor do que pressa. + const maxDelay = isNetworkErr ? RECONNECT_NETWORK_MAX_MS : RECONNECT_MAX_DELAY_MS; + const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempts - 1), maxDelay); + const scheduledAt = Date.now() + delay; + const nextAt = new Date(scheduledAt).toISOString(); + logger_1.logger.info({ instanceId, attempts, delayMs: delay, nextAt, isNetworkErr }, '[Reconexao] Agendando reconexao com backoff exponencial'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTING, + reconnectIn: delay, + attempt: attempts, + }); + this.scheduleReconnect(instanceId, tenantId, delay); + } + else { + // loggedOut → limpa sessão e exige novo QR + if (statusCode === baileys_1.DisconnectReason.loggedOut) { + logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao'); + await this.deleteSessionFiles(instanceId); + } + // forbidden → conta banida, apenas atualiza status + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.DISCONNECTED, + reason: baileys_1.DisconnectReason[statusCode] ?? statusCode, + }); + hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { }); + } + } + // ── Conectando ──────────────────────────────────────────────────────── + if (connection === 'connecting') { + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTING, + }); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC INCREMENTAL — Mensagens perdidas durante downtime + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Verifica e notifica sobre gap de mensagens durante downtime. + * + * Consulta a última mensagem no banco e calcula quanto tempo o servidor + * ficou offline. O Baileys com syncFullHistory=true já solicita as + * mensagens pendentes automaticamente — este método apenas: + * 1. Loga o gap para visibilidade (monitoramento) + * 2. Emite 'instance:syncing' para o frontend mostrar indicador de sync + */ + async syncMissedMessages(instanceId, tenantId) { + try { + const lastMsg = await prisma_1.prisma.message.findFirst({ + where: { instanceId }, + orderBy: { timestamp: 'desc' }, + select: { timestamp: true, messageId: true }, + }); + if (!lastMsg) { + logger_1.logger.info({ instanceId }, '[Sync] Nenhuma mensagem no DB — history sync completo via Baileys'); + return; + } + const gap = Date.now() - lastMsg.timestamp.getTime(); + const gapMinutes = Math.round(gap / 60000); + logger_1.logger.info({ instanceId, lastMsgAt: lastMsg.timestamp, gapMinutes }, '[Sync] Reconectado — gap de mensagens detectado'); + this.baileysLog(tenantId, instanceId, 'sync:gap', { + lastMsgAt: lastMsg.timestamp.toISOString(), + gapMinutes, + }); + // Notifica o frontend que está sincronizando + this.io.to(`tenant:${tenantId}`).emit('instance:syncing', { + instanceId, + gapMinutes, + since: lastMsg.timestamp.toISOString(), + }); + } + catch (err) { + logger_1.logger.error({ err, instanceId }, '[Sync] Erro ao verificar gap de mensagens'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // WATCHDOG — Detecta conexão morta silenciosamente + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Registra timestamp da última atividade para o watchdog. + * Chamado por TODOS os event listeners do Baileys. + */ + touchActivity(instanceId) { + this.lastActivity.set(instanceId, Date.now()); + } + /** + * Inicia o watchdog para uma instância. + * + * Fluxo conservador em 3 etapas — escolhido pra minimizar reconexões: + * 1. Se inativo > TIMEOUT (10 min): envia um presence update como + * ping leve e marca timestamp. Se o socket estiver vivo, Baileys + * responde e touchActivity é chamado no próximo evento. + * 2. Se continua inativo GRACE (+30s) após o ping: confirma que a + * conexão está de fato morta. Procede pro kill. + * 3. Antes de matar: verifica cool-down (15 min desde último kill) + * pra não entrar em loop quando a rede está oscilando. Se estiver + * no cool-down, só loga e espera o próximo ciclo. + * + * Esta sequência dá ~10-11 min de folga antes do primeiro kill, e no + * mínimo 15 min entre kills consecutivos — 7-8× mais conservador que + * a versão anterior (2 min sem verificação de estado). + */ + startWatchdog(instanceId) { + this.stopWatchdog(instanceId); + const timer = setInterval(async () => { + const now = Date.now(); + const lastTs = this.lastActivity.get(instanceId) ?? 0; + const elapsed = now - lastTs; + const tenantId = this.tenantByInstance.get(instanceId); + if (elapsed <= WATCHDOG_TIMEOUT_MS) { + // Conexão com atividade recente — reseta estado de ping/cool-down + this.lastWatchdogPing.delete(instanceId); + return; + } + const sock = this.sockets.get(instanceId); + // Caso 1: socket sumiu sem connection.close — reconecta imediatamente + if (!sock) { + if (tenantId) { + logger_1.logger.warn({ instanceId }, '[Watchdog] Socket ausente — reagendando conexao'); + this.stopWatchdog(instanceId); + this.connect(instanceId, tenantId).catch(() => { }); + } + return; + } + const pingedAt = this.lastWatchdogPing.get(instanceId) ?? 0; + const sincePing = now - pingedAt; + // Etapa 1: envia ping leve (presence update) se ainda não pingou + // neste ciclo de inatividade. Não conta como atividade — só estímulo. + if (pingedAt === 0 || sincePing > WATCHDOG_TIMEOUT_MS) { + try { + await sock.sendPresenceUpdate('available'); + this.lastWatchdogPing.set(instanceId, now); + logger_1.logger.debug({ instanceId, elapsedMs: elapsed }, '[Watchdog] Ping leve enviado — aguardando resposta'); + } + catch (err) { + // Ping falhou → socket já está morto, pula pra etapa de kill + logger_1.logger.warn({ instanceId, err }, '[Watchdog] Ping falhou — socket morto'); + this.forceKillSocket(instanceId, sock, tenantId, elapsed); + } + return; + } + // Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE. + // Confirma que o socket está zumbi e força reconexão. + if (sincePing > WATCHDOG_PING_GRACE_MS) { + this.forceKillSocket(instanceId, sock, tenantId, elapsed); + } + }, WATCHDOG_INTERVAL_MS); + this.watchdogTimers.set(instanceId, timer); + } + /** + * Força o fechamento do socket, respeitando cool-down para evitar loop. + * Só mata se não houve outro kill nos últimos WATCHDOG_COOLDOWN_MS. + */ + forceKillSocket(instanceId, sock, tenantId, elapsedMs) { + const now = Date.now(); + const lastKill = this.lastWatchdogKill.get(instanceId) ?? 0; + const sinceKill = now - lastKill; + if (sinceKill < WATCHDOG_COOLDOWN_MS) { + logger_1.logger.warn({ instanceId, elapsedMs, sinceLastKillMs: sinceKill }, '[Watchdog] Em cool-down — adiando kill para proteger numero de ban'); + return; + } + logger_1.logger.warn({ instanceId, elapsedMs }, '[Watchdog] Conexao morta confirmada — forcando reconexao'); + this.lastWatchdogKill.set(instanceId, now); + this.lastWatchdogPing.delete(instanceId); + try { + sock.end(new Error('Watchdog timeout')); + } + catch { /* ignore */ } + // Se o sock.end não disparar connection.close (raro), reconecta manualmente + if (!this.sockets.has(instanceId) && tenantId) { + this.stopWatchdog(instanceId); + this.connect(instanceId, tenantId).catch(() => { }); + } + } + /** Para o watchdog de uma instância */ + stopWatchdog(instanceId) { + const timer = this.watchdogTimers.get(instanceId); + if (timer) { + clearInterval(timer); + this.watchdogTimers.delete(instanceId); + } + // Limpa estado de ping pendente — novo ciclo começa limpo. + // NÃO limpa lastWatchdogKill aqui: o cool-down deve sobreviver a + // stop/start para bloquear reconexões em cascata. + this.lastWatchdogPing.delete(instanceId); + } + // ═══════════════════════════════════════════════════════════════════════════ + // TIMER DE RECONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + /** Cancela timer de reconexão pendente (evita reconexão duplicada) */ + clearReconnectTimer(instanceId) { + const timer = this.reconnectTimers.get(instanceId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(instanceId); + } + this.reconnectScheduledAt.delete(instanceId); + } + /** + * Agenda uma reconexão após `delay` ms. + * + * Centraliza o setTimeout + tracking do scheduledAt (usado pelo network + * probe pra saber se vale adiantar). Cancela qualquer timer anterior. + */ + scheduleReconnect(instanceId, tenantId, delay) { + this.clearReconnectTimer(instanceId); + const scheduledAt = Date.now() + delay; + this.reconnectScheduledAt.set(instanceId, scheduledAt); + const timer = setTimeout(() => { + this.reconnectScheduledAt.delete(instanceId); + this.connect(instanceId, tenantId); + }, delay); + this.reconnectTimers.set(instanceId, timer); + } + /** + * Classifica um erro de conexão como "rede transiente". + * + * Erros de DNS/TCP costumam resolver em segundos. Erros de status do + * WhatsApp (loggedOut, conflict, restartRequired) são categoria diferente + * e devem usar o backoff longo tradicional. + */ + isTransientNetworkError(err) { + if (!err) + return false; + const code = err.code || err.cause?.code || err.errno; + if (code && typeof code === 'string') { + const transient = [ + 'EAI_AGAIN', // DNS retry + 'ENOTFOUND', // DNS não resolveu + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENETDOWN', + 'EPIPE', + ]; + if (transient.includes(code)) + return true; + } + // Fallback: mensagem contém nome do host — é quase certo que é DNS/WS + const msg = (err.message || '').toString(); + if (/getaddrinfo|web\.whatsapp\.com|WebSocket Error/i.test(msg)) + return true; + return false; + } + // ═══════════════════════════════════════════════════════════════════════════ + // NETWORK PROBE — Acorda reconexões quando a rede volta + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Inicia o network probe global. + * + * A cada NETWORK_PROBE_INTERVAL_MS, testa se o DNS resolve + * `web.whatsapp.com`. Se resolver E houver alguma instância com retry + * agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o + * timer dessa instância e dispara o retry imediatamente. + * + * Por quê DNS em vez de HTTP? Zero bytes para o WhatsApp. DNS falha com + * EAI_AGAIN quando a rede está caída, e resolve rápido quando volta — + * é um sinal confiável de disponibilidade de rede sem tocar no WA. + */ + startNetworkProbe() { + if (this.networkProbeTimer) + return; + this.networkProbeTimer = setInterval(async () => { + // Curto-circuito: se não há retries pendentes, nem faz o DNS. + if (this.reconnectScheduledAt.size === 0) + return; + try { + await promises_2.default.lookup(PROBE_DNS_HOST); + } + catch { + return; // Rede ainda caída — não adianta nada, espera o tick seguinte. + } + // DNS OK → adianta qualquer retry que ainda falte > threshold + const now = Date.now(); + for (const [instanceId, scheduledAt] of this.reconnectScheduledAt) { + const remaining = scheduledAt - now; + if (remaining <= PROBE_ADVANCE_THRESHOLD_MS) + continue; + const tenantId = this.tenantByInstance.get(instanceId); + if (!tenantId) + continue; + logger_1.logger.info({ instanceId, remainingMs: remaining }, '[NetworkProbe] Rede detectada — adiantando retry pendente'); + this.clearReconnectTimer(instanceId); + // Dispara sem await — probe roda em interval e não deve bloquear + this.connect(instanceId, tenantId).catch((err) => logger_1.logger.error({ err, instanceId }, '[NetworkProbe] Falha ao adiantar retry')); + } + }, NETWORK_PROBE_INTERVAL_MS); + // Não segura o processo vivo só por causa do probe + this.networkProbeTimer.unref?.(); + } + // ═══════════════════════════════════════════════════════════════════════════ + // DESCONEXÃO MANUAL + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Desconecta uma instância manualmente (logout). + * + * Diferente de uma desconexão por erro — aqui o usuário está + * intencionalmente desconectando. Limpa todos os timers e + * faz logout no WhatsApp (invalidando a sessão). + */ + async disconnect(instanceId) { + const sock = this.sockets.get(instanceId); + if (!sock) + return; + this.clearReconnectTimer(instanceId); + this.stopWatchdog(instanceId); + this.reconnectAttempts.delete(instanceId); + // Logout manual zera o estado de cool-down: reconexão futura é um + // novo ciclo e não deve herdar o bloqueio do watchdog. + this.lastWatchdogKill.delete(instanceId); + this.lastActivity.delete(instanceId); + await sock.logout(); + this.sockets.delete(instanceId); + this.contactHandlers.delete(instanceId); + this.msgHandlers.delete(instanceId); + logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente'); + } + // ═══════════════════════════════════════════════════════════════════════════ + // ACESSORES PÚBLICOS + // ═══════════════════════════════════════════════════════════════════════════ + /** Retorna o socket Baileys ativo para uma instância (usado pelas rotas de envio) */ + getSocket(instanceId) { + return this.sockets.get(instanceId); + } + /** Retorna o Socket.IO server (usado pelas rotas para emitir eventos) */ + getIO() { + return this.io; + } + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS INTERNOS + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Atualiza o status da instância no banco E no cache. + * Dupla escrita para que o frontend possa consultar status tanto + * via API (banco) quanto via polling rápido (cache DragonflyDB). + */ + async updateInstanceStatus(instanceId, status) { + await Promise.all([ + prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { status } }) + .catch((err) => { if (err?.code !== 'P2025') + throw err; }), + dragonfly_1.dragonfly.set(CACHE_KEY_STATUS(instanceId), status), + ]); + } + /** Remove arquivos de sessão do disco (após loggedOut) */ + async deleteSessionFiles(instanceId) { + const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId); + await promises_1.default.rm(sessionDir, { recursive: true, force: true }); + } + // ═══════════════════════════════════════════════════════════════════════════ + // SINCRONIZAÇÃO DE CHATS + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Sincroniza lista de chats no banco. + * + * Chamado por: chats.upsert, chats.update, groups.upsert, groups.update, + * messaging-history.set + * + * Para cada chat: + * - Cria ou atualiza no banco com JID, nome (grupo), timestamps + * - Vincula ao Contact existente (se houver) + * - Sincroniza estado de pin/archive/unread do WhatsApp + * + * Otimização: busca todos os contatos existentes em uma única query + * e usa Map para lookup O(1) por JID. + */ + async syncChats(instanceId, tenantId, chats) { + if (chats.length === 0) + return; + // Busca todos os contatos existentes para os JIDs (1 query) + const jids = chats.map((c) => c.id).filter(Boolean); + const existingContacts = await prisma_1.prisma.contact.findMany({ + where: { tenantId, instanceId, jid: { in: jids } }, + select: { id: true, jid: true }, + }); + const contactIdByJid = new Map(existingContacts.map((c) => [c.jid, c.id])); + // Pré-carrega mapeamento LID → phone JID para chats @lid. + // ContactHandler.upsert já rodou antes (messaging-history.set processa + // contacts → chats → messages nessa ordem), então lidJid já está populado. + const lidJids = jids.filter((j) => j.endsWith('@lid')); + const lidToPhoneJid = new Map(); // @lid → @s.whatsapp.net + const lidToContactId = new Map(); // @lid → contactId + if (lidJids.length > 0) { + const resolvedByLid = await prisma_1.prisma.contact.findMany({ + where: { + tenantId, + instanceId, + lidJid: { in: lidJids }, + NOT: { jid: { endsWith: '@lid' } }, // só aceita se resolveu para phone JID + }, + select: { id: true, jid: true, lidJid: true }, + }); + for (const rc of resolvedByLid) { + if (rc.lidJid) { + lidToPhoneJid.set(rc.lidJid, rc.jid); + lidToContactId.set(rc.lidJid, rc.id); + } + } + } + for (const chat of chats) { + const rawJid = chat.id; + if (!rawJid) + continue; + // Descarta JIDs de sistema: broadcasts, newsletters e conta PSA (0@s.whatsapp.net) + if (rawJid.includes('@broadcast') || rawJid.endsWith('@newsletter') || rawJid.startsWith('0@')) + continue; + // ── Resolução LID → phone JID ───────────────────────────────────── + // Chats @lid são artefatos do protocolo WhatsApp multi-device: + // o JID real do contato é @s.whatsapp.net. + // Se já temos o mapeamento (ContactHandler rodou antes), usa o phone JID. + // Se não temos resolução, descarta — seria filtrado pelo ChatRepository. + let jid = rawJid; + if (rawJid.endsWith('@lid')) { + const phoneJid = lidToPhoneJid.get(rawJid); + if (phoneJid) { + jid = phoneJid; + // Garante que o contactId mapeado seja usado abaixo + const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid); + if (resolvedContactId) + contactIdByJid.set(jid, resolvedContactId); + } + else { + // Sem resolução disponível: chat será criado via handleHistory quando + // a mensagem chegar com senderPn resolvível. + logger_1.logger.debug({ rawJid, instanceId }, '[syncChats] @lid sem resolução — descartando'); + continue; + } + } + try { + // Converte timestamp Unix (segundos) para Date + const lastAt = chat.conversationTimestamp + ? new Date(Number(chat.conversationTimestamp) * 1000) + : undefined; + const isGroup = jid.endsWith('@g.us'); + // Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name + const groupName = isGroup ? (chat.name ?? null) : null; + const contactId = contactIdByJid.get(jid) ?? null; + await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + create: { + tenantId, + instanceId, + jid, + name: groupName, + unreadCount: chat.unreadCount ?? 0, + lastMessageAt: lastAt ?? null, + isPinned: !!chat.pinned, + isArchived: chat.archived ?? false, + ...(contactId && { contactId }), + }, + update: { + ...(groupName && { name: groupName }), + ...(contactId && { contactId }), + // Só aplica campos que vieram explicitamente no evento. + // chats.upsert pode ser parcial (nova mensagem, metadata) — se + // pinned/archived/unreadCount estiverem ausentes, não sobrescreve + // o valor salvo no banco. + ...(chat.unreadCount !== undefined && { unreadCount: chat.unreadCount }), + ...(lastAt && { lastMessageAt: lastAt }), + ...(chat.pinned !== undefined && { isPinned: !!chat.pinned }), + ...(chat.archived !== undefined && { isArchived: !!chat.archived }), + }, + }); + } + catch (err) { + logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa atualizações parciais de chats (pin, archive, unread). + * + * Diferente de syncChats (usado para bulk/history), este método: + * 1. Só atualiza campos presentes na atualização (sem sobrescrever com defaults) + * 2. Emite chat:upsert via Socket.IO para atualizar o frontend em tempo real + * + * O evento chats.update do Baileys traz objetos parciais — apenas os campos + * que mudaram. Por isso não podemos usar ?? 0 para unreadCount, pois isso + * sobrescreveria o valor correto com zero. + */ + async handleChatsUpdate(instanceId, tenantId, updates) { + for (const update of updates) { + const jid = update.id; + if (!jid || jid.endsWith('@lid')) + continue; + try { + // Monta patch com apenas os campos presentes na atualização + const patch = {}; + if (update.pinned !== undefined) + patch.isPinned = !!update.pinned; + if (update.archived !== undefined) + patch.isArchived = !!update.archived; + if (update.unreadCount !== undefined) + patch.unreadCount = update.unreadCount; + if (Object.keys(patch).length === 0) + continue; + const chat = await prisma_1.prisma.chat.update({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + data: patch, + include: { + contact: { + select: { + name: true, verifiedName: true, notify: true, + phone: true, avatarUrl: true, + scoreReputacao: true, flagRestricao: true, + }, + }, + }, + }).catch(() => null); + if (!chat) + continue; + // Emite para o frontend atualizar em tempo real + const c = chat.contact; + const isGroup = jid.endsWith('@g.us'); + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null); + this.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: 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: null, + }); + } + catch (err) { + logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // DEBUG LOG (página /baileys no frontend) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Emite evento de debug para a página /baileys do frontend. + * + * Permite monitorar em tempo real todos os eventos Baileys + * sem precisar acessar os logs do servidor. + */ + baileysLog(tenantId, instanceId, event, data) { + this.io.to(`tenant:${tenantId}`).emit('baileys:log', { + timestamp: new Date().toISOString(), + sessionId: instanceId, + event, + data, + }); + } +} +exports.WhatsAppConnectionManager = WhatsAppConnectionManager; +//# sourceMappingURL=WhatsAppConnectionManager.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js.map b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js.map new file mode 100644 index 0000000..ff37fd2 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js.map @@ -0,0 +1 @@ +{"version":3,"file":"WhatsAppConnectionManager.js","sourceRoot":"","sources":["WhatsAppConnectionManager.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts new file mode 100644 index 0000000..b9159dd --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts @@ -0,0 +1,1298 @@ +/** + * WhatsAppConnectionManager — Orquestra conexões Baileys para todas as instâncias. + * + * Esta é a classe central do módulo WhatsApp. Ela gerencia: + * 1. Ciclo de vida das conexões (connect, disconnect, reconnect) + * 2. Registro de TODOS os event listeners do Baileys + * 3. Reconexão automática com backoff exponencial + * 4. Watchdog para detectar conexões mortas silenciosamente + * 5. Sincronização de chats, contatos e histórico + * 6. Mapeamento LID → phone JID (via chats.phoneNumberShare) + * 7. Emissão de eventos Socket.IO para o frontend + * + * ARQUITETURA: + * - Uma instância de WhatsAppConnectionManager por servidor (singleton prático) + * - Cada instância WhatsApp (número conectado) tem seu próprio WASocket + * - Os Maps internos indexam por instanceId para acesso O(1) + * - Multi-tenant: tenantByInstance mapeia instância → tenant para isolamento + * + * EVENTOS BAILEYS REGISTRADOS: + * - creds.update → salva credenciais (auth state) + * - connection.update → gerencia QR, conexão, desconexão, reconexão + * - messages.upsert → delega para MessageHandler (tempo real + histórico) + * - messages.update → atualiza status (sent→delivered→read) + * - contacts.upsert/update → delega para ContactHandler + * - chats.upsert/update → sincroniza lista de chats no banco + * - chats.phoneNumberShare → captura mapeamento LID → phone JID + * - groups.upsert/update → sincroniza grupos (subject, metadata) + * - messaging-history.set → history sync (contatos, chats, mensagens em lote) + * + * @see MessageHandler — processa mensagens individuais + * @see ContactHandler — gerencia contatos e avatares + */ +import path from 'path' +import fs from 'fs/promises' +import dns from 'dns/promises' +import { + getEngine, + DisconnectReason, + type WASocket, + type ConnectionState, +} from '../engine' +import { Boom } from '@hapi/boom' +import QRCode from 'qrcode' +import { env } from '../../../config/env' +import { logger } from '../../../config/logger' +import { dragonfly } from '../../../infra/cache/dragonfly' +import { prisma } from '../../../infra/database/prisma' +import { InstanceStatus } from '@prisma/client' +import { MessageHandler } from '../handlers/MessageHandler' +import { ContactHandler } from '../handlers/ContactHandler' +import { ChatbotService } from '../../chatbot/chatbot.service' +import type { Server as SocketIOServer } from 'socket.io' +import { hookBus } from '../../../core/hook-bus' + +// ─── Chaves de cache no DragonflyDB (Redis-compatible) ────────────────────── +const CACHE_KEY_STATUS = (instanceId: string) => `instance:${instanceId}:status` +const CACHE_KEY_QR = (instanceId: string) => `instance:${instanceId}:qr` + +// ─── Configuração de reconexão automática ─────────────────────────────────── +// Backoff exponencial: 3s → 6s → 12s → 24s → 48s → ... → max 5min +// Nunca desiste, exceto loggedOut (usuário deslogou) ou forbidden (ban) +const RECONNECT_BASE_DELAY_MS = 3_000 // delay inicial +const RECONNECT_MAX_DELAY_MS = 300_000 // teto geral: 5 minutos +// Teto MENOR para erros transientes de rede (DNS, ECONN, ETIMEDOUT). +// Esses erros costumam resolver em segundos; um teto de 5min deixa a +// sessão "dormindo" num setTimeout longo enquanto a rede já voltou. +const RECONNECT_NETWORK_MAX_MS = 60_000 // teto rede: 60s +const RECONNECT_MAX_ATTEMPTS = Infinity + +// ─── Network probe — acorda reconexões pendentes quando a rede volta ──────── +// Enquanto uma instância está aguardando backoff, um DNS lookup leve é +// disparado em intervalo fixo. Se o lookup funciona E existe um retry +// agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o timer +// e dispara retry imediato. Zero tráfego para o WhatsApp — só DNS. +const NETWORK_PROBE_INTERVAL_MS = 30_000 // a cada 30s +const PROBE_ADVANCE_THRESHOLD_MS = 15_000 // só adianta se faltar > 15s +const PROBE_DNS_HOST = 'web.whatsapp.com' + +// ─── Watchdog: detecta conexão morta silenciosamente ──────────────────────── +// Conservador por design: reconexões frequentes sinalizam "cliente instável" +// para o WhatsApp e aumentam risco de banimento do número. Por isso: +// - Timeout alto (10 min): conversas calmas são normais, não disparam +// - Intervalo baixo de verificação (3 min): menos overhead +// - Ping leve antes de matar: tenta reviver sem derrubar o socket +// - Cool-down entre kills: evita loop de reconexão se a rede oscila +const WATCHDOG_INTERVAL_MS = 180_000 // verifica a cada 3 min +const WATCHDOG_TIMEOUT_MS = 600_000 // 10 min sem atividade antes de agir +const WATCHDOG_PING_GRACE_MS = 30_000 // grace padrão (official): 30s +const WATCHDOG_PING_GRACE_INFIN_MS = 60_000 // grace InfiniteAPI: 60s (ACK mais lento) +const WATCHDOG_COOLDOWN_MS = 900_000 // 15 min entre force-kills consecutivos + +export class WhatsAppConnectionManager { + // ─── Maps indexados por instanceId (UUID) ────────────────────────────── + /** Socket Baileys ativo por instância */ + private sockets = new Map() + /** Handler de contatos por instância */ + private contactHandlers = new Map() + /** Handler de mensagens por instância */ + private msgHandlers = new Map() + /** Contador de tentativas de reconexão (reseta ao conectar com sucesso) */ + private reconnectAttempts = new Map() + /** Timer de reconexão pendente (clearTimeout para cancelar) */ + private reconnectTimers = new Map>() + /** + * Timestamp absoluto (ms) em que o retry pendente vai disparar. + * Usado pelo network probe para decidir se vale adiantar um retry + * que ainda está dormindo num backoff longo. + */ + private reconnectScheduledAt = new Map() + /** Timer global do network probe (singleton) */ + private networkProbeTimer: ReturnType | null = null + /** Timer do watchdog (clearInterval para parar) */ + private watchdogTimers = new Map>() + /** Timestamp da última atividade (qualquer evento Baileys) */ + private lastActivity = new Map() + /** Timestamp do último force-kill do watchdog (cool-down anti-loop) */ + private lastWatchdogKill = new Map() + /** Timestamp do último ping do watchdog (aguarda resposta antes de matar) */ + private lastWatchdogPing = new Map() + /** Mapeamento instanceId → tenantId (necessário para reconexões) */ + private tenantByInstance = new Map() + /** Engine ativa por instância (usada pelo watchdog para ajustar tolerâncias) */ + private engineByInstance = new Map() + + /** Socket.IO server compartilhado — emite eventos para o frontend */ + private io: SocketIOServer + /** Serviço de chatbot — processa mensagens recebidas com IA */ + private chatbotService: ChatbotService + + constructor(io: SocketIOServer) { + this.io = io + this.chatbotService = new ChatbotService(io) + this.startNetworkProbe() + } + + // ═══════════════════════════════════════════════════════════════════════════ + // CONEXÃO PRINCIPAL + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Conecta (ou reconecta) uma instância WhatsApp. + * + * Fluxo: + * 1. Carrega auth state do disco (useMultiFileAuthState) + * 2. Cria socket Baileys com configuração padrão + * 3. Registra TODOS os event listeners + * 4. Baileys inicia handshake com WhatsApp Web + * 5. Se precisar de QR: emite evento 'qr' via Socket.IO + * 6. Se já autenticado: connection 'open' dispara sync + * + * IMPORTANTE: Este método é idempotente — se a instância já está + * conectada, retorna sem fazer nada (evita sockets duplicados). + */ + async connect(instanceId: string, tenantId: string): Promise { + if (this.sockets.has(instanceId)) { + // Socket já existe — re-emite o status atual para sincronizar o frontend + // (acontece quando o backend reconectou em background antes do frontend abrir) + const cachedStatus = await dragonfly.get(CACHE_KEY_STATUS(instanceId)) as string | null + const currentStatus = cachedStatus ?? InstanceStatus.CONNECTED + logger.info({ instanceId, currentStatus }, 'Instância já conectada — re-emitindo status para o frontend') + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: currentStatus, + }) + return + } + + // Limpa timers pendentes de reconexão anterior + this.clearReconnectTimer(instanceId) + this.tenantByInstance.set(instanceId, tenantId) + + // ── Auth state: credenciais persistidas em disco ────────────────────── + // Cada instância tem seu diretório em BAILEYS_SESSIONS_PATH/{instanceId}/ + // Contém: creds.json, app-state-sync-key-*, pre-key-*, etc. + const sessionDir = path.resolve(env.BAILEYS_SESSIONS_PATH, instanceId) + + try { + await fs.mkdir(sessionDir, { recursive: true }) + } catch (err) { + logger.error({ err, instanceId, sessionDir }, 'Falha ao criar diretório de sessão') + await this.updateInstanceStatus(instanceId, InstanceStatus.DISCONNECTED) + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: InstanceStatus.DISCONNECTED }) + return + } + + // ── Seleciona engine por instância ─────────────────────────────────── + // Lê o campo engine da instância no banco (padrão: "infinite"). + // Permite que cada instância use uma engine diferente sem reiniciar o container. + const instanceRecord = await prisma.instance.findFirst({ + where: { id: instanceId }, + select: { engine: true }, + }) + const engineType = (instanceRecord as any)?.engine ?? 'infinite' + const { + makeWASocket, + useMultiFileAuthState, + fetchLatestBaileysVersion, + makeCacheableSignalKeyStore, + } = getEngine(engineType) + this.engineByInstance.set(instanceId, engineType) + logger.info({ instanceId, engine: engineType }, 'Engine selecionada para instância') + + const { state, saveCreds } = await useMultiFileAuthState(sessionDir) + + // fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar + // se não houver internet. Usa versão embutida como fallback seguro. + let version: [number, number, number] = [2, 3000, 1023504162] + try { + const fetched = await fetchLatestBaileysVersion() + version = fetched.version + } catch (err) { + logger.warn({ instanceId }, 'fetchLatestBaileysVersion falhou — usando versão embutida como fallback') + } + + logger.info({ instanceId, version }, 'Iniciando socket Baileys') + + // ── Cria socket Baileys ────────────────────────────────────────────── + // DEBUG: logger trace só para capturar XML de mensagens enviadas + const debugLogger: any = { + level: 'trace', + child: () => debugLogger, + trace: (obj: any) => { + if (obj?.xml && typeof obj.xml === 'string' && obj.xml.startsWith(' {}, info: () => {}, warn: () => {}, error: () => {}, fatal: () => {}, + } + + const sock = makeWASocket({ + version, + auth: { + creds: state.creds, + // Cache de chaves Signal para performance (evita leituras de disco) + keys: makeCacheableSignalKeyStore(state.keys, logger as any), + }, + printQRInTerminal: false, + qrTimeout: 60_000, + logger: debugLogger, + browser: ['NewWhats', 'Chrome', '124.0.0'], + syncFullHistory: true, // Solicita histórico completo ao telefone + markOnlineOnConnect: true, // Marca como "online" ao conectar + // getMessage: callback para reenvio de mensagens (retry) + // O Baileys chama quando precisa reenviar uma mensagem que falhou + getMessage: async (key) => { + if (!key.id) return undefined + const msg = await prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId, messageId: key.id } }, + }) + if (!msg?.body) return undefined + return { conversation: msg.body } + }, + }) + + this.sockets.set(instanceId, sock) + this.touchActivity(instanceId) + + // ═════════════════════════════════════════════════════════════════════ + // REGISTRO DE EVENT LISTENERS DO BAILEYS + // ═════════════════════════════════════════════════════════════════════ + + // ─── Credenciais: salvar ao atualizar ──────────────────────────────── + // O Baileys atualiza credenciais periodicamente (rotação de chaves Signal) + ;(sock.ev as any).on('creds.update', saveCreds) + + // ─── Estado de conexão ─────────────────────────────────────────────── + // Eventos: qr (novo QR gerado), open (conectado), close (desconectado), + // connecting (tentando conectar) + ;(sock.ev as any).on('connection.update', (update: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'connection.update', { + connection: update.connection, + qr: update.qr ? '(qr gerado)' : undefined, + lastDisconnect: update.lastDisconnect + ? { statusCode: (update.lastDisconnect.error as any)?.output?.statusCode } + : undefined, + }) + this.handleConnectionUpdate(instanceId, tenantId, update, sock) + }) + + // ─── Mensagens recebidas (tempo real + histórico) ──────────────────── + // type 'notify': mensagem em tempo real → processMessage completo + // type 'append': histórico sync → handleHistory (bulk, sem socket) + const msgHandler = new MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService) + this.msgHandlers.set(instanceId, msgHandler) + + // ─── Intercepta stanzas brutas pra extrair peer_recipient_pn ───────── + // O Baileys (decode-wa-message.js) expõe só sender_pn/participant_pn no + // key — mas NÃO expõe peer_recipient_pn, que é a única fonte de verdade + // pro telefone real do DESTINATÁRIO em mensagens fromMe=true enviadas + // pelo celular pra contatos @lid novos (sem mapeamento salvo). + // + // Sem essa dica, `resolveLidToPhoneJid` retornaria null e a mensagem + // seria descartada — o usuário relatou exatamente esse sintoma ("envio + // pelo celular da sessão pro número X e a msg não aparece na inbox"). + // + // O listener roda em paralelo com o handler interno do Baileys: ambos + // são callbacks síncronos do EventEmitter de ws, então o mapa é + // populado antes do `messages.upsert` ser emitido de forma assíncrona. + ;(sock.ws as any).on('CB:message', (node: any) => { + const msgId = node?.attrs?.id + const peerRecipientPn = node?.attrs?.peer_recipient_pn + if (msgId && peerRecipientPn) { + msgHandler.setPeerRecipientHint(msgId, peerRecipientPn) + } + }) + + ;(sock.ev as any).on('messages.upsert', (m: any) => { + this.touchActivity(instanceId) + + // Log de debug para monitoramento via página /baileys + for (const msg of m.messages ?? []) { + const text = + msg.message?.conversation ?? + msg.message?.extendedTextMessage?.text ?? + msg.message?.imageMessage?.caption ?? + msg.message?.videoMessage?.caption ?? + null + this.baileysLog(tenantId, instanceId, 'message', { + fromMe: msg.key?.fromMe, + remoteJid: msg.key?.remoteJid, + body: text, + type: Object.keys(msg.message ?? {}).find(k => k !== 'messageContextInfo') ?? 'unknown', + status: msg.status, + }) + + // Log de monitoramento para mensagens LID + // Ajuda a verificar se key.senderPn está presente para resolução + if (msg.key?.remoteJid?.endsWith('@lid')) { + logger.debug({ + instanceId, + remoteJid: msg.key.remoteJid, + senderPn: (msg.key as any).senderPn, + pushName: msg.pushName, + }, '[LID] Mensagem @lid recebida — será resolvida pelo MessageHandler') + } + } + + // A persistência do mapeamento LID→phone vive dentro do + // MessageHandler.processMessage (no upsert do Contact), porque + // updateMany aqui afetava 0 rows em contatos inexistentes — bug + // silencioso que descartava o mapeamento na primeira mensagem LID. + + // Delega processamento completo para o MessageHandler + msgHandler.handle(m) + }) + + // ─── Status de mensagens (sent → delivered → read) ─────────────────── + // O WhatsApp envia atualizações de status conforme a mensagem é entregue + // e lida. Mapeamos os códigos numéricos para strings legíveis. + ;(sock.ev as any).on('messages.update', (updates: any) => { + this.touchActivity(instanceId) + for (const { key, update } of updates) { + if (!key.id || !update.status) continue + const statusMap: Record = { + 0: 'PENDING', 1: 'PENDING', 2: 'SENT', 3: 'DELIVERED', 4: 'READ', 5: 'READ', + } + const status = statusMap[update.status] + if (!status) continue + + // Atualiza no banco e emite para o frontend (ticks azuis/cinza) + prisma.message.updateMany({ + where: { instanceId, messageId: key.id }, + data: { status: status as any }, + }).then((r) => { + if (r.count === 0) return + this.io.to(`tenant:${tenantId}`).emit('message:status', { + messageId: key.id, + status, + }) + hookBus.emit('ext:message.update', { + instanceId, + tenantId, + messageId: key.id, + status, + timestamp: Date.now(), + }).catch(() => {}) + }).catch(() => {}) + } + }) + + // ─── Contatos ──────────────────────────────────────────────────────── + // contacts.upsert: lista completa de contatos (conexão inicial/sync) + // contacts.update: atualizações parciais (nome alterado, etc) + const contactHandler = new ContactHandler(instanceId, tenantId, sock, this.io) + this.contactHandlers.set(instanceId, contactHandler) + + ;(sock.ev as any).on('contacts.upsert', (contacts: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'contacts.upsert', { count: contacts.length }) + logger.info({ instanceId, count: contacts.length }, '[contacts.upsert] Recebido') + contactHandler.upsert(contacts) + }) + + ;(sock.ev as any).on('contacts.update', (contacts: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length }) + contactHandler.update(contacts) + }) + + // ─── LID → Phone mapping (chats.phoneNumberShare) ──────────────────── + // Evento emitido quando um contato envia uma mensagem de protocolo + // SHARE_PHONE_NUMBER, revelando o telefone real por trás do LID. + // + // Este é um dos mecanismos para construir o mapeamento LID → phone. + // Outros mecanismos: + // - key.senderPn nas mensagens (MessageHandler.resolveLidToPhoneJid) + // - campo `lid` nos contatos do history sync (ContactHandler.upsert) + // + // @see MessageHandler.resolveLidToPhoneJid() — consome este mapeamento + ;(sock.ev as any).on('chats.phoneNumberShare', ({ lid, jid: phoneJid }: any) => { + this.touchActivity(instanceId) + logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido') + if (lid && phoneJid) { + // Salva o mapeamento LID no contato que já existe com o phone JID + prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: phoneJid }, + data: { lidJid: lid }, + }).catch(() => {}) + + // Tenta corrigir contatos que foram criados com JID = LID + // (pode acontecer se o contato chegou antes do phoneNumberShare) + prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: lid }, + data: { jid: phoneJid, phone: phoneJid.split('@')[0], lidJid: lid }, + }).catch(() => {}) + } + }) + + // ─── Chats ─────────────────────────────────────────────────────────── + // chats.upsert: novos chats (ou chats que voltaram da lista) + // chats.update: atualizações (unread count, pin, archive, etc) + ;(sock.ev as any).on('chats.upsert', (chats: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length }) + this.syncChats(instanceId, tenantId, chats).catch(() => {}) + }) + + ;(sock.ev as any).on('chats.update', (chats: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length }) + this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => {}) + }) + + // ─── Grupos ────────────────────────────────────────────────────────── + // groups.upsert: grupo criado ou entrou em novo grupo + // groups.update: subject (nome) ou metadata alterados + // Convertemos para o formato de chat e sincronizamos via syncChats() + ;(sock.ev as any).on('groups.upsert', (groups: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length }) + const chatsFromGroups = groups.map((g: any) => ({ + id: g.id, + name: g.subject, + conversationTimestamp: g.creation, + unreadCount: 0, + pinned: false, + archived: false, + })) + this.syncChats(instanceId, tenantId, chatsFromGroups).catch(() => {}) + }) + + ;(sock.ev as any).on('groups.update', (updates: any) => { + this.touchActivity(instanceId) + this.baileysLog(tenantId, instanceId, 'groups.update', { count: updates.length }) + const chatsFromUpdates = updates + .filter((g: any) => g.id && g.subject) + .map((g: any) => ({ id: g.id, name: g.subject })) + this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => {}) + }) + + // ─── Histórico inicial (messaging-history.set) ─────────────────────── + // Evento principal de sincronização: o telefone envia chats, contatos + // e mensagens em lotes. Pode disparar múltiplas vezes. + // Quando isLatest=true, o sync terminou → dispara reconciliação de nomes. + ;(sock.ev as any).on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }: any) => { + this.touchActivity(instanceId) + logger.info({ + instanceId, + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }, '[History] messaging-history.set recebido') + + this.baileysLog(tenantId, instanceId, 'messaging-history.set', { + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }) + + // Processa na ordem: contatos → chats → mensagens + // (contatos primeiro para que o vínculo Chat→Contact funcione) + if (histContacts.length > 0) { + await contactHandler.upsert(histContacts as any[]).catch(() => {}) + } + if (histChats.length > 0) { + await this.syncChats(instanceId, tenantId, histChats).catch(() => {}) + } + if (histMessages.length > 0) { + await msgHandler.handleHistory(histMessages).catch(() => {}) + } + + // Quando isLatest=true, o sync do histórico terminou completamente. + // Inicia reconciliação: busca contatos sem nome e preenche via pushName + // das mensagens recebidas (o history sync não traz pushName nos contatos). + if (isLatest) { + logger.info({ instanceId }, '[History] Sync completo (isLatest=true) — iniciando reconciliação de nomes') + contactHandler.reconcileNamesFromMessages().catch(() => {}) + } + }) + + // ── Guard: emite DISCONNECTED se o Baileys lançar erro síncrono ───────── + // makeWASocket pode lançar em casos raros (OpenSSL, auth corrompida, etc.) + // Não há como capturar erros async do Baileys aqui além dos event listeners, + // mas garantimos que o socket foi registrado antes de prosseguir. + if (!this.sockets.has(instanceId)) { + logger.error({ instanceId }, 'Socket Baileys não foi registrado após inicialização — marcando como DISCONNECTED') + await this.updateInstanceStatus(instanceId, InstanceStatus.DISCONNECTED) + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: InstanceStatus.DISCONNECTED }) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÃO DE CONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa eventos de mudança de estado da conexão Baileys. + * + * Estados possíveis: + * - qr: QR Code gerado → emite para o frontend escanear + * - open: conectado com sucesso → inicia sync, watchdog, etc. + * - close: desconectado → decide se reconecta ou para + * - connecting: tentando conectar → atualiza status + * + * DECISÃO DE RECONEXÃO: + * - loggedOut (401) → sessão expirada, limpa arquivos, exige novo QR + * - forbidden (403) → conta banida, para completamente + * - Qualquer outro código → reconecta com backoff exponencial + */ + private async handleConnectionUpdate( + instanceId: string, + tenantId: string, + update: Partial, + sock: WASocket + ): Promise { + const { connection, lastDisconnect, qr } = update + + // ── QR Code emitido ─────────────────────────────────────────────────── + if (qr) { + logger.info({ instanceId }, 'QR Code gerado — aguardando escaneamento') + const qrBase64 = await QRCode.toDataURL(qr) + // Salva no cache com TTL de 60s (QR expira rapidamente) + await dragonfly.set(CACHE_KEY_QR(instanceId), qrBase64, 60) + await this.updateInstanceStatus(instanceId, InstanceStatus.QR_PENDING) + // Emite para a sala da instância (página de conexão) E para o tenant (dashboard) + this.io.to(`instance:${instanceId}`).emit('qr', { instanceId, qrBase64 }) + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: InstanceStatus.QR_PENDING, + qrBase64, + }) + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hookBus.emit('ext:session.qr', { instanceId, tenantId, qrBase64, timestamp: Date.now() }).catch(() => {}) + } + + // ── Conectado com sucesso ───────────────────────────────────────────── + if (connection === 'open') { + logger.info({ instanceId }, 'WhatsApp conectado com sucesso') + // Reseta contadores de reconexão + this.reconnectAttempts.set(instanceId, 0) + this.clearReconnectTimer(instanceId) + // Inicia watchdog para detectar conexão morta + this.startWatchdog(instanceId) + + await dragonfly.del(CACHE_KEY_QR(instanceId)) + await this.updateInstanceStatus(instanceId, InstanceStatus.CONNECTED) + + // Salva o número de telefone da sessão no banco + // (sock.user.id vem no formato "5511999999999:1@s.whatsapp.net") + const phoneJid = sock.user?.id + if (phoneJid) { + const phone = phoneJid.split(':')[0].split('@')[0] + await prisma.instance.update({ where: { id: instanceId }, data: { phone } }) + .catch(() => {}) + } + + // Pós-conexão: sincroniza avatar da instância e busca avatares ausentes + const handler = this.contactHandlers.get(instanceId) + if (handler) { + // Avatar da própria instância (foto de perfil do número) + handler.syncInstanceAvatar(instanceId) + .then((avatar) => { + if (avatar) { + this.io.to(`tenant:${tenantId}`).emit('instance:avatar', { instanceId, avatar }) + } + }) + .catch(() => {}) + + // Busca avatares ausentes após 5s (dá tempo para o sync inicial) + setTimeout(() => handler.syncMissingAvatars().catch(() => {}), 5000) + // Renova avatares próximos de expirar após 30s (não compete com syncMissing) + setTimeout(() => handler.syncExpiredAvatars().catch(() => {}), 30_000) + } + + // Sync incremental: verifica gap de mensagens durante downtime + this.syncMissedMessages(instanceId, tenantId).catch(() => {}) + + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: InstanceStatus.CONNECTED, + }) + hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'connected', timestamp: Date.now() }).catch(() => {}) + } + + // ── Desconectado ────────────────────────────────────────────────────── + if (connection === 'close') { + const rawErr = lastDisconnect?.error as any + const error = (rawErr as Boom)?.output + const statusCode = error?.statusCode + const isNetworkErr = this.isTransientNetworkError(rawErr) + + logger.warn({ instanceId, statusCode, isNetworkErr }, 'Conexao encerrada') + + // Limpa referências da instância desconectada + this.sockets.delete(instanceId) + this.contactHandlers.delete(instanceId) + this.msgHandlers.delete(instanceId) + this.engineByInstance.delete(instanceId) + this.stopWatchdog(instanceId) + + // Decide se deve reconectar baseado no código de erro + const shouldReconnect = + statusCode !== DisconnectReason.loggedOut && + statusCode !== DisconnectReason.forbidden + + if (shouldReconnect) { + const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1 + this.reconnectAttempts.set(instanceId, attempts) + + // Backoff exponencial: 3s, 6s, 12s, 24s, 48s… + // Teto menor (60s) para erros de rede transientes: DNS/ECONN se + // resolvem em segundos, não faz sentido dormir 5min. Teto geral + // (5min) fica para erros de servidor/protocolo do WhatsApp, onde + // paciência é melhor do que pressa. + const maxDelay = isNetworkErr ? RECONNECT_NETWORK_MAX_MS : RECONNECT_MAX_DELAY_MS + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * Math.pow(2, attempts - 1), + maxDelay, + ) + + const scheduledAt = Date.now() + delay + const nextAt = new Date(scheduledAt).toISOString() + logger.info({ instanceId, attempts, delayMs: delay, nextAt, isNetworkErr }, + '[Reconexao] Agendando reconexao com backoff exponencial') + + await this.updateInstanceStatus(instanceId, InstanceStatus.CONNECTING) + + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: InstanceStatus.CONNECTING, + reconnectIn: delay, + attempt: attempts, + }) + + this.scheduleReconnect(instanceId, tenantId, delay) + } else { + // loggedOut → limpa sessão e exige novo QR + if (statusCode === DisconnectReason.loggedOut) { + logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao') + await this.deleteSessionFiles(instanceId) + } + // forbidden → conta banida, apenas atualiza status + await this.updateInstanceStatus(instanceId, InstanceStatus.DISCONNECTED) + + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: InstanceStatus.DISCONNECTED, + reason: (DisconnectReason as unknown as Record)[statusCode] ?? statusCode, + }) + hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => {}) + } + } + + // ── Conectando ──────────────────────────────────────────────────────── + if (connection === 'connecting') { + await this.updateInstanceStatus(instanceId, InstanceStatus.CONNECTING) + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: InstanceStatus.CONNECTING, + }) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC INCREMENTAL — Mensagens perdidas durante downtime + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Verifica e notifica sobre gap de mensagens durante downtime. + * + * Consulta a última mensagem no banco e calcula quanto tempo o servidor + * ficou offline. O Baileys com syncFullHistory=true já solicita as + * mensagens pendentes automaticamente — este método apenas: + * 1. Loga o gap para visibilidade (monitoramento) + * 2. Emite 'instance:syncing' para o frontend mostrar indicador de sync + */ + private async syncMissedMessages(instanceId: string, tenantId: string): Promise { + try { + const lastMsg = await prisma.message.findFirst({ + where: { instanceId }, + orderBy: { timestamp: 'desc' }, + select: { timestamp: true, messageId: true }, + }) + + if (!lastMsg) { + logger.info({ instanceId }, '[Sync] Nenhuma mensagem no DB — history sync completo via Baileys') + return + } + + const gap = Date.now() - lastMsg.timestamp.getTime() + const gapMinutes = Math.round(gap / 60_000) + + logger.info({ instanceId, lastMsgAt: lastMsg.timestamp, gapMinutes }, + '[Sync] Reconectado — gap de mensagens detectado') + + this.baileysLog(tenantId, instanceId, 'sync:gap', { + lastMsgAt: lastMsg.timestamp.toISOString(), + gapMinutes, + }) + + // Notifica o frontend que está sincronizando + this.io.to(`tenant:${tenantId}`).emit('instance:syncing', { + instanceId, + gapMinutes, + since: lastMsg.timestamp.toISOString(), + }) + } catch (err) { + logger.error({ err, instanceId }, '[Sync] Erro ao verificar gap de mensagens') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WATCHDOG — Detecta conexão morta silenciosamente + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Registra timestamp da última atividade para o watchdog. + * Chamado por TODOS os event listeners do Baileys. + */ + private touchActivity(instanceId: string): void { + this.lastActivity.set(instanceId, Date.now()) + } + + /** + * Inicia o watchdog para uma instância. + * + * Fluxo conservador em 3 etapas — escolhido pra minimizar reconexões: + * 1. Se inativo > TIMEOUT (10 min): envia um presence update como + * ping leve e marca timestamp. Se o socket estiver vivo, Baileys + * responde e touchActivity é chamado no próximo evento. + * 2. Se continua inativo GRACE (+30s) após o ping: confirma que a + * conexão está de fato morta. Procede pro kill. + * 3. Antes de matar: verifica cool-down (15 min desde último kill) + * pra não entrar em loop quando a rede está oscilando. Se estiver + * no cool-down, só loga e espera o próximo ciclo. + * + * Esta sequência dá ~10-11 min de folga antes do primeiro kill, e no + * mínimo 15 min entre kills consecutivos — 7-8× mais conservador que + * a versão anterior (2 min sem verificação de estado). + */ + private startWatchdog(instanceId: string): void { + this.stopWatchdog(instanceId) + + const timer = setInterval(async () => { + const now = Date.now() + const lastTs = this.lastActivity.get(instanceId) ?? 0 + const elapsed = now - lastTs + const tenantId = this.tenantByInstance.get(instanceId) + + if (elapsed <= WATCHDOG_TIMEOUT_MS) { + // Conexão com atividade recente — reseta estado de ping/cool-down + this.lastWatchdogPing.delete(instanceId) + return + } + + const sock = this.sockets.get(instanceId) + + // Caso 1: socket sumiu sem connection.close — reconecta imediatamente + if (!sock) { + if (tenantId) { + logger.warn({ instanceId }, + '[Watchdog] Socket ausente — reagendando conexao') + this.stopWatchdog(instanceId) + this.connect(instanceId, tenantId).catch(() => {}) + } + return + } + + const pingedAt = this.lastWatchdogPing.get(instanceId) ?? 0 + const sincePing = now - pingedAt + + // Etapa 1: envia ping leve (presence update) se ainda não pingou + // neste ciclo de inatividade. Não conta como atividade — só estímulo. + if (pingedAt === 0 || sincePing > WATCHDOG_TIMEOUT_MS) { + try { + await sock.sendPresenceUpdate('available') + this.lastWatchdogPing.set(instanceId, now) + logger.debug({ instanceId, elapsedMs: elapsed }, + '[Watchdog] Ping leve enviado — aguardando resposta') + } catch (err) { + // Ping falhou → socket já está morto, pula pra etapa de kill + logger.warn({ instanceId, err }, + '[Watchdog] Ping falhou — socket morto') + this.forceKillSocket(instanceId, sock, tenantId, elapsed) + } + return + } + + // Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE. + // InfiniteAPI pode ter ACK mais lento — usa tolerância maior. + const engine = this.engineByInstance.get(instanceId) ?? 'infinite' + const graceMs = engine === 'infinite' ? WATCHDOG_PING_GRACE_INFIN_MS : WATCHDOG_PING_GRACE_MS + if (sincePing > graceMs) { + this.forceKillSocket(instanceId, sock, tenantId, elapsed) + } + }, WATCHDOG_INTERVAL_MS) + + this.watchdogTimers.set(instanceId, timer) + } + + /** + * Força o fechamento do socket, respeitando cool-down para evitar loop. + * Só mata se não houve outro kill nos últimos WATCHDOG_COOLDOWN_MS. + */ + private forceKillSocket( + instanceId: string, + sock: WASocket, + tenantId: string | undefined, + elapsedMs: number, + ): void { + const now = Date.now() + const lastKill = this.lastWatchdogKill.get(instanceId) ?? 0 + const sinceKill = now - lastKill + + if (sinceKill < WATCHDOG_COOLDOWN_MS) { + logger.warn( + { instanceId, elapsedMs, sinceLastKillMs: sinceKill }, + '[Watchdog] Em cool-down — adiando kill para proteger numero de ban', + ) + return + } + + logger.warn({ instanceId, elapsedMs }, + '[Watchdog] Conexao morta confirmada — forcando reconexao') + + this.lastWatchdogKill.set(instanceId, now) + this.lastWatchdogPing.delete(instanceId) + + try { sock.end(new Error('Watchdog timeout')) } catch { /* ignore */ } + + // Se o sock.end não disparar connection.close (raro), reconecta manualmente + if (!this.sockets.has(instanceId) && tenantId) { + this.stopWatchdog(instanceId) + this.connect(instanceId, tenantId).catch(() => {}) + } + } + + /** Para o watchdog de uma instância */ + private stopWatchdog(instanceId: string): void { + const timer = this.watchdogTimers.get(instanceId) + if (timer) { + clearInterval(timer) + this.watchdogTimers.delete(instanceId) + } + // Limpa estado de ping pendente — novo ciclo começa limpo. + // NÃO limpa lastWatchdogKill aqui: o cool-down deve sobreviver a + // stop/start para bloquear reconexões em cascata. + this.lastWatchdogPing.delete(instanceId) + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TIMER DE RECONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + + /** Cancela timer de reconexão pendente (evita reconexão duplicada) */ + private clearReconnectTimer(instanceId: string): void { + const timer = this.reconnectTimers.get(instanceId) + if (timer) { + clearTimeout(timer) + this.reconnectTimers.delete(instanceId) + } + this.reconnectScheduledAt.delete(instanceId) + } + + /** + * Agenda uma reconexão após `delay` ms. + * + * Centraliza o setTimeout + tracking do scheduledAt (usado pelo network + * probe pra saber se vale adiantar). Cancela qualquer timer anterior. + */ + private scheduleReconnect(instanceId: string, tenantId: string, delay: number): void { + this.clearReconnectTimer(instanceId) + const scheduledAt = Date.now() + delay + this.reconnectScheduledAt.set(instanceId, scheduledAt) + const timer = setTimeout(() => { + this.reconnectScheduledAt.delete(instanceId) + this.connect(instanceId, tenantId) + }, delay) + this.reconnectTimers.set(instanceId, timer) + } + + /** + * Classifica um erro de conexão como "rede transiente". + * + * Erros de DNS/TCP costumam resolver em segundos. Erros de status do + * WhatsApp (loggedOut, conflict, restartRequired) são categoria diferente + * e devem usar o backoff longo tradicional. + */ + private isTransientNetworkError(err: any): boolean { + if (!err) return false + const code = err.code || err.cause?.code || err.errno + if (code && typeof code === 'string') { + const transient = [ + 'EAI_AGAIN', // DNS retry + 'ENOTFOUND', // DNS não resolveu + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENETDOWN', + 'EPIPE', + ] + if (transient.includes(code)) return true + } + // Fallback: mensagem contém nome do host — é quase certo que é DNS/WS + const msg = (err.message || '').toString() + if (/getaddrinfo|web\.whatsapp\.com|WebSocket Error/i.test(msg)) return true + return false + } + + // ═══════════════════════════════════════════════════════════════════════════ + // NETWORK PROBE — Acorda reconexões quando a rede volta + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Inicia o network probe global. + * + * A cada NETWORK_PROBE_INTERVAL_MS, testa se o DNS resolve + * `web.whatsapp.com`. Se resolver E houver alguma instância com retry + * agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o + * timer dessa instância e dispara o retry imediatamente. + * + * Por quê DNS em vez de HTTP? Zero bytes para o WhatsApp. DNS falha com + * EAI_AGAIN quando a rede está caída, e resolve rápido quando volta — + * é um sinal confiável de disponibilidade de rede sem tocar no WA. + */ + private startNetworkProbe(): void { + if (this.networkProbeTimer) return + this.networkProbeTimer = setInterval(async () => { + // Curto-circuito: se não há retries pendentes, nem faz o DNS. + if (this.reconnectScheduledAt.size === 0) return + + try { + await dns.lookup(PROBE_DNS_HOST) + } catch { + return // Rede ainda caída — não adianta nada, espera o tick seguinte. + } + + // DNS OK → adianta qualquer retry que ainda falte > threshold + const now = Date.now() + for (const [instanceId, scheduledAt] of this.reconnectScheduledAt) { + const remaining = scheduledAt - now + if (remaining <= PROBE_ADVANCE_THRESHOLD_MS) continue + const tenantId = this.tenantByInstance.get(instanceId) + if (!tenantId) continue + logger.info({ instanceId, remainingMs: remaining }, + '[NetworkProbe] Rede detectada — adiantando retry pendente') + this.clearReconnectTimer(instanceId) + // Dispara sem await — probe roda em interval e não deve bloquear + this.connect(instanceId, tenantId).catch((err) => + logger.error({ err, instanceId }, '[NetworkProbe] Falha ao adiantar retry'), + ) + } + }, NETWORK_PROBE_INTERVAL_MS) + // Não segura o processo vivo só por causa do probe + this.networkProbeTimer.unref?.() + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DESCONEXÃO MANUAL + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Desconecta uma instância manualmente (logout). + * + * Diferente de uma desconexão por erro — aqui o usuário está + * intencionalmente desconectando. Limpa todos os timers e + * faz logout no WhatsApp (invalidando a sessão). + */ + async disconnect(instanceId: string): Promise { + const sock = this.sockets.get(instanceId) + if (!sock) return + this.clearReconnectTimer(instanceId) + this.stopWatchdog(instanceId) + this.reconnectAttempts.delete(instanceId) + // Logout manual zera o estado de cool-down: reconexão futura é um + // novo ciclo e não deve herdar o bloqueio do watchdog. + this.lastWatchdogKill.delete(instanceId) + this.lastActivity.delete(instanceId) + await sock.logout() + this.sockets.delete(instanceId) + this.contactHandlers.delete(instanceId) + this.msgHandlers.delete(instanceId) + this.engineByInstance.delete(instanceId) + logger.info({ instanceId }, 'Instancia desconectada manualmente') + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ACESSORES PÚBLICOS + // ═══════════════════════════════════════════════════════════════════════════ + + /** Retorna o socket Baileys ativo para uma instância (usado pelas rotas de envio) */ + getSocket(instanceId: string): WASocket | undefined { + return this.sockets.get(instanceId) + } + + /** Retorna o Socket.IO server (usado pelas rotas para emitir eventos) */ + getIO(): SocketIOServer { + return this.io + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS INTERNOS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Atualiza o status da instância no banco E no cache. + * Dupla escrita para que o frontend possa consultar status tanto + * via API (banco) quanto via polling rápido (cache DragonflyDB). + */ + private async updateInstanceStatus( + instanceId: string, + status: InstanceStatus + ): Promise { + await Promise.all([ + prisma.instance.update({ where: { id: instanceId }, data: { status } }) + .catch((err: any) => { if (err?.code !== 'P2025') throw err }), + dragonfly.set(CACHE_KEY_STATUS(instanceId), status), + ]) + } + + /** Remove arquivos de sessão do disco (após loggedOut) */ + public async deleteSessionFiles(instanceId: string): Promise { + const sessionDir = path.resolve(env.BAILEYS_SESSIONS_PATH, instanceId) + await fs.rm(sessionDir, { recursive: true, force: true }) + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SINCRONIZAÇÃO DE CHATS + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Sincroniza lista de chats no banco. + * + * Chamado por: chats.upsert, chats.update, groups.upsert, groups.update, + * messaging-history.set + * + * Para cada chat: + * - Cria ou atualiza no banco com JID, nome (grupo), timestamps + * - Vincula ao Contact existente (se houver) + * - Sincroniza estado de pin/archive/unread do WhatsApp + * + * Otimização: busca todos os contatos existentes em uma única query + * e usa Map para lookup O(1) por JID. + */ + private async syncChats(instanceId: string, tenantId: string, chats: any[]): Promise { + if (chats.length === 0) return + + // Busca todos os contatos existentes para os JIDs (1 query) + const jids = chats.map((c) => c.id).filter(Boolean) + const existingContacts = await prisma.contact.findMany({ + where: { tenantId, instanceId, jid: { in: jids } }, + select: { id: true, jid: true }, + }) + const contactIdByJid = new Map(existingContacts.map((c) => [c.jid, c.id])) + + // Pré-carrega mapeamento LID → phone JID para chats @lid. + // ContactHandler.upsert já rodou antes (messaging-history.set processa + // contacts → chats → messages nessa ordem), então lidJid já está populado. + const lidJids = jids.filter((j) => j.endsWith('@lid')) + const lidToPhoneJid = new Map() // @lid → @s.whatsapp.net + const lidToContactId = new Map() // @lid → contactId + if (lidJids.length > 0) { + const resolvedByLid = await prisma.contact.findMany({ + where: { + tenantId, + instanceId, + lidJid: { in: lidJids }, + NOT: { jid: { endsWith: '@lid' } }, // só aceita se resolveu para phone JID + }, + select: { id: true, jid: true, lidJid: true }, + }) + for (const rc of resolvedByLid) { + if (rc.lidJid) { + lidToPhoneJid.set(rc.lidJid, rc.jid) + lidToContactId.set(rc.lidJid, rc.id) + } + } + } + + for (const chat of chats) { + const rawJid = chat.id + if (!rawJid) continue + + // Descarta JIDs de sistema: broadcasts, newsletters e conta PSA (0@s.whatsapp.net) + if (rawJid.includes('@broadcast') || rawJid.endsWith('@newsletter') || rawJid.startsWith('0@')) continue + + // ── Resolução LID → phone JID ───────────────────────────────────── + // Chats @lid são artefatos do protocolo WhatsApp multi-device: + // o JID real do contato é @s.whatsapp.net. + // Fontes de resolução (ordem de prioridade): + // 1. Mapeamento no banco (ContactHandler.upsert populou via lidJid) + // 2. Campo pnJid do objeto de chat — enviado pelo InfiniteAPI v7+ no + // messaging-history.set direto do proto.IConversation.pnJid + let jid = rawJid + if (rawJid.endsWith('@lid')) { + const phoneJid = lidToPhoneJid.get(rawJid) + ?? (chat as any).pnJid as string | undefined + if (phoneJid) { + jid = phoneJid + // Garante que o contactId mapeado seja usado abaixo + const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid) + if (resolvedContactId) contactIdByJid.set(jid, resolvedContactId) + // Persiste o mapeamento no banco caso tenha vindo só do pnJid (fallback) + if (!lidToPhoneJid.has(rawJid)) { + prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: phoneJid }, + data: { lidJid: rawJid }, + }).catch(() => {}) + } + } else { + // Sem resolução disponível: chat será criado via handleHistory quando + // a mensagem chegar com senderPn resolvível. + logger.debug({ rawJid, instanceId }, '[syncChats] @lid sem resolução — descartando') + continue + } + } + + try { + // Converte timestamp Unix (segundos) para Date. + // Fallback: lastMessageRecvTimestamp (InfiniteAPI define a partir das mensagens do sync) + // quando conversationTimestamp é 0 ou null (ex: chats antigos fora da janela de sync). + const rawTs = Number(chat.conversationTimestamp || 0) + || Number((chat as any).lastMessageRecvTimestamp || 0) + const lastAt = rawTs > 0 ? new Date(rawTs * 1000) : undefined + + const isGroup = jid.endsWith('@g.us') + // Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name + const groupName = isGroup ? (chat.name ?? null) : null + const contactId = contactIdByJid.get(jid) ?? null + + await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + create: { + tenantId, + instanceId, + jid, + name: groupName, + unreadCount: chat.unreadCount ?? 0, + lastMessageAt: lastAt ?? null, + isPinned: !!chat.pinned, + isArchived: chat.archived ?? false, + ...(contactId && { contactId }), + }, + update: { + ...(groupName && { name: groupName }), + ...(contactId && { contactId }), + // Só aplica campos que vieram explicitamente no evento. + // chats.upsert pode ser parcial (nova mensagem, metadata) — se + // pinned/archived/unreadCount estiverem ausentes, não sobrescreve + // o valor salvo no banco. + ...(chat.unreadCount !== undefined && { unreadCount: chat.unreadCount }), + ...(lastAt && { lastMessageAt: lastAt }), + ...(chat.pinned !== undefined && { isPinned: !!chat.pinned }), + ...(chat.archived !== undefined && { isArchived: !!chat.archived }), + }, + }) + } catch (err) { + logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat') + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa atualizações parciais de chats (pin, archive, unread). + * + * Diferente de syncChats (usado para bulk/history), este método: + * 1. Só atualiza campos presentes na atualização (sem sobrescrever com defaults) + * 2. Emite chat:upsert via Socket.IO para atualizar o frontend em tempo real + * + * O evento chats.update do Baileys traz objetos parciais — apenas os campos + * que mudaram. Por isso não podemos usar ?? 0 para unreadCount, pois isso + * sobrescreveria o valor correto com zero. + */ + private async handleChatsUpdate(instanceId: string, tenantId: string, updates: any[]): Promise { + for (const update of updates) { + const jid = update.id + if (!jid || jid.endsWith('@lid')) continue + + try { + // Monta patch com apenas os campos presentes na atualização + const patch: Record = {} + if (update.pinned !== undefined) patch.isPinned = !!update.pinned + if (update.archived !== undefined) patch.isArchived = !!update.archived + if (update.unreadCount !== undefined) patch.unreadCount = update.unreadCount + + if (Object.keys(patch).length === 0) continue + + const chat = await prisma.chat.update({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + data: patch, + include: { + contact: { + select: { + name: true, verifiedName: true, notify: true, + phone: true, avatarUrl: true, + scoreReputacao: true, flagRestricao: true, + }, + }, + }, + }).catch(() => null) + + if (!chat) continue + + // Emite para o frontend atualizar em tempo real + const c = chat.contact + const isGroup = jid.endsWith('@g.us') + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null) + + this.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: 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: null, + }) + } catch (err) { + logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update') + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DEBUG LOG (página /baileys no frontend) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Emite evento de debug para a página /baileys do frontend. + * + * Permite monitorar em tempo real todos os eventos Baileys + * sem precisar acessar os logs do servidor. + */ + private baileysLog(tenantId: string, instanceId: string, event: string, data: unknown): void { + this.io.to(`tenant:${tenantId}`).emit('baileys:log', { + timestamp: new Date().toISOString(), + sessionId: instanceId, + event, + data, + }) + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/engine/index.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/index.ts new file mode 100644 index 0000000..99a5130 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/index.ts @@ -0,0 +1,60 @@ +import * as official from './official' +import * as infinite from './infinite' + +// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite') +const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite' + +function resolveEngine(type: string) { + return type === 'infinite' ? infinite : official +} + +/** + * Retorna os exports da engine selecionada para uma instância específica. + * Permite que cada instância use uma engine diferente (infinite ou official). + * Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite'). + */ +export function getEngine(engineType?: string | null): { + makeWASocket: any + useMultiFileAuthState: any + fetchLatestBaileysVersion: any + makeCacheableSignalKeyStore: any + downloadMediaMessage: any + getContentType: any + generateMessageIDV2: any + generateWAMessageFromContent: any + isJidGroup: any + isJidStatusBroadcast: any +} { + const e = resolveEngine(engineType ?? defaultEngineType) + return { + makeWASocket: e.makeWASocket as any, + useMultiFileAuthState: e.useMultiFileAuthState as any, + fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any, + makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any, + downloadMediaMessage: e.downloadMediaMessage as any, + getContentType: e.getContentType as any, + generateMessageIDV2: e.generateMessageIDV2 as any, + generateWAMessageFromContent: e.generateWAMessageFromContent as any, + isJidGroup: e.isJidGroup as any, + isJidStatusBroadcast: e.isJidStatusBroadcast as any, + } +} + +// Exports estáticos: usam a engine global (retrocompatibilidade com código existente) +const selectedEngine = resolveEngine(defaultEngineType) + +export const makeWASocket = selectedEngine.makeWASocket as any +export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any +export const DisconnectReason = selectedEngine.DisconnectReason as any +export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any +export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any +export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any +export const getContentType = selectedEngine.getContentType as any +export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any +export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any +export const isJidGroup = selectedEngine.isJidGroup as any +export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any +export { proto } from './official' + +// Exportação estática de tipos para garantir segurança em tempo de compilação +export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official' diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/engine/infinite.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/infinite.ts new file mode 100644 index 0000000..707df2d --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/infinite.ts @@ -0,0 +1 @@ +export * from 'baileys' diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/engine/official.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/official.ts new file mode 100644 index 0000000..65f2c8e --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/engine/official.ts @@ -0,0 +1 @@ +export * from '@whiskeysockets/baileys' diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js new file mode 100644 index 0000000..b12402b --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js @@ -0,0 +1,495 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ContactHandler = void 0; +const prisma_1 = require("../../../infra/database/prisma"); +const logger_1 = require("../../../config/logger"); +// ─── Configuração de rate-limit para busca de avatares ────────────────────── +// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl +// forem feitas em curto período. Estas constantes controlam o throttling. +/** Quantos avatares buscar em paralelo por lote */ +const AVATAR_BATCH_SIZE = 5; +/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */ +const AVATAR_DELAY_MS = 900; +/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */ +const MAX_MISSING_AVATAR_SYNC = 80; +class ContactHandler { + constructor( + /** UUID da instância WhatsApp */ + instanceId, + /** UUID do tenant (isolamento multi-tenant) */ + tenantId, + /** Socket Baileys — usado para profilePictureUrl e resyncAppState */ + sock, + /** Socket.IO — emite eventos de avatar/reconciliação para o frontend */ + io) { + this.instanceId = instanceId; + this.tenantId = tenantId; + this.sock = sock; + this.io = io; + /** Fila de JIDs aguardando busca de avatar */ + this.avatarQueue = []; + /** Flag de mutex simples — evita processamento concorrente da fila */ + this.processingAvatars = false; + } + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO DE NOME + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Regra de ouro para resolução de nome de contato: + * name (agenda do telefone) > verifiedName (conta business) > notify (pushname) + * + * Retorna undefined se nenhum campo estiver disponível. + */ + resolveName(c) { + return c.name ?? c.verifiedName ?? c.notify ?? undefined; + } + // ═══════════════════════════════════════════════════════════════════════════ + // UPSERT EM MASSA (contacts.upsert + messaging-history.set) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Cria ou atualiza contatos em massa. + * + * Dispara em dois cenários: + * 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão) + * 2. Campo contacts[] do messaging-history.set (history sync) + * + * Para cada contato: + * - Cria ou atualiza no banco com nome resolvido + * - Vincula chats órfãos (Chat sem contactId) ao contato + * - Enfileira busca de avatar se imgUrl não disponível + * - Captura mapeamento LID → phone JID (campo `lid` do history sync) + * + * TRATAMENTO DE LID: + * O Baileys pode enviar contatos com c.id no formato @lid. + * Quando o contato do history sync inclui campo `jid` (telefone real), + * usamos esse JID como chave primária e salvamos o LID em lidJid. + * Quando não há campo `jid`, o contato é criado com o LID como JID + * (será corrigido quando phoneNumberShare ou senderPn revelar o telefone). + */ + async upsert(contacts) { + /** JIDs que precisam buscar avatar via profilePictureUrl */ + const needFetchAvatar = []; + /** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */ + let directAvatarCount = 0; + for (const c of contacts) { + if (!c.id) + continue; + const isLid = c.id.endsWith('@lid'); + const isBroadcast = c.id.includes('@broadcast'); + if (isBroadcast) + continue; + // imgUrl pode vir do Baileys com valores especiais: + // - URL string: avatar disponível (salva direto) + // - 'changed': avatar foi alterado (precisa buscar novamente) + // - null/undefined: não disponível (precisa buscar via API) + const imgUrl = c.imgUrl; + // LID mapping: o history sync do Baileys traz campo `lid` nos contatos + // que associa o LID ao contato com JID real (@s.whatsapp.net) + // Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" } + const lidFromContact = c.lid; + try { + // Para contatos @lid que possuem campo `jid` (vem do history sync), + // usa o JID real como chave primária. Caso contrário, usa c.id como está. + // Isso evita criar contatos duplicados (um com LID, outro com phone). + const contactJid = isLid && c.jid ? c.jid : c.id; + const contact = await prisma_1.prisma.contact.upsert({ + where: { + tenantId_instanceId_jid: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + }, + }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0], + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + // Salva mapeamento LID: + // Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid + // Se o contato TEM campo lid (history sync): salva lid como lidJid + ...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}), + }, + update: { + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + ...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}), + }, + }); + // Garante vínculo Chat ↔ Contact para chats órfãos + // (chats criados antes do contato existir ficam com contactId=null) + await prisma_1.prisma.chat.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: c.id, + contactId: null, + }, + data: { contactId: contact.id }, + }); + // ── Lógica de avatar ────────────────────────────────────────────── + // JIDs @lid não suportam profilePictureUrl (retorna 404 no WA) + if (!isLid) { + if (imgUrl && imgUrl !== 'changed') { + // Avatar veio pronto do Baileys — notifica frontend imediatamente + directAvatarCount++; + this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { + instanceId: this.instanceId, + jid: c.id, + avatarUrl: imgUrl, + }); + } + else if (imgUrl === 'changed' || imgUrl === undefined) { + // Precisa buscar via API — adiciona à fila com rate-limit + needFetchAvatar.push(c.id); + } + } + } + catch (err) { + logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato'); + } + } + logger_1.logger.debug({ + instanceId: this.instanceId, + direct: directAvatarCount, + toFetch: needFetchAvatar.length, + }, '[ContactHandler] Avatares: diretos vs a buscar via API'); + // Enfileira busca de avatares com cap de 50 por batch + // (randomiza para priorizar contatos diferentes a cada sync) + if (this.sock && needFetchAvatar.length > 0) { + const toQueue = needFetchAvatar.length > 50 + ? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50) + : needFetchAvatar; + this.enqueueAvatars(toQueue); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // UPDATE PARCIAL (contacts.update) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Atualiza campos parciais de contatos existentes. + * + * Dispara no evento contacts.update do Baileys — diferente do upsert, + * aqui só atualizamos os campos que foram explicitamente alterados. + * Não cria contatos novos (se não existir, ignora silenciosamente). + */ + async update(contacts) { + for (const c of contacts) { + if (!c.id) + continue; + const resolvedName = this.resolveName(c); + // Se nenhum campo relevante mudou, pula + if (!resolvedName && c.verifiedName === undefined && c.notify === undefined) + continue; + try { + await prisma_1.prisma.contact.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: c.id, + }, + data: { + ...(resolvedName !== undefined && { name: resolvedName }), + ...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }), + ...(c.notify !== undefined && { notify: c.notify }), + }, + }); + } + catch (err) { + logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // AVATAR DA INSTÂNCIA (foto de perfil do número da sessão) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Busca e salva a foto de perfil do número WhatsApp da instância. + * Chamado pelo WhatsAppConnectionManager após connection 'open'. + */ + async syncInstanceAvatar(instanceId) { + if (!this.sock?.user?.id) + return null; + try { + const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image'); + if (url) { + await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } }); + logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado'); + return url; + } + } + catch { + // Avatar privado ou não disponível — normal para contas com privacidade restrita + } + return null; + } + // ═══════════════════════════════════════════════════════════════════════════ + // RECONCILIAÇÃO DE NOMES VIA PUSHNAME + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Reconcilia nomes de contatos após sync do histórico. + * + * PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos + * sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName. + * Os pushNames só aparecem nas mensagens, não nos contatos do sync. + * + * SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com + * name=null E notify=null, e preenche usando o pushName da mensagem + * recebida mais recente para cada JID. + * + * FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject) + * + * Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats. + */ + async reconcileNamesFromMessages() { + try { + // Busca contatos "sem nome" — candidatos à reconciliação + const unnamed = await prisma_1.prisma.contact.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + name: null, + notify: null, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + { jid: { endsWith: '@newsletter' } }, + { jid: { endsWith: '@g.us' } }, + ], + }, + select: { id: true, jid: true }, + }); + if (unnamed.length === 0) { + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome'); + return; + } + logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens'); + let updated = 0; + for (const contact of unnamed) { + // Busca a mensagem recebida mais recente com pushName para este JID + const msg = await prisma_1.prisma.message.findFirst({ + where: { + instanceId: this.instanceId, + remoteJid: contact.jid, + fromMe: false, + pushName: { not: null }, + }, + orderBy: { timestamp: 'desc' }, + select: { pushName: true }, + }); + if (msg?.pushName) { + await prisma_1.prisma.contact.update({ + where: { id: contact.id }, + data: { name: msg.pushName, notify: msg.pushName }, + }); + updated++; + } + } + logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída'); + // Notifica o frontend para atualizar a lista de chats + if (updated > 0) { + this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', { + instanceId: this.instanceId, + count: updated, + }); + } + } + catch (err) { + logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // FORCE CONTACT SYNC (resyncAppState) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Força re-sync dos contatos via app-state do Baileys. + * + * NOTA: Este método existe mas NÃO é chamado automaticamente. + * Em reconexões, o Baileys não re-envia contacts.upsert. Este método + * força o resync das app-state collections que contêm contatos. + * + * PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com + * "tried remove, but no previous op" — erro de estado corrompido + * no Baileys. Por isso foi removido do trigger automático pós-conexão. + * + * Pode ser chamado manualmente via API se necessário. + */ + async forceContactSync() { + if (!this.sock) + return; + try { + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state'); + await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false); + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído'); + } + catch (err) { + logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC PÓS-CONEXÃO: AVATARES AUSENTES + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Busca avatares ausentes para chats ativos após reconexão. + * + * Chamado pelo WhatsAppConnectionManager 5s após connection 'open'. + * Cobre o caso em que contacts.upsert não disparou (history sync timeout) + * ou contatos vieram sem imgUrl. + * + * Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro). + * Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA. + */ + async syncMissingAvatars() { + try { + const missing = await prisma_1.prisma.chat.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + isArchived: false, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + contact: { avatarUrl: null }, + }, + select: { jid: true }, + orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } }, + take: MAX_MISSING_AVATAR_SYNC, + }); + if (missing.length === 0) + return; + const jids = missing.map((c) => c.jid); + logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes'); + this.enqueueAvatars(jids); + } + catch (err) { + logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes'); + } + } + /** + * Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira + * em menos de 48 horas ou já expirou. + * + * URLs do WA têm parâmetro `oe=` que indica a expiração. + * Sem renovação, quebra silenciosamente no browser após o TTL. + */ + async syncExpiredAvatars() { + try { + const contacts = await prisma_1.prisma.contact.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + avatarUrl: { not: null }, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + }, + select: { jid: true, avatarUrl: true }, + orderBy: { updatedAt: 'asc' }, + take: 200, + }); + const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h + const expiring = contacts + .filter(c => { + const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i); + if (!m) + return false; + return parseInt(m[1], 16) < threshold; + }) + .map(c => c.jid); + if (expiring.length === 0) + return; + logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar'); + // Limpa as URLs expiradas antes de re-buscar, para que o browser + // não use URLs inválidas enquanto a fila processa + await prisma_1.prisma.contact.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: { in: expiring }, + }, + data: { avatarUrl: null }, + }); + this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC)); + } + catch (err) { + logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // FILA DE AVATARES COM RATE-LIMIT + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Adiciona JIDs à fila de busca de avatares (com deduplicação). + * + * A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de + * AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp. + * + * Se a fila já está sendo processada, os novos JIDs são adicionados + * e serão processados na próxima iteração do loop. + */ + enqueueAvatars(jids) { + const inQueue = new Set(this.avatarQueue); + for (const jid of jids) { + if (!inQueue.has(jid)) + this.avatarQueue.push(jid); + } + this.processAvatarQueue(); + } + /** + * Processa a fila de avatares com rate-limit. + * + * Usa Promise.allSettled() para que falhas individuais (foto privada, + * JID inválido) não interrompam o lote. Cada avatar encontrado é: + * 1. Salvo no campo avatarUrl do contato no banco + * 2. Emitido via Socket.IO para atualizar o frontend em tempo real + */ + async processAvatarQueue() { + // Mutex simples: apenas uma execução por vez + if (this.processingAvatars || this.avatarQueue.length === 0) + return; + this.processingAvatars = true; + logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares'); + while (this.avatarQueue.length > 0 && this.sock) { + // Pega o próximo lote (remove da fila) + const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE); + await Promise.allSettled(batch.map(async (jid) => { + try { + const url = await this.sock.profilePictureUrl(jid, 'image'); + if (url) { + // Persiste no banco + await prisma_1.prisma.contact.updateMany({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, jid }, + data: { avatarUrl: url }, + }); + // Notifica o frontend para atualizar o avatar na UI + this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { + instanceId: this.instanceId, + jid, + avatarUrl: url, + }); + } + } + catch { + // Foto privada ou JID inválido — ignora silenciosamente + // (não loga para evitar spam com milhares de contatos) + } + })); + // Delay entre lotes para respeitar rate-limit do WhatsApp + if (this.avatarQueue.length > 0) { + await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE)); + } + } + this.processingAvatars = false; + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído'); + } +} +exports.ContactHandler = ContactHandler; +//# sourceMappingURL=ContactHandler.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js.map b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js.map new file mode 100644 index 0000000..dcc298d --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ContactHandler.js","sourceRoot":"","sources":["ContactHandler.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts new file mode 100644 index 0000000..455f6f5 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/ContactHandler.ts @@ -0,0 +1,559 @@ +/** + * ContactHandler — Gerencia contatos e avatares do WhatsApp. + * + * Responsabilidades: + * 1. Upsert de contatos em massa (evento contacts.upsert do Baileys) + * 2. Update parcial de contatos (evento contacts.update) + * 3. Sincronização de avatares com rate-limit (fila com batch + delay) + * 4. Reconciliação de nomes via pushName das mensagens (pós history sync) + * 5. Mapeamento LID → phone JID (captura do campo `lid` nos contatos) + * 6. Sincronização do avatar da própria instância + * + * CONTEXTO LID: + * O Baileys pode enviar contatos com id no formato @lid (Linked ID). + * Quando o contato inclui o campo `jid` (history sync), usamos o JID real + * e armazenamos o LID em contacts.lidJid para resolução futura. + * @see MessageHandler.resolveLidToPhoneJid() + * + * REGRA DE PRIORIDADE DE NOMES: + * name (agenda do telefone) > verifiedName (conta business) > notify (pushname) + * Essa hierarquia é respeitada tanto no upsert quanto na resolução pelo frontend. + * + * @see WhatsAppConnectionManager — registra os event listeners + * @see MessageHandler — consome os contatos para vincular a mensagens + */ +import type { Contact, WASocket } from '../engine' +import { prisma } from '../../../infra/database/prisma' +import { logger } from '../../../config/logger' +import type { Server as SocketIOServer } from 'socket.io' + +// ─── Configuração de rate-limit para busca de avatares ────────────────────── +// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl +// forem feitas em curto período. Estas constantes controlam o throttling. + +/** Quantos avatares buscar em paralelo por lote */ +const AVATAR_BATCH_SIZE = 5 +/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */ +const AVATAR_DELAY_MS = 900 +/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */ +const MAX_MISSING_AVATAR_SYNC = 80 + +export class ContactHandler { + /** Fila de JIDs aguardando busca de avatar */ + private avatarQueue: string[] = [] + /** Flag de mutex simples — evita processamento concorrente da fila */ + private processingAvatars = false + + constructor( + /** UUID da instância WhatsApp */ + private instanceId: string, + /** UUID do tenant (isolamento multi-tenant) */ + private tenantId: string, + /** Socket Baileys — usado para profilePictureUrl e resyncAppState */ + private sock?: WASocket, + /** Socket.IO — emite eventos de avatar/reconciliação para o frontend */ + private io?: SocketIOServer + ) {} + + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO DE NOME + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Regra de ouro para resolução de nome de contato: + * name (agenda do telefone) > verifiedName (conta business) > notify (pushname) + * + * Retorna undefined se nenhum campo estiver disponível. + */ + private resolveName(c: Partial): string | undefined { + return c.name ?? c.verifiedName ?? c.notify ?? undefined + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UPSERT EM MASSA (contacts.upsert + messaging-history.set) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Cria ou atualiza contatos em massa. + * + * Dispara em dois cenários: + * 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão) + * 2. Campo contacts[] do messaging-history.set (history sync) + * + * Para cada contato: + * - Cria ou atualiza no banco com nome resolvido + * - Vincula chats órfãos (Chat sem contactId) ao contato + * - Enfileira busca de avatar se imgUrl não disponível + * - Captura mapeamento LID → phone JID (campo `lid` do history sync) + * + * TRATAMENTO DE LID: + * O Baileys pode enviar contatos com c.id no formato @lid. + * Quando o contato do history sync inclui campo `jid` (telefone real), + * usamos esse JID como chave primária e salvamos o LID em lidJid. + * Quando não há campo `jid`, o contato é criado com o LID como JID + * (será corrigido quando phoneNumberShare ou senderPn revelar o telefone). + */ + async upsert(contacts: Contact[]): Promise { + /** JIDs que precisam buscar avatar via profilePictureUrl */ + const needFetchAvatar: string[] = [] + /** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */ + let directAvatarCount = 0 + + for (const c of contacts) { + if (!c.id) continue + const isLid = c.id.endsWith('@lid') + const isBroadcast = c.id.includes('@broadcast') + if (isBroadcast) continue + + // imgUrl pode vir do Baileys com valores especiais: + // - URL string: avatar disponível (salva direto) + // - 'changed': avatar foi alterado (precisa buscar novamente) + // - null/undefined: não disponível (precisa buscar via API) + const imgUrl = (c as any).imgUrl as string | null | undefined + + // LID mapping: o history sync do Baileys traz campo `lid` nos contatos + // que associa o LID ao contato com JID real (@s.whatsapp.net) + // Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" } + const lidFromContact = (c as any).lid as string | undefined + + try { + // Para contatos @lid que possuem o JID real do telefone: + // - Baileys oficial envia o campo `jid` (@s.whatsapp.net) + // - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net) + // Ambos são suportados — o primeiro disponível vence. + // Isso evita criar contatos duplicados (um com LID, outro com phone). + const pnJid = (c as any).jid as string | undefined + ?? (c as any).phoneNumber as string | undefined + const contactJid = isLid && pnJid ? pnJid : c.id + + // Quando o contato É um @lid mas foi resolvido para phoneJid: + // lidJid = c.id (o @lid original) para manter o mapeamento no banco + // Quando o contato TEM campo `lid` (history sync Baileys oficial): + // lidJid = lidFromContact + const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined) + + const contact = await prisma.contact.upsert({ + where: { + tenantId_instanceId_jid: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + }, + }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0], + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + ...(finalLidJid ? { lidJid: finalLidJid } : {}), + }, + update: { + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + ...(finalLidJid ? { lidJid: finalLidJid } : {}), + }, + }) + + // Garante vínculo Chat ↔ Contact para chats órfãos + // (chats criados antes do contato existir ficam com contactId=null) + // Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID. + // Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid. + await prisma.chat.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + contactId: null, + }, + data: { contactId: contact.id }, + }) + + // ── Lógica de avatar ────────────────────────────────────────────── + // JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar + // pois @lid resolvido para phone JID deve buscar avatar normalmente. + const resolvedIsLid = contactJid.endsWith('@lid') + if (!resolvedIsLid) { + if (imgUrl && imgUrl !== 'changed') { + // Avatar veio pronto do Baileys — notifica frontend imediatamente + directAvatarCount++ + this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { + instanceId: this.instanceId, + jid: contactJid, + avatarUrl: imgUrl, + }) + } else if (imgUrl === 'changed' || imgUrl === undefined) { + // Precisa buscar via API — adiciona à fila com rate-limit + needFetchAvatar.push(contactJid) + } + } + } catch (err) { + logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato') + } + } + + logger.debug({ + instanceId: this.instanceId, + direct: directAvatarCount, + toFetch: needFetchAvatar.length, + }, '[ContactHandler] Avatares: diretos vs a buscar via API') + + // Enfileira busca de avatares com cap de 50 por batch + // (randomiza para priorizar contatos diferentes a cada sync) + if (this.sock && needFetchAvatar.length > 0) { + const toQueue = needFetchAvatar.length > 50 + ? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50) + : needFetchAvatar + this.enqueueAvatars(toQueue) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UPDATE PARCIAL (contacts.update) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Atualiza campos parciais de contatos existentes. + * + * Dispara no evento contacts.update do Baileys — diferente do upsert, + * aqui só atualizamos os campos que foram explicitamente alterados. + * Não cria contatos novos (se não existir, ignora silenciosamente). + */ + async update(contacts: Partial[]): Promise { + for (const c of contacts) { + if (!c.id) continue + const resolvedName = this.resolveName(c) + // Se nenhum campo relevante mudou, pula + if (!resolvedName && c.verifiedName === undefined && c.notify === undefined) continue + + try { + await prisma.contact.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: c.id, + }, + data: { + ...(resolvedName !== undefined && { name: resolvedName }), + ...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }), + ...(c.notify !== undefined && { notify: c.notify }), + }, + }) + } catch (err) { + logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato') + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // AVATAR DA INSTÂNCIA (foto de perfil do número da sessão) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Busca e salva a foto de perfil do número WhatsApp da instância. + * Chamado pelo WhatsAppConnectionManager após connection 'open'. + */ + async syncInstanceAvatar(instanceId: string): Promise { + if (!this.sock?.user?.id) return null + try { + const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image') + if (url) { + await prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } }) + logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado') + return url + } + } catch { + // Avatar privado ou não disponível — normal para contas com privacidade restrita + } + return null + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RECONCILIAÇÃO DE NOMES VIA PUSHNAME + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Reconcilia nomes de contatos após sync do histórico. + * + * PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos + * sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName. + * Os pushNames só aparecem nas mensagens, não nos contatos do sync. + * + * SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com + * name=null E notify=null, e preenche usando o pushName da mensagem + * recebida mais recente para cada JID. + * + * FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject) + * + * Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats. + */ + async reconcileNamesFromMessages(): Promise { + try { + // Busca contatos "sem nome" — candidatos à reconciliação + const unnamed = await prisma.contact.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + name: null, + notify: null, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + { jid: { endsWith: '@newsletter' } }, + { jid: { endsWith: '@g.us' } }, + ], + }, + select: { id: true, jid: true }, + }) + + if (unnamed.length === 0) { + logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome') + return + } + + logger.info({ instanceId: this.instanceId, count: unnamed.length }, + '[ContactHandler] Reconciliação: buscando pushNames nas mensagens') + + let updated = 0 + for (const contact of unnamed) { + // Busca a mensagem recebida mais recente com pushName para este JID + const msg = await prisma.message.findFirst({ + where: { + instanceId: this.instanceId, + remoteJid: contact.jid, + fromMe: false, + pushName: { not: null }, + }, + orderBy: { timestamp: 'desc' }, + select: { pushName: true }, + }) + + if (msg?.pushName) { + await prisma.contact.update({ + where: { id: contact.id }, + data: { name: msg.pushName, notify: msg.pushName }, + }) + updated++ + } + } + + logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, + '[ContactHandler] Reconciliação de nomes concluída') + + // Notifica o frontend para atualizar a lista de chats + if (updated > 0) { + this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', { + instanceId: this.instanceId, + count: updated, + }) + } + } catch (err) { + logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FORCE CONTACT SYNC (resyncAppState) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Força re-sync dos contatos via app-state do Baileys. + * + * NOTA: Este método existe mas NÃO é chamado automaticamente. + * Em reconexões, o Baileys não re-envia contacts.upsert. Este método + * força o resync das app-state collections que contêm contatos. + * + * PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com + * "tried remove, but no previous op" — erro de estado corrompido + * no Baileys. Por isso foi removido do trigger automático pós-conexão. + * + * Pode ser chamado manualmente via API se necessário. + */ + async forceContactSync(): Promise { + if (!this.sock) return + try { + logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state') + await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false) + logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído') + } catch (err) { + logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC PÓS-CONEXÃO: AVATARES AUSENTES + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Busca avatares ausentes para chats ativos após reconexão. + * + * Chamado pelo WhatsAppConnectionManager 5s após connection 'open'. + * Cobre o caso em que contacts.upsert não disparou (history sync timeout) + * ou contatos vieram sem imgUrl. + * + * Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro). + * Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA. + */ + async syncMissingAvatars(): Promise { + try { + const missing = await prisma.chat.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + isArchived: false, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + contact: { avatarUrl: null }, + }, + select: { jid: true }, + orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } }, + take: MAX_MISSING_AVATAR_SYNC, + }) + + if (missing.length === 0) return + + const jids = missing.map((c) => c.jid) + logger.info({ instanceId: this.instanceId, count: jids.length }, + '[ContactHandler] Sync pós-conexão: buscando avatares ausentes') + + this.enqueueAvatars(jids) + } catch (err) { + logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes') + } + } + + /** + * Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira + * em menos de 48 horas ou já expirou. + * + * URLs do WA têm parâmetro `oe=` que indica a expiração. + * Sem renovação, quebra silenciosamente no browser após o TTL. + */ + async syncExpiredAvatars(): Promise { + try { + const contacts = await prisma.contact.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + avatarUrl: { not: null }, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + }, + select: { jid: true, avatarUrl: true }, + orderBy: { updatedAt: 'asc' }, + take: 200, + }) + + const threshold = Math.floor(Date.now() / 1000) + 48 * 3600 // now + 48h + const expiring = contacts + .filter(c => { + const m = c.avatarUrl!.match(/[?&]oe=([0-9a-f]+)/i) + if (!m) return false + return parseInt(m[1], 16) < threshold + }) + .map(c => c.jid) + + if (expiring.length === 0) return + + logger.info({ instanceId: this.instanceId, count: expiring.length }, + '[ContactHandler] Renovando avatares próximos de expirar') + + // Limpa as URLs expiradas antes de re-buscar, para que o browser + // não use URLs inválidas enquanto a fila processa + await prisma.contact.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: { in: expiring }, + }, + data: { avatarUrl: null }, + }) + + this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC)) + } catch (err) { + logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FILA DE AVATARES COM RATE-LIMIT + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Adiciona JIDs à fila de busca de avatares (com deduplicação). + * + * A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de + * AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp. + * + * Se a fila já está sendo processada, os novos JIDs são adicionados + * e serão processados na próxima iteração do loop. + */ + enqueueAvatars(jids: string[]): void { + const inQueue = new Set(this.avatarQueue) + for (const jid of jids) { + if (!inQueue.has(jid)) this.avatarQueue.push(jid) + } + this.processAvatarQueue() + } + + /** + * Processa a fila de avatares com rate-limit. + * + * Usa Promise.allSettled() para que falhas individuais (foto privada, + * JID inválido) não interrompam o lote. Cada avatar encontrado é: + * 1. Salvo no campo avatarUrl do contato no banco + * 2. Emitido via Socket.IO para atualizar o frontend em tempo real + */ + private async processAvatarQueue(): Promise { + // Mutex simples: apenas uma execução por vez + if (this.processingAvatars || this.avatarQueue.length === 0) return + this.processingAvatars = true + + logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, + '[ContactHandler] Iniciando sync de avatares') + + while (this.avatarQueue.length > 0 && this.sock) { + // Pega o próximo lote (remove da fila) + const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE) + + await Promise.allSettled(batch.map(async (jid) => { + try { + const url = await this.sock!.profilePictureUrl(jid, 'image') + if (url) { + // Persiste no banco + await prisma.contact.updateMany({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, jid }, + data: { avatarUrl: url }, + }) + // Notifica o frontend para atualizar o avatar na UI + this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { + instanceId: this.instanceId, + jid, + avatarUrl: url, + }) + } + } catch { + // Foto privada ou JID inválido — ignora silenciosamente + // (não loga para evitar spam com milhares de contatos) + } + })) + + // Delay entre lotes para respeitar rate-limit do WhatsApp + if (this.avatarQueue.length > 0) { + await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE)) + } + } + + this.processingAvatars = false + logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído') + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js new file mode 100644 index 0000000..afe5d0f --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js @@ -0,0 +1,810 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MessageHandler = void 0; +/** + * MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico). + * + * Responsabilidades: + * 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append') + * 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net) + * 3. Criar/atualizar Contact, Chat e Message no banco + * 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert) + * 5. Download assíncrono de mídia (imagens, vídeos, docs) + * 6. Disparar chatbot para mensagens recebidas com texto + * + * CONTEXTO LID (Linked ID): + * A partir de 2024, o WhatsApp passou a usar LID como identificador primário + * nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez + * de "5511999999999@s.whatsapp.net". O telefone real está disponível em: + * - key.senderPn: campo do protocolo WA contendo o JID com telefone + * - key.participantPn: idem, usado em contextos de grupo + * - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente) + * + * @see WhatsAppConnectionManager — registra os event listeners do Baileys + * @see ContactHandler — gerencia contatos e avatares + */ +const path_1 = __importDefault(require("path")); +const promises_1 = __importDefault(require("fs/promises")); +const baileys_1 = require("@whiskeysockets/baileys"); +const client_1 = require("@prisma/client"); +const prisma_1 = require("../../../infra/database/prisma"); +const logger_1 = require("../../../config/logger"); +const whatsapp_1 = require("../../../shared/utils/whatsapp"); +const StorageProvider_1 = require("../../../core/StorageProvider"); +const hook_bus_1 = require("../../../core/hook-bus"); +/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ +const MEDIA_DIR = path_1.default.resolve('./media'); +/** + * Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário. + * Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message. + */ +const PROTOCOL_CONTENT_TYPES = new Set([ + 'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo) + 'protocolMessage', // revogações, timers de desaparecimento (protocolo) + 'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro) + 'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo) + 'messageContextInfo', // só metadados, sem conteúdo + 'appStateSyncKeyShare', // sync de estado do app (protocolo) + 'appStateFatalExceptionNotification', // notificação interna (protocolo) + 'keepInChatMessage', // manter no chat (protocolo) +]); +/** + * Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`. + * Sem desembrulhar, getContentType retorna o nome do wrapper (ex: + * `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia + * um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é + * descartada silenciosamente. Itera enquanto houver wrapper aninhado. + */ +const COMPOSITE_WRAPPER_TYPES = new Set([ + 'documentWithCaptionMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'editedMessage', + 'botInvokeMessage', +]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function unwrapInnerMessage(msg) { + let cur = msg; + let depth = 0; + while (cur && depth < 5) { + const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo'); + const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k)); + if (!wrapperKey) + return cur; + const inner = cur[wrapperKey]?.message; + if (!inner || typeof inner !== 'object') + return cur; + cur = inner; + depth++; + } + return cur; +} +class MessageHandler { + constructor( + /** Socket Baileys ativo — usado para download de mídia e reupload */ + sock, + /** UUID da instância WhatsApp (1 instância = 1 número conectado) */ + instanceId, + /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */ + tenantId, + /** Socket.IO server — emite eventos em tempo real para o frontend */ + io, + /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */ + chatbotService) { + this.sock = sock; + this.instanceId = instanceId; + this.tenantId = tenantId; + this.io = io; + this.chatbotService = chatbotService; + /** + * Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas. + * + * Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net"). + * + * Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true) + * para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa + * sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da + * sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do + * telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o + * Baileys NÃO expõe em `decode-wa-message.js`. + * + * Por isso, o WhatsAppConnectionManager registra um listener extra em + * `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa + * ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid` + * consome a dica e remove a entrada (mapa efêmero, não cresce). + */ + this.peerRecipientHints = new Map(); + } + /** + * Registra uma dica de destinatário extraída da stanza bruta. + * + * Chamado pelo listener `sock.ws.on('CB:message')` no + * WhatsAppConnectionManager. Entradas expiram em 60s como garantia + * contra vazamento caso o `messages.upsert` correspondente nunca chegue. + */ + setPeerRecipientHint(msgId, peerRecipientPn) { + this.peerRecipientHints.set(msgId, peerRecipientPn); + setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).unref(); + } + /** + * Ponto de entrada principal — chamado pelo event listener `messages.upsert`. + * + * @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado + * + * Diferença entre os modos: + * - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia) + * - append: apenas salva no banco em lote (bulk insert), sem emitir eventos + */ + async handle({ messages, type }) { + if (type === 'notify') { + for (const msg of messages) { + try { + await this.processMessage(msg); + } + catch (err) { + logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem'); + } + } + } + else if (type === 'append') { + await this.handleHistory(messages); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa mensagens históricas (history sync / append). + * + * Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany + * por grupo, minimizando round-trips ao banco. + * + * Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup. + * Se não for possível resolver, a mensagem é descartada silenciosamente + * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare). + */ + async handleHistory(messages) { + if (messages.length === 0) + return; + // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat + const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? ''); + // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID) + const byJid = new Map(); + for (const msg of messages) { + let jidRaw = msg.key.remoteJid; + if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) + continue; + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw); + if (ownJid && normalizedRaw === ownJid) + continue; + // ── Resolução LID → phone JID para mensagens históricas ────────── + // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid. + // Precisamos converter para @s.whatsapp.net antes de persistir, + // caso contrário o chat ficaria duplicado (um com LID, outro com phone). + if (jidRaw.endsWith('@lid')) { + // Tenta key.senderPn (disponível quando o protocolo WA inclui sender_pn) + const senderPn = msg.key.senderPn || msg.key.participantPn; + if (senderPn) { + jidRaw = (0, whatsapp_1.normalizeJid)(senderPn); + if (!jidRaw.endsWith('@s.whatsapp.net')) + continue; + } + else { + // Fallback: busca mapeamento LID → phone já salvo no banco + // (populado por phoneNumberShare ou processMessage anterior) + const contact = await prisma_1.prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw }, + select: { jid: true }, + }); + if (contact?.jid && !contact.jid.endsWith('@lid')) { + jidRaw = contact.jid; + } + else { + // Sem resolução possível, ou contato ainda armazenado com JID @lid + // (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn). + continue; + } + } + } + const jid = (0, whatsapp_1.normalizeJid)(jidRaw); + const list = byJid.get(jid) ?? []; + list.push(msg); + byJid.set(jid, list); + } + let totalSaved = 0; + for (const [jid, msgs] of byJid) { + try { + // Calcula o timestamp mais recente para atualizar lastMessageAt do chat + const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0); + const lastAt = latestTs ? new Date(latestTs * 1000) : null; + // 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação) + const contact = await prisma_1.prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + phone: jid.endsWith('@g.us') ? null : jid.split('@')[0], + }, + update: {}, + }); + // 2. Garantir que o Chat existe e está vinculado ao Contact + const chat = await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + contactId: contact.id, + lastMessageAt: lastAt, + unreadCount: 0 + }, + update: { + contactId: contact.id, + ...(lastAt && { lastMessageAt: lastAt }) + }, + }); + // 3. Prepara registros para bulk insert (createMany com skipDuplicates) + const rows = msgs.flatMap((msg) => { + const { key, messageTimestamp } = msg; + let message = msg.message; + if (!message || !key.id) + return []; + message = unwrapInnerMessage(message); + const contentType = (0, baileys_1.getContentType)(message); + if (!contentType) + return []; + // Descarta tipos de protocolo/sistema que não representam conteúdo real + if (PROTOCOL_CONTENT_TYPES.has(contentType)) + return []; + const msgType = this.mapContentType(contentType); + if (msgType === client_1.MessageType.UNSUPPORTED) + return []; + const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date(); + // Extrai o corpo textual — tenta vários campos em ordem de prioridade + const body = message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + null; + const isGroup = jid.endsWith('@g.us'); + return [{ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid: jid, + messageId: key.id, + fromMe: key.fromMe ?? false, + type: msgType, + body, + // pushName: nome público do remetente (só confiável em msgs recebidas) + pushName: (key.fromMe ? null : msg.pushName) ?? null, + // senderJid: em grupos, identifica quem enviou (JID do participante) + senderJid: isGroup ? (key.participant ?? null) : null, + status: 'READ', // históricas são consideradas já lidas + timestamp: ts, + }]; + }); + if (rows.length > 0) { + const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true }); + totalSaved += count; + } + } + catch (err) { + logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat'); + } + } + if (totalSaved > 0) { + logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // TEMPO REAL — Processa uma única mensagem (notify) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa uma mensagem recebida em tempo real. + * + * Fluxo completo: + * 1. Filtra broadcasts, newsletters + * 2. Resolve LID → telefone (se @lid) + * 3. Cria/atualiza Contact com pushName (se aplicável) + * 4. Cria/atualiza Chat vinculado ao Contact + * 5. Persiste a Message no banco + * 6. Emite socket events (message:new, chat:upsert) + * 7. Agenda download de mídia (assíncrono) + * 8. Dispara chatbot (assíncrono) + */ + async processMessage(msg) { + const { key, messageTimestamp, pushName } = msg; + let message = msg.message; + if (!message || !key.remoteJid) + return; + // Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc). + // Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED. + message = unwrapInnerMessage(message); + // Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp + if (key.remoteJid === 'status@broadcast') + return; + if (key.remoteJid.endsWith('@newsletter')) + return; + if (key.remoteJid.startsWith('0@')) + return; + // ── Resolução LID → telefone ────────────────────────────────────────── + // WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net. + // O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB. + // + // SEM esta resolução, mensagens de contatos que usam LID seriam descartadas + // e o chat apareceria com a foto/número errado no frontend (bug crítico). + // + // Prioridade de resolução: + // 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos) + // 2. key.participantPn — idem, usado em contextos de grupo + // 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare) + /** + * Guarda o JID @lid original quando a resolução é bem-sucedida, para + * persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact). + * Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para + * contatos novos — bug silencioso que fazia o mapeamento ser perdido. + */ + let originalLidJid = null; + if (key.remoteJid.endsWith('@lid')) { + const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key); + if (!phoneJid) { + // Sem resolução: não há como saber o número real do contato. + // A mensagem será perdida, mas o mapeamento será capturado no futuro + // via chats.phoneNumberShare e as próximas mensagens serão processadas. + logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando'); + return; + } + originalLidJid = key.remoteJid; + key.remoteJid = phoneJid; + } + const contentType = (0, baileys_1.getContentType)(message); + if (!contentType) + return; + // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário) + if (PROTOCOL_CONTENT_TYPES.has(contentType)) + return; + const msgType = this.mapContentType(contentType); + // Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED + if (msgType === client_1.MessageType.UNSUPPORTED) + return; + const fromMe = key.fromMe ?? false; + const remoteJidRaw = key.remoteJid; + /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */ + const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw); + const messageId = key.id; + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? ''); + if (ownJid && remoteJid === ownJid) + return; + // ── 1. Contact: criar ou atualizar ──────────────────────────────────── + // REGRA IMPORTANTE sobre pushName: + // - pushName é o "nome público" que o remetente escolheu no WhatsApp + // - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável + // - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão + // → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho") + // - Para GRUPOS: pushName é do remetente individual, não do grupo + // → O nome do grupo vem de groups.upsert → Chat.name (subject) + const isGroupMessage = remoteJid.endsWith('@g.us'); + const participantJid = key.participant ?? msg.participant ?? null; + const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null; + const contact = await prisma_1.prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + name: safePushName, + notify: safePushName, + phone: isGroupMessage ? null : remoteJid.split('@')[0], + // Se resolvemos a partir de @lid, persiste o mapeamento já na criação. + // Sem isso, o próximo @lid do mesmo contato falharia o DB lookup. + ...(originalLidJid && { lidJid: originalLidJid }), + }, + update: { + // Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert + ...(safePushName && { notify: safePushName }), + // Garante que o lidJid fique salvo mesmo em contatos pré-existentes + // que ainda não tinham o mapeamento (ex: criados via history sync). + ...(originalLidJid && { lidJid: originalLidJid }), + }, + }); + // ── 2. Chat: criar ou atualizar, vinculando ao Contact ──────────────── + const chat = await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + contactId: contact.id, + unreadCount: fromMe ? 0 : 1, + lastMessageAt: new Date(messageTimestamp * 1000), + }, + update: { + contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos) + lastMessageAt: new Date(messageTimestamp * 1000), + // fromMe: zera unread (estamos respondendo); !fromMe: incrementa + unreadCount: fromMe ? { set: 0 } : { increment: 1 }, + }, + }); + // ── 3. Extrai corpo textual da mensagem ─────────────────────────────── + // nativeFlowResponseMessage: resposta ao clique de botão/lista + let nativeFlowBody = null; + if (message.interactiveResponseMessage?.nativeFlowResponseMessage) { + try { + const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}'); + nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null; + } + catch { /* ignora JSON inválido */ } + } + const body = message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + nativeFlowBody ?? + null; + // ── 4. Contexto de reply (se for resposta a outra mensagem) ─────────── + const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null; + let replyToId = null; + if (replyToWaId) { + const replyMsg = await prisma_1.prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } }, + }); + replyToId = replyMsg?.id ?? null; + } + // ── 5. Persiste a mensagem (mídia será baixada depois) ──────────────── + const saved = await prisma_1.prisma.message.upsert({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid, + messageId, + fromMe, + type: msgType, + body, + pushName: fromMe ? null : (pushName ?? null), + senderJid: isGroupMessage ? participantJid : null, + replyToId, + status: fromMe ? 'SENT' : 'PENDING', + timestamp: new Date(messageTimestamp * 1000), + }, + update: {}, // Se já existe (duplicata), não sobrescreve + }); + // ── 6. Emite eventos Socket.IO ──────────────────────────────────────── + // 6a. Para quem está com o chat aberto (sala chat:{id}) + // Inclui replyTo quando é uma resposta — o frontend precisa para exibir + // a caixa de citação (quoted block) em tempo real, sem precisar recarregar. + const emitMsg = replyToId + ? await prisma_1.prisma.message.findUnique({ + where: { id: saved.id }, + include: { + replyTo: { + select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true }, + }, + }, + }) ?? saved + : saved; + this.io.to(`chat:${chat.id}`).emit('message:new', { + ...emitMsg, + pushName, + senderJid: isGroupMessage ? participantJid : null, + }); + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hook_bus_1.hookBus.emit('ext:message.new', { + instanceId: this.instanceId, + tenantId: this.tenantId, + chatId: chat.id, + message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null }, + timestamp: Date.now(), + }).catch(() => { }); + // 6b. Para TODOS os clientes do tenant (atualiza a lista de chats) + // Necessário para: novos chats, chats não abertos, badge de unread + this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp }); + // ── 7. Download de mídia assíncrono (não bloqueia o event loop) ──────── + if (this.isMediaMessage(contentType)) { + // Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc). + const msgUnwrapped = { ...msg, message }; + setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType)); + } + // ── 8. Dispara chatbot para mensagens recebidas com texto ───────────── + if (!fromMe && body && this.chatbotService) { + setImmediate(() => this.chatbotService.handleIncoming({ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + jid: remoteJid, + text: body, + sock: this.sock, + }).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono'))); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO LID → TELEFONE + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Converte um JID LID (ex: "123456789@lid") para o JID com telefone + * (ex: "5511999999999@s.whatsapp.net"). + * + * O WhatsApp moderno usa LID (Linked ID) como identificador interno. + * Para exibir o contato corretamente, precisamos mapear para o telefone real. + * + * Fontes de resolução (em ordem de prioridade): + * 1. key.senderPn / key.participantPn — campo do protocolo WA. + * Útil para mensagens RECEBIDAS e contextos de grupo. + * 2. peerRecipientHints[msgId] — dica extraída da stanza bruta + * (atributo `peer_recipient_pn`, não exposto pelo Baileys). + * Única fonte confiável para mensagens fromMe=true enviadas pelo + * celular pra um contato @lid que ainda não temos mapeado. + * 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente). + * Populado por: processamento anterior, chats.phoneNumberShare, + * ou contacts.upsert do history sync. + * + * A persistência do mapeamento LID→phone foi movida para processMessage + * (após o upsert do Contact), para evitar o bug anterior onde o + * `updateMany` disparado aqui afetava 0 rows em contatos inexistentes. + * + * @param lidJid - O JID no formato @lid (ex: "123456789@lid") + * @param key - WAMessageKey (id, fromMe, senderPn, participantPn) + * @returns O JID @s.whatsapp.net correspondente, ou null se não resolver + */ + async resolveLidToPhoneJid(lidJid, key) { + // 1. senderPn vem direto do protocolo WhatsApp (mais confiável) + // OBS: em mensagens fromMe=true sincronizadas do celular, senderPn + // descreve o DONO da sessão, não o destinatário — inútil aqui. + const senderPn = key.senderPn || key.participantPn; + if (senderPn && !key.fromMe) { + const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn); + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid; + } + } + // 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para + // mensagens fromMe=true que chegam via sync de outro dispositivo. + if (key.fromMe && key.id) { + const hint = this.peerRecipientHints.get(key.id); + this.peerRecipientHints.delete(key.id); // uso único + if (hint) { + const phoneJid = (0, whatsapp_1.normalizeJid)(hint); + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid; + } + } + } + // 3. Busca no DB por mapeamento LID salvo anteriormente + const contact = await prisma_1.prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid }, + select: { jid: true }, + }); + if (contact?.jid) + return contact.jid; + return null; + } + // ═══════════════════════════════════════════════════════════════════════════ + // EMISSÃO DE EVENTOS SOCKET.IO + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Emite evento `chat:upsert` para todos os clientes do tenant. + * + * Usado para atualizar a lista de chats no frontend em tempo real: + * - Novos chats aparecem instantaneamente + * - Chats existentes atualizam lastMessage, unreadCount, etc. + * - Chats @lid são filtrados (não devem aparecer no frontend) + * + * O frontend usa este evento para reordenar a lista por lastMessageAt + * e exibir o preview da última mensagem. + */ + async emitChatUpsert(chatId, lastMessage) { + try { + const chat = await prisma_1.prisma.chat.findUnique({ + where: { id: chatId }, + include: { + contact: { + select: { + name: true, + verifiedName: true, + notify: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + }, + }, + }, + }); + if (!chat) + return; + // Chats @lid são artefatos internos — nunca expor ao frontend + if (chat.jid.endsWith('@lid')) + return; + const c = chat.contact; + const isGroup = chat.jid.endsWith('@g.us'); + // Regra de resolução de nome: + // Grupos: usa Chat.name (subject do grupo, vem de groups.upsert) + // Individuais: name (agenda) > verifiedName (business) > notify (pushname) + // NUNCA usar pushName do remetente como nome de grupo + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null); + this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', { + id: chat.id, + tenantId: chat.tenantId, + instanceId: chat.instanceId, + jid: chat.jid, + name: chat.name ?? null, + unreadCount: chat.unreadCount, + lastMessageAt: chat.lastMessageAt, + isPinned: chat.isPinned, + isArchived: chat.isArchived, + botPaused: chat.botPaused, + contact: c + ? { + name: displayName, + phone: c.phone, + avatarUrl: c.avatarUrl, + scoreReputacao: c.scoreReputacao, + flagRestricao: c.flagRestricao, + } + : isGroup + ? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + : null, + lastMessage, + }); + } + catch (err) { + logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD DE MÍDIA + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Baixa o conteúdo de mídia de uma mensagem e salva no disco. + * + * Executado via setImmediate() para não bloquear o event loop. + * Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl + * e emite `message:media_ready` para o frontend renderizar o preview. + * + * Organização: /media/{instanceId}/{messageId}.{ext} + */ + async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) { + try { + const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage }); + if (!buffer) + return; + // Para documentos: preserva nome e extensão originais do arquivo + const docMsg = msg.message?.documentMessage; + const originalFileName = docMsg?.fileName?.trim() || null; + const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType); + let ext; + if (contentType === 'documentMessage' && originalFileName) { + ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin'; + } + else { + ext = this.extensionForType(contentType); + } + const fileName = `${savedMsgId}.${ext}`; + const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName); + await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true }); + await promises_1.default.writeFile(filePath, buffer); + // Tenta upload para Wasabi; se falhar usa path local como fallback + let mediaUrl = `/media/${this.instanceId}/${fileName}`; + let mediaPath = filePath; + let uploadedToRemote = false; + if (StorageProvider_1.storageProvider.isRegistered()) { + try { + const result = await StorageProvider_1.storageProvider.uploadFile({ + category: 'whatsapp', + usuarioSis: this.tenantId, + sessionJID: this.instanceId, + file: { + originalname: originalFileName ?? fileName, + buffer: buffer, + mimetype: originalMimetype, + }, + }); + mediaUrl = result.path; + if (result.provider !== 'local_fallback') { + mediaPath = null; + uploadedToRemote = true; + } + logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage'); + } + catch (uploadErr) { + logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local'); + } + } + await prisma_1.prisma.message.update({ + where: { id: savedMsgId }, + data: { + mediaPath, + mediaUrl, + // Preserva o nome original do documento para exibição no frontend + ...(contentType === 'documentMessage' && originalFileName + ? { fileName: originalFileName } + : {}), + }, + }); + if (uploadedToRemote) { + promises_1.default.unlink(filePath).catch((err) => { + logger_1.logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto'); + }); + } + // Notifica frontend que a mídia está pronta para exibição + this.io.to(`chat:${chatId}`).emit('message:media_ready', { + messageId: savedMsgId, + mediaUrl, + }); + logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva'); + } + catch (err) { + logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITÁRIOS + // ═══════════════════════════════════════════════════════════════════════════ + /** Verifica se o contentType é de mídia (precisa de download) */ + isMediaMessage(contentType) { + return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType); + } + /** + * Mapeia o contentType do Baileys para o enum MessageType do Prisma. + * Tipos não reconhecidos são mapeados para UNSUPPORTED. + */ + mapContentType(contentType) { + const map = { + conversation: client_1.MessageType.TEXT, + extendedTextMessage: client_1.MessageType.TEXT, + imageMessage: client_1.MessageType.IMAGE, + videoMessage: client_1.MessageType.VIDEO, + audioMessage: client_1.MessageType.AUDIO, + documentMessage: client_1.MessageType.DOCUMENT, + stickerMessage: client_1.MessageType.STICKER, + locationMessage: client_1.MessageType.LOCATION, + contactMessage: client_1.MessageType.CONTACT, + reactionMessage: client_1.MessageType.REACTION, + pollCreationMessage: client_1.MessageType.POLL, + pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos + pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select + // Resposta ao clique de botão / seleção de item de lista + interactiveResponseMessage: client_1.MessageType.INTERACTIVE, + // Mensagem interativa recebida de outro remetente (botões/lista/carrossel) + interactiveMessage: client_1.MessageType.BUTTONS, + }; + return map[contentType] ?? client_1.MessageType.UNSUPPORTED; + } + /** Retorna a extensão de arquivo adequada para o tipo de mídia */ + extensionForType(contentType) { + const map = { + imageMessage: 'jpg', + videoMessage: 'mp4', + audioMessage: 'ogg', + documentMessage: 'bin', + stickerMessage: 'webp', + }; + return map[contentType] ?? 'bin'; + } + /** Retorna o mimetype adequado para o tipo de mídia */ + mimetypeForType(contentType) { + const map = { + imageMessage: 'image/jpeg', + videoMessage: 'video/mp4', + audioMessage: 'audio/ogg', + documentMessage: 'application/octet-stream', + stickerMessage: 'image/webp', + }; + return map[contentType] ?? 'application/octet-stream'; + } +} +exports.MessageHandler = MessageHandler; +//# sourceMappingURL=MessageHandler.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js.map b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js.map new file mode 100644 index 0000000..bab5741 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.js.map @@ -0,0 +1 @@ +{"version":3,"file":"MessageHandler.js","sourceRoot":"","sources":["MessageHandler.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts new file mode 100644 index 0000000..165f4c3 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts @@ -0,0 +1,965 @@ +/** + * MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico). + * + * Responsabilidades: + * 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append') + * 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net) + * 3. Criar/atualizar Contact, Chat e Message no banco + * 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert) + * 5. Download assíncrono de mídia (imagens, vídeos, docs) + * 6. Disparar chatbot para mensagens recebidas com texto + * + * CONTEXTO LID (Linked ID): + * A partir de 2024, o WhatsApp passou a usar LID como identificador primário + * nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez + * de "5511999999999@s.whatsapp.net". O telefone real está disponível em: + * - key.senderPn: campo do protocolo WA contendo o JID com telefone + * - key.participantPn: idem, usado em contextos de grupo + * - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente) + * + * @see WhatsAppConnectionManager — registra os event listeners do Baileys + * @see ContactHandler — gerencia contatos e avatares + */ +import path from 'path' +import fs from 'fs/promises' +import { + type WASocket, + type WAMessage, + downloadMediaMessage, + getContentType, +} from '../engine' +import { MessageType } from '@prisma/client' +import { prisma } from '../../../infra/database/prisma' +import { logger } from '../../../config/logger' +import { normalizeJid } from '../../../shared/utils/whatsapp' +import { storageProvider } from '../../../core/StorageProvider' +import type { Server as SocketIOServer } from 'socket.io' +import type { ChatbotService } from '../../../modules/chatbot/chatbot.service' +import { hookBus } from '../../../core/hook-bus' + +/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ +const MEDIA_DIR = path.resolve('./media') + +/** + * Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário. + * Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message. + */ +const PROTOCOL_CONTENT_TYPES = new Set([ + 'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo) + 'protocolMessage', // revogações, timers de desaparecimento (protocolo) + 'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro) + 'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo) + 'messageContextInfo', // só metadados, sem conteúdo + 'appStateSyncKeyShare', // sync de estado do app (protocolo) + 'appStateFatalExceptionNotification', // notificação interna (protocolo) + 'keepInChatMessage', // manter no chat (protocolo) +]) + +/** + * Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`. + * Sem desembrulhar, getContentType retorna o nome do wrapper (ex: + * `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia + * um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é + * descartada silenciosamente. Itera enquanto houver wrapper aninhado. + */ +const COMPOSITE_WRAPPER_TYPES = new Set([ + 'documentWithCaptionMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'editedMessage', + 'botInvokeMessage', +]) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function unwrapInnerMessage(msg: any): any { + let cur = msg + let depth = 0 + while (cur && depth < 5) { + const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo') + const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k)) + if (!wrapperKey) return cur + const inner = cur[wrapperKey]?.message + if (!inner || typeof inner !== 'object') return cur + cur = inner + depth++ + } + return cur +} + +export class MessageHandler { + /** + * Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas. + * + * Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net"). + * + * Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true) + * para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa + * sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da + * sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do + * telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o + * Baileys NÃO expõe em `decode-wa-message.js`. + * + * Por isso, o WhatsAppConnectionManager registra um listener extra em + * `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa + * ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid` + * consome a dica e remove a entrada (mapa efêmero, não cresce). + */ + private peerRecipientHints = new Map() + + constructor( + /** Socket Baileys ativo — usado para download de mídia e reupload */ + private sock: WASocket, + /** UUID da instância WhatsApp (1 instância = 1 número conectado) */ + private instanceId: string, + /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */ + private tenantId: string, + /** Socket.IO server — emite eventos em tempo real para o frontend */ + private io: SocketIOServer, + /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */ + private chatbotService?: ChatbotService + ) {} + + /** + * Registra uma dica de destinatário extraída da stanza bruta. + * + * Chamado pelo listener `sock.ws.on('CB:message')` no + * WhatsAppConnectionManager. Entradas expiram em 60s como garantia + * contra vazamento caso o `messages.upsert` correspondente nunca chegue. + */ + setPeerRecipientHint(msgId: string, peerRecipientPn: string): void { + this.peerRecipientHints.set(msgId, peerRecipientPn) + setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref() + } + + /** + * Ponto de entrada principal — chamado pelo event listener `messages.upsert`. + * + * @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado + * + * Diferença entre os modos: + * - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia) + * - append: apenas salva no banco em lote (bulk insert), sem emitir eventos + */ + async handle({ messages, type }: { messages: WAMessage[]; type: string }): Promise { + if (type === 'notify') { + for (const msg of messages) { + try { + await this.processMessage(msg) + } catch (err) { + logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem') + } + } + } else if (type === 'append') { + await this.handleHistory(messages) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa mensagens históricas (history sync / append). + * + * Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany + * por grupo, minimizando round-trips ao banco. + * + * Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup. + * Se não for possível resolver, a mensagem é descartada silenciosamente + * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare). + */ + async handleHistory(messages: WAMessage[]): Promise { + if (messages.length === 0) return + + // Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos) + const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000) + + const mensagensRecentes: WAMessage[] = [] + const mensagensAntigas: WAMessage[] = [] + + // Separa as mensagens por idade + for (const msg of messages) { + const ts = Number(msg.messageTimestamp ?? 0) + if (ts >= doisDiasAtrasEmSegundos) { + mensagensRecentes.push(msg) + } else { + mensagensAntigas.push(msg) + } + } + + // 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato) + if (mensagensRecentes.length > 0) { + logger.info({ + instanceId: this.instanceId, + count: mensagensRecentes.length + }, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!'); + + await this.processHistoryBatch(mensagensRecentes) + } + + // 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano) + if (mensagensAntigas.length > 0) { + logger.info({ + instanceId: this.instanceId, + count: mensagensAntigas.length + }, '[History] Agendando histórico antigo para sincronização gradual em background...'); + + this.processOlderHistoryGradually(mensagensAntigas) + } + } + + /** + * Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos + */ + private processOlderHistoryGradually(messages: WAMessage[]): void { + const tamanhoDoLote = 100 + const intervaloMs = 3000 // 3 segundos de folga para a CPU/Postgres respirar + let offset = 0 + + const processarProximoLote = async () => { + // Se processou tudo, encerra o ciclo + if (offset >= messages.length) { + logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!') + return + } + + // Corta o próximo pedaço do histórico + const lote = messages.slice(offset, offset + tamanhoDoLote) + offset += tamanhoDoLote + + try { + await this.processHistoryBatch(lote) + } catch (err) { + logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo') + } + + // Agenda o próximo lote de forma não bloqueante + setTimeout(processarProximoLote, intervaloMs).unref() + } + + // Inicia o sincronismo em background após 5 segundos, garantindo que + // a conexão inicial e o frontend já estejam 100% carregados e estáveis + setTimeout(processarProximoLote, 5000).unref() + } + + /** + * Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar) + */ + private async processHistoryBatch(messages: WAMessage[]): Promise { + if (messages.length === 0) return + + // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat + const ownJid = normalizeJid(this.sock.user?.id ?? '') + + // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID) + const byJid = new Map() + for (const msg of messages) { + let jidRaw = msg.key.remoteJid + if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) continue + + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const normalizedRaw = normalizeJid(jidRaw) + if (ownJid && normalizedRaw === ownJid) continue + + // ── Resolução LID → phone JID para mensagens históricas ────────── + // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid. + // Precisamos converter para @s.whatsapp.net antes de persistir, + // caso contrário o chat ficaria duplicado (um com LID, outro com phone). + // + // Fontes de resolução (ordem de prioridade): + // 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID) + // 2. key.senderPn / participantPn — campo do protocolo WA + // 3. DB lookup — contacts.lidJid salvo por sessões anteriores + if (jidRaw.endsWith('@lid')) { + // 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas. + // Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa). + const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined + if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) { + jidRaw = normalizeJid(remoteJidAlt) + } else { + // 2. senderPn / participantPn + const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn + if (senderPn) { + jidRaw = normalizeJid(senderPn) + if (!jidRaw.endsWith('@s.whatsapp.net')) continue + } else { + // 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler + const contact = await prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw }, + select: { jid: true }, + }) + if (contact?.jid && !contact.jid.endsWith('@lid')) { + jidRaw = contact.jid + } else { + // Sem resolução possível — descarta (será capturada via phoneNumberShare futuro) + continue + } + } + } + } + + const jid = normalizeJid(jidRaw) + const list = byJid.get(jid) ?? [] + list.push(msg) + byJid.set(jid, list) + } + + let totalSaved = 0 + + for (const [jid, msgs] of byJid) { + try { + // Calcula o timestamp mais recente para atualizar lastMessageAt do chat + const latestTs = msgs.reduce((max, m) => + Math.max(max, Number(m.messageTimestamp ?? 0)), 0) + const lastAt = latestTs ? new Date(latestTs * 1000) : null + + // 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação) + const contact = await prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + phone: jid.endsWith('@g.us') ? null : jid.split('@')[0], + }, + update: {}, + }) + + // 2. Garantir que o Chat existe e está vinculado ao Contact + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + contactId: contact.id, + lastMessageAt: lastAt, + unreadCount: 0 + }, + update: { + contactId: contact.id, + ...(lastAt && { lastMessageAt: lastAt }) + }, + }) + + // 3. Prepara registros para bulk insert (createMany com skipDuplicates) + const rows = msgs.flatMap((msg) => { + const { key, messageTimestamp } = msg + let message = msg.message + if (!message || !key.id) return [] + message = unwrapInnerMessage(message) + const contentType = getContentType(message as any) + if (!contentType) return [] + // Descarta tipos de protocolo/sistema que não representam conteúdo real + if (PROTOCOL_CONTENT_TYPES.has(contentType)) return [] + const msgType = this.mapContentType(contentType) + if (msgType === MessageType.UNSUPPORTED) return [] + const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date() + + // Extrai o corpo textual — tenta vários campos em ordem de prioridade + const body = + message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + null + + const isGroup = jid.endsWith('@g.us') + return [{ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid: jid, + messageId: key.id, + fromMe: key.fromMe ?? false, + type: msgType, + body, + // pushName: nome público do remetente (só confiável em msgs recebidas) + pushName: (key.fromMe ? null : msg.pushName) ?? null, + // senderJid: em grupos, identifica quem enviou (JID do participante) + senderJid: isGroup ? (key.participant ?? null) : null, + status: 'READ' as const, // históricas são consideradas já lidas + timestamp: ts, + }] + }) + + if (rows.length > 0) { + const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true }) + totalSaved += count + } + } catch (err) { + logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat') + } + } + + if (totalSaved > 0) { + logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, + `[History] Lote de ${totalSaved} mensagens históricas salvas`) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TEMPO REAL — Processa uma única mensagem (notify) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa uma mensagem recebida em tempo real. + * + * Fluxo completo: + * 1. Filtra broadcasts, newsletters + * 2. Resolve LID → telefone (se @lid) + * 3. Cria/atualiza Contact com pushName (se aplicável) + * 4. Cria/atualiza Chat vinculado ao Contact + * 5. Persiste a Message no banco + * 6. Emite socket events (message:new, chat:upsert) + * 7. Agenda download de mídia (assíncrono) + * 8. Dispara chatbot (assíncrono) + */ + private async processMessage(msg: WAMessage): Promise { + const { key, messageTimestamp, pushName } = msg + let message = msg.message + + if (!message || !key.remoteJid) return + + // Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc). + // Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED. + message = unwrapInnerMessage(message) + + // Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp + if (key.remoteJid === 'status@broadcast') return + if (key.remoteJid.endsWith('@newsletter')) return + if (key.remoteJid.startsWith('0@')) return + + // ── Resolução LID → telefone ────────────────────────────────────────── + // WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net. + // O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB. + // + // SEM esta resolução, mensagens de contatos que usam LID seriam descartadas + // e o chat apareceria com a foto/número errado no frontend (bug crítico). + // + // Prioridade de resolução: + // 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos) + // 2. key.participantPn — idem, usado em contextos de grupo + // 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare) + /** + * Guarda o JID @lid original quando a resolução é bem-sucedida, para + * persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact). + * Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para + * contatos novos — bug silencioso que fazia o mapeamento ser perdido. + */ + let originalLidJid: string | null = null + if (key.remoteJid.endsWith('@lid')) { + const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any) + if (!phoneJid) { + // Sem resolução: não há como saber o número real do contato. + // A mensagem será perdida, mas o mapeamento será capturado no futuro + // via chats.phoneNumberShare e as próximas mensagens serão processadas. + logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, + '[MessageHandler] @lid sem resolução de telefone — descartando') + return + } + originalLidJid = key.remoteJid + // Reescreve o remoteJid para que TODO o processamento abaixo use o JID real. + // Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net. + ;(key as any).remoteJid = phoneJid + } + + const contentType = getContentType(message as any) + if (!contentType) return + + // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário) + if (PROTOCOL_CONTENT_TYPES.has(contentType)) return + + const msgType = this.mapContentType(contentType) + // Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED + if (msgType === MessageType.UNSUPPORTED) return + + const fromMe = key.fromMe ?? false + const remoteJidRaw = key.remoteJid + /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */ + const remoteJid = normalizeJid(remoteJidRaw) + const messageId = key.id! + + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const ownJid = normalizeJid(this.sock.user?.id ?? '') + if (ownJid && remoteJid === ownJid) return + + // ── 1. Contact: criar ou atualizar ──────────────────────────────────── + // REGRA IMPORTANTE sobre pushName: + // - pushName é o "nome público" que o remetente escolheu no WhatsApp + // - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável + // - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão + // → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho") + // - Para GRUPOS: pushName é do remetente individual, não do grupo + // → O nome do grupo vem de groups.upsert → Chat.name (subject) + const isGroupMessage = remoteJid.endsWith('@g.us') + const participantJid = key.participant ?? (msg as any).participant ?? null + + const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null + + const contact = await prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + name: safePushName, + notify: safePushName, + phone: isGroupMessage ? null : remoteJid.split('@')[0], + // Se resolvemos a partir de @lid, persiste o mapeamento já na criação. + // Sem isso, o próximo @lid do mesmo contato falharia o DB lookup. + ...(originalLidJid && { lidJid: originalLidJid }), + }, + update: { + // Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert + ...(safePushName && { notify: safePushName }), + // Garante que o lidJid fique salvo mesmo em contatos pré-existentes + // que ainda não tinham o mapeamento (ex: criados via history sync). + ...(originalLidJid && { lidJid: originalLidJid }), + }, + }) + + // ── 2. Chat: criar ou atualizar, vinculando ao Contact ──────────────── + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + contactId: contact.id, + unreadCount: fromMe ? 0 : 1, + lastMessageAt: new Date((messageTimestamp as number) * 1000), + }, + update: { + contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos) + lastMessageAt: new Date((messageTimestamp as number) * 1000), + // fromMe: zera unread (estamos respondendo); !fromMe: incrementa + unreadCount: fromMe ? { set: 0 } : { increment: 1 }, + }, + }) + + // ── 3. Extrai corpo textual da mensagem ─────────────────────────────── + // nativeFlowResponseMessage: resposta ao clique de botão/lista + let nativeFlowBody: string | null = null + if (message.interactiveResponseMessage?.nativeFlowResponseMessage) { + try { + const params = JSON.parse( + message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}' + ) + nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null + } catch { /* ignora JSON inválido */ } + } + + const body = + message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + nativeFlowBody ?? + null + + // ── 4. Contexto de reply (se for resposta a outra mensagem) ─────────── + const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null + let replyToId: string | null = null + + if (replyToWaId) { + const replyMsg = await prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } }, + }) + replyToId = replyMsg?.id ?? null + } + + // ── 5. Persiste a mensagem (mídia será baixada depois) ──────────────── + const saved = await prisma.message.upsert({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid, + messageId, + fromMe, + type: msgType, + body, + pushName: fromMe ? null : (pushName ?? null), + senderJid: isGroupMessage ? participantJid : null, + replyToId, + status: fromMe ? 'SENT' : 'PENDING', + timestamp: new Date((messageTimestamp as number) * 1000), + }, + update: {}, // Se já existe (duplicata), não sobrescreve + }) + + // ── 6. Emite eventos Socket.IO ──────────────────────────────────────── + + // 6a. Para quem está com o chat aberto (sala chat:{id}) + // Inclui replyTo quando é uma resposta — o frontend precisa para exibir + // a caixa de citação (quoted block) em tempo real, sem precisar recarregar. + const emitMsg = replyToId + ? await prisma.message.findUnique({ + where: { id: saved.id }, + include: { + replyTo: { + select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true }, + }, + }, + }) ?? saved + : saved + + this.io.to(`chat:${chat.id}`).emit('message:new', { + ...emitMsg, + pushName, + senderJid: isGroupMessage ? participantJid : null, + }) + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hookBus.emit('ext:message.new', { + instanceId: this.instanceId, + tenantId: this.tenantId, + chatId: chat.id, + message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null }, + timestamp: Date.now(), + }).catch(() => {}) + + // 6b. Para TODOS os clientes do tenant (atualiza a lista de chats) + // Necessário para: novos chats, chats não abertos, badge de unread + this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp }) + + // ── 7. Download de mídia assíncrono (não bloqueia o event loop) ──────── + if (this.isMediaMessage(contentType)) { + // Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc). + const msgUnwrapped = { ...msg, message } + setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType)) + } + + // ── 8. Dispara chatbot para mensagens recebidas com texto ───────────── + if (!fromMe && body && this.chatbotService) { + setImmediate(() => + this.chatbotService!.handleIncoming({ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + jid: remoteJid, + text: body, + sock: this.sock, + }).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono')) + ) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO LID → TELEFONE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Converte um JID LID (ex: "123456789@lid") para o JID com telefone + * (ex: "5511999999999@s.whatsapp.net"). + * + * O WhatsApp moderno usa LID (Linked ID) como identificador interno. + * Para exibir o contato corretamente, precisamos mapear para o telefone real. + * + * Fontes de resolução (em ordem de prioridade): + * 1. key.senderPn / key.participantPn — campo do protocolo WA. + * Útil para mensagens RECEBIDAS e contextos de grupo. + * 2. peerRecipientHints[msgId] — dica extraída da stanza bruta + * (atributo `peer_recipient_pn`, não exposto pelo Baileys). + * Única fonte confiável para mensagens fromMe=true enviadas pelo + * celular pra um contato @lid que ainda não temos mapeado. + * 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente). + * Populado por: processamento anterior, chats.phoneNumberShare, + * ou contacts.upsert do history sync. + * + * A persistência do mapeamento LID→phone foi movida para processMessage + * (após o upsert do Contact), para evitar o bug anterior onde o + * `updateMany` disparado aqui afetava 0 rows em contatos inexistentes. + * + * @param lidJid - O JID no formato @lid (ex: "123456789@lid") + * @param key - WAMessageKey (id, fromMe, senderPn, participantPn) + * @returns O JID @s.whatsapp.net correspondente, ou null se não resolver + */ + private async resolveLidToPhoneJid( + lidJid: string, + key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string } + ): Promise { + // 1. senderPn vem direto do protocolo WhatsApp (mais confiável) + // OBS: em mensagens fromMe=true sincronizadas do celular, senderPn + // descreve o DONO da sessão, não o destinatário — inútil aqui. + const senderPn = key.senderPn || key.participantPn + if (senderPn && !key.fromMe) { + const phoneJid = normalizeJid(senderPn) + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid + } + } + + // 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para + // mensagens fromMe=true que chegam via sync de outro dispositivo. + if (key.fromMe && key.id) { + const hint = this.peerRecipientHints.get(key.id) + this.peerRecipientHints.delete(key.id) // uso único + if (hint) { + const phoneJid = normalizeJid(hint) + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid + } + } + } + + // 3. Busca no DB por mapeamento LID salvo anteriormente + const contact = await prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid }, + select: { jid: true }, + }) + if (contact?.jid) return contact.jid + + return null + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EMISSÃO DE EVENTOS SOCKET.IO + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Emite evento `chat:upsert` para todos os clientes do tenant. + * + * Usado para atualizar a lista de chats no frontend em tempo real: + * - Novos chats aparecem instantaneamente + * - Chats existentes atualizam lastMessage, unreadCount, etc. + * - Chats @lid são filtrados (não devem aparecer no frontend) + * + * O frontend usa este evento para reordenar a lista por lastMessageAt + * e exibir o preview da última mensagem. + */ + private async emitChatUpsert( + chatId: string, + lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date } + ): Promise { + try { + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { + contact: { + select: { + name: true, + verifiedName: true, + notify: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + }, + }, + }, + }) + if (!chat) return + + // Chats @lid são artefatos internos — nunca expor ao frontend + if (chat.jid.endsWith('@lid')) return + + const c = chat.contact + const isGroup = chat.jid.endsWith('@g.us') + + // Regra de resolução de nome: + // Grupos: usa Chat.name (subject do grupo, vem de groups.upsert) + // Individuais: name (agenda) > verifiedName (business) > notify (pushname) + // NUNCA usar pushName do remetente como nome de grupo + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null) + + this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', { + id: chat.id, + tenantId: chat.tenantId, + instanceId: chat.instanceId, + jid: chat.jid, + name: chat.name ?? null, + unreadCount: chat.unreadCount, + lastMessageAt: chat.lastMessageAt, + isPinned: chat.isPinned, + isArchived: chat.isArchived, + botPaused: chat.botPaused, + contact: c + ? { + name: displayName, + phone: c.phone, + avatarUrl: c.avatarUrl, + scoreReputacao: c.scoreReputacao, + flagRestricao: c.flagRestricao, + } + : isGroup + ? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + : null, + lastMessage, + }) + } catch (err) { + logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD DE MÍDIA + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Baixa o conteúdo de mídia de uma mensagem e salva no disco. + * + * Executado via setImmediate() para não bloquear o event loop. + * Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl + * e emite `message:media_ready` para o frontend renderizar o preview. + * + * Organização: /media/{instanceId}/{messageId}.{ext} + */ + private async downloadAndPersistMedia( + msg: WAMessage, + savedMsgId: string, + chatId: string, + contentType: string + ): Promise { + try { + const buffer = await downloadMediaMessage( + msg as any, + 'buffer', + {}, + { logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any } + ) + + if (!buffer) return + + // Para documentos: preserva nome e extensão originais do arquivo + const docMsg = msg.message?.documentMessage + const originalFileName = docMsg?.fileName?.trim() || null + const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType) + + let ext: string + if (contentType === 'documentMessage' && originalFileName) { + ext = path.extname(originalFileName).replace('.', '') || 'bin' + } else { + ext = this.extensionForType(contentType) + } + + const fileName = `${savedMsgId}.${ext}` + const filePath = path.join(MEDIA_DIR, this.instanceId, fileName) + + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer as Buffer) + + // Tenta upload para Wasabi; se falhar usa path local como fallback + let mediaUrl = `/media/${this.instanceId}/${fileName}` + let mediaPath: string | null = filePath + let uploadedToRemote = false + + if (storageProvider.isRegistered()) { + try { + const result = await storageProvider.uploadFile({ + category: 'whatsapp', + usuarioSis: this.tenantId, + sessionJID: this.instanceId, + file: { + originalname: originalFileName ?? fileName, + buffer: buffer as Buffer, + mimetype: originalMimetype, + }, + }) + mediaUrl = result.path + if (result.provider !== 'local_fallback') { + mediaPath = null + uploadedToRemote = true + } + logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage') + } catch (uploadErr) { + logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local') + } + } + + await prisma.message.update({ + where: { id: savedMsgId }, + data: { + mediaPath, + mediaUrl, + // Preserva o nome original do documento para exibição no frontend + ...(contentType === 'documentMessage' && originalFileName + ? { fileName: originalFileName } + : {}), + }, + }) + + if (uploadedToRemote) { + fs.unlink(filePath).catch((err) => { + logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto') + }) + } + + // Notifica frontend que a mídia está pronta para exibição + this.io.to(`chat:${chatId}`).emit('message:media_ready', { + messageId: savedMsgId, + mediaUrl, + }) + + logger.debug({ savedMsgId, filePath }, 'Mídia salva') + } catch (err) { + logger.error({ err, savedMsgId }, 'Falha ao baixar mídia') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITÁRIOS + // ═══════════════════════════════════════════════════════════════════════════ + + /** Verifica se o contentType é de mídia (precisa de download) */ + private isMediaMessage(contentType: string): boolean { + return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType) + } + + /** + * Mapeia o contentType do Baileys para o enum MessageType do Prisma. + * Tipos não reconhecidos são mapeados para UNSUPPORTED. + */ + private mapContentType(contentType: string): MessageType { + const map: Record = { + conversation: MessageType.TEXT, + extendedTextMessage: MessageType.TEXT, + imageMessage: MessageType.IMAGE, + videoMessage: MessageType.VIDEO, + audioMessage: MessageType.AUDIO, + documentMessage: MessageType.DOCUMENT, + stickerMessage: MessageType.STICKER, + locationMessage: MessageType.LOCATION, + contactMessage: MessageType.CONTACT, + reactionMessage: MessageType.REACTION, + pollCreationMessage: MessageType.POLL, + pollCreationMessageV2: MessageType.POLL, // poll multi-select grupos + pollCreationMessageV3: MessageType.POLL, // poll single-select + // Resposta ao clique de botão / seleção de item de lista + interactiveResponseMessage: MessageType.INTERACTIVE, + // Mensagem interativa recebida de outro remetente (botões/lista/carrossel) + interactiveMessage: MessageType.BUTTONS, + } + return map[contentType] ?? MessageType.UNSUPPORTED + } + + /** Retorna a extensão de arquivo adequada para o tipo de mídia */ + private extensionForType(contentType: string): string { + const map: Record = { + imageMessage: 'jpg', + videoMessage: 'mp4', + audioMessage: 'ogg', + documentMessage: 'bin', + stickerMessage: 'webp', + } + return map[contentType] ?? 'bin' + } + + /** Retorna o mimetype adequado para o tipo de mídia */ + private mimetypeForType(contentType: string): string { + const map: Record = { + imageMessage: 'image/jpeg', + videoMessage: 'video/mp4', + audioMessage: 'audio/ogg', + documentMessage: 'application/octet-stream', + stickerMessage: 'image/webp', + } + return map[contentType] ?? 'application/octet-stream' + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts.bak b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts.bak new file mode 100644 index 0000000..8046976 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts.bak @@ -0,0 +1,888 @@ +/** + * MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico). + * + * Responsabilidades: + * 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append') + * 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net) + * 3. Criar/atualizar Contact, Chat e Message no banco + * 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert) + * 5. Download assíncrono de mídia (imagens, vídeos, docs) + * 6. Disparar chatbot para mensagens recebidas com texto + * + * CONTEXTO LID (Linked ID): + * A partir de 2024, o WhatsApp passou a usar LID como identificador primário + * nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez + * de "5511999999999@s.whatsapp.net". O telefone real está disponível em: + * - key.senderPn: campo do protocolo WA contendo o JID com telefone + * - key.participantPn: idem, usado em contextos de grupo + * - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente) + * + * @see WhatsAppConnectionManager — registra os event listeners do Baileys + * @see ContactHandler — gerencia contatos e avatares + */ +import path from 'path' +import fs from 'fs/promises' +import { + type WASocket, + type WAMessage, + downloadMediaMessage, + getContentType, +} from '../engine' +import { MessageType } from '@prisma/client' +import { prisma } from '../../../infra/database/prisma' +import { logger } from '../../../config/logger' +import { normalizeJid } from '../../../shared/utils/whatsapp' +import { storageProvider } from '../../../core/StorageProvider' +import type { Server as SocketIOServer } from 'socket.io' +import type { ChatbotService } from '../../../modules/chatbot/chatbot.service' +import { hookBus } from '../../../core/hook-bus' + +/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ +const MEDIA_DIR = path.resolve('./media') + +/** + * Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário. + * Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message. + */ +const PROTOCOL_CONTENT_TYPES = new Set([ + 'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo) + 'protocolMessage', // revogações, timers de desaparecimento (protocolo) + 'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro) + 'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo) + 'messageContextInfo', // só metadados, sem conteúdo + 'appStateSyncKeyShare', // sync de estado do app (protocolo) + 'appStateFatalExceptionNotification', // notificação interna (protocolo) + 'keepInChatMessage', // manter no chat (protocolo) +]) + +/** + * Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`. + * Sem desembrulhar, getContentType retorna o nome do wrapper (ex: + * `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia + * um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é + * descartada silenciosamente. Itera enquanto houver wrapper aninhado. + */ +const COMPOSITE_WRAPPER_TYPES = new Set([ + 'documentWithCaptionMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'editedMessage', + 'botInvokeMessage', +]) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function unwrapInnerMessage(msg: any): any { + let cur = msg + let depth = 0 + while (cur && depth < 5) { + const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo') + const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k)) + if (!wrapperKey) return cur + const inner = cur[wrapperKey]?.message + if (!inner || typeof inner !== 'object') return cur + cur = inner + depth++ + } + return cur +} + +export class MessageHandler { + /** + * Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas. + * + * Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net"). + * + * Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true) + * para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa + * sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da + * sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do + * telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o + * Baileys NÃO expõe em `decode-wa-message.js`. + * + * Por isso, o WhatsAppConnectionManager registra um listener extra em + * `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa + * ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid` + * consome a dica e remove a entrada (mapa efêmero, não cresce). + */ + private peerRecipientHints = new Map() + + constructor( + /** Socket Baileys ativo — usado para download de mídia e reupload */ + private sock: WASocket, + /** UUID da instância WhatsApp (1 instância = 1 número conectado) */ + private instanceId: string, + /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */ + private tenantId: string, + /** Socket.IO server — emite eventos em tempo real para o frontend */ + private io: SocketIOServer, + /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */ + private chatbotService?: ChatbotService + ) {} + + /** + * Registra uma dica de destinatário extraída da stanza bruta. + * + * Chamado pelo listener `sock.ws.on('CB:message')` no + * WhatsAppConnectionManager. Entradas expiram em 60s como garantia + * contra vazamento caso o `messages.upsert` correspondente nunca chegue. + */ + setPeerRecipientHint(msgId: string, peerRecipientPn: string): void { + this.peerRecipientHints.set(msgId, peerRecipientPn) + setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref() + } + + /** + * Ponto de entrada principal — chamado pelo event listener `messages.upsert`. + * + * @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado + * + * Diferença entre os modos: + * - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia) + * - append: apenas salva no banco em lote (bulk insert), sem emitir eventos + */ + async handle({ messages, type }: { messages: WAMessage[]; type: string }): Promise { + if (type === 'notify') { + for (const msg of messages) { + try { + await this.processMessage(msg) + } catch (err) { + logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem') + } + } + } else if (type === 'append') { + await this.handleHistory(messages) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa mensagens históricas (history sync / append). + * + * Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany + * por grupo, minimizando round-trips ao banco. + * + * Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup. + * Se não for possível resolver, a mensagem é descartada silenciosamente + * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare). + */ + async handleHistory(messages: WAMessage[]): Promise { + if (messages.length === 0) return + + // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat + const ownJid = normalizeJid(this.sock.user?.id ?? '') + + // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID) + const byJid = new Map() + for (const msg of messages) { + let jidRaw = msg.key.remoteJid + if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) continue + + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const normalizedRaw = normalizeJid(jidRaw) + if (ownJid && normalizedRaw === ownJid) continue + + // ── Resolução LID → phone JID para mensagens históricas ────────── + // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid. + // Precisamos converter para @s.whatsapp.net antes de persistir, + // caso contrário o chat ficaria duplicado (um com LID, outro com phone). + // + // Fontes de resolução (ordem de prioridade): + // 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID) + // 2. key.senderPn / participantPn — campo do protocolo WA + // 3. DB lookup — contacts.lidJid salvo por sessões anteriores + if (jidRaw.endsWith('@lid')) { + // 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas. + // Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa). + const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined + if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) { + jidRaw = normalizeJid(remoteJidAlt) + } else { + // 2. senderPn / participantPn + const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn + if (senderPn) { + jidRaw = normalizeJid(senderPn) + if (!jidRaw.endsWith('@s.whatsapp.net')) continue + } else { + // 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler + const contact = await prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw }, + select: { jid: true }, + }) + if (contact?.jid && !contact.jid.endsWith('@lid')) { + jidRaw = contact.jid + } else { + // Sem resolução possível — descarta (será capturada via phoneNumberShare futuro) + continue + } + } + } + } + + const jid = normalizeJid(jidRaw) + const list = byJid.get(jid) ?? [] + list.push(msg) + byJid.set(jid, list) + } + + let totalSaved = 0 + + for (const [jid, msgs] of byJid) { + try { + // Calcula o timestamp mais recente para atualizar lastMessageAt do chat + const latestTs = msgs.reduce((max, m) => + Math.max(max, Number(m.messageTimestamp ?? 0)), 0) + const lastAt = latestTs ? new Date(latestTs * 1000) : null + + // 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação) + const contact = await prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + phone: jid.endsWith('@g.us') ? null : jid.split('@')[0], + }, + update: {}, + }) + + // 2. Garantir que o Chat existe e está vinculado ao Contact + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + contactId: contact.id, + lastMessageAt: lastAt, + unreadCount: 0 + }, + update: { + contactId: contact.id, + ...(lastAt && { lastMessageAt: lastAt }) + }, + }) + + // 3. Prepara registros para bulk insert (createMany com skipDuplicates) + const rows = msgs.flatMap((msg) => { + const { key, messageTimestamp } = msg + let message = msg.message + if (!message || !key.id) return [] + message = unwrapInnerMessage(message) + const contentType = getContentType(message as any) + if (!contentType) return [] + // Descarta tipos de protocolo/sistema que não representam conteúdo real + if (PROTOCOL_CONTENT_TYPES.has(contentType)) return [] + const msgType = this.mapContentType(contentType) + if (msgType === MessageType.UNSUPPORTED) return [] + const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date() + + // Extrai o corpo textual — tenta vários campos em ordem de prioridade + const body = + message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + null + + const isGroup = jid.endsWith('@g.us') + return [{ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid: jid, + messageId: key.id, + fromMe: key.fromMe ?? false, + type: msgType, + body, + // pushName: nome público do remetente (só confiável em msgs recebidas) + pushName: (key.fromMe ? null : msg.pushName) ?? null, + // senderJid: em grupos, identifica quem enviou (JID do participante) + senderJid: isGroup ? (key.participant ?? null) : null, + status: 'READ' as const, // históricas são consideradas já lidas + timestamp: ts, + }] + }) + + if (rows.length > 0) { + const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true }) + totalSaved += count + } + } catch (err) { + logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat') + } + } + + if (totalSaved > 0) { + logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, + '[History] Mensagens históricas salvas') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TEMPO REAL — Processa uma única mensagem (notify) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Processa uma mensagem recebida em tempo real. + * + * Fluxo completo: + * 1. Filtra broadcasts, newsletters + * 2. Resolve LID → telefone (se @lid) + * 3. Cria/atualiza Contact com pushName (se aplicável) + * 4. Cria/atualiza Chat vinculado ao Contact + * 5. Persiste a Message no banco + * 6. Emite socket events (message:new, chat:upsert) + * 7. Agenda download de mídia (assíncrono) + * 8. Dispara chatbot (assíncrono) + */ + private async processMessage(msg: WAMessage): Promise { + const { key, messageTimestamp, pushName } = msg + let message = msg.message + + if (!message || !key.remoteJid) return + + // Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc). + // Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED. + message = unwrapInnerMessage(message) + + // Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp + if (key.remoteJid === 'status@broadcast') return + if (key.remoteJid.endsWith('@newsletter')) return + if (key.remoteJid.startsWith('0@')) return + + // ── Resolução LID → telefone ────────────────────────────────────────── + // WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net. + // O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB. + // + // SEM esta resolução, mensagens de contatos que usam LID seriam descartadas + // e o chat apareceria com a foto/número errado no frontend (bug crítico). + // + // Prioridade de resolução: + // 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos) + // 2. key.participantPn — idem, usado em contextos de grupo + // 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare) + /** + * Guarda o JID @lid original quando a resolução é bem-sucedida, para + * persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact). + * Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para + * contatos novos — bug silencioso que fazia o mapeamento ser perdido. + */ + let originalLidJid: string | null = null + if (key.remoteJid.endsWith('@lid')) { + const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any) + if (!phoneJid) { + // Sem resolução: não há como saber o número real do contato. + // A mensagem será perdida, mas o mapeamento será capturado no futuro + // via chats.phoneNumberShare e as próximas mensagens serão processadas. + logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, + '[MessageHandler] @lid sem resolução de telefone — descartando') + return + } + originalLidJid = key.remoteJid + // Reescreve o remoteJid para que TODO o processamento abaixo use o JID real. + // Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net. + ;(key as any).remoteJid = phoneJid + } + + const contentType = getContentType(message as any) + if (!contentType) return + + // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário) + if (PROTOCOL_CONTENT_TYPES.has(contentType)) return + + const msgType = this.mapContentType(contentType) + // Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED + if (msgType === MessageType.UNSUPPORTED) return + + const fromMe = key.fromMe ?? false + const remoteJidRaw = key.remoteJid + /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */ + const remoteJid = normalizeJid(remoteJidRaw) + const messageId = key.id! + + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const ownJid = normalizeJid(this.sock.user?.id ?? '') + if (ownJid && remoteJid === ownJid) return + + // ── 1. Contact: criar ou atualizar ──────────────────────────────────── + // REGRA IMPORTANTE sobre pushName: + // - pushName é o "nome público" que o remetente escolheu no WhatsApp + // - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável + // - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão + // → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho") + // - Para GRUPOS: pushName é do remetente individual, não do grupo + // → O nome do grupo vem de groups.upsert → Chat.name (subject) + const isGroupMessage = remoteJid.endsWith('@g.us') + const participantJid = key.participant ?? (msg as any).participant ?? null + + const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null + + const contact = await prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + name: safePushName, + notify: safePushName, + phone: isGroupMessage ? null : remoteJid.split('@')[0], + // Se resolvemos a partir de @lid, persiste o mapeamento já na criação. + // Sem isso, o próximo @lid do mesmo contato falharia o DB lookup. + ...(originalLidJid && { lidJid: originalLidJid }), + }, + update: { + // Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert + ...(safePushName && { notify: safePushName }), + // Garante que o lidJid fique salvo mesmo em contatos pré-existentes + // que ainda não tinham o mapeamento (ex: criados via history sync). + ...(originalLidJid && { lidJid: originalLidJid }), + }, + }) + + // ── 2. Chat: criar ou atualizar, vinculando ao Contact ──────────────── + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + contactId: contact.id, + unreadCount: fromMe ? 0 : 1, + lastMessageAt: new Date((messageTimestamp as number) * 1000), + }, + update: { + contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos) + lastMessageAt: new Date((messageTimestamp as number) * 1000), + // fromMe: zera unread (estamos respondendo); !fromMe: incrementa + unreadCount: fromMe ? { set: 0 } : { increment: 1 }, + }, + }) + + // ── 3. Extrai corpo textual da mensagem ─────────────────────────────── + // nativeFlowResponseMessage: resposta ao clique de botão/lista + let nativeFlowBody: string | null = null + if (message.interactiveResponseMessage?.nativeFlowResponseMessage) { + try { + const params = JSON.parse( + message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}' + ) + nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null + } catch { /* ignora JSON inválido */ } + } + + const body = + message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + nativeFlowBody ?? + null + + // ── 4. Contexto de reply (se for resposta a outra mensagem) ─────────── + const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null + let replyToId: string | null = null + + if (replyToWaId) { + const replyMsg = await prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } }, + }) + replyToId = replyMsg?.id ?? null + } + + // ── 5. Persiste a mensagem (mídia será baixada depois) ──────────────── + const saved = await prisma.message.upsert({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid, + messageId, + fromMe, + type: msgType, + body, + pushName: fromMe ? null : (pushName ?? null), + senderJid: isGroupMessage ? participantJid : null, + replyToId, + status: fromMe ? 'SENT' : 'PENDING', + timestamp: new Date((messageTimestamp as number) * 1000), + }, + update: {}, // Se já existe (duplicata), não sobrescreve + }) + + // ── 6. Emite eventos Socket.IO ──────────────────────────────────────── + + // 6a. Para quem está com o chat aberto (sala chat:{id}) + // Inclui replyTo quando é uma resposta — o frontend precisa para exibir + // a caixa de citação (quoted block) em tempo real, sem precisar recarregar. + const emitMsg = replyToId + ? await prisma.message.findUnique({ + where: { id: saved.id }, + include: { + replyTo: { + select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true }, + }, + }, + }) ?? saved + : saved + + this.io.to(`chat:${chat.id}`).emit('message:new', { + ...emitMsg, + pushName, + senderJid: isGroupMessage ? participantJid : null, + }) + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hookBus.emit('ext:message.new', { + instanceId: this.instanceId, + tenantId: this.tenantId, + chatId: chat.id, + message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null }, + timestamp: Date.now(), + }).catch(() => {}) + + // 6b. Para TODOS os clientes do tenant (atualiza a lista de chats) + // Necessário para: novos chats, chats não abertos, badge de unread + this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp }) + + // ── 7. Download de mídia assíncrono (não bloqueia o event loop) ──────── + if (this.isMediaMessage(contentType)) { + // Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc). + const msgUnwrapped = { ...msg, message } + setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType)) + } + + // ── 8. Dispara chatbot para mensagens recebidas com texto ───────────── + if (!fromMe && body && this.chatbotService) { + setImmediate(() => + this.chatbotService!.handleIncoming({ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + jid: remoteJid, + text: body, + sock: this.sock, + }).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono')) + ) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO LID → TELEFONE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Converte um JID LID (ex: "123456789@lid") para o JID com telefone + * (ex: "5511999999999@s.whatsapp.net"). + * + * O WhatsApp moderno usa LID (Linked ID) como identificador interno. + * Para exibir o contato corretamente, precisamos mapear para o telefone real. + * + * Fontes de resolução (em ordem de prioridade): + * 1. key.senderPn / key.participantPn — campo do protocolo WA. + * Útil para mensagens RECEBIDAS e contextos de grupo. + * 2. peerRecipientHints[msgId] — dica extraída da stanza bruta + * (atributo `peer_recipient_pn`, não exposto pelo Baileys). + * Única fonte confiável para mensagens fromMe=true enviadas pelo + * celular pra um contato @lid que ainda não temos mapeado. + * 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente). + * Populado por: processamento anterior, chats.phoneNumberShare, + * ou contacts.upsert do history sync. + * + * A persistência do mapeamento LID→phone foi movida para processMessage + * (após o upsert do Contact), para evitar o bug anterior onde o + * `updateMany` disparado aqui afetava 0 rows em contatos inexistentes. + * + * @param lidJid - O JID no formato @lid (ex: "123456789@lid") + * @param key - WAMessageKey (id, fromMe, senderPn, participantPn) + * @returns O JID @s.whatsapp.net correspondente, ou null se não resolver + */ + private async resolveLidToPhoneJid( + lidJid: string, + key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string } + ): Promise { + // 1. senderPn vem direto do protocolo WhatsApp (mais confiável) + // OBS: em mensagens fromMe=true sincronizadas do celular, senderPn + // descreve o DONO da sessão, não o destinatário — inútil aqui. + const senderPn = key.senderPn || key.participantPn + if (senderPn && !key.fromMe) { + const phoneJid = normalizeJid(senderPn) + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid + } + } + + // 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para + // mensagens fromMe=true que chegam via sync de outro dispositivo. + if (key.fromMe && key.id) { + const hint = this.peerRecipientHints.get(key.id) + this.peerRecipientHints.delete(key.id) // uso único + if (hint) { + const phoneJid = normalizeJid(hint) + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid + } + } + } + + // 3. Busca no DB por mapeamento LID salvo anteriormente + const contact = await prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid }, + select: { jid: true }, + }) + if (contact?.jid) return contact.jid + + return null + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EMISSÃO DE EVENTOS SOCKET.IO + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Emite evento `chat:upsert` para todos os clientes do tenant. + * + * Usado para atualizar a lista de chats no frontend em tempo real: + * - Novos chats aparecem instantaneamente + * - Chats existentes atualizam lastMessage, unreadCount, etc. + * - Chats @lid são filtrados (não devem aparecer no frontend) + * + * O frontend usa este evento para reordenar a lista por lastMessageAt + * e exibir o preview da última mensagem. + */ + private async emitChatUpsert( + chatId: string, + lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date } + ): Promise { + try { + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { + contact: { + select: { + name: true, + verifiedName: true, + notify: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + }, + }, + }, + }) + if (!chat) return + + // Chats @lid são artefatos internos — nunca expor ao frontend + if (chat.jid.endsWith('@lid')) return + + const c = chat.contact + const isGroup = chat.jid.endsWith('@g.us') + + // Regra de resolução de nome: + // Grupos: usa Chat.name (subject do grupo, vem de groups.upsert) + // Individuais: name (agenda) > verifiedName (business) > notify (pushname) + // NUNCA usar pushName do remetente como nome de grupo + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null) + + this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', { + id: chat.id, + tenantId: chat.tenantId, + instanceId: chat.instanceId, + jid: chat.jid, + name: chat.name ?? null, + unreadCount: chat.unreadCount, + lastMessageAt: chat.lastMessageAt, + isPinned: chat.isPinned, + isArchived: chat.isArchived, + botPaused: chat.botPaused, + contact: c + ? { + name: displayName, + phone: c.phone, + avatarUrl: c.avatarUrl, + scoreReputacao: c.scoreReputacao, + flagRestricao: c.flagRestricao, + } + : isGroup + ? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + : null, + lastMessage, + }) + } catch (err) { + logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD DE MÍDIA + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Baixa o conteúdo de mídia de uma mensagem e salva no disco. + * + * Executado via setImmediate() para não bloquear o event loop. + * Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl + * e emite `message:media_ready` para o frontend renderizar o preview. + * + * Organização: /media/{instanceId}/{messageId}.{ext} + */ + private async downloadAndPersistMedia( + msg: WAMessage, + savedMsgId: string, + chatId: string, + contentType: string + ): Promise { + try { + const buffer = await downloadMediaMessage( + msg as any, + 'buffer', + {}, + { logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any } + ) + + if (!buffer) return + + // Para documentos: preserva nome e extensão originais do arquivo + const docMsg = msg.message?.documentMessage + const originalFileName = docMsg?.fileName?.trim() || null + const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType) + + let ext: string + if (contentType === 'documentMessage' && originalFileName) { + ext = path.extname(originalFileName).replace('.', '') || 'bin' + } else { + ext = this.extensionForType(contentType) + } + + const fileName = `${savedMsgId}.${ext}` + const filePath = path.join(MEDIA_DIR, this.instanceId, fileName) + + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer as Buffer) + + // Tenta upload para Wasabi; se falhar usa path local como fallback + let mediaUrl = `/media/${this.instanceId}/${fileName}` + let mediaPath: string | null = filePath + let uploadedToRemote = false + + if (storageProvider.isRegistered()) { + try { + const result = await storageProvider.uploadFile({ + category: 'whatsapp', + usuarioSis: this.tenantId, + sessionJID: this.instanceId, + file: { + originalname: originalFileName ?? fileName, + buffer: buffer as Buffer, + mimetype: originalMimetype, + }, + }) + mediaUrl = result.path + if (result.provider !== 'local_fallback') { + mediaPath = null + uploadedToRemote = true + } + logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage') + } catch (uploadErr) { + logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local') + } + } + + await prisma.message.update({ + where: { id: savedMsgId }, + data: { + mediaPath, + mediaUrl, + // Preserva o nome original do documento para exibição no frontend + ...(contentType === 'documentMessage' && originalFileName + ? { fileName: originalFileName } + : {}), + }, + }) + + if (uploadedToRemote) { + fs.unlink(filePath).catch((err) => { + logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto') + }) + } + + // Notifica frontend que a mídia está pronta para exibição + this.io.to(`chat:${chatId}`).emit('message:media_ready', { + messageId: savedMsgId, + mediaUrl, + }) + + logger.debug({ savedMsgId, filePath }, 'Mídia salva') + } catch (err) { + logger.error({ err, savedMsgId }, 'Falha ao baixar mídia') + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITÁRIOS + // ═══════════════════════════════════════════════════════════════════════════ + + /** Verifica se o contentType é de mídia (precisa de download) */ + private isMediaMessage(contentType: string): boolean { + return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType) + } + + /** + * Mapeia o contentType do Baileys para o enum MessageType do Prisma. + * Tipos não reconhecidos são mapeados para UNSUPPORTED. + */ + private mapContentType(contentType: string): MessageType { + const map: Record = { + conversation: MessageType.TEXT, + extendedTextMessage: MessageType.TEXT, + imageMessage: MessageType.IMAGE, + videoMessage: MessageType.VIDEO, + audioMessage: MessageType.AUDIO, + documentMessage: MessageType.DOCUMENT, + stickerMessage: MessageType.STICKER, + locationMessage: MessageType.LOCATION, + contactMessage: MessageType.CONTACT, + reactionMessage: MessageType.REACTION, + pollCreationMessage: MessageType.POLL, + pollCreationMessageV2: MessageType.POLL, // poll multi-select grupos + pollCreationMessageV3: MessageType.POLL, // poll single-select + // Resposta ao clique de botão / seleção de item de lista + interactiveResponseMessage: MessageType.INTERACTIVE, + // Mensagem interativa recebida de outro remetente (botões/lista/carrossel) + interactiveMessage: MessageType.BUTTONS, + } + return map[contentType] ?? MessageType.UNSUPPORTED + } + + /** Retorna a extensão de arquivo adequada para o tipo de mídia */ + private extensionForType(contentType: string): string { + const map: Record = { + imageMessage: 'jpg', + videoMessage: 'mp4', + audioMessage: 'ogg', + documentMessage: 'bin', + stickerMessage: 'webp', + } + return map[contentType] ?? 'bin' + } + + /** Retorna o mimetype adequado para o tipo de mídia */ + private mimetypeForType(contentType: string): string { + const map: Record = { + imageMessage: 'image/jpeg', + videoMessage: 'video/mp4', + audioMessage: 'audio/ogg', + documentMessage: 'application/octet-stream', + stickerMessage: 'image/webp', + } + return map[contentType] ?? 'application/octet-stream' + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/repositories/InstanceRepository.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/repositories/InstanceRepository.ts new file mode 100644 index 0000000..0fbb36a --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/repositories/InstanceRepository.ts @@ -0,0 +1,29 @@ +import { prisma } from '../../../infra/database/prisma' +import type { InstanceStatus } from '@prisma/client' + +export class InstanceRepository { + async findAllByTenant(tenantId: string) { + return prisma.instance.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'asc' }, + }) + } + + async findById(id: string) { + return prisma.instance.findUnique({ where: { id } }) + } + + async create(tenantId: string, name: string) { + return prisma.instance.create({ + data: { tenantId, name }, + }) + } + + async updateStatus(id: string, status: InstanceStatus) { + return prisma.instance.update({ where: { id }, data: { status } }) + } + + async delete(id: string) { + return prisma.instance.delete({ where: { id } }) + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/rich-message.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/rich-message.ts new file mode 100644 index 0000000..81f6780 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/rich-message.ts @@ -0,0 +1,411 @@ +/** + * rich-message.ts — Construção e envio de mensagens ricas via Baileys padrão + * + * O pacote @whiskeysockets/baileys não suporta os campos `nativeButtons`, + * `nativeList` e `nativeCarousel` que são específicos do fork InfiniteAPI. + * Este módulo replica a lógica de transformação do InfiniteAPI para que + * o motor newwhats possa enviar botões, listas e carrosséis usando o + * sock.relayMessage() com o proto correto. + * + * Referência: github:rsalcara/InfiniteAPI — lib/Socket/messages-send.js + * + * IMPORTANTE: O WhatsApp Web NÃO suporta viewOnceMessage para mensagens interativas. + * Por isso usamos interactiveMessage DIRETO (sem wrapper viewOnce) + additionalNodes. + * + * O que é necessário para botões/listas/carrosséis funcionarem no WhatsApp + * (celular E web): + * + * 1. interactiveMessage direto (NÃO viewOnceMessage) — viewOnce não carrega no Web + * + * 2. Nó adicional + * injetado no stanza via relayMessage(additionalNodes) + * (sem isso o WhatsApp Web exibe "Não foi possível carregar") + * + * 3. Nó para chats 1-a-1 + * (necessário para interactive messages em conversas privadas) + * + * Lista (single_select) também usa o mesmo fluxo — InfiniteAPI converte + * o viewOnceMessage > nativeFlowMessage de volta para listMessage no relayMessage, + * mas testamos que a forma direta com nó biz > list também funciona. + */ +import { generateMessageIDV2, generateWAMessageFromContent, isJidGroup, isJidStatusBroadcast } from './engine' +import { proto } from './engine' +import type { WASocket, BinaryNode } from './engine' + +// ─── Tipos de botão (espelho do formato nativo InfiniteAPI) ────────────────── +type NativeBtn = + | { type: 'reply'; id: string; text: string } + | { type: 'url'; text: string; url: string } + | { type: 'copy'; text: string; copyText: string } + | { type: 'call'; text: string; phoneNumber: string } + +// ─── Payload rico de entrada (o que o frontend/ext-api envia) ──────────────── +export interface RichPayload { + text?: string + footer?: string + nativeButtons?: NativeBtn[] + nativeList?: { + buttonText: string + sections: Array<{ + title: string + rows: Array<{ id: string; title: string; description?: string }> + }> + } + nativeCarousel?: { + cards: Array<{ + title?: string + body?: string + footer?: string + image?: { url: string } + buttons: Array<{ type?: string; id?: string; text: string }> + }> + } + poll?: { + name: string + values: string[] + selectableCount?: number + } +} + +// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ───── +function formatNativeFlowButton(btn: NativeBtn): { name: string; buttonParamsJson: string } { + switch (btn.type) { + case 'url': + return { + name: 'cta_url', + buttonParamsJson: JSON.stringify({ + display_text: btn.text, + url: btn.url, + merchant_url: btn.url, + }), + } + case 'copy': + return { + name: 'cta_copy', + buttonParamsJson: JSON.stringify({ + display_text: btn.text, + copy_code: btn.copyText, + }), + } + case 'reply': + return { + name: 'quick_reply', + buttonParamsJson: JSON.stringify({ + display_text: btn.text, + id: btn.id, + }), + } + case 'call': + return { + name: 'cta_call', + buttonParamsJson: JSON.stringify({ + display_text: btn.text, + phone_number: btn.phoneNumber, + }), + } + } +} + +// ─── Nó biz > interactive > native_flow (obrigatório para renderizar no Web) ── +const BIZ_NATIVE_FLOW_NODE: BinaryNode = { + tag: 'biz', + attrs: {}, + content: [ + { + tag: 'interactive', + attrs: { type: 'native_flow', v: '1' }, + content: [ + { + tag: 'native_flow', + attrs: { v: '9', name: 'mixed' }, + }, + ], + }, + ], +} + +// ─── Nó biz para listMessage legado (formato que renderiza no Web) ─────────── +// InfiniteAPI usa com proto.listMessage +// (não interactiveMessage). Comentário do fork: "Modern format causes rejection +// (error 479) — Legacy format works on all platforms". +const BIZ_LIST_NODE: BinaryNode = { + tag: 'biz', + attrs: {}, + content: [ + { + tag: 'list', + attrs: { type: 'product_list', v: '2' }, + }, + ], +} + +// ─── Nó bot (necessário para chats 1-a-1 com interactive messages) ──────────── +const BOT_NODE: BinaryNode = { + tag: 'bot', + attrs: { biz_bot: '1' }, +} + +// ─── Helper: jid é chat privado (não grupo, não status, não bot) ───────────── +function isPrivateChat(jid: string): boolean { + return ( + !isJidGroup(jid) && + !isJidStatusBroadcast(jid) && + !jid.includes('newsletter') && + !jid.includes('broadcast') + ) +} + +// ─── Helper: envia proto pré-construído pelo MESMO caminho do sendMessage ──── +// Diferença crítica vs `sock.relayMessage` direto: +// sendMessage() → generateWAMessage() → WAProto.Message.fromObject() → relayMessage() +// ↑ normalização proto que o WA Web exige +// Sem esse passo, o Web descarta a mensagem ("Não foi possível carregar"). +// É o mesmo motivo pelo qual nossas enquetes (via sendMessage) funcionam no Web +// e os botões (via relayMessage direto) não funcionavam. +async function sendBuiltProto( + sock: WASocket, + jid: string, + protoMsg: any, + msgId: string, + additionalNodes: BinaryNode[], + quoted?: { key: any; message: any }, +): Promise { + const fullMsg = generateWAMessageFromContent(jid, protoMsg, { + userJid: sock.user?.id, + messageId: msgId, + quoted, + } as any) + + await sock.relayMessage(jid, fullMsg.message as any, { + messageId: fullMsg.key.id!, + additionalNodes, + }) + return fullMsg.key.id! +} + +// ─── Helper: monta interactiveMessage DIRETO (sem viewOnceMessage wrapper) ──── +// viewOnceMessage wrapper faz o WhatsApp Web exibir "Não foi possível carregar": +// o Web trata viewOnce como mensagem efêmera e recusa conteúdo interativo dentro. +// Baileys padrão não adiciona messageContextInfo para interactiveMessage +// (só faz isso para poll, com messageSecret). Usar interactiveMessage direto +// + nó no additionalNodes é o caminho correto com @whiskeysockets/baileys. +function makeInteractiveProto(interactiveMsg: any): any { + return { + interactiveMessage: interactiveMsg, + } +} + +/** + * Envia uma mensagem rica pelo socket Baileys padrão. + * + * - Buttons / CTAs: interactiveMessage direto + nó biz>interactive>native_flow + nó bot + * - Carousel: interactiveMessage direto + nó biz>interactive>native_flow + * - List: interactiveMessage direto + nó biz>interactive>native_flow + nó bot + * - Poll: sock.sendMessage (requer encriptação especial do Baileys) + * - Text: sock.sendMessage normal + * + * Retorna o messageId gerado. + */ +export async function sendRichMessage( + sock: WASocket, + jid: string, + payload: RichPayload, + quoted?: { key: any; message: any }, + engineType?: string, +): Promise { + const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload + const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite' + + // Se a engine ativa for 'infinite', utilizamos o suporte nativo do fork InfiniteAPI + if (engine === 'infinite') { + // ── Poll: delegado ao sendMessage padrão (requer encriptação especial do Baileys) ── + if (poll) { + const sent = await sock.sendMessage( + jid, + { + poll: { + name: poll.name, + values: poll.values, + selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length), + }, + }, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `poll-${Date.now()}` + } + + // ── Botões / CTAs nativos (InfiniteAPI) ── + if (nativeButtons) { + const sent = await sock.sendMessage( + jid, + { + text: text ?? '', + footer, + nativeButtons, + } as any, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `btn-${Date.now()}` + } + + // ── Carrossel nativo (InfiniteAPI) ── + if (nativeCarousel) { + const sent = await sock.sendMessage( + jid, + { + text: text ?? '', + footer, + nativeCarousel, + } as any, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `carousel-${Date.now()}` + } + + // ── Lista nativa (InfiniteAPI) ── + if (nativeList) { + const sent = await sock.sendMessage( + jid, + { + text: text ?? '', + footer, + nativeList, + } as any, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `list-${Date.now()}` + } + + // ── Texto simples (InfiniteAPI) ── + const sent = await sock.sendMessage( + jid, + { text: text ?? '' }, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `text-${Date.now()}` + } + + // FALLBACK: Lógica de montagem manual de nós binários para o Baileys oficial + // ── Poll: delegado ao sendMessage padrão (requer messageSecret) ─────────── + if (poll) { + const sent = await sock.sendMessage( + jid, + { + poll: { + name: poll.name, + values: poll.values, + selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length), + }, + }, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `poll-${Date.now()}` + } + + // ── Texto simples: delegado ao sendMessage padrão ───────────────────────── + if (!nativeButtons && !nativeList && !nativeCarousel) { + const sent = await sock.sendMessage( + jid, + { text: text ?? '' }, + quoted ? { quoted } : undefined, + ) + return sent?.key?.id ?? `text-${Date.now()}` + } + + const msgId = generateMessageIDV2(sock.user?.id) + const isPrivate = isPrivateChat(jid) + + // ── Botões / CTAs: interactiveMessage + nativeFlowMessage ──────────────────── + if (nativeButtons) { + const formattedButtons = nativeButtons.map(formatNativeFlowButton) + + const protoMsg = makeInteractiveProto({ + body: { text: text ?? '' }, + footer: footer ? { text: footer } : undefined, + header: { title: '', subtitle: '', hasMediaAttachment: false }, + nativeFlowMessage: { + buttons: formattedButtons, + messageParamsJson: JSON.stringify({}), + messageVersion: 2, + }, + }) + + const additionalNodes: BinaryNode[] = [BIZ_NATIVE_FLOW_NODE] + return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) + } + + // ── Carrossel: viewOnceMessage > interactiveMessage > carouselMessage ───── + if (nativeCarousel) { + const cards = nativeCarousel.cards + if (cards.length < 2) throw new Error('Carrossel requer no mínimo 2 cards') + if (cards.length > 10) throw new Error('Carrossel suporta no máximo 10 cards') + + const carouselCards: any[] = cards.map((card) => { + const rawButtons = card.buttons ?? [] + const cardButtons = rawButtons.length > 0 + ? rawButtons.map((btn) => formatNativeFlowButton({ + type: (btn.type as any) ?? 'reply', + id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'), + text: btn.text, + } as any)) + : [{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Ver mais', id: 'see_more' }) }] + + return { + header: { + title: card.title ?? '', + subtitle: '', + hasMediaAttachment: !!(card.image?.url), + }, + body: { text: card.body ?? '' }, + footer: card.footer ? { text: card.footer } : undefined, + nativeFlowMessage: { + buttons: cardButtons, + messageParamsJson: JSON.stringify({}), + }, + } + }) + + const protoMsg = makeInteractiveProto({ + body: { text: text ?? '' }, + footer: footer ? { text: footer } : undefined, + header: { title: '', subtitle: '', hasMediaAttachment: false }, + carouselMessage: { + cards: carouselCards, + messageVersion: 1, + }, + }) + + const additionalNodes: BinaryNode[] = [BIZ_NATIVE_FLOW_NODE] + return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) + } + + // ── Lista: proto.listMessage LEGADO + nó ── + if (nativeList) { + const protoMsg: any = { + listMessage: { + title: '', + description: text ?? '', + buttonText: nativeList.buttonText, + footerText: footer ?? '', + listType: 2, + sections: nativeList.sections.map((s) => ({ + title: s.title, + rows: s.rows.map((r) => ({ + rowId: r.id, + title: r.title, + description: r.description ?? '', + })), + })), + }, + } + + const additionalNodes: BinaryNode[] = [BIZ_LIST_NODE] + if (isPrivate) additionalNodes.push(BOT_NODE) + + return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) + } + + // Fallback (não deve chegar aqui) + const sent = await sock.sendMessage(jid, { text: text ?? '' }) + return sent?.key?.id ?? `fallback-${Date.now()}` +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/warmer/WhatsAppWarmer.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/warmer/WhatsAppWarmer.ts new file mode 100644 index 0000000..86aeac1 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/warmer/WhatsAppWarmer.ts @@ -0,0 +1,131 @@ +/** + * WhatsApp Warmer — aquecimento gradual de número novo. + * + * Envia mensagens em intervalos aleatórios crescentes para simular + * uso humano e reduzir risco de ban em números recém-criados. + */ +import { logger } from '../../../config/logger' +import type { WhatsAppConnectionManager } from '../connection/WhatsAppConnectionManager' + +interface WarmerConfig { + instanceId: string + targets: string[] // JIDs destino (ex: contatos da agenda) + totalDays: number // Duração do aquecimento (ex: 7 dias) + dailyMessageLimit: number // Máximo de msgs por dia (cresce gradualmente) +} + +interface WarmerSession { + config: WarmerConfig + dayCount: number + sentToday: number + timer: ReturnType | null +} + +const MESSAGES = [ + 'Olá! Tudo bem?', + 'Oi, como vai?', + 'Boa tarde!', + 'Só passando para dizer oi 😊', + 'Tudo certo por aí?', + 'Bom dia! 🌤', + 'E aí, tudo bem?', + 'Boa noite!', +] + +function randomBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function randomMessage(): string { + return MESSAGES[Math.floor(Math.random() * MESSAGES.length)] +} + +export class WhatsAppWarmer { + private sessions = new Map() + + constructor(private manager: WhatsAppConnectionManager) {} + + start(config: WarmerConfig): void { + if (this.sessions.has(config.instanceId)) { + logger.warn({ instanceId: config.instanceId }, 'Warmer já em execução para esta instância') + return + } + + const session: WarmerSession = { + config, + dayCount: 0, + sentToday: 0, + timer: null, + } + + this.sessions.set(config.instanceId, session) + logger.info({ instanceId: config.instanceId, totalDays: config.totalDays }, '🔥 Warmer iniciado') + this.scheduleNext(config.instanceId) + } + + stop(instanceId: string): void { + const session = this.sessions.get(instanceId) + if (!session) return + if (session.timer) clearTimeout(session.timer) + this.sessions.delete(instanceId) + logger.info({ instanceId }, 'Warmer encerrado') + } + + private scheduleNext(instanceId: string): void { + const session = this.sessions.get(instanceId) + if (!session) return + + const { config, dayCount, sentToday } = session + + // Limite diário cresce linearmente: day 1 = 10%, day N = 100% + const progressRatio = Math.min(1, (dayCount + 1) / config.totalDays) + const limitHoje = Math.ceil(config.dailyMessageLimit * progressRatio) + + if (sentToday >= limitHoje) { + // Dia completo — aguarda até o próximo dia (24h + jitter) + const nextDayMs = 24 * 60 * 60 * 1000 + randomBetween(0, 30 * 60 * 1000) + logger.debug({ instanceId, dayCount, sentToday }, 'Limite diário atingido — aguardando próximo dia') + + if (dayCount + 1 >= config.totalDays) { + logger.info({ instanceId }, '✅ Aquecimento concluído!') + this.sessions.delete(instanceId) + return + } + + session.dayCount += 1 + session.sentToday = 0 + session.timer = setTimeout(() => this.scheduleNext(instanceId), nextDayMs) + return + } + + // Intervalo aleatório entre mensagens (1–5 minutos com jitter) + const delayMs = randomBetween(60_000, 5 * 60_000) + session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), delayMs) + } + + private async sendWarmerMessage(instanceId: string): Promise { + const session = this.sessions.get(instanceId) + if (!session) return + + const sock = this.manager.getSocket(instanceId) + if (!sock) { + logger.warn({ instanceId }, 'Warmer: socket não encontrado — pausando') + session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), 60_000) + return + } + + const targets = session.config.targets + const target = targets[Math.floor(Math.random() * targets.length)] + const text = randomMessage() + + try { + await sock.sendMessage(target, { text }) + session.sentToday += 1 + logger.debug({ instanceId, target, sentToday: session.sentToday }, 'Warmer: mensagem enviada') + } catch (err) { + logger.error({ err, instanceId, target }, 'Warmer: falha ao enviar mensagem') + } + + this.scheduleNext(instanceId) + } +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.media.routes.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.media.routes.ts new file mode 100644 index 0000000..3eb913d --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.media.routes.ts @@ -0,0 +1,282 @@ +import path from 'path' +import fs from 'fs/promises' +import { Router, type Request, type Response } from 'express' +import multer from 'multer' +import { prisma } from '../../infra/database/prisma' +import { logger } from '../../config/logger' +import { storageProvider } from '../../core/StorageProvider' +import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager' + +// ─── Tipos de mídia suportados ──────────────────────────────────────────────── + +const MIME_MAP: Record = { + 'image/jpeg': 'image', + 'image/png': 'image', + 'image/webp': 'sticker', + 'image/gif': 'video', // GIFs são enviados como video no Baileys + 'video/mp4': 'video', + 'video/3gpp': 'video', + 'video/quicktime': 'video', + 'audio/ogg': 'audio', + 'audio/mpeg': 'audio', + 'audio/mp4': 'audio', + 'audio/wav': 'audio', + 'audio/webm': 'audio', + 'application/pdf': 'document', + 'application/msword': 'document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', + 'application/vnd.ms-excel': 'document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', + 'application/zip': 'document', + 'text/plain': 'document', +} + +const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64 MB + +// ─── Multer: armazena em memória (buffer) ───────────────────────────────────── +// Buffers são passados diretamente ao Baileys sem gravar arquivo temporário + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (_req, file, cb) => { + if (MIME_MAP[file.mimetype]) { + cb(null, true) + } else { + cb(new Error(`Tipo de arquivo não suportado: ${file.mimetype}`)) + } + }, +}) + +// ─── Helper: salva mídia enviada no disco (para histórico local) ────────────── + +async function saveMediaLocally( + instanceId: string, + messageId: string, + buffer: Buffer, + originalName: string +): Promise { + const ext = path.extname(originalName) || '.bin' + const fileName = `${messageId}${ext}` + const filePath = path.resolve('./media', instanceId, 'sent', fileName) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, buffer) + return `/media/${instanceId}/sent/${fileName}` +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router { + const router = Router({ mergeParams: true }) + + // ── POST /api/instances/:instanceId/send-media ───────────────────────────── + // Body: multipart/form-data { file, jid, caption?, replyToMessageId? } + router.post( + '/send-media', + upload.single('file'), + async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const { jid, caption, replyToMessageId } = req.body as { + jid: string + caption?: string + replyToMessageId?: string + } + + if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return } + if (!jid) { res.status(400).json({ error: 'jid é obrigatório' }); return } + + const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } }) + if (!instance || instance.status !== 'CONNECTED') { + res.status(400).json({ error: 'Instância não conectada' }); return + } + + const sock = manager.getSocket(instanceId) + if (!sock) { res.status(503).json({ error: 'Socket não disponível' }); return } + + const { buffer, mimetype, originalname, size } = req.file + const mediaType = MIME_MAP[mimetype] ?? 'document' + + // ── Monta o content para o Baileys ──────────────────────────────────── + let content: Record + + switch (mediaType) { + case 'image': + content = { image: buffer, mimetype, caption: caption ?? '' } + break + case 'video': + content = { + video: buffer, + mimetype, + caption: caption ?? '', + gifPlayback: mimetype === 'image/gif', + } + break + case 'audio': + content = { audio: buffer, mimetype, ptt: false } + break + case 'sticker': + content = { sticker: buffer, mimetype } + break + default: + content = { document: buffer, mimetype, fileName: originalname, caption: caption ?? '' } + } + + // ── Reply context ───────────────────────────────────────────────────── + let quotedMsg: any + if (replyToMessageId) { + const reply = await prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } }, + }) + if (reply?.body) { + quotedMsg = { + key: { remoteJid: jid, fromMe: reply.fromMe, id: reply.messageId }, + message: { conversation: reply.body }, + } + } + } + + // ── Envia via Baileys ───────────────────────────────────────────────── + const sent = await sock.sendMessage(jid, content as any, quotedMsg ? { quoted: quotedMsg } : undefined) + + // ── Salva arquivo e faz upload para Wasabi ──────────────────────────── + const messageId = sent?.key.id ?? `local-${Date.now()}` + const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname) + + let mediaUrl = localUrl + if (storageProvider.isRegistered()) { + try { + const result = await storageProvider.uploadFile({ + category: 'whatsapp', + usuarioSis: tenantId, + sessionJID: instanceId, + file: { originalname, buffer, mimetype }, + }) + mediaUrl = result.path + } catch (uploadErr) { + logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (mídia enviada), usando path local') + } + } + + // ── Garante chat + persiste mensagem ───────────────────────────────── + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + create: { tenantId, instanceId, jid, lastMessageAt: new Date() }, + update: { lastMessageAt: new Date() }, + }) + + const messageType = mediaType.toUpperCase() as any + const msg = await prisma.message.create({ + data: { + tenantId, + instanceId, + chatId: chat.id, + remoteJid: jid, + messageId, + fromMe: true, + type: messageType, + body: caption ?? null, + mediaUrl, + mimeType: mimetype, + fileName: mediaType === 'document' ? originalname : null, + status: 'SENT', + timestamp: new Date(), + }, + }) + + logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada') + res.status(201).json(msg) + } catch (err: any) { + logger.error({ err }, 'Erro ao enviar mídia') + res.status(500).json({ error: err.message ?? 'Erro ao enviar mídia' }) + } + } + ) + + // ── POST /api/instances/:instanceId/send-audio ───────────────────────────── + // Áudio gravado pelo InputBar (PTT — Push to Talk) + // Body: multipart/form-data { audio (blob ogg/webm), jid } + router.post( + '/send-audio', + upload.single('audio'), + async (req: Request, res: Response) => { + try { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const { jid } = req.body as { jid: string } + + if (!req.file) { res.status(400).json({ error: 'Áudio não enviado' }); return } + if (!jid) { res.status(400).json({ error: 'jid é obrigatório' }); return } + + const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } }) + if (!instance || instance.status !== 'CONNECTED') { + res.status(400).json({ error: 'Instância não conectada' }); return + } + + const sock = manager.getSocket(instanceId) + if (!sock) { res.status(503).json({ error: 'Socket não disponível' }); return } + + const { buffer, mimetype, originalname } = req.file + + // PTT (push-to-talk) — aparece como nota de voz no WhatsApp + const sent = await sock.sendMessage(jid, { + audio: buffer, + mimetype: mimetype || 'audio/ogg; codecs=opus', + ptt: true, + } as any) + + const messageId = sent?.key.id ?? `local-${Date.now()}` + const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname || 'audio.ogg') + + let mediaUrl = localUrl + if (storageProvider.isRegistered()) { + try { + const result = await storageProvider.uploadFile({ + category: 'whatsapp', + usuarioSis: tenantId, + sessionJID: instanceId, + file: { originalname: originalname || 'audio.ogg', buffer, mimetype: mimetype || 'audio/ogg' }, + }) + mediaUrl = result.path + } catch (uploadErr) { + logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (áudio enviado), usando path local') + } + } + + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + create: { tenantId, instanceId, jid, lastMessageAt: new Date() }, + update: { lastMessageAt: new Date() }, + }) + + // upsert evita colisão com o registro criado pelo MessageHandler via messages.upsert + const msg = await prisma.message.upsert({ + where: { instanceId_messageId: { instanceId, messageId } }, + create: { + tenantId, + instanceId, + chatId: chat.id, + remoteJid: jid, + messageId, + fromMe: true, + type: 'AUDIO', + mimeType: mimetype || 'audio/ogg', + mediaUrl, + status: 'SENT', + timestamp: new Date(), + }, + update: { mediaUrl, status: 'SENT' }, + }) + + logger.info({ instanceId, jid }, 'Áudio PTT enviado') + res.status(201).json(msg) + } catch (err: any) { + logger.error({ err }, 'Erro ao enviar áudio') + res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' }) + } + } + ) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.routes.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.routes.ts new file mode 100644 index 0000000..c530260 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.routes.ts @@ -0,0 +1,205 @@ +import { Router, type Request, type Response } from 'express' +import fs from 'fs/promises' +import path from 'path' +import { InstanceRepository } from './repositories/InstanceRepository' +import { dragonfly } from '../../infra/cache/dragonfly' +import { prisma } from '../../infra/database/prisma' +import { logger } from '../../config/logger' +import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager' +import { storageProvider } from '../../core/StorageProvider' + +const ENGINE_CHANGE_COOLDOWN_MS = 5 * 60 * 1000 // 5 minutos entre trocas + +export function buildWhatsAppRoutes(manager: WhatsAppConnectionManager): Router { + const router = Router() + const repo = new InstanceRepository() + + // Listar instâncias do tenant + router.get('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instances = await repo.findAllByTenant(tenantId) + res.json(instances) + }) + + // Criar instância + router.post('/', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const { name } = req.body as { name: string } + if (!name) { + res.status(400).json({ error: 'name é obrigatório' }) + return + } + const instance = await repo.create(tenantId, name) + res.status(201).json(instance) + }) + + // Conectar instância (emite QR via Socket.IO) + router.post('/:id/connect', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + // Não aguarda — a conexão ocorre em background e o QR chega via Socket.IO + manager.connect(id, tenantId).catch((err: unknown) => { + console.error('[Connect] Falha ao iniciar conexão:', err) + }) + + res.json({ message: 'Conectando... aguarde o QR Code via WebSocket' }) + }) + + // Desconectar instância + router.post('/:id/disconnect', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + await manager.disconnect(id) + res.json({ message: 'Desconectado' }) + }) + + // QR Code atual (fallback REST — normalmente via Socket.IO) + router.get('/:id/qr', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + const qrBase64 = await dragonfly.get(`instance:${id}:qr`) + if (!qrBase64) { + res.status(404).json({ error: 'QR Code não disponível. Inicie a conexão primeiro.' }) + return + } + + res.json({ qrBase64 }) + }) + + // Deletar instância + router.delete('/:id', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + // 1. Desconectar e deslogar a sessão do WhatsApp se estiver ativa + await manager.disconnect(id).catch(() => {}) + + // 2. Garantir a exclusão física da pasta de credenciais da sessão no servidor + await manager.deleteSessionFiles(id).catch(() => {}) + + // 3. Garantir a exclusão física da pasta de mídia local ./media/{instanceId} no servidor + const localMediaDir = path.resolve('./media', id) + await fs.rm(localMediaDir, { recursive: true, force: true }).catch((err: any) => { + console.error(`[deleteInstance] Erro ao deletar pasta local de mídia da instância ${id}:`, err) + }) + + // 4. Apagar do banco de dados + await repo.delete(id) + + // 5. Deleta mídias associadas à instância no storage remoto (Wasabi/S3) de forma assíncrona + storageProvider.deletePrefix(`whatsapp/${tenantId}/${id}/`).catch((err: any) => { + console.error(`[StorageProvider] Erro ao deletar mídias da instância ${id}:`, err) + }) + + res.json({ message: 'Instância removida com sucesso de todos os storages e servidores' }) + }) + + // Estatísticas de mensagens da instância (contagens do banco) + router.get('/:id/stats', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + const [received, sent, media, chats] = await Promise.all([ + prisma.message.count({ where: { instanceId: id, fromMe: false } }), + prisma.message.count({ where: { instanceId: id, fromMe: true } }), + prisma.message.count({ + where: { + instanceId: id, + type: { in: ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] }, + }, + }), + prisma.chat.count({ where: { instanceId: id } }), + ]) + + res.json({ received, sent, media, chats }) + }) + + // Trocar engine de uma instância (com cooldown + auditoria) + router.patch('/:id/engine', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const id = req.params['id'] as string + const { engine } = req.body as { engine?: string } + + if (engine !== 'infinite' && engine !== 'official') { + res.status(400).json({ error: 'engine deve ser "infinite" ou "official"' }) + return + } + + const instance = await repo.findById(id) + if (!instance || instance.tenantId !== tenantId) { + res.status(404).json({ error: 'Instância não encontrada' }) + return + } + + // Cooldown: bloqueia nova troca por 5 min após a última + const cooldownKey = `instance:${id}:engine_change_lock` + const lockedAt = await dragonfly.get(cooldownKey) as string | null + if (lockedAt) { + const remaining = Math.ceil((ENGINE_CHANGE_COOLDOWN_MS - (Date.now() - Number(lockedAt))) / 1000) + res.status(429).json({ error: `Aguarde ${remaining}s antes de trocar a engine novamente` }) + return + } + + const fromEngine = instance.engine ?? 'infinite' + if (fromEngine === engine) { + res.status(200).json({ message: 'Engine já está definida para este valor', engine }) + return + } + + // Persiste a troca + await prisma.instance.update({ where: { id }, data: { engine } }) + + // Audit log estruturado + logger.info( + { tenantId, instanceId: id, fromEngine, toEngine: engine }, + '[EngineChange] Troca de engine auditada', + ) + + // Aplica cooldown (5 min via TTL no Redis) + await dragonfly.psetex(cooldownKey, ENGINE_CHANGE_COOLDOWN_MS, String(Date.now())) + + // Reconecta a instância com a nova engine + const isConnected = manager.getSocket(id) != null + if (isConnected) { + await manager.disconnect(id).catch(() => {}) + manager.connect(id, tenantId).catch(() => {}) + } + + res.json({ message: 'Engine atualizada', engine, reconnecting: isConnected }) + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts new file mode 100644 index 0000000..bdf0a29 --- /dev/null +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/whatsapp.send.routes.ts @@ -0,0 +1,792 @@ +/** + * Rotas de envio e operações WhatsApp. + * + * Endpoints: + * POST /api/instances/:instanceId/send — Envia mensagem de texto (com @mentions e reply) + * GET /api/instances/:instanceId/groups/:jid/participants — Lista participantes de grupo + * POST /api/instances/:instanceId/reconcile-names — Reconcilia nomes via pushName (bulk SQL) + * POST /api/instances/:instanceId/broadcast — Disparo em massa (async, até 500 números) + * GET /api/instances/:instanceId/broadcast — Lista broadcasts da instância + * GET /api/instances/:instanceId/broadcast/:id — Detalhe de um broadcast + * GET /api/inbox/avatar/:jid — Proxy de foto de perfil (evita CORS) + * + * Todas as rotas exigem autenticação (tenantId no req). + * O WhatsAppConnectionManager é injetado via buildSendRoutes() para + * acessar os sockets Baileys ativos. + * + * @see WhatsAppConnectionManager — provê getSocket() e getIO() + * @see ContactHandler — reconciliação de nomes (mesma lógica, mas via endpoint HTTP) + */ +import { Router, type Request, type Response } from 'express' +import { createHash } from 'crypto' +import { z } from 'zod' +import { prisma } from '../../infra/database/prisma' +import { dragonfly } from '../../infra/cache/dragonfly' +import { logger } from '../../config/logger' +import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager' +import { sendRichMessage } from './rich-message' + +/** + * Constrói as rotas de envio e operações WhatsApp. + * + * Montadas em: /api/instances/:instanceId/ + * Usa mergeParams para herdar :instanceId do router pai. + */ +export function buildSendRoutes(manager: WhatsAppConnectionManager): Router { + const router = Router({ mergeParams: true }) + + // ═══════════════════════════════════════════════════════════════════════════ + // POST /api/instances/:instanceId/send — Envio de mensagem de texto + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Envia uma mensagem de texto para um JID (contato ou grupo). + * + * Body: + * - jid: string (obrigatório) — JID destino (ex: "5511999999999@s.whatsapp.net") + * - text: string (obrigatório) — Texto da mensagem (até 4096 chars) + * - replyToMessageId?: string — ID da mensagem sendo respondida (quote/reply) + * - mentions?: string[] — Lista de JIDs mencionados com @ (para grupos) + * + * Fluxo: + * 1. Valida instância conectada e socket disponível + * 2. Monta contexto de reply (se houver replyToMessageId) + * 3. Envia via Baileys sock.sendMessage() + * 4. Cria/atualiza Chat no banco + * 5. Persiste a mensagem enviada + * 6. Retorna 201 com o registro da mensagem + */ + router.post('/send', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const { + jid, + text, + footer, + replyToMessageId, + mentions, + // ── Tipos ricos (InfiniteAPI/Baileys) ──────────────────────────────── + nativeButtons, + nativeList, + nativeCarousel, + poll, + } = req.body as { + jid: string + text?: string + footer?: string + replyToMessageId?: string + mentions?: string[] + nativeButtons?: Array<{ type: string; id?: string; text: string; url?: string; copyText?: string; phoneNumber?: string }> + nativeList?: { buttonText: string; sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }> } + nativeCarousel?: { cards: Array<{ title?: string; body?: string; footer?: string; image?: { url: string }; buttons: Array<{ type?: string; id?: string; text: string }> }> } + poll?: { name: string; values: string[]; selectableCount?: number } + } + + const hasRichContent = !!(nativeButtons || nativeList || nativeCarousel || poll) + + if (!jid || (!text && !hasRichContent)) { + res.status(400).json({ error: 'jid e text (ou tipo rico) são obrigatórios' }) + return + } + + // Valida que a instância existe e está conectada + const instance = await prisma.instance.findFirst({ + where: { id: instanceId, tenantId }, + }) + if (!instance || instance.status !== 'CONNECTED') { + res.status(400).json({ error: 'Instância não conectada' }) + return + } + + // Busca o socket Baileys ativo + const sock = manager.getSocket(instanceId) + if (!sock) { + res.status(503).json({ error: 'Socket não disponível' }) + return + } + + // ── Resolve o JID real via onWhatsApp (corrige o dígito 9 brasileiro) ──── + // Números brasileiros migraram de 8 para 9 dígitos. O usuário pode digitar + // com ou sem o 9 — o WhatsApp sabe o JID correto. Grupos (@g.us) são + // excluídos pois já têm JID definitivo. + let resolvedJid = jid + if (!jid.endsWith('@g.us')) { + try { + const waResults = await sock.onWhatsApp(jid) + const waResult = waResults?.[0] + if (!waResult?.exists) { + res.status(422).json({ error: 'Número não encontrado no WhatsApp' }) + return + } + // Se onWhatsApp retornar @lid (WhatsApp moderno), ignoramos — o Baileys + // resolve @lid internamente ao enviar. Armazenar @lid no banco criaria + // um chat duplicado conflitando com o @s.whatsapp.net do MessageHandler. + if (!waResult.jid.endsWith('@lid')) { + resolvedJid = waResult.jid // JID correto com ou sem o 9 + } + } catch (err) { + logger.warn({ err, jid }, 'onWhatsApp falhou — usando JID original') + // Continua com o JID original se a consulta falhar + } + } + + // Monta contexto de reply (quoted message) se necessário + // O Baileys precisa da mensagem original para exibir o quote no WhatsApp + let quotedMsg: any + if (replyToMessageId) { + const reply = await prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } }, + }) + if (reply?.body) { + quotedMsg = { + key: { remoteJid: resolvedJid, fromMe: reply.fromMe, id: reply.messageId }, + message: { conversation: reply.body }, + } + } + } + + // ── Mensagens ricas: delega ao rich-message builder ────────────────────── + // O @whiskeysockets/baileys padrão não suporta nativeButtons/nativeList/ + // nativeCarousel. O builder converte para os protos corretos e usa + // relayMessage() para enviar sem passar pelo generateWAMessageContent. + const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll) + + let sentMessageId: string + if (hasRich) { + sentMessageId = await sendRichMessage( + sock, + resolvedJid, + { text, footer, nativeButtons, nativeList, nativeCarousel, poll } as any, + quotedMsg, + instance.engine, + ) + } else { + // Texto simples (com suporte a @mentions) + const msgContent: any = { text: text ?? '' } + if (mentions?.length) msgContent.mentions = mentions + const sent = await sock.sendMessage(resolvedJid, msgContent, quotedMsg ? { quoted: quotedMsg } : undefined) + sentMessageId = sent?.key?.id ?? `text-${Date.now()}` + } + + const sent = { key: { id: sentMessageId } } + + // ── Persiste chat e mensagem imediatamente ──────────────────────────────── + // O Baileys dispara messages.upsert com type='append' para mensagens enviadas + // por nós, o que não emite eventos Socket.IO. Por isso fazemos a criação aqui + // e emitimos os eventos manualmente para o frontend atualizar em tempo real. + + const isGroup = resolvedJid.endsWith('@g.us') + + // Garantir que o Contato existe no banco + const contact = await prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } }, + create: { + tenantId, + instanceId, + jid: resolvedJid, + phone: isGroup ? null : resolvedJid.split('@')[0], + }, + update: {}, + }) + + const chat = await prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } }, + create: { tenantId, instanceId, jid: resolvedJid, contactId: contact.id, lastMessageAt: new Date() }, + update: { contactId: contact.id, lastMessageAt: new Date() }, + }) + + const messageId = sent?.key.id ?? `local-${Date.now()}` + + // Determina tipo, body e richPayload para persistência + let msgType: 'TEXT' | 'POLL' | 'BUTTONS' | 'LIST' | 'CAROUSEL' | 'INTERACTIVE' = 'TEXT' + let msgBody = text ?? '' + let richPayloadData: Record | undefined + + if (poll) { + msgType = 'POLL' + msgBody = poll.name + richPayloadData = { poll } + } else if (nativeButtons) { + // Detecta se há botões CTA (url/copy/call) → INTERACTIVE, senão → BUTTONS + const hasCta = nativeButtons.some((b: any) => ['url','copy','call'].includes(b.type)) + msgType = hasCta ? 'INTERACTIVE' : 'BUTTONS' + msgBody = text ?? '' + richPayloadData = { text, footer, nativeButtons } + } else if (nativeList) { + msgType = 'LIST' + msgBody = text ?? nativeList.buttonText + richPayloadData = { text, footer, nativeList } + } else if (nativeCarousel) { + msgType = 'CAROUSEL' + msgBody = text ?? nativeCarousel.cards[0]?.title ?? '[Carrossel]' + richPayloadData = { text, footer, nativeCarousel } + } + + const msg = await prisma.message.upsert({ + where: { instanceId_messageId: { instanceId, messageId } }, + create: { + tenantId, + instanceId, + chatId: chat.id, + remoteJid: resolvedJid, + messageId, + fromMe: true, + type: msgType, + body: msgBody, + richPayload: richPayloadData ? JSON.parse(JSON.stringify(richPayloadData)) : undefined, + status: 'SENT', + timestamp: new Date(), + }, + update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve + }) + + // ── Emite eventos Socket.IO para o frontend atualizar em tempo real ─────── + const io = manager.getIO() + + // Inclui replyTo quando é uma resposta — necessário para exibir a caixa + // de citação em sessões adicionais do mesmo tenant que tenham o chat aberto. + const msgToEmit = replyToMessageId + ? await prisma.message.findUnique({ + where: { id: msg.id }, + include: { + replyTo: { + select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true }, + }, + }, + }) ?? msg + : msg + + // Atualiza o chat aberto (se o usuário já estiver nele) + io.to(`chat:${chat.id}`).emit('message:new', { ...msgToEmit, pushName: null, senderJid: null }) + + const displayName = isGroup + ? (chat.name ?? null) + : (contact.name ?? contact.verifiedName ?? contact.notify ?? null) + + // Atualiza a lista de chats do tenant (novo chat ou reordenar pelo lastMessageAt) + io.to(`tenant:${tenantId}`).emit('chat:upsert', { + id: chat.id, + tenantId: chat.tenantId, + instanceId: chat.instanceId, + jid: chat.jid, + name: chat.name ?? null, + unreadCount: chat.unreadCount, + lastMessageAt: chat.lastMessageAt, + isPinned: chat.isPinned, + isArchived: chat.isArchived, + botPaused: chat.botPaused, + contact: { + name: displayName, + phone: contact.phone, + avatarUrl: contact.avatarUrl, + scoreReputacao: contact.scoreReputacao, + flagRestricao: contact.flagRestricao, + }, + lastMessage: { + body: msgBody, + fromMe: true, + status: 'SENT', + type: msgType, + timestamp: msg.timestamp, + }, + }) + + res.status(201).json({ id: msg.id, messageId: msg.messageId }) + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/instances/:instanceId/groups/:groupJid/participants + // Lista participantes de um grupo (para autocomplete de @mentions) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Retorna a lista de participantes de um grupo WhatsApp. + * + * Usado pelo InputBar do frontend para autocomplete de @mentions. + * Busca metadata via Baileys groupMetadata() e enriquece com dados do banco: + * - Nome do contato (se existir no banco) + * - Para JIDs @lid sem nome: busca pushName da última mensagem no grupo + * - Telefone formatado + * + * Response: { groupJid, subject, participants: [{ jid, admin, name, phone }] } + */ + router.get('/groups/:groupJid/participants', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const groupJid = req.params['groupJid'] as string + + if (!groupJid?.endsWith('@g.us')) { + res.status(400).json({ error: 'JID de grupo inválido' }) + return + } + + const instance = await prisma.instance.findFirst({ + where: { id: instanceId, tenantId }, + }) + if (!instance || instance.status !== 'CONNECTED') { + res.status(400).json({ error: 'Instância não conectada' }) + return + } + + const sock = manager.getSocket(instanceId) + if (!sock) { + res.status(503).json({ error: 'Socket não disponível' }) + return + } + + try { + // Busca metadata do grupo via Baileys (inclui lista de participantes) + const metadata = await sock.groupMetadata(groupJid) + + const participants = await Promise.all( + metadata.participants.map(async (p) => { + // Enriquece com dados do banco (nome, telefone) + const contact = await prisma.contact.findFirst({ + where: { tenantId, instanceId, jid: p.id }, + select: { name: true, notify: true, phone: true }, + }) + + let name = contact?.name || contact?.notify || null + let phone = contact?.phone || null + + // Para @lid JIDs sem nome: tenta buscar pushName da última mensagem no grupo + // (LID não tem telefone no JID, então o nome só pode vir de mensagens) + if (!name && p.id.endsWith('@lid')) { + const lastMsg = await prisma.message.findFirst({ + where: { + instanceId, + remoteJid: groupJid, + senderJid: p.id, + fromMe: false, + pushName: { not: null }, + }, + orderBy: { timestamp: 'desc' }, + select: { pushName: true }, + }) + if (lastMsg?.pushName) name = lastMsg.pushName + } + + // Extrai telefone do JID (apenas para @s.whatsapp.net, não @lid) + if (!phone) { + phone = p.id.endsWith('@lid') ? null : p.id.split('@')[0].split(':')[0] + } + + return { + jid: p.id, + admin: p.admin ?? null, // 'admin' | 'superadmin' | null + name, + phone, + } + }) + ) + res.json({ groupJid, subject: metadata.subject, participants }) + } catch (err) { + logger.error({ err, groupJid }, '[Groups] Erro ao buscar participantes') + res.status(500).json({ error: 'Erro ao buscar participantes do grupo' }) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // POST /api/instances/:instanceId/reconcile-names + // Reconciliação bulk de nomes via pushName (endpoint HTTP) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Reconcilia nomes de contatos usando pushName das mensagens recebidas. + * + * Diferente de ContactHandler.reconcileNamesFromMessages() que roda no sync, + * este endpoint pode ser chamado pelo frontend a qualquer momento (ex: page reload). + * + * Implementação: + * - Usa raw SQL com DISTINCT ON para buscar o pushName mais recente por JID + * - Uma única query UPDATE...FROM (subquery) atualiza todos os contatos sem nome + * - Cache DragonflyDB de 30min para evitar re-execução em loop + * - Idempotente: se já executou nos últimos 30min, retorna 204 + * + * NOTA sobre tabelas: Prisma usa nomes como "Contact" e "Message" nos models, + * mas as tabelas PostgreSQL reais são "contacts" e "messages" (via @@map). + * No $executeRaw, SEMPRE usar os nomes reais das tabelas. + */ + // Fallback in-memory para o TTL de reconciliação quando Redis está fora + // Map + const reconcileMemCache = new Map() + const RECONCILE_TTL_MS = 30 * 60 * 1000 // 30 min + + router.post('/reconcile-names', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const cacheKey = `reconcile:${tenantId}:${instanceId}` + + // Camada 1: Redis (30min TTL) + const cached = await dragonfly.get(cacheKey) + if (cached) { res.status(204).end(); return } + + // Camada 2: in-memory (protege quando Redis está fora) + const memTs = reconcileMemCache.get(cacheKey) + if (memTs && Date.now() - memTs < RECONCILE_TTL_MS) { + res.status(204).end() + return + } + + try { + // Query bulk: encontra contatos sem nome que têm msgs recebidas com pushName. + // Usa DISTINCT ON (PostgreSQL) para pegar o pushName mais recente por JID. + // Uma única query UPDATE...FROM atualiza todos — O(1) round-trips ao banco. + const updated = await prisma.$executeRaw` + UPDATE contacts c + SET + "name" = sub."pushName", + "notify" = COALESCE(c."notify", sub."pushName"), + "updatedAt" = NOW() + FROM ( + SELECT DISTINCT ON (m."remoteJid") + m."remoteJid", + m."pushName" + FROM messages m + WHERE m."instanceId" = ${instanceId} + AND m."fromMe" = false + AND m."pushName" IS NOT NULL + AND m."pushName" != '' + ORDER BY m."remoteJid", m."timestamp" DESC + ) sub + WHERE c."instanceId" = ${instanceId} + AND c."tenantId" = ${tenantId} + AND c."jid" = sub."remoteJid" + AND c."name" IS NULL + AND c."notify" IS NULL + ` + + // Seta flag no Redis (30min) + in-memory — independente do resultado + await dragonfly.set(cacheKey, String(updated), 1800) + reconcileMemCache.set(cacheKey, Date.now()) + + if (updated > 0) { + logger.info({ instanceId, updated }, '[Reconcile] Nomes atualizados via pushName') + // Notifica frontend para re-buscar a lista de chats + const io = manager.getIO() + if (io) { + io.to(`tenant:${tenantId}`).emit('chats:refresh', { instanceId, reason: 'reconcile', updated }) + } + } + + res.json({ updated }) + } catch (err) { + logger.error({ err, instanceId }, '[Reconcile] Erro na reconciliação bulk') + // Mesmo com erro, seta cache curto (60s) para não ficar retentando em loop + await dragonfly.set(cacheKey, '0', 60).catch(() => {}) + res.status(500).json({ error: 'Erro na reconciliação' }) + } + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // POST /api/instances/:instanceId/broadcast — Disparo em massa + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Inicia um disparo em massa (broadcast) para até 500 números. + * + * Validação: + * - numbers: array de strings (mínimo 5 chars cada, 1-500 itens) + * - message: texto (1-4096 chars) + * - delayMs: delay entre envios (500-60000ms, default 2000ms) + * + * Fluxo: + * 1. Valida payload via Zod + * 2. Cria registro Broadcast com status 'RUNNING' + * 3. Retorna 202 com jobId imediatamente + * 4. Processa envios em background (async IIFE) + * 5. Atualiza sentCount/failedCount a cada envio + * 6. Marca como 'DONE' ao finalizar + * + * IMPORTANTE: O delay entre envios é obrigatório para evitar ban do WhatsApp. + * O default de 2s é conservador — ajuste conforme volume e tolerância ao risco. + */ + router.post('/broadcast', async (req: Request, res: Response) => { + const schema = z.object({ + numbers: z.array(z.string().min(5)).min(1).max(500), + message: z.string().min(1).max(4096), + delayMs: z.number().int().min(500).max(60000).default(2000), + }) + + let body: z.infer + try { + body = schema.parse(req.body) + } catch (err: any) { + res.status(400).json({ error: err.errors ?? err.message }); return + } + + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + + const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } }) + if (!instance || instance.status !== 'CONNECTED') { + res.status(400).json({ error: 'Instância não conectada' }); return + } + + const sock = manager.getSocket(instanceId) + if (!sock) { + res.status(503).json({ error: 'Socket não disponível' }); return + } + + // Cria registro do broadcast no banco + const broadcast = await prisma.broadcast.create({ + data: { + tenantId, + instanceId, + message: body.message, + totalCount: body.numbers.length, + sentCount: 0, + failedCount: 0, + status: 'RUNNING', + }, + }) + + // Retorna imediatamente (processamento assíncrono) + res.status(202).json({ jobId: broadcast.id, total: body.numbers.length }) + + // ── Processamento em background ───────────────────────────────────── + ;(async () => { + let sent = 0, failed = 0 + + for (let i = 0; i < body.numbers.length; i++) { + const rawNumber = body.numbers[i]! + // Normaliza número: remove caracteres não-numéricos + const number = rawNumber.replace(/\D/g, '') + // Se já é um JID completo (contém @), usa como está; senão, adiciona sufixo + const jid = number.includes('@') ? number : `${number}@s.whatsapp.net` + + try { + await sock.sendMessage(jid, { text: body.message }) + + await prisma.broadcastRecipient.create({ + data: { broadcastId: broadcast.id, jid, status: 'SENT' }, + }) + sent++ + } catch (err) { + logger.warn({ jid, err }, '[Broadcast] Falha ao enviar') + await prisma.broadcastRecipient.create({ + data: { broadcastId: broadcast.id, jid, status: 'FAILED' }, + }).catch(() => {}) + failed++ + } + + // Atualiza contadores em tempo real (permite consulta de progresso) + await prisma.broadcast.update({ + where: { id: broadcast.id }, + data: { sentCount: sent, failedCount: failed }, + }) + + // Delay entre envios para evitar ban do WhatsApp (pula no último item) + if (i < body.numbers.length - 1) { + await new Promise(r => setTimeout(r, body.delayMs)) + } + } + + // Marca como concluído + await prisma.broadcast.update({ + where: { id: broadcast.id }, + data: { status: 'DONE', finishedAt: new Date() }, + }) + })().catch(err => { + logger.error({ err }, '[Broadcast] Erro no job') + prisma.broadcast.update({ where: { id: broadcast.id }, data: { status: 'FAILED' } }).catch(() => {}) + }) + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/instances/:instanceId/broadcast — Lista broadcasts + // ═══════════════════════════════════════════════════════════════════════════ + + /** Lista os últimos broadcasts da instância (paginado, max 50) */ + router.get('/broadcast', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const instanceId = req.params['instanceId'] as string + const limit = Math.min(Number(req.query['limit'] ?? 20), 50) + + const rows = await prisma.broadcast.findMany({ + where: { tenantId, instanceId }, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { _count: { select: { recipients: true } } }, + }) + res.json(rows) + }) + + // ═══════════════════════════════════════════════════════════════════════════ + // GET /api/instances/:instanceId/broadcast/:id — Detalhe de broadcast + // ═══════════════════════════════════════════════════════════════════════════ + + /** Retorna detalhe de um broadcast específico, incluindo lista de destinatários */ + router.get('/broadcast/:broadcastId', async (req: Request, res: Response) => { + const tenantId = req.tenantId! + const broadcastId = req.params['broadcastId'] as string + + const row = await prisma.broadcast.findFirst({ + where: { id: broadcastId, tenantId }, + include: { recipients: { orderBy: { createdAt: 'asc' } } }, + }) + if (!row) { res.status(404).json({ error: 'Não encontrado' }); return } + res.json(row) + }) + + return router +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ROTA DE AVATAR — Proxy de foto de perfil (evita CORS) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Constrói a rota de proxy de avatar. + * + * Montada em: /api/inbox/avatar/:jid + * + * O frontend não pode buscar avatares diretamente do CDN do WhatsApp (CORS). + * Esta rota age como proxy: + * 1. Busca avatarUrl salvo no banco (cache DB) + * 2. Se URL da DB expirou, busca via Baileys profilePictureUrl + * 3. Faz proxy do buffer (download → response) para evitar redirect/CORS + * 4. Salva URL nova no banco para próximas requisições + * + * Retorna 204 (sem conteúdo) quando avatar não disponível: + * - Foto de perfil privada + * - JID @lid (não suporta profilePictureUrl) + * - Socket não disponível + * O frontend trata 204 mostrando iniciais em vez da foto. + * + * Cache HTTP: max-age=86400 (24h) + stale-while-revalidate=604800 (7d) para + * avatares encontrados; 300s para 204. Valida revalidação via ETag. + * + * Negative cache: quando decide 204, grava `avatar:none:*` no Dragonfly + * com TTL de 1h — próximos hits respondem sem tocar no Prisma nem no Baileys. + */ +export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router { + const router = Router() + + // Cabeçalho longo com stale-while-revalidate: o browser serve do cache + // local por 24h e, entre 24h e 7d, usa o stale enquanto revalida em + // background. Corta requisições repetidas na navegação normal. + const HIT_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800' + const MISS_CACHE_CONTROL = 'public, max-age=300' + const NO_AVATAR_TTL_SECONDS = 3600 // 1h + + const noAvatarKey = (tenantId: string, instanceId: string, jid: string) => + `avatar:none:${tenantId}:${instanceId}:${jid}` + + // ETag baseado no avatarUrl conhecido. É determinístico (hash da string), + // logo quando a URL do banco muda — sinal de que a foto mudou — o ETag + // também muda e força o browser a buscar bytes novos. + const etagFor = (urlOrMarker: string) => + `"${createHash('md5').update(urlOrMarker).digest('hex')}"` + + router.get('/avatar/:jid', async (req: Request, res: Response) => { + const jid = req.params['jid'] as string + const instanceId = req.query['instance'] as string | undefined + const t0 = Date.now() + const tenantId = req.tenantId! + + // Helper: retorna 204 No Content (não 404) para que o browser não logue + // erro no console. O do frontend detecta a falta de imagem + // e mostra iniciais. Também grava no negative cache para curto-circuitar + // as próximas chamadas ao mesmo JID. + const noAvatar = (persist = true) => { + if (persist && instanceId) { + dragonfly + .set(noAvatarKey(tenantId, instanceId, jid), '1', NO_AVATAR_TTL_SECONDS) + .catch(() => {}) + } + res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end() + } + + if (!instanceId) { + res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end() + return + } + + // 0. Negative cache: se sabemos que não tem avatar há menos de 1h, corta + // antes de tocar Prisma/Baileys (o backend está sob pressão de memória). + try { + if (await dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) { + res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end() + logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached') + return + } + } catch { + // Dragonfly indisponível — segue o fluxo normal sem quebrar + } + + // 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID) + const contact = await prisma.contact.findFirst({ + where: { tenantId, instanceId, jid }, + select: { avatarUrl: true }, + }) + + if (contact?.avatarUrl) { + // Revalidação condicional: se o browser mandou If-None-Match e bate, + // responde 304 sem baixar do CDN. Reduz o custo em rehit a ~zero. + const etag = etagFor(contact.avatarUrl) + if (req.headers['if-none-match'] === etag) { + res.set('ETag', etag) + res.set('Cache-Control', HIT_CACHE_CONTROL) + res.status(304).end() + logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304') + return + } + + try { + // Busca imagem do CDN do WhatsApp (URL temporária, expira em ~24h) + const upstream = await fetch(contact.avatarUrl) + if (upstream.ok) { + const buffer = Buffer.from(await upstream.arrayBuffer()) + res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') + res.set('Cache-Control', HIT_CACHE_CONTROL) + res.set('ETag', etag) + res.set('Content-Length', String(buffer.length)) + res.send(buffer) + logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served') + return + } + // URL da DB expirou — tenta via Baileys abaixo + logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys') + } catch { + // URL da DB expirou ou erro de rede — tenta via Baileys abaixo + } + } + + // 2. JIDs @lid não suportam profilePictureUrl no Baileys (retorna 404 no WA) + if (jid.endsWith('@lid')) { noAvatar(); return } + + // 3. Busca URL fresca via Baileys profilePictureUrl + const sock = manager.getSocket(instanceId) + if (!sock) { noAvatar(); return } + + try { + const url = await sock.profilePictureUrl(jid, 'image') + if (!url) { noAvatar(); return } + + // Faz proxy do buffer — evita CORS ao redirecionar para o CDN do WhatsApp + const upstream = await fetch(url) + if (!upstream.ok) { noAvatar(); return } + + const buffer = Buffer.from(await upstream.arrayBuffer()) + res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg') + res.set('Cache-Control', HIT_CACHE_CONTROL) + res.set('ETag', etagFor(url)) + res.set('Content-Length', String(buffer.length)) + res.send(buffer) + logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served') + + // Salva URL nova no banco para próximas requisições (fire-and-forget). + // Também invalida o negative cache, caso exista: agora temos foto. + await prisma.contact.updateMany({ + where: { tenantId, instanceId, jid }, + data: { avatarUrl: url }, + }).catch(() => {}) + dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {}) + } catch { + noAvatar() + } + }) + + return router +} diff --git a/clube67/newwhats.local/backend/src/server.ts b/clube67/newwhats.local/backend/src/server.ts new file mode 100644 index 0000000..cd2fdf1 --- /dev/null +++ b/clube67/newwhats.local/backend/src/server.ts @@ -0,0 +1,221 @@ +import 'dotenv/config' +import http from 'http' +import { existsSync } from 'fs' +import express from 'express' +import cors from 'cors' +import helmet from 'helmet' +import path from 'path' + +import { env } from './config/env' +import { logger } from './config/logger' +import { prisma } from './infra/database/prisma' +// prisma re-usado inline abaixo (plan/status route) +import { dragonfly } from './infra/cache/dragonfly' +import { buildSocketServer } from './infra/socket/socketServer' +import { hookBus } from './core/hook-bus' +import { pluginConfig } from './core/plugin-config' +import { db, ensurePluginTables } from './core/db' +import { loadPlugins } from './core/plugin-loader' +import { pluginRegistry } from './core/plugin-registry' +import { WhatsAppConnectionManager } from './modules/whatsapp/connection/WhatsAppConnectionManager' +import { buildWhatsAppRoutes } from './modules/whatsapp/whatsapp.routes' +import { buildChatRoutes } from './modules/chats/chat.routes' +import { buildSendRoutes, buildAvatarRoute } from './modules/whatsapp/whatsapp.send.routes' +import { buildMediaRoutes } from './modules/whatsapp/whatsapp.media.routes' +import { buildChatbotRoutes } from './modules/chatbot/chatbot.routes' +import { buildContactRoutes } from './modules/contacts/contact.routes' +import { buildSectorRoutes } from './modules/sectors/sector.routes' +import { buildApiKeyRoutes } from './modules/api-keys/apikey.routes' +import { authRouter } from './modules/auth/auth.routes' +import { adminRouter } from './modules/auth/admin.routes' +import { pairRouter } from './modules/pair/pair.routes' +import { buildTemplateRoutes } from './modules/templates/template.routes' +import { buildScheduledRoutes } from './modules/scheduled/scheduled.routes' +import { SchedulerService } from './modules/scheduled/scheduler.service' +import { authMiddleware } from './shared/middlewares/auth.middleware' +import { adminMiddleware } from './shared/middlewares/admin.middleware' +import { errorMiddleware } from './shared/middlewares/error.middleware' + +async function bootstrap() { + // ── Express ────────────────────────────────────────────────────────────── + const app = express() + + app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })) + app.use(cors({ origin: env.FRONTEND_URL, credentials: true })) + app.use(express.json({ limit: '10mb' })) + + // Servir arquivos de mídia baixados (ambos os prefixos: acesso direto e via proxy nginx) + const mediaStatic = express.static(path.resolve('./media'), { maxAge: '7d' }) + app.use('/media', mediaStatic) + app.use('/api/media', mediaStatic) + + // ── HTTP Server + Socket.IO ─────────────────────────────────────────────── + const httpServer = http.createServer(app) + const io = buildSocketServer(httpServer) + + // ── WhatsApp Connection Manager ─────────────────────────────────────────── + const whatsAppManager = new WhatsAppConnectionManager(io) + // Expõe para o plugin ext-api (sem circular dep) + ;(globalThis as any).__whatsAppManager = whatsAppManager + + // ── Rotas ───────────────────────────────────────────────────────────────── + app.use('/api/auth', authRouter) + app.use('/api/pair', 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.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', authMiddleware, adminMiddleware, adminRouter) + app.use('/api/chats', authMiddleware, buildChatRoutes(whatsAppManager)) + app.use('/api/instances', authMiddleware, buildWhatsAppRoutes(whatsAppManager)) + app.use('/api/instances/:instanceId', authMiddleware, buildSendRoutes(whatsAppManager)) + app.use('/api/instances/:instanceId', authMiddleware, buildMediaRoutes(whatsAppManager)) + app.use('/api/chatbot', authMiddleware, buildChatbotRoutes()) + app.use('/api/contacts', authMiddleware, buildContactRoutes()) + app.use('/api/sectors', authMiddleware, buildSectorRoutes()) + app.use('/api/api-keys', authMiddleware, buildApiKeyRoutes()) + app.use('/api/inbox', authMiddleware, buildAvatarRoute(whatsAppManager)) + + // ── Templates e Agendamentos ────────────────────────────────────────────── + const scheduler = new SchedulerService(whatsAppManager) + app.use('/api/templates', authMiddleware, buildTemplateRoutes()) + app.use('/api/scheduled', authMiddleware, buildScheduledRoutes(scheduler)) + + // Banner do frontend — dias restantes do plano + app.get('/api/plan/status', authMiddleware, async (req, res) => { + const user = await 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', authMiddleware, async (req, res) => { + const online = await 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', authMiddleware, (_req, res) => { + const items = pluginRegistry.getActiveMenuItems() + res.json(items) + }) + + app.use(errorMiddleware) + + // ── Infra: conectar Dragonfly e Prisma ─────────────────────────────────── + await dragonfly.connect() + await prisma.$connect() + logger.info('PostgreSQL conectado via Prisma') + + // ── Plugin System ───────────────────────────────────────────────────────── + await ensurePluginTables(db) + + const loadedPlugins = await loadPlugins() + const pluginNames = loadedPlugins.map((p) => p.manifest.name) + await pluginConfig.loadAll(pluginNames) + + const pluginCtx = { + app, + db, + prisma, + logger, + hooks: hookBus, + config: pluginConfig, + io, + httpServer, + } + + pluginRegistry.setContext(pluginCtx) + pluginRegistry.register(loadedPlugins) + await pluginRegistry.activateAll() + 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.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.broadcast.updateMany({ + where: { status: 'RUNNING' }, + data: { status: 'FAILED', finishedAt: new Date() }, + }) + if (stuckCount.count > 0) { + 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.instance.findMany({ + where: { status: { notIn: ['BANNED'] } }, + }) + + for (const inst of candidates) { + const credsPath = path.join(env.BAILEYS_SESSIONS_PATH, inst.id, 'creds.json') + if (!existsSync(credsPath)) { + // Nunca foi pareada — pular pra não disparar QR indesejado no boot + continue + } + 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.error({ err, instanceId: inst.id }, 'Falha ao reconectar instância'), + ) + } + + // ── Scheduler de mensagens ──────────────────────────────────────────────── + scheduler.start() + + // ── Iniciar servidor ────────────────────────────────────────────────────── + const port = parseInt(env.PORT, 10) + httpServer.listen(port, () => { + logger.info(`🚀 NewWhats backend rodando em http://localhost:${port}`) + logger.info(` Socket.IO pronto — frontend: ${env.FRONTEND_URL}`) + }) + + // ── Graceful shutdown ───────────────────────────────────────────────────── + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Encerrando servidor...') + scheduler.stop() + httpServer.close() + await 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) +}) diff --git a/clube67/newwhats.local/backend/src/shared/middlewares/admin.middleware.ts b/clube67/newwhats.local/backend/src/shared/middlewares/admin.middleware.ts new file mode 100644 index 0000000..097b01c --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/middlewares/admin.middleware.ts @@ -0,0 +1,13 @@ +import type { Request, Response, NextFunction } from 'express' + +/** + * Garante que apenas usuários com role ADMIN acessem a rota. + * Deve ser usado APÓS o authMiddleware. + */ +export function adminMiddleware(req: Request, res: Response, next: NextFunction): void { + if (req.userRole !== 'ADMIN') { + res.status(403).json({ error: 'Acesso restrito a administradores' }) + return + } + next() +} diff --git a/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js new file mode 100644 index 0000000..e45c259 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js @@ -0,0 +1,28 @@ +"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 + 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' }); + } +} +//# sourceMappingURL=auth.middleware.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js.map b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js.map new file mode 100644 index 0000000..d2d7b7e --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.js.map @@ -0,0 +1 @@ +{"version":3,"file":"auth.middleware.js","sourceRoot":"","sources":["auth.middleware.ts"],"names":[],"mappings":";;;;;AASA,wCAmBC;AA3BD,gEAA8B;AAC9B,0CAAsC;AAOtC,SAAgB,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC5E,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAA;IACxC,sEAAsE;IACtE,MAAM,UAAU,GAAG,OAAO,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IAErF,IAAI,CAAC,MAAM,EAAE,UAAU,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;QAClD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAA;QACtD,OAAM;IACR,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,EAAE,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,UAAW,CAAA;IAC3E,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,SAAG,CAAC,UAAU,CAAe,CAAA;QAC/D,GAAG,CAAC,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAA;QAC1B,GAAG,CAAC,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAA;QAC3B,IAAI,EAAE,CAAA;IACR,CAAC;IAAC,MAAM,CAAC;QACP,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC,CAAA;IAC/D,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.ts b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.ts new file mode 100644 index 0000000..8dfe457 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/middlewares/auth.middleware.ts @@ -0,0 +1,29 @@ +import type { Request, Response, NextFunction } from 'express' +import jwt from 'jsonwebtoken' +import { env } from '../../config/env' + +interface JwtPayload { + sub: string // userId (tenantId) + role: string +} + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const header = req.headers.authorization + // Aceita token via query string para uso em + 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 = jwt.verify(token, env.JWT_SECRET) as JwtPayload + req.tenantId = payload.sub + req.userRole = payload.role + next() + } catch { + res.status(401).json({ error: 'Token inválido ou expirado' }) + } +} diff --git a/clube67/newwhats.local/backend/src/shared/middlewares/error.middleware.ts b/clube67/newwhats.local/backend/src/shared/middlewares/error.middleware.ts new file mode 100644 index 0000000..c304335 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/middlewares/error.middleware.ts @@ -0,0 +1,12 @@ +import type { Request, Response, NextFunction } from 'express' +import { logger } from '../../config/logger' + +export function errorMiddleware( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +): void { + logger.error(err, 'Unhandled error') + res.status(500).json({ error: 'Erro interno do servidor' }) +} diff --git a/clube67/newwhats.local/backend/src/shared/types/express.d.ts b/clube67/newwhats.local/backend/src/shared/types/express.d.ts new file mode 100644 index 0000000..a8024f9 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/types/express.d.ts @@ -0,0 +1,6 @@ +declare namespace Express { + interface Request { + tenantId?: string + userRole?: string + } +} diff --git a/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js new file mode 100644 index 0000000..378c052 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js @@ -0,0 +1,69 @@ +"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; +} +//# sourceMappingURL=whatsapp.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js.map b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js.map new file mode 100644 index 0000000..0f043ed --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.js.map @@ -0,0 +1 @@ +{"version":3,"file":"whatsapp.js","sourceRoot":"","sources":["whatsapp.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;GAgBG;;AAkBH,oCASC;AAcD,oCAOC;AA9CD;;;;;;;;;;;;;;;GAeG;AACH,SAAgB,YAAY,CAAC,GAA8B;IACzD,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,CAAC;IAEpB,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM;QAAE,OAAO,GAAG,CAAC,CAAC,iDAAiD;IAE1E,kEAAkE;IAClE,MAAM,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,GAAG,SAAS,IAAI,MAAM,EAAE,CAAC;AAClC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,YAAY,CAAC,GAA8B;IACzD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,IAAI,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,yCAAyC;IAEjF,MAAM,CAAC,QAAQ,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAClC,MAAM,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,SAAS,CAAC;AACnB,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/backend/src/shared/utils/whatsapp.ts b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.ts new file mode 100644 index 0000000..646ede0 --- /dev/null +++ b/clube67/newwhats.local/backend/src/shared/utils/whatsapp.ts @@ -0,0 +1,65 @@ +/** + * 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 + */ + +/** + * 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 + */ +export function normalizeJid(jid: string | null | undefined): string { + 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 + */ +export function extractPhone(jid: string | null | undefined): string | null { + 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; +} diff --git a/clube67/newwhats.local/backend/tsconfig.json b/clube67/newwhats.local/backend/tsconfig.json new file mode 100644 index 0000000..e547e44 --- /dev/null +++ b/clube67/newwhats.local/backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": false, + "strictNullChecks": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/clube67/newwhats.local/chat-fund.png b/clube67/newwhats.local/chat-fund.png new file mode 100644 index 0000000..12be842 Binary files /dev/null and b/clube67/newwhats.local/chat-fund.png differ diff --git a/clube67/newwhats.local/compartilhamento_instrucoes.md b/clube67/newwhats.local/compartilhamento_instrucoes.md new file mode 100644 index 0000000..2978e25 --- /dev/null +++ b/clube67/newwhats.local/compartilhamento_instrucoes.md @@ -0,0 +1,152 @@ +# 📂 Guia de Compartilhamento da Pasta de Instruções entre VPSs + +Este documento descreve as melhores estratégias e o passo a passo técnico para que **agentes (humanos, automatizados ou IA)** em outras VPSs da sua rede segura (`10.99.0.0/24`) tenham acesso de leitura e escrita à pasta `/home/deploy/instrucoes`. + +Como você já possui uma infraestrutura de rede privada criptografada baseada em **WireGuard**, podemos aproveitar essa segurança para compartilhar os arquivos de forma extremamente protegida e eficiente. + +--- + +## 🗺️ Tabela Comparativa de Abordagens + +| Abordagem | Tipo | Sincronização | Segurança | Complexidade | Recomendação | +| :--- | :--- | :--- | :--- | :--- | :--- | +| **1. Git Privado** | Repositório | Manual / CI-CD | 🟢 **Máxima** (Chaves SSH) | 🟡 Baixa | **Altamente Recomendado** para desenvolvimento e IA | +| **2. Syncthing** | P2P Ativo | Instantânea | 🟢 **Excelente** (TLS + VPN) | 🟡 Média | **Ideal** para espelhamento em tempo real bidirecional | +| **3. SSHFS** | Mount Direto | Sob demanda | 🟢 **Excelente** (SSH s/ VPN) | 🟢 Muito Baixa | **Excelente** para montagem rápida sem duplicar arquivos | +| **4. Rsync + Cron** | Script Push/Pull | Periódica | 🟢 **Excelente** (SSH) | 🟢 Muito Baixa | Para espelhamento simples de leitura apenas | + +--- + +## 🕸️ Opção 1: Repositório Git Privado (Recomendado para Agentes & IA) + +Esta é a abordagem padrão da indústria. Em vez de compartilhar a pasta fisicamente via rede (o que gera dependência de conexão direta), transformamos a pasta `instrucoes` em um **repositório Git privado** (ex: no GitHub ou GitLab). + +### Como funciona: +Cada VPS possui um clone local em `/home/deploy/instrucoes`. Quando um agente ou você altera algo, faz um `git commit` e `git push`. As outras VPSs fazem `git pull`. + +### Vantagens para Agentes de IA: +Os agentes de IA (como o Antigravity e outros) lidam nativamente de forma excelente com comandos Git. Isso garante controle de versão, histórico de quem alterou o quê, facilidade de rollback e elimina riscos de corrupção de arquivos por escrita simultânea. + +### Passo a Passo de Configuração: + +1. **Inicializar o Git localmente na máquina de origem (ex: VPS 1):** + ```bash + cd /home/deploy/instrucoes + git init + # Adicionar um .gitignore para ignorar arquivos temporários ou chaves sensíveis que não devem subir + echo "gcal_key.json" >> .gitignore + echo "ssh_alerts_config.json" >> .gitignore + git add . + git commit -m "feat: commit inicial das instrucoes" + ``` + +2. **Criar um Repositório Privado** no GitHub/GitLab. + +3. **Gerar chaves de acesso SSH (Deploy Keys) nas outras VPSs:** + Em cada VPS que precisa de acesso, gere uma chave SSH para o usuário `deploy`: + ```bash + ssh-keygen -t ed25519 -C "vps-instrucoes-deploy" -f ~/.ssh/id_ed25519_github + ``` + Adicione a chave pública (`.pub`) gerada como **Deploy Key** (com permissão de escrita, se necessário) nas configurações do repositório no GitHub. + +4. **Clonar o repositório nas outras VPSs:** + ```bash + cd /home/deploy + git clone git@github.com:seu-usuario/seu-repositorio.git instrucoes + ``` + +5. **Automatizar o Pull (Opcional):** + Se quiser que as outras VPSs atualizem as instruções automaticamente sempre que houver mudanças no repositório remoto, configure um cronjob de 5 em 5 minutos para rodar o pull: + ```bash + crontab -e + # Adicione a linha abaixo: + */5 * * * * cd /home/deploy/instrucoes && git pull origin main > /dev/null 2>&1 + ``` + +--- + +## 🔄 Opção 2: Syncthing (Sincronização em Tempo Real Descentralizada) + +O **Syncthing** é um serviço peer-to-peer de sincronização contínua de arquivos. Ele roda como um daemon leve nas VPSs e garante que qualquer arquivo criado ou modificado em uma pasta seja propagado instantaneamente para as outras em questão de segundos. + +### Como configurar de forma segura via VPN WireGuard: + +1. **Instalar o Syncthing em todas as VPSs:** + ```bash + sudo apt-get update + sudo apt-get install -y syncthing + ``` + +2. **Configurar para escutar apenas no IP da VPN (Zero-Trust):** + Edite o arquivo de configuração do Syncthing (gerado após a primeira execução em `~/.config/syncthing/config.xml`) e ajuste o endereço da interface gráfica e de sincronização para rodar apenas nos IPs locais ou de VPN, prevenindo exposição pública. + * Altere a linha do GUI para escutar localmente: `
127.0.0.1:8384
` (Você pode acessar usando um túnel SSH do seu notebook: `ssh -L 8384:127.0.0.1:8384 deploy@10.99.0.1`). + +3. **Adicionar os Servidores como Peers:** + * No painel do Syncthing da VPS 1, copie o **Device ID**. + * Adicione esse Device ID no painel da VPS 4/5, especificando o endereço fixo da VPN (ex: `tcp://10.99.0.1:22000`). + * Compartilhe a pasta `/home/deploy/instrucoes` entre eles. + +4. **Habilitar o Serviço na inicialização:** + ```bash + sudo systemctl enable syncthing@deploy.service + sudo systemctl start syncthing@deploy.service + ``` + +--- + +## 🔗 Opção 3: SSHFS (Montagem de Pasta sobre SSH na VPN) + +O **SSHFS** permite montar um diretório remoto de um servidor em outro servidor de forma transparente, utilizando o próprio protocolo SSH de transporte. Como a sua porta SSH (22) está aberta e protegida dentro da rede privada da VPN (`10.99.0.0/24`), este método é extremamente simples e não exige serviços extras rodando em segundo plano além do próprio SSH. + +### Como configurar: + +Digamos que a pasta física está na **VPS 1 (IP `10.99.0.1`)** e você deseja acessá-la em tempo real na **VPS 4 (IP `10.99.0.4`)**: + +1. **Instalar o SSHFS na VPS de destino (ex: VPS 4):** + ```bash + sudo apt-get update + sudo apt-get install -y sshfs + ``` + +2. **Garantir que o usuário `deploy` da VPS 4 tem acesso SSH sem senha à VPS 1:** + Adicione a chave SSH pública (`~/.ssh/id_ed25519.pub`) do usuário `deploy` da VPS 4 no arquivo `/home/deploy/.ssh/authorized_keys` da VPS 1. + +3. **Criar a pasta de montagem na VPS de destino (VPS 4):** + ```bash + mkdir -p /home/deploy/instrucoes + ``` + +4. **Montar o diretório manualmente para testar:** + ```bash + sshfs deploy@10.99.0.1:/home/deploy/instrucoes /home/deploy/instrucoes -o IdentityFile=/home/deploy/.ssh/id_ed25519 -o allow_other -o reconnect -o ServerAliveInterval=15 + ``` + +5. **Tornar a montagem permanente pós-reboot (fstab):** + Adicione a seguinte linha ao final do arquivo `/etc/fstab` na VPS de destino: + ```text + deploy@10.99.0.1:/home/deploy/instrucoes /home/deploy/instrucoes fuse.sshfs x-systemd.automount,IdentityFile=/home/deploy/.ssh/id_ed25519,allow_other,reconnect,ServerAliveInterval=15,ServerAliveCountMax=3,_netdev 0 0 + ``` + +--- + +## 🚀 Opção 4: Rsync Automatizado via Cron (Espelhamento Simples) + +Se os agentes nas outras VPSs precisam apenas ler as instruções ou executar instaladores (como o `install.sh`) e você quer uma solução extremamente leve e direta sem montagens de disco. + +### Script de sincronização unidirecional (VPS 1 -> VPS de Destino): + +Na **VPS 1**, crie um script simples ou configure uma tarefa recorrente no Cron para enviar as atualizações: + +```bash +# Sincroniza a pasta de instruções para a VPS 4 através da VPN segura +rsync -avz --delete -e "ssh -i /home/deploy/.ssh/id_ed25519" /home/deploy/instrucoes/ deploy@10.99.0.4:/home/deploy/instrucoes/ +``` + +Você pode programar isso para rodar a cada hora ou a cada 15 minutos de forma silenciosa e imperceptível. + +--- + +## 🎯 Veredito: Qual escolher? + +* **Escolha o Git Privado (Opção 1)** se você deseja que múltiplos agentes (incluindo IAs) façam alterações nos scripts e nos documentos de forma colaborativa, sem riscos de conflitos cegos de escrita e mantendo histórico de auditoria perfeito. +* **Escolha o SSHFS (Opção 3)** se você quer facilidade máxima de implementação imediata: a pasta fica unificada, consome armazenamento físico de apenas uma máquina e reflete edições instantaneamente sem sincronizações adicionais. diff --git a/clube67/newwhats.local/continuar.md b/clube67/newwhats.local/continuar.md new file mode 100644 index 0000000..ac920ca --- /dev/null +++ b/clube67/newwhats.local/continuar.md @@ -0,0 +1,86 @@ +# Continuar — NewWhats como Motor Central (SaaS multi-tenant) + +## Instrução inicial para a próxima sessão + +> Faça uma **análise profissional** da ideia descrita abaixo. Antes de escrever qualquer código: +> 1. Gere uma **lista de tarefas detalhada** (fases, dependências, critérios de aceite) e salve em `TAREFAS.md`. +> 2. Mantenha um **relatório vivo** de todas as implementações em `RELATORIO.md` (o que foi feito, decisões técnicas, pontos de atenção, próximos passos). +> 3. **Regra-ouro:** toda funcionalidade nova deve ser pensada e entregue **como plugin** (em `/plugins//` com `manifest.json`, hooks, config_schema). Nada vai direto ao core se couber como plugin. + +--- + +## Contexto do projeto + +- **Projeto motor:** `/home/deploy/projetos/newwhats.local` — já possui Baileys (WhatsApp), inbox, sessões, storage (Wasabi), sistema de plugins estilo WordPress, e API de pareamento OTP (`/api/pair/*`) com `integration_key`. +- **Projetos satélites:** múltiplos sistemas (ex.: Alemão Conveniências em `/home/deploy/projetos/alemaoconveniencias.local`) que **não devem instalar Baileys**. Devem consumir o motor via API autenticada por `integration_key`. +- **Já implementado:** pair system (OTP por e-mail), plugin `newwhats` no Alemão com `x-nw-key` auth, modal de pareamento em 3 passos no dashboard. +- **Falta:** expor inbox, sessões e secretária virtual como serviço consumível pelos satélites. + +## Ideia a analisar + +Transformar o NewWhats em **motor SaaS multi-tenant**, onde cada projeto satélite é um cliente leve que consome: +- **Inbox** (chats, mensagens, envio, mídias) +- **Sessões** (status WA, QR, pareamento de números) +- **Secretária virtual** (IA com contexto — roda 100% no motor, satélite só consome output) + +## Arquitetura-alvo + +``` +NewWhats (motor) + ├─ Baileys + Inbox + Storage + Secretária (IA) + └─ API /api/ext/* (auth: x-nw-key) + │ + ▼ HTTPS + WebSocket + ┌───────┴────────┬──────────┐ + Alemão Projeto B Projeto C + (plugin (plugin (plugin + nw-client) nw-client) nw-client) +``` + +## Decisões propostas (validar antes de implementar) + +1. **Plugin `nw-client` portável** em `/plugins-sdk/newwhats-client/` — reutilizável em todos os satélites, zero dependência de Baileys. +2. **API `/api/ext/*` nova** (além de `/api/pair/*` já existente), autenticada por `x-nw-key`: + - `GET /api/ext/sessions` + - `GET /api/ext/inbox?session=X` + - `GET /api/ext/inbox/:chatId/messages` + - `POST /api/ext/inbox/:chatId/send` + - `POST /api/ext/secretaria/ask` + - `WS /api/ext/stream` (eventos realtime — fallback polling 5s) +3. **Secretária virtual sempre no motor** — centraliza custo LLM e contexto. +4. **Isolamento multi-tenant** — `integration_key` resolve para `user_id` + `allowed_sessions[]`. Satélite nunca vê dados de outro tenant. +5. **Distribuição do client plugin** — opções: + - (a) `npm publish @newwhats/client` (registry privado/público) + - (b) git submodule + - (c) **CLI `nw plugin install`** que baixa tarball assinado do motor ← recomendado +6. **Tudo como plugin:** + - Motor: `plugins/ext-api/` (expõe `/api/ext/*`), `plugins/secretaria-virtual/` (IA), `plugins/realtime-bridge/` (WS). + - Satélite: `plugins/newwhats-client/` (consumidor único, com UI de inbox embutida). + +## Tradeoffs a documentar no relatório + +- **WS vs polling** — WS é melhor para UX, mas adiciona complexidade de reconexão/auth. V1 polling 5s, V2 WS. +- **Proxy de mídias** — satélite baixa mídia via motor (`/api/ext/media/:id`) ou recebe URL assinada Wasabi? URL assinada é mais leve mas expõe storage. +- **Rate limit por integration_key** — necessário desde o v1 para proteger motor. +- **Versionamento da API** — `/api/ext/v1/*` desde o início. + +## Pendências herdadas (do `project_alemao_plugin_session.md`) + +- `aria-hidden` warning no pairModal (Bootstrap 5 — usar `inert` ou Bootstrap 5.3+). +- Inbox no Alemão via plugin — **esta é a primeira entrega desta nova fase**. +- SMTP não configurado no NewWhats — dev_code exposto na resposta até configurar. + +## Entregáveis esperados da próxima sessão + +1. `TAREFAS.md` — backlog fase-a-fase (motor → SDK → satélite → secretária). +2. `RELATORIO.md` — iniciado com análise profissional da ideia, riscos, e decisões arquiteturais. +3. Esqueleto dos plugins (manifests + hooks declarados, sem lógica ainda) para validar a forma antes de implementar. +4. Pergunta ao usuário: **(A)** contrato detalhado da API primeiro, ou **(B)** PoC end-to-end de `/api/ext/inbox` no Alemão para validar fluxo. + +## Referências rápidas + +- Sistema de plugins: `backend/src/core/` (types, hook-bus, plugin-loader, plugin-registry) +- Pair system: `backend/src/modules/pair/pair.routes.ts` + `pair.service.ts` +- Plugin consumidor existente: `/home/deploy/projetos/alemaoconveniencias.local/sistema-nuvem/plugins/newwhats/` +- Build backend: `npm run build` em `/home/deploy/projetos/newwhats.local/backend` +- Serviços: `newwhats-backend.service` (8008), `alemaoconveniencias.service` (4001) diff --git a/clube67/newwhats.local/ecosystem.dev.config.js b/clube67/newwhats.local/ecosystem.dev.config.js new file mode 100644 index 0000000..191e53e --- /dev/null +++ b/clube67/newwhats.local/ecosystem.dev.config.js @@ -0,0 +1,27 @@ +module.exports = { + apps: [ + { + name: 'newwhats-backend', + script: 'npm', + args: 'run dev', + cwd: '/home/deploy/projetos/newwhats.local/backend', + watch: false, // tsx watch já cuida do reload + env: { + NODE_ENV: 'development', + }, + // Mostra logs coloridos no pm2 logs + time: true, + }, + { + name: 'newwhats-frontend', + script: 'npm', + args: 'run dev', + cwd: '/home/deploy/projetos/newwhats.local/frontend', + watch: false, // next dev tem Fast Refresh nativo + env: { + NODE_ENV: 'development', + }, + time: true, + }, + ], +} diff --git a/clube67/newwhats.local/frontend/.dockerignore b/clube67/newwhats.local/frontend/.dockerignore new file mode 100644 index 0000000..276606b --- /dev/null +++ b/clube67/newwhats.local/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.log +.env*.local diff --git a/clube67/newwhats.local/frontend/Dockerfile b/clube67/newwhats.local/frontend/Dockerfile new file mode 100644 index 0000000..86c1f7b --- /dev/null +++ b/clube67/newwhats.local/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ENV NEXT_PUBLIC_API_URL=https://clube67.com/api +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +COPY package*.json ./ +# Instala produção + typescript/@types/node (necessários para next dev) +RUN npm ci --omit=dev && npm install --no-save typescript @types/node && npm cache clean --force +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.mjs ./next.config.mjs + +EXPOSE 3003 +ENV PORT=3003 +ENV NODE_ENV=production +CMD ["npm", "run", "start"] diff --git a/clube67/newwhats.local/frontend/components/ChatList.tsx b/clube67/newwhats.local/frontend/components/ChatList.tsx new file mode 100644 index 0000000..716bfaa --- /dev/null +++ b/clube67/newwhats.local/frontend/components/ChatList.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React from 'react'; +import { MessageCircle } from 'lucide-react'; +import type { NewWhatsChat, PresenceState } from '../types/inboxTypes'; +import ChatListItem from './ChatListItem'; + +interface ChatListProps { + chats: NewWhatsChat[]; + loading: boolean; + selectedChat: NewWhatsChat | null; + onSelectChat: (chat: NewWhatsChat) => void; + onMenuAction: (action: any, chat: NewWhatsChat) => void; + presenceByJid: Record; + isFavoriteChat: (chat: NewWhatsChat) => boolean; + drafts?: Record; +} + +export default function ChatList({ + chats, + loading, + selectedChat, + onSelectChat, + onMenuAction, + presenceByJid, + isFavoriteChat, + drafts = {}, +}: ChatListProps) { + if (loading) { + return ( +
+
+
+ ); + } + + if (chats.length === 0) { + return ( +
+
+ +
+

Nenhuma conversa encontrada

+
+ ); + } + + return ( +
+ {chats.map((chat) => ( + + ))} +
+ ); +} diff --git a/clube67/newwhats.local/frontend/components/ChatListItem.tsx b/clube67/newwhats.local/frontend/components/ChatListItem.tsx new file mode 100644 index 0000000..80e1e1f --- /dev/null +++ b/clube67/newwhats.local/frontend/components/ChatListItem.tsx @@ -0,0 +1,287 @@ +'use client'; + +import React, { useMemo, useRef, useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { + Pin, Check, CheckCheck, Clock, ChevronDown, + VolumeX, Star, StarOff, Archive, ArchiveRestore, + Trash2, MailOpen, Volume2, PinOff, + Mic, FileText, Video, Image as ImageIcon, + AlertCircle, +} from 'lucide-react'; +import type { NewWhatsChat, PresenceState } from '../types/inboxTypes'; +import { + normalizeJid, +} from '../utils/inboxUtils'; +import Avatar from './ui/Avatar'; + +interface ChatListItemProps { + chat: NewWhatsChat; + isActive: boolean; + onSelect: (chat: NewWhatsChat) => void; + onMenuAction: (action: any, chat: NewWhatsChat) => void; + presenceByJid: Record; + isFavorite: boolean; + draft?: string; +} + +const renderLastStatus = (chat: NewWhatsChat) => { + if (chat.last_message_from_me !== 1) return null; + switch (chat.last_message_status) { + case 'pending': + case 'sending': + return ; + case 'sent': + return ; + case 'delivered': + return ; + case 'read': + return ; + case 'failed': + return ; + default: + return null; + } +}; + +const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => { + if (draft) return Rascunho: {draft}; + + const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value; + + // Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente + const isGroup = chat.remote_jid.endsWith('@g.us'); + const senderPrefix = isGroup + ? (chat.last_message_from_me === 1 + ? 'Você: ' + : (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : '')) + : ''; + + const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => ( + + + {senderPrefix}{label} + + ); + + // Use last_message_type (pipeline do chatStore) para ícones de mídia + const msgType = (chat.last_message_type ?? '').toLowerCase(); + + if (msgType === 'audio' || msgType === 'ptt') return ; + if (msgType === 'sticker') return ; + if (msgType === 'image') { + const caption = chat.last_message_text?.trim(); + return caption + ? + : ; + } + if (msgType === 'video') { + const caption = chat.last_message_text?.trim(); + return caption + ? + : ; + } + if (msgType === 'document') return ; + + // Texto puro (text, extendedtext, etc.) + const text = chat.last_message_text ?? ''; + if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`; + + return senderPrefix || ''; +}; + +export default function ChatListItem({ + chat, + isActive, + onSelect, + onMenuAction, + presenceByJid, + isFavorite, + draft +}: ChatListItemProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); + const menuBtnRef = useRef(null); + + const relativeTime = useMemo(() => { + if (!chat.last_message_time) return ''; + const date = new Date(chat.last_message_time); + if (isNaN(date.getTime())) return ''; + + // Comparação por data de calendário (não por 24h corridas) + const today = new Date(); today.setHours(0, 0, 0, 0); + const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0); + const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); + } + if (diffDays === 1) return 'Ontem'; + if (diffDays < 7) { + // "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom" + const short = date.toLocaleDateString('pt-BR', { weekday: 'short' }); + return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase()); + } + return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }); + }, [chat.last_message_time]); + + const isMuted = useMemo(() => { + return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now()); + }, [chat.muted_until]); + + const menuItems = useMemo(() => { + const isPinned = Boolean(chat.is_pinned); + const isArchived = Boolean(chat.is_archived); + const isGroup = chat.remote_jid.endsWith('@g.us'); + + return [ + { key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? : }, + { key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? : }, + { key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? : }, + { key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? : }, + { key: 'mark_read', label: 'Marcar como lida', icon: }, + { key: 'delete', label: 'Apagar conversa', icon: , danger: true }, + ]; + }, [chat, isFavorite, isMuted]); + + const MENU_H = 252; // 6 itens × ~40px + 12px padding container + const MENU_W = 224; // w-56 + + const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => { + const triggerBottom = anchorBottom ?? anchorY; + const spaceBelow = window.innerHeight - triggerBottom; + // Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger + const top = spaceBelow >= MENU_H + 8 + ? triggerBottom + 4 + : anchorY - MENU_H; + const left = Math.min(anchorX, window.innerWidth - MENU_W - 8); + setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) }); + setMenuOpen(true); + }; + + return ( + <> + {/* Usamos div+role="button" em vez de + + + + + + + {/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */} + {menuOpen && menuPos && typeof window !== 'undefined' && createPortal( + <> +
{ e.stopPropagation(); setMenuOpen(false); }} /> +
e.stopPropagation()} + > + {menuItems.map((item: any) => ( + + ))} +
+ , + document.body + )} + + ); +} diff --git a/clube67/newwhats.local/frontend/components/ChatSidebar.tsx b/clube67/newwhats.local/frontend/components/ChatSidebar.tsx new file mode 100644 index 0000000..9c9f572 --- /dev/null +++ b/clube67/newwhats.local/frontend/components/ChatSidebar.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import type { NewWhatsChat, PresenceState } from '../types/inboxTypes'; +import ChatList from './ChatList'; + +interface ChatSidebarProps { + chats: NewWhatsChat[]; + loading: boolean; + selectedChat: NewWhatsChat | null; + searchTerm: string; + selectedLabelFilter: string | null; + activeFilter: string; + chatScope: 'inbox' | 'favorites' | 'archived' | 'custom_list'; + activeCustomListName?: string | null; + presenceByJid: Record; + isFavoriteChat: (chat: NewWhatsChat) => boolean; + onSelectChat: (chat: NewWhatsChat) => void; + onMenuAction: (action: any, chat: NewWhatsChat) => void; + drafts?: Record; +} + +export default function ChatSidebar({ + chats, + loading, + selectedChat, + searchTerm, + selectedLabelFilter, + activeFilter, + chatScope, + activeCustomListName, + presenceByJid, + isFavoriteChat, + onSelectChat, + onMenuAction, + drafts = {}, +}: ChatSidebarProps) { + // UI Pills (Simplified, for now we keep layout consistent) + const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all'); + + const filteredChats = useMemo(() => chats.filter((chat) => { + const isGroup = chat.remote_jid.endsWith('@g.us'); + + // Filter by pill + if (pillFilter === 'unread' && chat.unread_count === 0) return false; + if (pillFilter === 'groups' && !isGroup) return false; + if (pillFilter === 'favorites' && !isFavoriteChat(chat)) return false; + + const displayName = chat.displayName || chat.subject || chat.phone || chat.remote_jid; + const matchesSearch = displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + (chat.last_message_text || '').toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesLabel = !selectedLabelFilter || + (chat.labels && chat.labels.some((l) => l.label_id === selectedLabelFilter)); + + return matchesSearch && matchesLabel; + }), [chats, searchTerm, selectedLabelFilter, pillFilter, isFavoriteChat]); + + const sortedFilteredChats = useMemo( + () => [...filteredChats].sort((a, b) => { + const aPinned = Boolean(a.is_pinned); + const bPinned = Boolean(b.is_pinned); + if (aPinned !== bPinned) return aPinned ? -1 : 1; + const tA = a.last_message_time ? new Date(a.last_message_time).getTime() : 0; + const tB = b.last_message_time ? new Date(b.last_message_time).getTime() : 0; + return tB - tA; + }), + [filteredChats] + ); + + + return ( +
+ {/* Filter Pills */} +
+ {(['all', 'unread', 'favorites', 'groups'] as const).map((filter) => ( + + ))} +
+ + {/* Scrollable Chat List */} + +
+ ); +} diff --git a/clube67/newwhats.local/frontend/components/ContactPickerModal.tsx b/clube67/newwhats.local/frontend/components/ContactPickerModal.tsx new file mode 100644 index 0000000..39d5d70 --- /dev/null +++ b/clube67/newwhats.local/frontend/components/ContactPickerModal.tsx @@ -0,0 +1,490 @@ +'use client' + +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + Search, X, Users, Check, ChevronDown, + Loader2, UserCheck, BookUser, +} from 'lucide-react' +import { contactApi, type BackendContact } from '../services/chatApiService' +import { formatPhone, getAvatarProxyUrl } from '../utils/inboxUtils' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function resolveDisplayName(c: BackendContact): string { + return ( + c.name ?? + c.verifiedName ?? + c.notify ?? + formatPhone(c.phone ?? c.jid.split('@')[0], c.jid) + ) +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/) + if (parts.length >= 2) return (parts[0][0]! + parts[1]![0]).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +function avatarColor(jid: string): string { + let h = 0 + for (let i = 0; i < jid.length; i++) h = jid.charCodeAt(i) + ((h << 5) - h) + return `hsl(${Math.abs(h % 360)}, 58%, 42%)` +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Props { + open: boolean + instanceId: string | null + onConfirm: (phones: string[]) => void + onClose: () => void +} + +/** + * URL do avatar via proxy local (evita expiração do CDN do WhatsApp). + * + * Só retorna URL se o backend já confirmou que existe avatar (c.avatarUrl != null). + * Se for null, evitamos disparar uma requisição que o proxy só pode responder + * com 204 — economiza até 60 round-trips por página de contatos sem foto. + */ +function resolveAvatarUrl(c: BackendContact, instanceId: string | null): string | null { + if (!instanceId) return null + // @lid e @g.us não suportam profilePictureUrl — proxy retornaria 204 + if (c.jid.endsWith('@lid') || c.jid.endsWith('@g.us')) return null + // Banco já sabe que não tem foto — pula o proxy direto pro fallback de iniciais + if (!c.avatarUrl) return null + return getAvatarProxyUrl({ instance_name: instanceId, remote_jid: c.jid }) +} + +const PAGE_SIZE = 60 + +// ─── Avatar de linha (sem hack de DOM no onError) ───────────────────────────── + +interface RowAvatarProps { + url: string | null + name: string + jid: string +} + +/** + * Avatar de uma linha da lista. Renderiza quando há URL e ela carrega; + * cai pro fallback de iniciais coloridas em qualquer outro caso. + * + * Substitui o `onError` antigo que mexia em `nextSibling` direto no DOM — + * isso quebrava em re-renders do React e às vezes escondia a inicial em + * vez do . Aqui o estado de erro vive no React. + */ +function RowAvatar({ url, name, jid }: RowAvatarProps) { + const [failed, setFailed] = useState(false) + // Reseta o estado de falha quando a URL muda (ex: re-fetch da lista) + useEffect(() => { setFailed(false) }, [url]) + + if (url && !failed) { + return ( + setFailed(true)} + /> + ) + } + return ( +
+ {getInitials(name)} +
+ ) +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function ContactPickerModal({ open, instanceId, onConfirm, onClose }: Props) { + const [search, setSearch] = useState('') + const [contacts, setContacts] = useState([]) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [offset, setOffset] = useState(0) + const [total, setTotal] = useState(0) + const [selected, setSelected] = useState>(new Set()) + const debounceRef = useRef | null>(null) + const searchRef = useRef(null) + // Marca um run do effect de busca pra ser ignorado. Usado quando o effect + // de open chama setSearch('') — esse setSearch dispara o effect de busca, + // que agenda um refetch em 280ms; mas o open já fez fetchContacts imediato, + // então o refetch é redundante e cria race com cliques em "carregar mais". + const skipNextDebounceRef = useRef(false) + // Contador de geração — qualquer fetch só aplica seu resultado se ainda for + // o mais recente. Defende contra cenários onde uma busca/clear/load-more + // dispara um fetch antes da resposta anterior chegar. + const fetchGenRef = useRef(0) + + // ── Fetch ────────────────────────────────────────────────────────────────── + + const fetchContacts = useCallback(async (q: string, off: number, replace = false) => { + if (!instanceId) return + const gen = ++fetchGenRef.current + setLoading(true) + try { + const res = await contactApi.list({ + instanceId, + search: q.trim() || undefined, + blocked: false, + limit: PAGE_SIZE, + offset: off, + }) + // Resposta obsoleta — outro fetch já foi disparado depois deste. Ignora + // pra não sobrescrever o estado correto com dados antigos. + if (gen !== fetchGenRef.current) return + // O `offset` é o SKIP da query SQL no backend, então tem que avançar + // pelo tamanho CRU da página devolvida — não pelo filtrado. Senão, ao + // descartar @g.us/@lid client-side, a próxima página começa "para trás" + // do que já mostramos e o backend devolve linhas que já estão na tela. + const raw = res.contacts ?? [] + const rows = raw.filter((c: BackendContact) => + !c.jid.endsWith('@g.us') && !c.jid.endsWith('@lid') + ) + const total = res.total ?? raw.length + const nextOffset = off + raw.length + setTotal(total) + setContacts(prev => { + const merged = replace ? rows : [...prev, ...rows] + // Dedupe por jid — defesa contra fetches concorrentes (debounce de + // busca x effect de open) que podem reaplicar rows já presentes. + const seen = new Set() + const unique: BackendContact[] = [] + for (const c of merged) { + if (seen.has(c.jid)) continue + seen.add(c.jid) + unique.push(c) + } + return unique + }) + setOffset(nextOffset) + setHasMore(nextOffset < total) + } catch { + // silent + } finally { + // Só limpa o loading se este fetch ainda for o mais recente — senão + // o spinner some enquanto o request "vencedor" continua em voo. + if (gen === fetchGenRef.current) setLoading(false) + } + }, [instanceId]) + + // ── Open/reset ───────────────────────────────────────────────────────────── + + useEffect(() => { + if (!open) return + // Se a busca tiver conteúdo de uma sessão anterior, o setSearch('') abaixo + // vai disparar o effect debounced. Marca esse run pra ser ignorado — o + // fetchContacts imediato logo abaixo já cobre o caso, e deixar o debounce + // rodar cria race com cliques em "carregar mais" nos primeiros 280ms. + if (search !== '') skipNextDebounceRef.current = true + setSearch('') + setSelected(new Set()) + setContacts([]) + setOffset(0) + setHasMore(false) + fetchContacts('', 0, true) + setTimeout(() => searchRef.current?.focus(), 100) + }, [open, instanceId]) + + // ── Debounced search ─────────────────────────────────────────────────────── + + useEffect(() => { + if (!open) return + if (skipNextDebounceRef.current) { + skipNextDebounceRef.current = false + return + } + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + // sem setOffset(0) — fetchContacts já atualiza o offset com base na + // resposta, e setá-lo aqui no meio do voo capturava o valor errado no + // onClick do botão "carregar mais" durante a janela de race. + fetchContacts(search, 0, true) + }, 280) + return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } + // fetchContacts incluído nos deps para evitar stale closure quando instanceId muda + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [search, fetchContacts]) + + // ── Keyboard close ───────────────────────────────────────────────────────── + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + if (open) document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open, onClose]) + + // ── Helpers ──────────────────────────────────────────────────────────────── + + const getPhone = (c: BackendContact) => c.phone ?? c.jid.split('@')[0] + + const toggle = useCallback((c: BackendContact) => { + const phone = getPhone(c) + setSelected(prev => { + const next = new Set(prev) + next.has(phone) ? next.delete(phone) : next.add(phone) + return next + }) + }, []) + + const allVisible = contacts.every(c => selected.has(getPhone(c))) && contacts.length > 0 + + const toggleAll = () => { + if (allVisible) { + setSelected(prev => { + const next = new Set(prev) + contacts.forEach(c => next.delete(getPhone(c))) + return next + }) + } else { + setSelected(prev => { + const next = new Set(prev) + contacts.forEach(c => next.add(getPhone(c))) + return next + }) + } + } + + const handleConfirm = () => { + onConfirm(Array.from(selected)) + onClose() + } + + // ───────────────────────────────────────────────────────────────────────────── + + return ( + + {open && ( + <> + {/* Backdrop */} + + + {/* Panel */} + +
e.stopPropagation()} + > + + {/* ── Header ──────────────────────────────────────────────────── */} +
+
+ +
+
+

Selecionar da Agenda

+

+ {total > 0 ? `${total} contatos sincronizados` : 'Contatos do WhatsApp'} +

+
+ +
+ + {/* ── Search ──────────────────────────────────────────────────── */} +
+
+ + setSearch(e.target.value)} + placeholder="Buscar por nome ou número…" + className="w-full bg-white/[0.05] border border-white/[0.08] rounded-xl pl-9 pr-10 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-brand-500/40 focus:ring-1 focus:ring-brand-500/25 transition-all" + /> + {search && ( + + )} +
+ + {/* Toolbar */} +
+ + +
+ {selected.size > 0 && ( + + )} + {contacts.length} exibidos +
+
+
+ + {/* ── List ────────────────────────────────────────────────────── */} +
+ {loading && contacts.length === 0 ? ( +
+ +

Carregando contatos…

+
+ + ) : contacts.length === 0 ? ( +
+ +

Nenhum contato encontrado

+

+ {search + ? 'Tente outro nome ou número.' + : 'Os contatos são sincronizados automaticamente quando o WhatsApp está conectado.'} +

+
+ + ) : ( + <> + {contacts.map((c) => { + const phone = getPhone(c) + const name = resolveDisplayName(c) + const sel = selected.has(phone) + const avatarUrl = resolveAvatarUrl(c, instanceId) + + return ( + + ) + })} + + {/* Load more */} + {hasMore && ( +
+ +
+ )} + + )} +
+ + {/* ── Footer ──────────────────────────────────────────────────── */} +
+ {selected.size > 0 ? ( +

+ {selected.size} + + {' '}{selected.size === 1 ? 'contato selecionado' : 'contatos selecionados'} + +

+ ) : ( +

Toque em um contato para selecioná-lo

+ )} + +
+ + +
+
+ +
+
+ + )} +
+ ) +} diff --git a/clube67/newwhats.local/frontend/components/ContactProfile.tsx b/clube67/newwhats.local/frontend/components/ContactProfile.tsx new file mode 100644 index 0000000..c6db653 --- /dev/null +++ b/clube67/newwhats.local/frontend/components/ContactProfile.tsx @@ -0,0 +1,173 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { + X, + ChevronRight, + Image as ImageIcon, + Video as VideoIcon, + FileText, + Mic, + Bell, + Star, + Clock, + Ban, + ThumbsDown, + Trash2 +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + formatPhone, + getMediaUrl +} from '../utils/inboxUtils'; +import Avatar from './ui/Avatar'; +import { Message } from '../types/inboxTypes'; + +interface ContactProfileProps { + isOpen: boolean; + chat: any; + messages: Message[]; + onClose: () => void; +} + +const ContactProfile: React.FC = ({ + isOpen, + chat, + messages, + onClose, +}) => { + // Filter media messages + const mediaMessages = useMemo(() => { + return messages.filter(m => m.type === 'image' || m.type === 'video' || m.type === 'document' || m.type === 'audio'); + }, [messages]); + + const photoVideos = mediaMessages.filter(m => m.type === 'image' || m.type === 'video'); + const documents = mediaMessages.filter(m => m.type === 'document'); + const audios = mediaMessages.filter(m => m.type === 'audio'); + + if (!chat) return null; + + return ( + + {isOpen && ( + + {/* Header */} +
+ +

Dados do contato

+
+ + {/* Content */} +
+ {/* Profile Section */} +
+ {/* Profile Photo */} + +

+ {chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)} +

+

+ {formatPhone(chat.phone, chat.remote_jid)} +

+ {chat.bio && ( +

+ {chat.bio} +

+ )} +
+ + {/* Status (Bio) */} +
+

Recado

+

+ {chat.bio || 'Sem recado disponível'} +

+
+ + {/* Media Section */} +
+ + + {mediaMessages.length > 0 ? ( +
+ {photoVideos.slice(0, 3).map((m, i) => ( +
{ + const url = getMediaUrl(m.media_url); + if (url) window.open(url, '_blank'); + }} + > +
+ {m.type === 'video' ? : } +
+
+ ))} + {mediaMessages.length > 3 && ( +
+ +{mediaMessages.length - 3} +
+ )} +
+ ) : ( +
+

Nenhuma mídia compartilhada

+
+ )} +
+ + {/* Actions Section */} +
+ {[ + { icon: Bell, label: 'Silenciar notificações', color: '#111b21' }, + { icon: Star, label: 'Mensagens favoritas', color: '#111b21' }, + { icon: Clock, label: 'Mensagens temporárias', sub: 'Desativadas', color: '#111b21' }, + ].map((item, idx) => ( + + ))} +
+ +
+ {[ + { icon: Ban, label: `Bloquear ${chat.lead_name || 'Contato'}`, color: '#ea0038' }, + { icon: ThumbsDown, label: `Denunciar ${chat.lead_name || 'Contato'}`, color: '#ea0038' }, + { icon: Trash2, label: 'Apagar conversa', color: '#ea0038' } + ].map((item, idx) => ( + + ))} +
+
+
+ )} +
+ ); +}; + +export default ContactProfile; diff --git a/clube67/newwhats.local/frontend/components/InboxHeader.tsx b/clube67/newwhats.local/frontend/components/InboxHeader.tsx new file mode 100644 index 0000000..5e8a1ed --- /dev/null +++ b/clube67/newwhats.local/frontend/components/InboxHeader.tsx @@ -0,0 +1,159 @@ +'use client'; + +import React from 'react'; +import { + ArrowLeft, + Search, + MoreVertical, + RefreshCw, + Tag, + X, + ClipboardList, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + formatPhone, +} from '../utils/inboxUtils'; +import Avatar from './ui/Avatar'; + +interface InboxHeaderProps { + selectedChat: any; + isMobile: boolean; + isTyping: boolean; + isChatMenuOpen: boolean; + isSyncingAvatar: boolean; + isProtocolOpen?: boolean; + hasActiveProtocol?: boolean; + onBack: () => void; + onSearchClick: () => void; + onToggleMenu: () => void; + onSyncAvatar: () => void; + onAvatarClick?: () => void; + onProtocolClick?: () => void; +} + +const InboxHeader: React.FC = ({ + selectedChat, + isMobile, + isTyping, + isChatMenuOpen, + isSyncingAvatar, + isProtocolOpen, + hasActiveProtocol, + onBack, + onSearchClick, + onToggleMenu, + onSyncAvatar, + onAvatarClick, + onProtocolClick, +}) => { + return ( +
+
+ {isMobile && ( + + )} + + {/* Identity Area - Clickable */} +
+ {/* Avatar */} + + +
+

+ {selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)} +

+
+ {isTyping ? ( + + digitando... + + ) : ( + + {selectedChat.bio || ( + selectedChat.remote_jid?.endsWith('@g.us') + ? `Clique para dados do grupo` + : 'Clique para informações do contato' + )} + + )} +
+
+
+
+ +
+ {onProtocolClick && ( + + )} + + +
+ + + + {isChatMenuOpen && ( + + + +
+ +
+ )} +
+
+
+
+ ); +}; + +export default InboxHeader; diff --git a/clube67/newwhats.local/frontend/components/InputBar.tsx b/clube67/newwhats.local/frontend/components/InputBar.tsx new file mode 100644 index 0000000..a763fb4 --- /dev/null +++ b/clube67/newwhats.local/frontend/components/InputBar.tsx @@ -0,0 +1,657 @@ +'use client'; + +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { + Smile, + Paperclip, + Mic, + Send, + X, + StopCircle, + Pause, + Play, + LayoutTemplate, + BookOpen, + Plus, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import EmojiPicker, { Theme } from 'emoji-picker-react'; +import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService'; +import RichMessageComposer from './RichMessageComposer'; +import TemplateLibrary from './TemplateLibrary'; +import ScheduleMessageModal from './ScheduleMessageModal'; + +interface Message { + id: number; + chat_id: number; + message_id: string; + text: string; + direction: 'in' | 'out'; +} + +interface InputBarProps { + newMessage: string; + onNewMessageChange: (val: string) => void; + onSend: (e?: React.FormEvent) => void; + onPresence: () => void; + onSendMedia: (file: File) => Promise; + onSendAudio: (blob: Blob) => Promise; + replyingTo: Message | null; + onCancelReply: () => void; + isMobile: boolean; + selectedChat: any; + instanceId?: string | null; + mentions?: string[]; + onMentionsChange?: (mentions: string[]) => void; +} + +const InputBar: React.FC = ({ + newMessage, + onNewMessageChange, + onSend, + onPresence, + onSendMedia, + onSendAudio, + replyingTo, + onCancelReply, + isMobile, + selectedChat, + instanceId, + mentions = [], + onMentionsChange, +}) => { + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [showAttachMenu, setShowAttachMenu] = useState(false); + const attachMenuRef = useRef(null); + const [showRichComposer, setShowRichComposer] = useState(false); + const [showTemplateLibrary, setShowTemplateLibrary] = useState(false); + const [richInitialPayload, setRichInitialPayload] = useState | undefined>(); + const [richInitialType, setRichInitialType] = useState(); + const [showScheduleModal, setShowScheduleModal] = useState(false); + const [schedulingPayload, setSchedulingPayload] = useState>({}); + const [schedulingType, setSchedulingType] = useState('TEXT'); + const emojiPickerRef = useRef(null); + + // ── Mention autocomplete state ────────────────────────────────────── + const [mentionQuery, setMentionQuery] = useState(null); + const [mentionIndex, setMentionIndex] = useState(0); + const [participants, setParticipants] = useState([]); + const participantsCache = useRef>({}); + const mentionDropdownRef = useRef(null); + + const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us'); + const groupJid = selectedChat?.remote_jid || selectedChat?.jid; + + // Fetch group participants when group chat is selected + useEffect(() => { + if (!isGroupChat || !instanceId || !groupJid) { + setParticipants([]); + return; + } + if (participantsCache.current[groupJid]) { + setParticipants(participantsCache.current[groupJid]); + return; + } + groupApi.getParticipants(instanceId, groupJid) + .then((data) => { + participantsCache.current[groupJid] = data.participants; + setParticipants(data.participants); + }) + .catch(() => setParticipants([])); + }, [isGroupChat, instanceId, groupJid]); + + // Filter participants by mention query + const filteredParticipants = useMemo(() => { + if (mentionQuery === null || !isGroupChat) return []; + const q = mentionQuery.toLowerCase(); + // Only show participants that have a displayable name or phone + return participants.filter((p) => { + const label = p.name || p.phone || ''; + if (!label) return false; + return q === '' || label.toLowerCase().includes(q); + }).slice(0, 8); + }, [mentionQuery, participants, isGroupChat]); + + // Detect @ trigger in text + const detectMentionQuery = useCallback((text: string, cursorPos: number) => { + if (!isGroupChat) { setMentionQuery(null); return; } + const before = text.slice(0, cursorPos); + const atMatch = before.match(/@([^\s@]*)$/); + if (atMatch) { + setMentionQuery(atMatch[1]); + setMentionIndex(0); + } else { + setMentionQuery(null); + } + }, [isGroupChat]); + + const selectMention = useCallback((participant: GroupParticipant) => { + const textarea = textareaRef.current; + if (!textarea) return; + const cursorPos = textarea.selectionStart; + const before = newMessage.slice(0, cursorPos); + const after = newMessage.slice(cursorPos); + const atIdx = before.lastIndexOf('@'); + if (atIdx === -1) return; + + const displayName = participant.name || participant.phone; + const replacement = `@${displayName} `; + const newText = before.slice(0, atIdx) + replacement + after; + + onNewMessageChange(newText); + setMentionQuery(null); + + // Add JID to mentions list + if (onMentionsChange && !mentions.includes(participant.jid)) { + onMentionsChange([...mentions, participant.jid]); + } + + // Restore cursor + requestAnimationFrame(() => { + if (textareaRef.current) { + const newCursorPos = atIdx + replacement.length; + textareaRef.current.selectionStart = newCursorPos; + textareaRef.current.selectionEnd = newCursorPos; + textareaRef.current.focus(); + } + }); + }, [newMessage, onNewMessageChange, mentions, onMentionsChange]); + + // Audio recording states + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [recordingDuration, setRecordingDuration] = useState(0); + const [micError, setMicError] = useState(null); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const timerRef = useRef(null); + + // navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost) + const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia; + + // Auto-expand textarea + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`; + } + }, [newMessage]); + + // Close emoji picker and attach menu on click outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) { + setShowEmojiPicker(false); + } + if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) { + setShowAttachMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Mention dropdown navigation + if (mentionQuery !== null && filteredParticipants.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setMentionIndex((prev) => (prev + 1) % filteredParticipants.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + selectMention(filteredParticipants[mentionIndex]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setMentionQuery(null); + return; + } + } + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + const onEmojiClick = (emojiData: any) => { + onNewMessageChange(newMessage + emojiData.emoji); + // Do not close picker, common UX allows multiple emojis + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + await onSendMedia(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const startRecording = async () => { + if (!micAvailable) { + setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.'); + setTimeout(() => setMicError(null), 4000); + return; + } + setMicError(null); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + audioChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = async () => { + const mimeType = mediaRecorder.mimeType || 'audio/webm' + const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); + await onSendAudio(audioBlob); + stream.getTracks().forEach(track => track.stop()); + }; + + mediaRecorder.start(); + setIsRecording(true); + setIsPaused(false); + setRecordingDuration(0); + timerRef.current = setInterval(() => { + setRecordingDuration(prev => prev + 1); + }, 1000); + } catch (err: any) { + const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError'; + const msg = denied + ? 'Permissão de microfone negada.' + : 'Não foi possível acessar o microfone.'; + setMicError(msg); + setTimeout(() => setMicError(null), 4000); + } + }; + + const togglePause = () => { + const mr = mediaRecorderRef.current + if (!mr) return + if (isPaused) { + mr.resume() + setIsPaused(false) + timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000) + } else { + mr.pause() + setIsPaused(true) + if (timerRef.current) clearInterval(timerRef.current) + } + } + + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + // Se estava pausado, retoma antes de parar para capturar o último chunk + if (isPaused && mediaRecorderRef.current.state === 'paused') { + mediaRecorderRef.current.resume() + } + mediaRecorderRef.current.stop(); + setIsRecording(false); + setIsPaused(false); + if (timerRef.current) clearInterval(timerRef.current); + } + }; + + const handleTemplateSelect = (template: MessageTemplate) => { + setRichInitialPayload(template.payload) + setRichInitialType(template.type) + setShowRichComposer(true) + } + + const handleScheduleFromComposer = (type: TemplateType, payload: Record) => { + setSchedulingType(type) + setSchedulingPayload(payload) + setShowRichComposer(false) + setShowScheduleModal(true) + } + + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + return ( + <> +
+ {/* Hidden File Input */} + + + {/* Emoji Picker Popover */} + + {showEmojiPicker && ( + + + + )} + + + {/* Reply Preview */} + + {replyingTo && ( + +
+
+ {replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'} +
+
+ {replyingTo.text || 'Anexo/Mídia'} +
+
+ +
+ )} +
+ + {/* Mic error banner */} + + {micError && ( + + + {micError} + + )} + + + {/* Main Input Bar */} +
+ {!isRecording ? ( + <> +
+ {/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */} +
+ + + + {showAttachMenu && ( + + {/* Anexar arquivo */} + + + {/* Biblioteca de templates */} + + + {/* Mensagem rica */} + + + )} + +
+ + {/* Emoji — mantém no lugar */} + +
+ +
+ {/* Mention Autocomplete Dropdown */} + + {mentionQuery !== null && filteredParticipants.length > 0 && ( + + {filteredParticipants.map((p, i) => ( + + ))} + + )} + + + + +
+ + +
+ + + + +
+ + +
+ + +
+
+``` + +### Frontend — `public/app.js` + +```js +// --- Adiciona uma linha de input para nova opção (chamado via data-for="menuOptions") --- +function addMenuOption() { + // addRow(containerId, htmlInterna): + // cria um div.item-row com o input + botão "Remover" automático + addRow('menuOptionsList', ''); +} + +// --- Monta o payload para envio ao backend --- +function getMenuPayload() { + const options = []; + + // Coleta o valor de cada input data-field="opt" não vazio + document.querySelectorAll('#menuOptionsList .item-row input[data-field="opt"]').forEach((inp) => { + const v = inp.value.trim(); + if (v) options.push(v); + }); + + return { + url: '/v1/messages/send_menu', // endpoint do backend + body: { + instance: document.getElementById('dispatchInstance').value, // instância WhatsApp + to: document.getElementById('dispatchTo').value.trim(), // número(s) destino + title: document.getElementById('menuTitle').value.trim() || 'Menu', + text: document.getElementById('menuText').value.trim() || 'Escolha uma opção:', + options: options.length ? options : ['Opção 1'], // fallback se vazio + footer: document.getElementById('menuFooter').value.trim() || undefined, + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 22–58) + +```ts +// POST /v1/messages/send_menu +router.post('/send_menu', async (req: Request, res: Response) => { + try { + // Desestrutura o body: instance (default 'main'), número destino, título, texto, opções e rodapé + const { instance = 'main', to, title, text, options, footer } = req.body; + + // Valida presença obrigatória: destino e ao menos 1 opção + if (!to || !Array.isArray(options) || options.length === 0) { + return res.status(400).json({ ok: false, error: 'missing to/options' }); + } + + // Busca e valida a instância Baileys (deve existir e estar conectada) + const ctx = validateInstance(instance, res); + if (!ctx) return; + + // Converte número para JID WhatsApp: "5511999..." → "5511999...@s.whatsapp.net" + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // Monta o texto numerado: + // *Título*\n\nTexto\n\n*1.* Opção 1\n*2.* Opção 2\n\n_Rodapé_ + let menuText = ''; + if (title) menuText += `*${title}*\n\n`; // título em negrito + if (text) menuText += `${text}\n\n`; // texto introdutório + options.forEach((opt, idx) => { + // Suporta opção como string simples ou objeto { text } + const label = typeof opt === 'string' ? opt : opt.text ?? `Opção ${idx + 1}`; + menuText += `*${idx + 1}.* ${label}\n`; // "1. Opção" em negrito + }); + if (footer) menuText += `\n_${footer}_`; // rodapé em itálico + + // Envia mensagem de texto simples (sem recurso especial do Baileys) + await ctx.sock.sendMessage(jid, { text: menuText.trim() }); + + return res.json({ ok: true, hint: 'User should reply with the option number (1, 2, 3...)' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo (JSON enviado pelo frontend):** +```json +{ + "instance": "main", + "to": "5511999999999", + "title": "Menu de Opções", + "text": "Escolha uma opção:", + "options": ["Produto A", "Produto B", "Falar com atendente"], + "footer": "Responda com o número" +} +``` + +--- + +## 2. Botões quick reply + +**Valor no select:** `buttons` +**Endpoint:** `POST /v1/messages/send_buttons_helpers` +**Descrição:** Mensagem com até **3 botões clicáveis** de resposta rápida (`nativeButtons` do Baileys). O usuário toca no botão sem digitar nada. + +### Frontend — `public/index.html` (linhas 105–114) + +```html + + +``` + +### Frontend — `public/app.js` + +```js +// Adiciona linha com 2 inputs: ID do botão e texto visível +function addButtonRow() { + addRow( + 'buttonsList', + '' + + '' + ); +} + +function getButtonsPayload() { + const buttons = []; + + // Coleta pares (id, text) de cada linha — ignora linhas incompletas + document.querySelectorAll('#buttonsList .item-row').forEach((row) => { + const id = row.querySelector('[data-field="id"]')?.value?.trim(); + const text = row.querySelector('[data-field="text"]')?.value?.trim(); + if (id && text) buttons.push({ id, text }); + }); + + return { + url: '/v1/messages/send_buttons_helpers', + body: { + instance: document.getElementById('dispatchInstance').value, + to: document.getElementById('dispatchTo').value.trim(), + text: document.getElementById('buttonsText').value.trim() || 'Escolha:', + footer: document.getElementById('buttonsFooter').value.trim() || undefined, + // Limita a 3 botões no frontend; o backend também aplica o limit de config + buttons: buttons.length ? buttons.slice(0, 3) : [{ id: 'btn1', text: 'Opção 1' }], + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 61–99) + +```ts +// POST /v1/messages/send_buttons_helpers +router.post('/send_buttons_helpers', async (req: Request, res: Response) => { + try { + const { instance = 'main', to, text, buttons, footer } = req.body; + + if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) { + return res.status(400).json({ ok: false, error: 'missing to/text/buttons' }); + } + + // config.limits.maxButtons = 3 (definido em src/config.ts) + const limited = buttons.slice(0, config.limits.maxButtons); + + const ctx = validateInstance(instance, res); + if (!ctx) return; + + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // Converte array de { id, text } para o formato nativeButtons do InfiniteAPI/Baileys + const nativeButtons = limited.map((btn, idx) => ({ + type: 'reply' as const, // tipo "reply" = botão de resposta rápida + id: btn.id ?? `btn_${idx}`, // ID retornado quando o usuário clicar + text: btn.text ?? `Botão ${idx + 1}`, + })); + + // sendMessage com nativeButtons ativa o recurso de quick reply no WhatsApp + const result = await ctx.sock.sendMessage(jid, { + nativeButtons, + text: String(text), + footer: footer ? String(footer) : undefined, + }); + + return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo:** +```json +{ + "instance": "main", + "to": "5511999999999", + "text": "Como posso ajudar?", + "footer": "Atendimento 24h", + "buttons": [ + { "id": "btn_sim", "text": "✅ Sim" }, + { "id": "btn_nao", "text": "❌ Não" }, + { "id": "btn_ajuda", "text": "🆘 Ajuda" } + ] +} +``` + +--- + +## 3. Botões CTA (URL / Copiar / Ligar) + +**Valor no select:** `interactive` +**Endpoint:** `POST /v1/messages/send_interactive_helpers` +**Descrição:** Botões de ação que abrem URLs, copiam código ou ligam para um número. Usa também `nativeButtons` mas com tipos `url`, `copy` e `call`. + +### Frontend — `public/index.html` (linhas 116–125) + +```html + + +``` + +### Frontend — `public/app.js` + +```js +// Cada linha CTA tem: select de tipo + texto + valor extra (url/código/fone) +function addInteractiveRow() { + addRow( + 'interactiveList', + ` + + ` + ); +} + +function getInteractivePayload() { + const buttons = []; + + document.querySelectorAll('#interactiveList .item-row').forEach((row) => { + const type = row.querySelector('[data-field="type"]')?.value || 'url'; + const text = row.querySelector('[data-field="text"]')?.value?.trim(); + const extra = row.querySelector('[data-field="extra"]')?.value?.trim(); + if (!text || !extra) return; // ignora linhas incompletas + + const btn = { type, text }; + // O campo "extra" tem significado diferente conforme o tipo: + if (type === 'url') btn.url = extra; // link a abrir + if (type === 'copy') btn.copyCode = extra; // código a copiar (ex: cupom) + if (type === 'call') btn.phoneNumber = extra; // número a ligar + buttons.push(btn); + }); + + return { + url: '/v1/messages/send_interactive_helpers', + body: { + instance: document.getElementById('dispatchInstance').value, + to: document.getElementById('dispatchTo').value.trim(), + text: document.getElementById('interactiveText').value.trim() || 'Confira:', + footer: document.getElementById('interactiveFooter').value.trim() || undefined, + buttons, + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 101–154) + +```ts +// POST /v1/messages/send_interactive_helpers +router.post('/send_interactive_helpers', async (req: Request, res: Response) => { + try { + const { instance = 'main', to, text, buttons, footer } = req.body; + + if (!to || !text || !Array.isArray(buttons) || buttons.length === 0) { + return res.status(400).json({ ok: false, error: 'missing to/text/buttons' }); + } + + const ctx = validateInstance(instance, res); + if (!ctx) return; + + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // Converte cada botão para o formato nativo do InfiniteAPI: + const nativeButtons = buttons.slice(0, config.limits.maxButtons).map((btn, idx) => { + const type = (btn.type ?? 'reply').toLowerCase(); + + if (type === 'url' || btn.url) { + // Botão que abre uma URL no navegador + return { type: 'url' as const, text: btn.text ?? 'Abrir', url: btn.url! }; + } + if (type === 'copy' || btn.copyCode || btn.copyText) { + // Botão que copia um código/texto para a área de transferência + return { type: 'copy' as const, text: btn.text ?? 'Copiar', copyText: btn.copyCode ?? btn.copyText ?? '' }; + } + if (type === 'call' || btn.phoneNumber) { + // Botão que inicia uma chamada telefônica + return { type: 'call' as const, text: btn.text ?? 'Ligar', phoneNumber: btn.phoneNumber! }; + } + // Fallback: reply simples + return { type: 'reply' as const, id: `btn_${idx}`, text: btn.text ?? 'Botão' }; + }); + + const result = await ctx.sock.sendMessage(jid, { + nativeButtons, + text: String(text), + footer: footer ? String(footer) : undefined, + }); + + return res.json({ ok: true, format: 'nativeButtons', messageId: result?.key?.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo:** +```json +{ + "instance": "main", + "to": "5511999999999", + "text": "Confira nossas opções:", + "buttons": [ + { "type": "url", "text": "🌐 Ver site", "url": "https://exemplo.com" }, + { "type": "copy", "text": "📋 Copiar cupom","copyCode": "DESC10" }, + { "type": "call", "text": "📞 Ligar", "phoneNumber": "5511999999999" } + ] +} +``` + +--- + +## 4. Lista dropdown + +**Valor no select:** `list` +**Endpoint:** `POST /v1/messages/send_list_helpers` +**Descrição:** Mensagem com botão que abre uma lista em dropdown. Suporta seções e itens com ID, título e descrição. Usa `nativeList` do InfiniteAPI. + +### Frontend — `public/index.html` (linhas 127–137) + +```html + + +``` + +### Frontend — `public/app.js` + +```js +// Cria um bloco "seção" com título + lista de itens aninhados +function addListSection() { + const container = document.getElementById('listSectionsList'); + const block = document.createElement('div'); + block.className = 'block-section'; // classe usada pelo backend para buscar seções + block.innerHTML = ` +
Seção
+ +
+ + + `; + + // Botão "Adicionar item" dentro da seção cria um item com id / título / descrição + block.querySelector('.add-row-in-section').addEventListener('click', () => { + const row = document.createElement('div'); + row.className = 'item-row'; + row.innerHTML = ` + + + + + `; + row.querySelector('.btn-remove').onclick = () => row.remove(); + block.querySelector('.section-rows').appendChild(row); + }); + + block.querySelector('.btn-remove-block').onclick = () => block.remove(); + container.appendChild(block); +} + +function getListPayload() { + const sections = []; + + // Itera cada bloco-seção e coleta título + seus itens + document.querySelectorAll('#listSectionsList .block-section').forEach((block) => { + const title = block.querySelector('.section-title')?.value?.trim() || 'Seção'; + const rows = []; + block.querySelectorAll('.section-rows .item-row').forEach((row) => { + const id = row.querySelector('[data-field="id"]')?.value?.trim(); + const titleR= row.querySelector('[data-field="title"]')?.value?.trim(); + const desc = row.querySelector('[data-field="desc"]')?.value?.trim(); + if (id && titleR) rows.push({ id, title: titleR, description: desc || '' }); + }); + if (rows.length) sections.push({ title, rows }); + }); + + return { + url: '/v1/messages/send_list_helpers', + body: { + instance: document.getElementById('dispatchInstance').value, + to: document.getElementById('dispatchTo').value.trim(), + text: document.getElementById('listText').value.trim() || 'Escolha:', + buttonText: document.getElementById('listButtonText').value.trim() || 'Ver opções', + footer: document.getElementById('listFooter').value.trim() || undefined, + // Fallback: 1 seção com 1 item se o usuário não preencheu nada + sections: sections.length ? sections : [ + { title: 'Opções', rows: [{ id: 'opt1', title: 'Opção 1', description: '' }] } + ], + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 156–199) + +```ts +// POST /v1/messages/send_list_helpers +router.post('/send_list_helpers', async (req: Request, res: Response) => { + try { + const { instance = 'main', to, text, footer, buttonText, sections } = req.body; + + if (!to || !text || !buttonText || !Array.isArray(sections) || sections.length === 0) { + return res.status(400).json({ ok: false, error: 'missing to/text/buttonText/sections' }); + } + + const ctx = validateInstance(instance, res); + if (!ctx) return; + + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // nativeList é o formato do InfiniteAPI para listas dropdown + const result = await ctx.sock.sendMessage(jid, { + nativeList: { + buttonText: String(buttonText), // texto do botão que abre o dropdown + sections: sections.map((s) => ({ + title: s.title ?? 'Opções', + rows: (s.rows ?? []).map((row, idx) => ({ + id: row.id ?? `row_${idx}`, // identificador retornado ao clicar + title: row.title ?? 'Item', + description: row.description ?? '', // subtítulo opcional + })), + })), + }, + text: String(text), + footer: footer ? String(footer) : undefined, + }); + + return res.json({ ok: true, format: 'nativeList', messageId: result?.key?.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo:** +```json +{ + "instance": "main", + "to": "5511999999999", + "text": "Escolha uma categoria:", + "buttonText": "Ver Cardápio", + "footer": "Delivery grátis acima de R$ 50", + "sections": [ + { + "title": "🍕 Pizzas", + "rows": [ + { "id": "pizza_marg", "title": "Margherita", "description": "Molho, mozzarella, manjericão" }, + { "id": "pizza_cal", "title": "Calabresa", "description": "Molho, calabresa, cebola" } + ] + }, + { + "title": "🥤 Bebidas", + "rows": [ + { "id": "beb_refri", "title": "Refrigerante", "description": "Lata 350ml" } + ] + } + ] +} +``` + +--- + +## 5. Enquete (Poll) + +**Valor no select:** `poll` +**Endpoint:** `POST /v1/messages/send_poll` +**Descrição:** Cria uma enquete nativa do WhatsApp. O usuário vota sem precisar digitar. Suporta múltipla escolha via `selectableCount`. + +### Frontend — `public/index.html` (linhas 139–148) + +```html + + +``` + +### Frontend — `public/app.js` + +```js +// Cada opção de poll é um input de texto simples +function addPollOption() { + addRow('pollOptionsList', ''); +} + +function getPollPayload() { + const options = []; + + document.querySelectorAll('#pollOptionsList .item-row input[data-field="opt"]').forEach((inp) => { + const v = inp.value.trim(); + if (v) options.push(v); + }); + + return { + url: '/v1/messages/send_poll', + body: { + instance: document.getElementById('dispatchInstance').value, + to: document.getElementById('dispatchTo').value.trim(), + name: document.getElementById('pollName').value.trim() || 'Enquete', + // Mínimo 2 opções; fallback se o usuário não preencheu + options: options.length >= 2 ? options : ['Sim', 'Não'], + selectableCount: parseInt(document.getElementById('pollSelectable').value, 10) || 1, + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 201–236) + +```ts +// POST /v1/messages/send_poll +router.post('/send_poll', async (req: Request, res: Response) => { + try { + const { instance = 'main', to, name, options, selectableCount } = req.body; + + // Enquete exige ao menos 2 opções + if (!to || !name || !Array.isArray(options) || options.length < 2) { + return res.status(400).json({ ok: false, error: 'missing to/name/options (min 2)' }); + } + + // Limita a 12 opções (config.limits.maxPollOptions) e garante strings + const opts = options.slice(0, config.limits.maxPollOptions).map((o) => + typeof o === 'string' ? o : String(o) + ); + + const ctx = validateInstance(instance, res); + if (!ctx) return; + + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // Formato poll do InfiniteAPI/Baileys + const result = await ctx.sock.sendMessage(jid, { + poll: { + name: String(name), // pergunta exibida + values: opts, // array de strings com as opções + selectableCount: Math.min( + Math.max(1, selectableCount ?? 1), // mínimo 1 + opts.length // máximo = total de opções + ), + }, + }); + + return res.json({ ok: true, messageId: result?.key?.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo:** +```json +{ + "instance": "main", + "to": "5511999999999", + "name": "Qual seu framework favorito?", + "options": ["React", "Vue", "Angular", "Svelte"], + "selectableCount": 1 +} +``` + +--- + +## 6. Carrossel + +**Valor no select:** `carousel` +**Endpoint:** `POST /v1/messages/send_carousel_helpers` +**Descrição:** Mensagem com cards horizontais deslizáveis (`nativeCarousel`). Cada card pode ter título, corpo, imagem e botões próprios. + +### Frontend — `public/index.html` (linhas 150–159) + +```html + + +``` + +### Frontend — `public/app.js` + +```js +// Cria um bloco-card com campos próprios (title, body, footer, imageUrl) + botões internos +function addCarouselCard() { + const container = document.getElementById('carouselCardsList'); + const block = document.createElement('div'); + block.className = 'block-section'; + block.innerHTML = ` +
Card
+
+
+
+
+
+ + + `; + + // Botões dentro do card (quick reply específico do card) + block.querySelector('.add-card-btn').addEventListener('click', () => { + const row = document.createElement('div'); + row.className = 'item-row'; + row.innerHTML = ` + + + + `; + row.querySelector('.btn-remove').onclick = () => row.remove(); + block.querySelector('.card-buttons').appendChild(row); + }); + + block.querySelector('.btn-remove-block').onclick = () => block.remove(); + container.appendChild(block); +} + +function getCarouselPayload() { + const cards = []; + + document.querySelectorAll('#carouselCardsList .block-section').forEach((block) => { + const title = block.querySelector('[data-field="title"]')?.value?.trim(); + const body = block.querySelector('[data-field="body"]')?.value?.trim(); + const footer = block.querySelector('[data-field="footer"]')?.value?.trim(); + const imageUrl = block.querySelector('[data-field="imageUrl"]')?.value?.trim(); + const buttons = []; + + // Coleta botões internos do card + block.querySelectorAll('.card-buttons .item-row').forEach((row) => { + const id = row.querySelector('[data-field="id"]')?.value?.trim(); + const text = row.querySelector('[data-field="text"]')?.value?.trim(); + if (id && text) buttons.push({ id, text }); + }); + + cards.push({ + title: title || '', + body: body || '', + footer: footer || undefined, + imageUrl: imageUrl || undefined, + // Fallback: ao menos 1 botão por card (exigência do InfiniteAPI) + buttons: buttons.length ? buttons : [{ id: 'btn1', text: 'Ver' }], + }); + }); + + return { + url: '/v1/messages/send_carousel_helpers', + body: { + instance: document.getElementById('dispatchInstance').value, + to: document.getElementById('dispatchTo').value.trim(), + text: document.getElementById('carouselText').value.trim() || undefined, + footer: document.getElementById('carouselFooter').value.trim() || undefined, + cards: cards.length ? cards : [ + { title: 'Card', body: '', buttons: [{ id: 'b1', text: 'Botão' }] } + ], + }, + }; +} +``` + +### Backend — `src/routes/messages.ts` (linhas 238–289) + +```ts +// POST /v1/messages/send_carousel_helpers +router.post('/send_carousel_helpers', async (req: Request, res: Response) => { + try { + const { instance = 'main', to, text, footer, cards } = req.body; + + if (!to || !Array.isArray(cards) || cards.length === 0) { + return res.status(400).json({ ok: false, error: 'missing to/cards' }); + } + + // Limita a 10 cards (config.limits.maxCarouselCards) + const limited = cards.slice(0, config.limits.maxCarouselCards); + + const ctx = validateInstance(instance, res); + if (!ctx) return; + + const jid = toJid(to); + if (!jid) return res.status(400).json({ ok: false, error: 'invalid_phone' }); + + // Formata cada card para o schema do InfiniteAPI/Baileys + const formattedCards = limited.map((card, idx) => ({ + title: card.title ?? `Card ${idx + 1}`, + body: card.body ?? '', + footer: card.footer, // opcional + // Se imageUrl for fornecida, passa como { url } para o Baileys + image: card.imageUrl ? { url: card.imageUrl } : undefined, + buttons: (card.buttons ?? []).map((btn, bIdx) => ({ + type: 'reply' as const, + id: btn.id ?? `card${idx}_btn${bIdx}`, + text: btn.text ?? 'Botão', + })), + })); + + // nativeCarousel = array de cards deslizáveis + const result = await ctx.sock.sendMessage(jid, { + nativeCarousel: { cards: formattedCards }, + text: text ? String(text) : undefined, + footer: footer ? String(footer) : undefined, + }); + + return res.json({ ok: true, format: 'nativeCarousel', messageId: result?.key?.id }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return res.status(500).json({ ok: false, error: message }); + } +}); +``` + +**Payload de exemplo:** +```json +{ + "instance": "main", + "to": "5511999999999", + "text": "🔥 Ofertas especiais de hoje:", + "cards": [ + { + "title": "Pizza Margherita", + "body": "Molho, mozzarella, manjericão", + "footer": "R$ 49,90", + "imageUrl": "https://exemplo.com/pizza.jpg", + "buttons": [{ "id": "pedir_pizza", "text": "🍕 Pedir agora" }] + }, + { + "title": "Combo Bebida", + "body": "Refrigerante 2L + suco", + "footer": "R$ 19,90", + "buttons": [{ "id": "pedir_combo", "text": "🥤 Adicionar" }] + } + ] +} +``` + +--- + +## Arquivo de Limites — `src/config.ts` + +```ts +export const config = { + port: parseInt(process.env.PORT ?? '8787', 10), + apiKey: process.env.API_KEY ?? 'ACFH4RFOTME4RU50R4FKGNW34LDFG8DSQ', + authFolder: process.env.AUTH_FOLDER ?? 'auth', + limits: { + maxButtons: 3, // botões quick reply / CTA (types 2 e 3) + maxCarouselCards: 10, // cards no carrossel (type 6) + maxListSections: 10, // seções na lista dropdown (type 4) + maxListRowsPerSection: 10,// itens por seção na lista (type 4) + maxPollOptions: 12, // opções de enquete (type 5) + }, +} as const; +``` + +--- + +## Tipos TypeScript envolvidos — `src/types/whatsapp.ts` + +```ts +// Tipos dos botões nativos (usados em "buttons" e "interactive") +interface NativeButtonReply { type: 'reply'; id: string; text: string } // quick reply +interface NativeButtonUrl { type: 'url'; text: string; url: string } // abre link +interface NativeButtonCopy { type: 'copy'; text: string; copyText: string } // copia texto +interface NativeButtonCall { type: 'call'; text: string; phoneNumber: string } // disca + +// MessageContent: tudo que pode ser passado para sock.sendMessage() +interface MessageContent { + text?: string; + footer?: string; + nativeButtons?: NativeButton[]; // tipos 2 e 3 + nativeList?: { // tipo 4 + buttonText: string; + sections: NativeListSection[]; + }; + nativeCarousel?: { cards: NativeCarouselCard[] }; // tipo 6 + poll?: { // tipo 5 + name: string; + values: string[]; + selectableCount?: number; + }; +} +``` + +--- + +## Helper `toJid` — `src/utils/helpers.ts` + +```ts +// Converte número (+55 35 9882-8503 ou 553598828503) para JID do WhatsApp +export function toJid(phone: string | null | undefined): string | null { + if (!phone || typeof phone !== 'string') return null; + const cleaned = phone.replace(/\D/g, ''); // remove tudo que não é dígito + if (cleaned.length < 10) return null; + if (phone.includes('@')) return phone; // já é JID, retorna como está + return `${cleaned}@s.whatsapp.net`; +} +``` diff --git a/clube67/newwhats.local/monitor_cal_install.sh b/clube67/newwhats.local/monitor_cal_install.sh new file mode 100755 index 0000000..d0201ef --- /dev/null +++ b/clube67/newwhats.local/monitor_cal_install.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# ============================================================================== +# 🚀 SCRIPT DE INSTALAÇÃO UNIFICADA: ALERTAS SSH (GCAL + TELEGRAM + PAM) +# ============================================================================== +# Este script automatiza o deploy completo da arquitetura de monitoramento em +# qualquer VPS da rede. Ele configura o PAM, Python, Telegram e Banners. +# ============================================================================== + +# Cores para formatação de saída +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # Sem Cor + +echo -e "${YELLOW}========================================================================${NC}" +echo -e "${YELLOW} 🛡️ MONITORAMENTO SSH AUTOMÁTICO - INSTALADOR DE DEPLOY ${NC}" +echo -e "${YELLOW}========================================================================${NC}" + +# 1. Verificar se é root +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Erro: Este script precisa ser executado como root (sudo ./install.sh).${NC}" + exit 1 +fi + +# 2. Captura de Parâmetro de VPS (IP ou Nome) +VPS_PREFIX="" +while [[ "$#" -gt 0 ]]; do + case $1 in + --vps) VPS_PREFIX="$2"; shift ;; + *) echo "Parâmetro desconhecido: $1"; exit 1 ;; + esac + shift +done + +# Se não foi passado --vps, tenta auto-detectar o IP da interface wg0 (VPN) ou eth0 +if [ -z "$VPS_PREFIX" ]; then + echo -e "${YELLOW}Auto-detectando o IP da VPS...${NC}" + # Tenta pegar o IP da interface wg0 primeiro + VPS_PREFIX=$(ip -o -4 addr show dev wg0 2>/dev/null | awk '{split($4,a,"/"); print a[1]}') + + if [ -z "$VPS_PREFIX" ]; then + # Tenta pegar o IP da interface eth0 + VPS_PREFIX=$(ip -o -4 addr show dev eth0 2>/dev/null | awk '{split($4,a,"/"); print a[1]}') + fi +fi + +if [ -z "$VPS_PREFIX" ]; then + VPS_PREFIX="127.0.0.1" +fi + +echo -e "${GREEN}✅ Prefixo/IP Identificado para esta VPS: ${YELLOW}$VPS_PREFIX${NC}" + +# 3. Instalação de Dependências +echo -e "\n${YELLOW}[Passo 1/6] Instalando pacotes e dependências (Apt)...${NC}" +apt-get update -qq +apt-get install -y -qq python3-googleapi curl +echo -e "${GREEN}✅ Pacotes instalados com sucesso.${NC}" + +# 4. Criar Chave de Conta de Serviço se não existir +echo -e "\n${YELLOW}[Passo 2/6] Verificando chave da Conta de Serviço Google Cloud...${NC}" +if [ ! -f "/etc/ssh/gcal_key.json" ]; then + echo -e "${YELLOW}⚠️ Aviso: O arquivo /etc/ssh/gcal_key.json não foi encontrado.${NC}" + echo -e "${YELLOW}Por favor, certifique-se de salvar a chave JSON da conta de serviço em /etc/ssh/gcal_key.json e aplicar as permissões corretas.${NC}" +else + echo -e "${GREEN}✅ Chave da Conta de Serviço detectada em /etc/ssh/gcal_key.json.${NC}" + chown root:deploy /etc/ssh/gcal_key.json 2>/dev/null || chown root:root /etc/ssh/gcal_key.json + chmod 640 /etc/ssh/gcal_key.json +fi + +# 5. Criar arquivo de configurações se não existir +echo -e "\n${YELLOW}[Passo 3/6] Criando arquivo de configurações centralizadas (/etc/ssh/ssh_alerts_config.json)...${NC}" +if [ ! -f "/etc/ssh/ssh_alerts_config.json" ]; then + cat < /etc/ssh/ssh_alerts_config.json +{ + "telegram_token": "8641435108:AAGL3rOiVj1GpYAyUi4D-pdeDJ9K_JUdlgM", + "telegram_chat_id": "", + "calendar_id": "385857c79ef3aed0f4923b29007637a8b7764d6f8359959500bce27611833f5d@group.calendar.google.com" +} +EOF + echo -e "${GREEN}✅ Arquivo de configurações criado.${NC}" +else + echo -e "${GREEN}✅ Arquivo de configurações já existe. Mantendo configurações atuais.${NC}" +fi +chown root:deploy /etc/ssh/ssh_alerts_config.json 2>/dev/null || chown root:root /etc/ssh/ssh_alerts_config.json +chmod 640 /etc/ssh/ssh_alerts_config.json + +# 6. Criar Scripts Integradores no Servidor +echo -e "\n${YELLOW}[Passo 4/6] Configurando scripts integradores em /usr/local/bin/...${NC}" + +# Copia ou escreve o script principal Python +if [ -f "ssh_gcal_alert.py" ]; then + cp ssh_gcal_alert.py /usr/local/bin/ssh_gcal_alert.py +else + # Escreve o arquivo diretamente se rodando solto + curl -s https://raw.githubusercontent.com/sua-uri-ou-copia-local/ssh_gcal_alert.py -o /usr/local/bin/ssh_gcal_alert.py 2>/dev/null +fi +chown root:root /usr/local/bin/ssh_gcal_alert.py +chmod 755 /usr/local/bin/ssh_gcal_alert.py + +# Copia ou escreve o script do instalador Telegram +if [ -f "ssh_alert_setup_telegram.py" ]; then + cp ssh_alert_setup_telegram.py /usr/local/bin/ssh_alert_setup_telegram.py +else + curl -s https://raw.githubusercontent.com/sua-uri-ou-copia-local/ssh_alert_setup_telegram.py -o /usr/local/bin/ssh_alert_setup_telegram.py 2>/dev/null +fi +chown root:root /usr/local/bin/ssh_alert_setup_telegram.py +chmod 755 /usr/local/bin/ssh_alert_setup_telegram.py + +# Criar o gatilho do PAM dinamicamente com o VPS_PREFIX correto +cat < /usr/local/bin/ssh_pam_trigger.sh +#!/bin/bash +# Executa apenas se o estágio do PAM for 'open_session' e o serviço for o 'sshd' +if [ "\$PAM_TYPE" = "open_session" ] && [ "\$PAM_SERVICE" = "sshd" ]; then + USER_LOGIN="\$PAM_USER" + IP_LOGIN="\$PAM_RHOST" + DATE_LOGIN=\$(date "+%Y-%m-%d %H:%M:%S") + + # Se o IP vier vazio (comum em alguns túneis locais), assume localhost + if [ -z "\$IP_LOGIN" ]; then + IP_LOGIN="127.0.0.1" + fi + + # Validação de IP seguro da VPN + if [[ ! "\$IP_LOGIN" =~ ^10\.99\.0\. ]]; then + AVISO_INDEVIDO="🚨 ALERTA: Acesso fora da rede VPN segura detectado!" + else + AVISO_INDEVIDO="✅ Acesso autorizado através da rede VPN segura." + fi + + # Dispara o script Python de calendário e Telegram em segundo plano + /usr/local/bin/ssh_gcal_alert.py --vps "$VPS_PREFIX" --user "\$USER_LOGIN" --ip "\$IP_LOGIN" --status "\$AVISO_INDEVIDO" > /dev/null 2>&1 & +fi +EOF +chown root:root /usr/local/bin/ssh_pam_trigger.sh +chmod 755 /usr/local/bin/ssh_pam_trigger.sh +echo -e "${GREEN}✅ Scripts integradores configurados e autorizados.${NC}" + +# 7. Configurar o PAM +echo -e "\n${YELLOW}[Passo 5/6] Configurando o subsistema PAM para SSH (/etc/pam.d/sshd)...${NC}" +PAM_LINE="session optional pam_exec.so type=open_session /usr/local/bin/ssh_pam_trigger.sh" + +if grep -q "ssh_pam_trigger.sh" /etc/pam.d/sshd; then + echo -e "${GREEN}✅ PAM para SSH já está devidamente configurado (ignorado para evitar duplicidade).${NC}" +else + echo "$PAM_LINE" >> /etc/pam.d/sshd + echo -e "${GREEN}✅ Gatilho do PAM anexado com sucesso ao fim do arquivo /etc/pam.d/sshd.${NC}" +fi + +# 8. Configurar o Banner Visual do Terminal (profile.d) +echo -e "\n${YELLOW}[Passo 6/6] Configurando banner visual de auditoria no terminal (/etc/profile.d/ssh_alert.sh)...${NC}" +cat < /etc/profile.d/ssh_alert.sh +#!/bin/bash +# Script de Banner de Auditoria SSH + +USER_LOGIN=\$USER +IP_LOGIN=\$(echo \$SSH_CONNECTION | awk '{print \$1}') +DATE_LOGIN=\$(date "+%Y-%m-%d %H:%M:%S") + +# Validação de IP seguro da VPN (10.99.0.X) +if [[ ! "\$IP_LOGIN" =~ ^10\.99\.0\. ]]; then + AVISO_INDEVIDO="🚨 ALERTA: Acesso fora da rede VPN segura detectado!" +else + AVISO_INDEVIDO="✅ Acesso autorizado através da rede VPN segura." +fi + +# Exibição do Banner de Auditoria ao Usuário no Terminal +echo -e "\033[1;33m" +echo "=========================================================================" +echo " 🖥️ AUDITORIA DE ACESSO SISTÊMICO " +echo "=========================================================================" +echo -e "\033[0m" +echo " Usuário Conectado: \$USER_LOGIN" +echo " IP de Origem: \$IP_LOGIN" +echo " Data do Login: \$DATE_LOGIN" +echo " Status do Canal: \$AVISO_INDEVIDO" +echo "" +echo " * Todas as operações estão sendo monitoradas e auditadas." +echo "=========================================================================" +echo "" +EOF +chmod 755 /etc/profile.d/ssh_alert.sh +echo -e "${GREEN}✅ Banner visual de terminal ativado.${NC}" + +# Finalização e Próximos Passos +echo -e "\n${GREEN}========================================================================${NC}" +echo -e "${GREEN}🎉 INSTALAÇÃO CONCLUÍDA COM SUCESSO!${NC}" +echo -e "${GREEN}========================================================================${NC}" +echo -e "\n${YELLOW}👉 PRÓXIMO PASSO (VINCULAR TELEGRAM):${NC}" +echo -e "Para receber notificações instantâneas no seu Telegram, execute o comando abaixo" +echo -e "como administrador e envie uma mensagem para o seu bot:" +echo -e "\n ${GREEN}sudo /usr/local/bin/ssh_alert_setup_telegram.py${NC}\n" +echo -e "========================================================================" diff --git a/clube67/newwhats.local/monitoramento-calendar.md b/clube67/newwhats.local/monitoramento-calendar.md new file mode 100644 index 0000000..5f5a27f --- /dev/null +++ b/clube67/newwhats.local/monitoramento-calendar.md @@ -0,0 +1,121 @@ +# 📅 Guia de Implementação: Alertas de SSH no Google Calendar & Telegram + +Este guia fornece o instalador automatizado, scripts de configuração dinâmica e documentação técnica para implantar o sistema de monitoramento, auditoria de logins e alertas em tempo real em qualquer servidor da sua rede (ex: VPS 4, VPS 1, etc.). + +--- + +## 🏗️ Como o Sistema Funciona + +Para garantir que **100% de todos os acessos** sejam auditados (incluindo acessos de túneis de portas `-N`, SFTP, conexões VS Code e logins interativos) de forma robusta e assíncrona, o sistema é dividido em dois módulos integrados: + +```text +[Login SSH Concluído] + │ + ├─► [Gatilho do PAM: /etc/pam.d/sshd] (Intercepta todas as sessões: túneis, VS Code, shell, etc.) + │ │ + │ └─► [Executa em Background: /usr/local/bin/ssh_pam_trigger.sh] + │ │ + │ └─► [Chama: /usr/local/bin/ssh_gcal_alert.py] + │ │ + │ ├─► [Busca GeoIP & ISP] (Cidade, Estado, País e Operadora) + │ │ + │ ├─► [Rate-Limiting / Debounce] (Evita enchentes de alertas em curto tempo) + │ │ + │ ├─► [Local Syslog] (Salva logs localmente em caso de falha de rede) + │ │ + │ ├─► [Telegram Push] ──► Mensagem instantânea no Celular + │ │ + │ └─► [Google Calendar API] ──► Registra Bloco Visual na Agenda + │ + └─► [Sourced: /etc/profile.d/ssh_alert.sh] (Apenas em shells de login interativos) + │ + └─► Exibe Banner de Auditoria Amarelo no Terminal do Usuário +``` + +--- + +## ⚡ Método Rápido (Recomendado - Instalação em 10 segundos) + +Se você já possui o repositório de instruções em seu servidor, a instalação é totalmente automatizada através do script `install.sh`. + +### Passo 1: Executar o Instalador Automático +Acesse a pasta de instruções e execute o instalador como `root` especificando o IP/Prefixo desta nova VPS (ex: `10.99.0.4`): + +```bash +sudo ./install.sh --vps "10.99.0.X" +``` +*(O script irá baixar e configurar todas as dependências, permissões, scripts integradores, PAM e o banner visual do terminal automaticamente).* + +### Passo 2: Vincular o seu Chat do Telegram ao Bot +Para receber as notificações instantâneas no seu celular através do bot `@AcessosVPSbot`, execute o assistente de emparelhamento automático: + +```bash +sudo /usr/local/bin/ssh_alert_setup_telegram.py +``` + +1. Abra o Telegram no seu celular. +2. Pesquise por **`@AcessosVPSbot`** (ou acesse [t.me/AcessosVPSbot](https://t.me/AcessosVPSbot)) e clique em **"Começar"** ou envie `/start`. +3. O terminal detectará instantaneamente a sua mensagem, capturará o seu Chat ID, atualizará a configuração do servidor e enviará uma confirmação no seu chat! + +--- + +## 🛠️ Detalhamento Técnico e Estrutura de Arquivos + +Caso queira entender a estrutura ou realizar alterações manuais, o ecossistema é formado pelos seguintes componentes: + +### 1. Arquivo de Configuração Centralizada (`/etc/ssh/ssh_alerts_config.json`) +Guarda as chaves e credenciais de forma segura, com permissões restritas apenas para leitura (`chmod 640` para `root:deploy`). +```json +{ + "telegram_token": "8641435108:AAGL3rOiVj1GpYAyUi4D-pdeDJ9K_JUdlgM", + "telegram_chat_id": "SEU_CHAT_ID_CAPTURADO_AQUI", + "calendar_id": "385857c79ef3aed0f4923b29007637a8b7764d6f8359959500bce27611833f5d@group.calendar.google.com" +} +``` + +### 2. Script de Integração e Decisão (`/usr/local/bin/ssh_gcal_alert.py`) +Escrito em Python 3, realiza a chamada da API do Google Calendar e do Telegram, com tratamento de erros integrado no `syslog` local, controle de frequência (Rate-Limiting de 120s para o mesmo usuário/IP) e enriquecimento de GeoIP/ISP. + +### 3. Script de Gatilho do PAM (`/usr/local/bin/ssh_pam_trigger.sh`) +Intercepta no nível de kernel/autenticação cada abertura de sessão SSH e envia os dados para o script Python em segundo plano. +```bash +#!/bin/bash +if [ "$PAM_TYPE" = "open_session" ] && [ "$PAM_SERVICE" = "sshd" ]; then + USER_LOGIN="$PAM_USER" + IP_LOGIN="$PAM_RHOST" + DATE_LOGIN=$(date "+%Y-%m-%d %H:%M:%S") + + if [ -z "$IP_LOGIN" ]; then + IP_LOGIN="127.0.0.1" + fi + + if [[ ! "$IP_LOGIN" =~ ^10\.99\.0\. ]]; then + AVISO_INDEVIDO="🚨 ALERTA: Acesso fora da rede VPN segura detectado!" + else + AVISO_INDEVIDO="✅ Acesso autorizado através da rede VPN segura." + fi + + /usr/local/bin/ssh_gcal_alert.py --vps "10.99.0.X" --user "$USER_LOGIN" --ip "$IP_LOGIN" --status "$AVISO_INDEVIDO" > /dev/null 2>&1 & +fi +``` + +### 4. Configuração do PAM (`/etc/pam.d/sshd`) +Garante que o gatilho acima seja executado. É adicionada a seguinte linha ao final do arquivo: +```text +session optional pam_exec.so type=open_session /usr/local/bin/ssh_pam_trigger.sh +``` + +### 5. Banner de Terminal (`/etc/profile.d/ssh_alert.sh`) +Exibe o banner amarelo chamativo de auditoria apenas para usuários conectando de forma convencional via shell de terminal interativo. + +--- + +## 🧪 Como Testar + +Para certificar-se de que tudo está rodando adequadamente e simular o disparo direto do PAM com busca de GeoIP externa: + +```bash +sudo PAM_TYPE=open_session PAM_SERVICE=sshd PAM_USER=deploy PAM_RHOST=8.8.8.8 /usr/local/bin/ssh_pam_trigger.sh +``` + +O evento correspondente deverá aparecer na sua agenda imediatamente (em vermelho intenso indicando fora da VPN), e uma mensagem push de alerta chegará instantaneamente no seu Telegram! diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/backend/Optimizer.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/backend/Optimizer.ts new file mode 100644 index 0000000..f072bf1 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/backend/Optimizer.ts @@ -0,0 +1,63 @@ +import sharp from 'sharp'; + +export interface OptimizationResult { + buffer: Buffer; + mimetype: string; +} + +export class ImageOptimizer { + /** + * Optimizes an image: converts to WebP, strips metadata, + * and applies a 1:1 Smart Crop if needed. + */ + static async optimize( + buffer: Buffer, + category: string = 'media', + options: { aspect?: string; size?: number } = {} + ): Promise { + try { + let pipeline = sharp(buffer); + const metadata = await pipeline.metadata(); + + if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' }; + + // 1. Identify Target Size + // Default sizes based on category + let targetSize = options.size || 1024; + if (category === 'partners' || category === 'users') targetSize = 400; + if (category === 'benefits') targetSize = 800; + + // 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category + const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category); + + if (shouldCrop) { + pipeline = pipeline.resize({ + width: targetSize, + height: targetSize, + fit: sharp.fit.cover, + position: sharp.strategy.entropy // Smart focal point detection + }); + } else { + // Resize without cropping if it's too large + pipeline = pipeline.resize({ + width: targetSize, + withoutEnlargement: true, + fit: sharp.fit.inside + }); + } + + // 3. Convert to WebP & Strip Metadata + const optimizedBuffer = await pipeline + .webp({ quality: 85, effort: 4 }) + .toBuffer(); + + return { + buffer: optimizedBuffer, + mimetype: 'image/webp' + }; + } catch (err) { + console.error('[SmartVision] Optimization failed:', err); + return { buffer, mimetype: 'image/jpeg' }; // Fallback + } + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/index.ts new file mode 100644 index 0000000..12f97c5 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/index.ts @@ -0,0 +1,35 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { ImageOptimizer } from './backend/Optimizer'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + // Register for the upload:transform hook + ctx.hooks.register('upload:transform', async (payload: any) => { + const { buffer, mimetype, category } = payload; + + // Only process common image formats + const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']; + if (!imageTypes.includes(mimetype)) { + return null; // Skip non-images + } + + ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`); + + const result = await ImageOptimizer.optimize(buffer, category); + + return { + buffer: result.buffer, + mimetype: result.mimetype + }; + }); + + ctx.logger.info('SmartVision Optimizer activated'); + }, + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('SmartVision Optimizer deactivated'); + } +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/manifest.json new file mode 100644 index 0000000..267d598 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/SmartVision/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "SmartVision", + "displayName": "SmartVision Optimizer", + "version": "1.0.0", + "description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.", + "author": "Clube67 Team", + "category": "integration", + "enabled": true, + "canDisable": true, + "dependencies": [ + "uploads" + ], + "hooks": { + "subscribes": [ + "upload:transform" + ], + "emits": [] + } +} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/index.ts new file mode 100644 index 0000000..16b00ba --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/index.ts @@ -0,0 +1,296 @@ +import { S3Client, PutObjectCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { storageProvider, StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider'; +import { pluginConfig } from '../../backend/src/core/plugin-config'; +import sharp from 'sharp'; +import fs from 'fs'; +import path from 'path'; + +const VALID_CATEGORIES = ['cdi', 'xrays', 'documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media']; + +class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface { + manifest: any; + private s3: S3Client | null = null; + private bucket: string = ''; + private prefix: string = ''; + private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback'); + private ctx!: PluginContext; + + /** Activation logic */ + async activate(ctx: PluginContext): Promise { + this.ctx = ctx; + const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {}; + + // Ensure local fallback directory exists + if (!fs.existsSync(this.localFallbackDir)) { + fs.mkdirSync(this.localFallbackDir, { recursive: true }); + } + + const accessKey = storedConfig.wasabiAccessKey || process.env.WASABI_ACCESS_KEY; + const secretKey = storedConfig.wasabiSecretKey || process.env.WASABI_SECRET_KEY; + const region = storedConfig.wasabiRegion || process.env.WASABI_REGION || 'us-east-1'; + this.bucket = storedConfig.wasabiBucket || process.env.WASABI_BUCKET || ''; + this.prefix = storedConfig.wasabiBucketPrefix || process.env.WASABI_BUCKET_PREFIX || ''; + + const rawEndpoint = storedConfig.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com'; + const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; + + if (!storedConfig['category_bucket_whatsapp']) { + storedConfig['category_bucket_whatsapp'] = this.bucket || 'clube67-storage'; + pluginConfig.set('UnifiedStorageProvider', storedConfig); + } + + if (!accessKey || !secretKey || !this.bucket) { + ctx.logger.error('Wasabi credentials or bucket missing in .env or plugin config'); + } else { + this.s3 = new S3Client({ + region, + endpoint, + credentials: { + accessKeyId: accessKey, + secretAccessKey: secretKey, + }, + forcePathStyle: true, + }); + + storageProvider.register(this); + ctx.logger.info(`UnifiedStorageProvider activated (Endpoint: ${endpoint})`); + + // start the background worker + this.startSyncWorker(); + } + + // --- Proxy Route --- + ctx.app.get('/api/storage/view/*', async (req, res) => { + const filePath = req.params[0]; + if (!filePath) return res.status(400).send('Path missing'); + + try { + // 1. Check local fallback + const localPath = path.join(this.localFallbackDir, filePath); + if (fs.existsSync(localPath)) { + const data = fs.readFileSync(localPath); + const ext = filePath.split('.').pop()?.toLowerCase(); + const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'webp': 'image/webp', 'pdf': 'application/pdf' }; + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + res.setHeader('X-Storage-Source', 'local_fallback'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + return res.send(data); + } + + // 2. Wasabi Logic + if (!this.s3 || !this.bucket) return res.status(500).send('Storage not initialized'); + + const category = filePath.split('/')[0]; + const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {}; + const mappedBucket = storedConfig[`category_bucket_${category}`] || this.bucket; + const legacyBucket = (category === 'whatsapp') ? 'clube67-whatsapp-v1' : null; + + const tryFetch = async (bucket: string) => { + return this.s3!.send(new GetObjectCommand({ Bucket: bucket, Key: filePath })); + }; + + let data; + try { + data = await tryFetch(mappedBucket); + } catch (e: any) { + if (legacyBucket && legacyBucket !== mappedBucket) { + try { data = await tryFetch(legacyBucket); } catch (e2) { throw e; } + } else { throw e; } + } + + const ext = filePath.split('.').pop()?.toLowerCase(); + const mimeTypes: any = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'pdf': 'application/pdf' }; + res.setHeader('Content-Type', data.ContentType || mimeTypes[ext] || 'application/octet-stream'); + res.setHeader('X-Storage-Source', 'wasabi'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + + // In v3, Body has utility methods to convert the stream + if (data.Body) { + const bytes = await (data.Body as any).transformToByteArray(); + res.send(Buffer.from(bytes)); + } else { + res.status(404).send('Body empty'); + } + } catch (err: any) { + res.status(404).send('File not found'); + } + }); + + // --- Admin/Fetch Routes --- + ctx.app.post('/api/storage/list-files', async (req, res) => { + const { accessKey, secretKey, bucket, prefix, region, endpoint } = req.body; + if (!accessKey || !secretKey || !bucket) return res.status(400).json({ error: 'Params missing' }); + try { + const s3 = new S3Client({ + region: region || 'us-east-1', + endpoint: endpoint || 'https://s3.wasabisys.com', + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true + }); + const data = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || '', MaxKeys: 20 })); + const files = data.Contents?.map(item => ({ key: item.Key, size: item.Size, lastModified: item.LastModified })) || []; + res.json({ files }); + } catch (err: any) { res.status(500).json({ error: err.message }); } + }); + + setInterval(() => { storageProvider.updateHealth(); }, 30000); + } + + async deactivate(ctx: PluginContext): Promise { ctx.logger.info('UnifiedStorageProvider deactivated'); } + + async checkHealth(): Promise { + if (!this.s3 || !this.bucket) return false; + try { await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); return true; } + catch (err) { return false; } + } + + async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> { + const storedConfig = pluginConfig.get('UnifiedStorageProvider') || {}; + const { category, file } = payload; + if (!VALID_CATEGORIES.includes(category)) throw new Error(`Invalid category: ${category}`); + + let buffer = file.buffer; + let originalName = file.originalname; + let mimetype = file.mimetype; + + if (mimetype.startsWith('image/') && buffer.length > 150 * 1024) { + try { + this.ctx.logger.info(`[Optimizer] Processing ${originalName}`); + const optimized = await sharp(buffer) + .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 75 }) + .toBuffer(); + buffer = optimized; + mimetype = 'image/webp'; + originalName = originalName.replace(/\.[^.]+$/, '.webp'); + } catch (err: any) { this.ctx.logger.warn(`[Optimizer] Failed: ${err.message}`); } + } + + const fileName = `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9.-]/g, '_')}`; + const relativePath = `${category}/${fileName}`; + + try { + if (!this.s3 || !this.bucket) throw new Error('S3 not initialized'); + const targetBucket = storedConfig[`category_bucket_${category}`] || this.bucket; + + const parallelUploads3 = new Upload({ + client: this.s3, + params: { + Bucket: targetBucket, + Key: relativePath, + Body: buffer, + ContentType: mimetype, + ACL: 'public-read' + }, + }); + + await parallelUploads3.done(); + return { provider: 'wasabi', path: relativePath }; + } catch (err: any) { + this.ctx.logger.error(`[Wasabi] Fallback to LOCAL: ${err.message}`); + + // Log fallback attempt + try { + await this.ctx.db('janitor_logs').insert({ + filename: originalName, + category: category, + status: 'fallback', + message: `Erro Wasabi: ${err.message.substring(0, 100)}` + }); + } catch (dbErr) { } + + const localDest = path.join(this.localFallbackDir, category); + if (!fs.existsSync(localDest)) fs.mkdirSync(localDest, { recursive: true }); + fs.writeFileSync(path.join(this.localFallbackDir, relativePath), buffer); + return { provider: 'local_fallback', path: relativePath }; + } + } + + private startSyncWorker() { + setInterval(async () => { + if (!this.s3 || !this.bucket) return; + const scanFolders = [ + { path: this.localFallbackDir, isCategorized: true }, + { path: path.resolve(process.cwd(), 'uploads'), isCategorized: false, category: 'media' } + ]; + for (const folder of scanFolders) { + try { + if (!fs.existsSync(folder.path)) continue; + if (folder.isCategorized) { + const categories = fs.readdirSync(folder.path); + for (const cat of categories) { + const catDir = path.join(folder.path, cat); + if (!fs.statSync(catDir).isDirectory()) continue; + await this.syncFolder(catDir, cat); + } + } else if (folder.category) { + await this.syncFolder(folder.path, folder.category); + } + } catch (err: any) { } + } + }, 120000); + } + + private async syncFolder(dir: string, category: string) { + if (!this.s3 || !this.bucket) return; + try { + const files = fs.readdirSync(dir); + if (files.length === 0) return; + for (const file of files) { + const localPath = path.join(dir, file); + if (!fs.existsSync(localPath) || fs.statSync(localPath).isDirectory()) continue; + + try { + // Log step 1: Wasabi + const logId = await this.ctx.db('janitor_logs').insert({ + filename: file, + category: category, + status: 'wasabi', + message: 'Sincronizando com Wasabi...' + }); + + const buffer = fs.readFileSync(localPath); + const targetBucket = pluginConfig.get('UnifiedStorageProvider')?.[`category_bucket_${category}`] || this.bucket; + + const parallelUploads3 = new Upload({ + client: this.s3, + params: { + Bucket: targetBucket, + Key: `${category}/${file}`, + Body: buffer + }, + }); + + await parallelUploads3.done(); + + // Log step 2: Deleting VPS + await this.ctx.db('janitor_logs').where('id', logId[0]).update({ + status: 'vps_delete', + message: 'Wasabi OK, excluindo do servidor...' + }); + + fs.unlinkSync(localPath); + + // Log step 3: Fim + await this.ctx.db('janitor_logs').where('id', logId[0]).update({ + status: 'fim', + message: 'Limpeza concluída com sucesso.' + }); + } catch (err: any) { + this.ctx.logger.error(`[Janitor] Sync failed for ${file}: ${err.message}`); + await this.ctx.db('janitor_logs').insert({ + filename: file, + category: category, + status: 'erro', + message: `Falha: ${err.message.substring(0, 100)}` + }); + } + } + } catch (err: any) { } + } +} + +const instance = new UnifiedStorageProviderPlugin(); +export default instance; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/manifest.json new file mode 100644 index 0000000..acb25e9 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/UnifiedStorageProvider/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "UnifiedStorageProvider", + "displayName": "Unified Storage Provider", + "version": "1.0.0", + "description": "Central storage provider using Wasabi (S3 compatible)", + "author": "Clube67", + "category": "core", + "enabled": true, + "canDisable": false, + "dependencies": [], + "backend": { + "routePrefix": "/api/storage", + "hasMigrations": false + } +} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js new file mode 100644 index 0000000..7ea96c7 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js @@ -0,0 +1,148 @@ +"use strict"; +// ============================================================ +// Plugin: core-auth — Routes +// ============================================================ +// Migrated from: backend/src/routes/auth.routes.ts +// backend/src/controllers/auth.controller.ts +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAuthRoutes = createAuthRoutes; +const express_1 = require("express"); +const bcryptjs_1 = __importDefault(require("bcryptjs")); +const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); +function createAuthRoutes(ctx) { + const router = (0, express_1.Router)(); + const { db, hooks } = ctx; + // ── POST /login ─────────────────────────────────────── + router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'Email e senha são obrigatórios' }); + } + const user = await db('users').where({ email }).first(); + if (!user) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + const isValid = await bcryptjs_1.default.compare(password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + const accessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') }); + const refreshToken = jsonwebtoken_1.default.sign({ id: user.id, type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') }); + // Emit hook event + await hooks.emit('user:login', { userId: user.id, ip: req.ip }); + // Audit log + await db('audit_log').insert({ + user_id: user.id, + action: 'LOGIN', + entity_type: 'user', + entity_id: user.id, + details: 'Usuário logou no sistema', + ip_address: req.ip, + }).catch(() => { }); // non-critical + const { password_hash, ...safeUser } = user; + res.json({ user: safeUser, accessToken, refreshToken }); + } + catch (err) { + ctx.logger.error(`Login error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + // ── POST /register ──────────────────────────────────── + router.post('/register', async (req, res) => { + try { + const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' }); + } + const existing = await db('users').where({ email }).first(); + if (existing) { + return res.status(409).json({ error: 'Email já cadastrado' }); + } + const password_hash = await bcryptjs_1.default.hash(password, 12); + const [id] = await db('users').insert({ + name, + email, + password_hash, + cpf: cpf || null, + whatsapp: whatsapp || null, + city: city || null, + neighborhood: neighborhood || null, + state: state || null, + role: 'user', + status: 'active', + }); + await hooks.emit('user:register', { userId: id, email }); + res.status(201).json({ id, name, email, role: 'user' }); + } + catch (err) { + ctx.logger.error(`Register error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + // ── GET /me ─────────────────────────────────────────── + router.get('/me', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Token não fornecido' }); + } + const token = authHeader.split(' ')[1]; + const payload = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET); + const user = await db('users') + .where({ id: payload.id }) + .select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at') + .first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + let partnerConsent = false; + let partnerConsentAt = null; + let partnerConsentIp = null; + if (user?.partner_id) { + const partner = await db('partners') + .where({ id: user.partner_id }) + .select('data_consent', 'consent_at', 'consent_ip') + .first(); + partnerConsent = Boolean(partner?.data_consent); + partnerConsentAt = partner?.consent_at || null; + partnerConsentIp = partner?.consent_ip || null; + } + res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp }); + } + catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expirado' }); + } + res.status(401).json({ error: 'Token inválido' }); + } + }); + // ── POST /refresh ───────────────────────────────────── + router.post('/refresh', async (req, res) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token obrigatório' }); + } + const payload = jsonwebtoken_1.default.verify(refreshToken, process.env.JWT_REFRESH_SECRET); + if (payload.type !== 'refresh') { + return res.status(401).json({ error: 'Token inválido' }); + } + const user = await db('users').where({ id: payload.id }).first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + const newAccessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') }); + await hooks.emit('token:refresh', { userId: user.id }); + res.json({ accessToken: newAccessToken }); + } + catch (err) { + res.status(401).json({ error: 'Refresh token inválido ou expirado' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js.map new file mode 100644 index 0000000..d3d9124 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/core-auth/backend/routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.ts new file mode 100644 index 0000000..1f612a4 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/backend/routes.ts @@ -0,0 +1,180 @@ +// ============================================================ +// Plugin: core-auth — Routes +// ============================================================ +// Migrated from: backend/src/routes/auth.routes.ts +// backend/src/controllers/auth.controller.ts + +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { PluginContext } from '../../../backend/src/core/types'; + +export function createAuthRoutes(ctx: PluginContext): Router { + const router = Router(); + const { db, hooks } = ctx; + + // ── POST /login ─────────────────────────────────────── + router.post('/login', async (req: Request, res: Response) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email e senha são obrigatórios' }); + } + + const user = await db('users').where({ email }).first(); + if (!user) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + + const accessToken = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET!, + { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any } + ); + + const refreshToken = jwt.sign( + { id: user.id, type: 'refresh' }, + process.env.JWT_REFRESH_SECRET!, + { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any } + ); + + // Emit hook event + await hooks.emit('user:login', { userId: user.id, ip: req.ip }); + + // Audit log + await db('audit_log').insert({ + user_id: user.id, + action: 'LOGIN', + entity_type: 'user', + entity_id: user.id, + details: 'Usuário logou no sistema', + ip_address: req.ip, + }).catch(() => { }); // non-critical + + const { password_hash, ...safeUser } = user; + res.json({ user: safeUser, accessToken, refreshToken }); + } catch (err: any) { + ctx.logger.error(`Login error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + + // ── POST /register ──────────────────────────────────── + router.post('/register', async (req: Request, res: Response) => { + try { + const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body; + + if (!name || !email || !password) { + return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' }); + } + + const existing = await db('users').where({ email }).first(); + if (existing) { + return res.status(409).json({ error: 'Email já cadastrado' }); + } + + const password_hash = await bcrypt.hash(password, 12); + + const [id] = await db('users').insert({ + name, + email, + password_hash, + cpf: cpf || null, + whatsapp: whatsapp || null, + city: city || null, + neighborhood: neighborhood || null, + state: state || null, + role: 'user', + status: 'active', + }); + + await hooks.emit('user:register', { userId: id, email }); + + res.status(201).json({ id, name, email, role: 'user' }); + } catch (err: any) { + ctx.logger.error(`Register error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + + // ── GET /me ─────────────────────────────────────────── + router.get('/me', async (req: Request, res: Response) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Token não fornecido' }); + } + + const token = authHeader.split(' ')[1]; + const payload = jwt.verify(token, process.env.JWT_SECRET!) as any; + + const user = await db('users') + .where({ id: payload.id }) + .select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at') + .first(); + + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + + let partnerConsent = false; + let partnerConsentAt = null; + let partnerConsentIp = null; + if (user?.partner_id) { + const partner = await db('partners') + .where({ id: user.partner_id }) + .select('data_consent', 'consent_at', 'consent_ip') + .first(); + partnerConsent = Boolean(partner?.data_consent); + partnerConsentAt = partner?.consent_at || null; + partnerConsentIp = partner?.consent_ip || null; + } + res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp }); + } catch (err: any) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expirado' }); + } + res.status(401).json({ error: 'Token inválido' }); + } + }); + + // ── POST /refresh ───────────────────────────────────── + router.post('/refresh', async (req: Request, res: Response) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token obrigatório' }); + } + + const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as any; + if (payload.type !== 'refresh') { + return res.status(401).json({ error: 'Token inválido' }); + } + + const user = await db('users').where({ id: payload.id }).first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + + const newAccessToken = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET!, + { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any } + ); + + await hooks.emit('token:refresh', { userId: user.id }); + + res.json({ accessToken: newAccessToken }); + } catch (err: any) { + res.status(401).json({ error: 'Refresh token inválido ou expirado' }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js new file mode 100644 index 0000000..ee1e4c4 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js @@ -0,0 +1,23 @@ +"use strict"; +// ============================================================ +// Plugin: core-auth — Entry Point +// ============================================================ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + const router = (0, routes_1.createAuthRoutes)(ctx); + ctx.app.use('/api/auth', router); + ctx.logger.info('Auth routes registered at /api/auth'); + }, + async deactivate(ctx) { + ctx.logger.info('Auth plugin deactivated'); + }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js.map new file mode 100644 index 0000000..e4d9a26 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/core-auth/index.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kCAAkC;AAClC,+DAA+D;;;;;AAG/D,6CAAoD;AACpD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,yBAAgB,EAAC,GAAG,CAAC,CAAC;QACrC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.ts new file mode 100644 index 0000000..9d2e49b --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/index.ts @@ -0,0 +1,23 @@ +// ============================================================ +// Plugin: core-auth — Entry Point +// ============================================================ + +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createAuthRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + + async activate(ctx: PluginContext): Promise { + const router = createAuthRoutes(ctx); + ctx.app.use('/api/auth', router); + ctx.logger.info('Auth routes registered at /api/auth'); + }, + + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('Auth plugin deactivated'); + }, +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/manifest.json new file mode 100644 index 0000000..57b2d25 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/core-auth/manifest.json @@ -0,0 +1,46 @@ +{ + "name": "core-auth", + "displayName": "Autenticação", + "version": "1.0.0", + "description": "Login, JWT, refresh tokens, sessões, Google OAuth e RBAC.", + "author": "Clube67", + "category": "core", + "enabled": true, + "canDisable": false, + "dependencies": [], + "backend": { + "routePrefix": "/api/auth", + "hasMigrations": true, + "globalMiddleware": [ + "authenticate" + ] + }, + "frontend": { + "menuItems": [], + "pages": [ + { + "path": "/login", + "component": "LoginPage", + "roles": [ + "*" + ] + }, + { + "path": "/register", + "component": "RegisterPage", + "roles": [ + "*" + ] + } + ] + }, + "hooks": { + "subscribes": [], + "emits": [ + "user:login", + "user:logout", + "user:register", + "token:refresh" + ] + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js new file mode 100644 index 0000000..c7a992e --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js @@ -0,0 +1,117 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createFinanceRoutes = createFinanceRoutes; +const express_1 = require("express"); +function createFinanceRoutes(ctx) { + const router = (0, express_1.Router)(); + const { db, hooks } = ctx; + // GET /plans — List subscription plans + router.get('/plans', async (_req, res) => { + try { + const plans = await db('subscription_plans').orderBy('monthly_price', 'asc'); + res.json({ plans }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao listar planos' }); + } + }); + // GET /invoices — List invoices (optional partner filter) + router.get('/invoices', async (req, res) => { + try { + const partnerId = req.query.partner_id; + const status = req.query.status; + let query = db('invoices') + .join('partners', 'invoices.partner_id', 'partners.id') + .select('invoices.*', 'partners.company_name'); + if (partnerId) + query = query.where({ 'invoices.partner_id': partnerId }); + if (status) + query = query.where({ 'invoices.status': status }); + const invoices = await query.orderBy('invoices.due_date', 'desc'); + res.json({ invoices }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao listar faturas' }); + } + }); + // POST /invoices — Create invoice + router.post('/invoices', async (req, res) => { + try { + const { partner_id, plan_id, amount, due_date } = req.body; + const [id] = await db('invoices').insert({ + partner_id, plan_id, amount, + issue_date: db.fn.now(), + due_date, + status: 'Pendente', + }); + await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id }); + const invoice = await db('invoices').where({ id }).first(); + res.status(201).json(invoice); + } + catch (err) { + res.status(500).json({ error: 'Erro ao criar fatura' }); + } + }); + // PUT /invoices/:id/pay — Mark invoice as paid + router.put('/invoices/:id/pay', async (req, res) => { + try { + await db('invoices').where({ id: req.params.id }).update({ + status: 'Paga', + paid_at: db.fn.now(), + }); + await hooks.emit('invoice:paid', { invoiceId: req.params.id }); + res.json({ message: 'Fatura paga com sucesso' }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao pagar fatura' }); + } + }); + // GET /dashboard — Financial overview + router.get('/dashboard', async (_req, res) => { + try { + const [totalRevenue] = await db('invoices') + .where({ status: 'Paga' }) + .sum('amount as total'); + const [pendingRevenue] = await db('invoices') + .where({ status: 'Pendente' }) + .sum('amount as total'); + const [overdueRevenue] = await db('invoices') + .where({ status: 'Atrasada' }) + .sum('amount as total'); + const [activeSubscriptions] = await db('partners') + .where({ status: 'active' }) + .count('id as count'); + const planDistribution = await db('partners') + .select('subscription_plan_id as plan') + .count('id as count') + .where({ status: 'active' }) + .groupBy('subscription_plan_id'); + res.json({ + totalRevenue: Number(totalRevenue.total || 0), + pendingRevenue: Number(pendingRevenue.total || 0), + overdueRevenue: Number(overdueRevenue.total || 0), + activeSubscriptions: Number(activeSubscriptions.count), + planDistribution, + }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' }); + } + }); + // GET /commissions — List commissions + router.get('/commissions', async (req, res) => { + try { + const partnerId = req.query.partner_id; + let query = db('commissions'); + if (partnerId) + query = query.where({ partner_id: partnerId }); + const commissions = await query.orderBy('transaction_date', 'desc'); + res.json({ commissions }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao listar comissões' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js.map new file mode 100644 index 0000000..2733672 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/financeiro/backend/routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.ts new file mode 100644 index 0000000..f79e878 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/backend/routes.ts @@ -0,0 +1,123 @@ +import { Router, Request, Response } from 'express'; +import { PluginContext } from '../../../backend/src/core/types'; + +export function createFinanceRoutes(ctx: PluginContext): Router { + const router = Router(); + const { db, hooks } = ctx; + + // GET /plans — List subscription plans + router.get('/plans', async (_req: Request, res: Response) => { + try { + const plans = await db('subscription_plans').orderBy('monthly_price', 'asc'); + res.json({ plans }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao listar planos' }); + } + }); + + // GET /invoices — List invoices (optional partner filter) + router.get('/invoices', async (req: Request, res: Response) => { + try { + const partnerId = req.query.partner_id as string; + const status = req.query.status as string; + + let query = db('invoices') + .join('partners', 'invoices.partner_id', 'partners.id') + .select('invoices.*', 'partners.company_name'); + + if (partnerId) query = query.where({ 'invoices.partner_id': partnerId }); + if (status) query = query.where({ 'invoices.status': status }); + + const invoices = await query.orderBy('invoices.due_date', 'desc'); + res.json({ invoices }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao listar faturas' }); + } + }); + + // POST /invoices — Create invoice + router.post('/invoices', async (req: Request, res: Response) => { + try { + const { partner_id, plan_id, amount, due_date } = req.body; + + const [id] = await db('invoices').insert({ + partner_id, plan_id, amount, + issue_date: db.fn.now(), + due_date, + status: 'Pendente', + }); + + await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id }); + const invoice = await db('invoices').where({ id }).first(); + res.status(201).json(invoice); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao criar fatura' }); + } + }); + + // PUT /invoices/:id/pay — Mark invoice as paid + router.put('/invoices/:id/pay', async (req: Request, res: Response) => { + try { + await db('invoices').where({ id: req.params.id }).update({ + status: 'Paga', + paid_at: db.fn.now(), + }); + await hooks.emit('invoice:paid', { invoiceId: req.params.id }); + res.json({ message: 'Fatura paga com sucesso' }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao pagar fatura' }); + } + }); + + // GET /dashboard — Financial overview + router.get('/dashboard', async (_req: Request, res: Response) => { + try { + const [totalRevenue] = await db('invoices') + .where({ status: 'Paga' }) + .sum('amount as total'); + + const [pendingRevenue] = await db('invoices') + .where({ status: 'Pendente' }) + .sum('amount as total'); + + const [overdueRevenue] = await db('invoices') + .where({ status: 'Atrasada' }) + .sum('amount as total'); + + const [activeSubscriptions] = await db('partners') + .where({ status: 'active' }) + .count('id as count'); + + const planDistribution = await db('partners') + .select('subscription_plan_id as plan') + .count('id as count') + .where({ status: 'active' }) + .groupBy('subscription_plan_id'); + + res.json({ + totalRevenue: Number(totalRevenue.total || 0), + pendingRevenue: Number(pendingRevenue.total || 0), + overdueRevenue: Number(overdueRevenue.total || 0), + activeSubscriptions: Number(activeSubscriptions.count), + planDistribution, + }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' }); + } + }); + + // GET /commissions — List commissions + router.get('/commissions', async (req: Request, res: Response) => { + try { + const partnerId = req.query.partner_id as string; + let query = db('commissions'); + if (partnerId) query = query.where({ partner_id: partnerId }); + const commissions = await query.orderBy('transaction_date', 'desc'); + res.json({ commissions }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao listar comissões' }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js new file mode 100644 index 0000000..a90d19f --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js @@ -0,0 +1,24 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + const router = (0, routes_1.createFinanceRoutes)(ctx); + ctx.app.use('/api/finance', router); + // Listen for new partners to create initial invoice + ctx.hooks.register('partner:created', async (data) => { + ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`); + }); + ctx.logger.info('Finance routes registered at /api/finance'); + }, + async deactivate(ctx) { + ctx.logger.info('Financeiro plugin deactivated'); + }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js.map new file mode 100644 index 0000000..04e8442 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/financeiro/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAuD;AACvD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,4BAAmB,EAAC,GAAG,CAAC,CAAC;QACxC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAEpC,oDAAoD;QACpD,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,iBAAiB,EAAE,KAAK,EAAE,IAA2B,EAAE,EAAE;YACxE,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,wCAAwC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,2CAA2C,CAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,+BAA+B,CAAC,CAAC;IACrD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.ts new file mode 100644 index 0000000..5b8d978 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/index.ts @@ -0,0 +1,25 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createFinanceRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + + async activate(ctx: PluginContext): Promise { + const router = createFinanceRoutes(ctx); + ctx.app.use('/api/finance', router); + + // Listen for new partners to create initial invoice + ctx.hooks.register('partner:created', async (data: { partnerId: string }) => { + ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`); + }); + + ctx.logger.info('Finance routes registered at /api/finance'); + }, + + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('Financeiro plugin deactivated'); + }, +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/manifest.json new file mode 100644 index 0000000..0b284d8 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/financeiro/manifest.json @@ -0,0 +1,66 @@ +{ + "name": "financeiro", + "displayName": "Financeiro", + "version": "1.0.0", + "description": "Planos de assinatura, faturas, comissões e controle financeiro de parceiros.", + "author": "Clube67", + "category": "business", + "enabled": true, + "canDisable": true, + "dependencies": [ + "core-auth", + "partners" + ], + "backend": { + "routePrefix": "/api/finance", + "hasMigrations": true + }, + "frontend": { + "menuItems": [ + { + "id": "finance-dashboard", + "label": "Financeiro", + "icon": "DollarSign", + "href": "/admin/finance", + "roles": [ + "super_admin", + "finance" + ], + "order": 50 + }, + { + "id": "finance-invoices", + "label": "Faturas", + "icon": "FileText", + "href": "/partner/invoices", + "roles": [ + "partner_admin" + ], + "order": 55 + } + ], + "widgets": [ + { + "id": "finance-mrr", + "component": "MRRWidget", + "span": 2, + "roles": [ + "super_admin", + "finance" + ], + "order": 30 + } + ] + }, + "hooks": { + "subscribes": [ + "partner:created", + "partner:deactivated" + ], + "emits": [ + "invoice:created", + "invoice:paid", + "invoice:overdue" + ] + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js new file mode 100644 index 0000000..25ede7f --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js @@ -0,0 +1,87 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createGoogleOAuthRoutes = createGoogleOAuthRoutes; +const express_1 = require("express"); +const plugin_config_1 = require("../../../backend/src/core/plugin-config"); +const auth_1 = require("../../../backend/src/middleware/auth"); +function createGoogleOAuthRoutes(ctx) { + const router = (0, express_1.Router)(); + const { db } = ctx; + const storedConfig = plugin_config_1.pluginConfig.get('google-oauth') || {}; + const GOOGLE_CLIENT_ID = storedConfig.clientId || process.env.GOOGLE_CLIENT_ID; + const GOOGLE_CLIENT_SECRET = storedConfig.clientSecret || process.env.GOOGLE_CLIENT_SECRET; + const GOOGLE_CALLBACK = storedConfig.callbackUrl || process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback'; + const rawBasePath = storedConfig.basePath || process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH || ''; + const basePath = rawBasePath ? `/${String(rawBasePath).replace(/^\/+|\/+$/g, '')}` : ''; + // GET / — Redirect to Google + router.get('/', (_req, res) => { + if (!GOOGLE_CLIENT_ID) + return res.status(503).json({ error: 'Google OAuth não configurado' }); + const url = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(GOOGLE_CALLBACK)}` + + `&response_type=code&scope=openid%20email%20profile&access_type=offline`; + res.redirect(url); + }); + // GET /callback — Handle Google callback + router.get('/callback', async (req, res) => { + try { + const { code } = req.query; + if (!code || !GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) { + return res.status(400).json({ error: 'Código ou config ausente' }); + } + // Exchange code for token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: GOOGLE_CALLBACK, grant_type: 'authorization_code', + }), + }); + const tokenData = await tokenRes.json(); + if (!tokenRes.ok || !(tokenData === null || tokenData === void 0 ? void 0 : tokenData.access_token)) { + ctx.logger.error(`Google OAuth token error: ${JSON.stringify(tokenData)}`); + return res.status(400).json({ error: 'Falha ao obter token do Google' }); + } + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + const googleUser = await userRes.json(); + if (!userRes.ok || !(googleUser === null || googleUser === void 0 ? void 0 : googleUser.email)) { + ctx.logger.error(`Google OAuth userinfo error: ${JSON.stringify(googleUser)}`); + return res.status(400).json({ error: 'Falha ao obter e-mail do Google' }); + } + // Find or create user + let user = await db('users').where({ email: googleUser.email }).first(); + if (!user) { + const [id] = await db('users').insert({ + name: googleUser.name, email: googleUser.email, + role: 'user', status: 'active', google_id: googleUser.id, + created_at: new Date(), + updated_at: new Date(), + }); + user = { id, name: googleUser.name, email: googleUser.email, role: 'user' }; + } + else if (!user.google_id) { + await db('users').where({ id: user.id }).update({ google_id: googleUser.id, updated_at: new Date() }); + } + if (user.deleted_at || user.status === 'banned') { + return res.status(401).json({ error: 'Conta desativada' }); + } + const tokens = (0, auth_1.generateTokens)(user); + await ctx.hooks.emit('user:google_login', { userId: user.id, email: user.email }); + // Redirect to frontend with token + res.redirect(`${basePath}/login?token=${encodeURIComponent(tokens.accessToken)}&google=1`); + } + catch (err) { + ctx.logger.error(`Google OAuth error: ${err.message}`); + res.status(500).json({ error: 'Erro no OAuth' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js.map new file mode 100644 index 0000000..8965960 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/google-oauth/backend/routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.ts new file mode 100644 index 0000000..c8feda5 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/backend/routes.ts @@ -0,0 +1,120 @@ +import { Router, Request, Response } from 'express'; +import { PluginContext } from '../../../backend/src/core/types'; +import { pluginConfig } from '../../../backend/src/core/plugin-config'; +import { generateTokens } from '../../../backend/src/middleware/auth'; + +export function createGoogleOAuthRoutes(ctx: PluginContext): Router { + const router = Router(); + const { db } = ctx; + const storedConfig = pluginConfig.get('google-oauth') || {}; + const GOOGLE_CLIENT_ID = storedConfig.clientId || process.env.GOOGLE_CLIENT_ID; + const GOOGLE_CLIENT_SECRET = storedConfig.clientSecret || process.env.GOOGLE_CLIENT_SECRET; + const GOOGLE_CALLBACK = storedConfig.callbackUrl || process.env.GOOGLE_CALLBACK_URL || '/api/auth/google/callback'; + const rawBasePath = storedConfig.basePath || process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH || ''; + const basePath = rawBasePath ? `/${String(rawBasePath).replace(/^\/+|\/+$/g, '')}` : ''; + + // GET / — Redirect to Google + router.get('/', (req: Request, res: Response) => { + if (!GOOGLE_CLIENT_ID) return res.status(503).json({ error: 'Google OAuth não configurado' }); + + const reqBasePath = req.query.basePath as string || ''; + const state = reqBasePath ? Buffer.from(JSON.stringify({ basePath: reqBasePath })).toString('base64') : ''; + + // ALWAYS use the registered GOOGLE_CALLBACK URI for the OAuth flow + // The 'state' parameter will carry the basePath information to route back correctly + const redirectUri = GOOGLE_CALLBACK; + + const url = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&response_type=code&scope=openid%20email%20profile&access_type=offline` + + (state ? `&state=${encodeURIComponent(state)}` : ''); + + res.redirect(url); + }); + + // GET /callback — Handle Google callback + router.get('/callback', async (req: Request, res: Response) => { + try { + const { code } = req.query; + if (!code || !GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) { + return res.status(400).json({ error: 'Código ou config ausente' }); + } + + // Recover basePath from state + const { state } = req.query; + let currentBasePath = ''; // Default to root for Live + const targetRedirectUri = GOOGLE_CALLBACK; // Standard URI used for the code exchange + + if (state) { + try { + const decodedState = JSON.parse(Buffer.from(state as string, 'base64').toString()); + if (decodedState.basePath) { + currentBasePath = decodedState.basePath; + // Always ensure basePath starts with / and doesn't end with / + currentBasePath = `/${String(currentBasePath).replace(/^\/+|\/+$/g, '')}`; + if (currentBasePath === '/') currentBasePath = ''; + } + } catch (e) { + ctx.logger.error(`Error decoding OAuth state: ${e}`); + } + } + + // Exchange code for token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: targetRedirectUri, grant_type: 'authorization_code', + }), + }); + const tokenData = await tokenRes.json() as any; + if (!tokenRes.ok || !tokenData?.access_token) { + ctx.logger.error(`Google OAuth token error: ${JSON.stringify(tokenData)}`); + return res.status(400).json({ error: 'Falha ao obter token do Google' }); + } + + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + const googleUser = await userRes.json() as any; + + ctx.logger.info(`Google User Info: ${JSON.stringify(googleUser)}`); + + if (!userRes.ok || !googleUser?.email) { + ctx.logger.error(`Google OAuth userinfo error: ${JSON.stringify(googleUser)}`); + return res.status(400).json({ error: 'Falha ao obter e-mail do Google' }); + } + + // Find or create user + let user = await db('users').where({ email: googleUser.email }).first(); + if (!user) { + const [id] = await db('users').insert({ + name: googleUser.name, email: googleUser.email, + role: 'user', status: 'active', google_id: googleUser.id, + created_at: new Date(), + updated_at: new Date(), + }); + user = { id, name: googleUser.name, email: googleUser.email, role: 'user' }; + } else if (!user.google_id) { + await db('users').where({ id: user.id }).update({ google_id: googleUser.id, updated_at: new Date() }); + } + if (user.deleted_at || user.status === 'banned') { + return res.status(401).json({ error: 'Conta desativada' }); + } + + const tokens = generateTokens(user); + + await ctx.hooks.emit('user:google_login', { userId: user.id, email: user.email }); + + // Redirect to frontend with token + res.redirect(`${currentBasePath}/login?token=${encodeURIComponent(tokens.accessToken)}&google=1`); + } catch (err: any) { + ctx.logger.error(`Google OAuth error: ${err.message}`); + res.status(500).json({ error: 'Erro no OAuth' }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js new file mode 100644 index 0000000..d9964e2 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js @@ -0,0 +1,17 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + ctx.app.use('/api/auth/google', (0, routes_1.createGoogleOAuthRoutes)(ctx)); + ctx.logger.info('Google OAuth routes registered'); + }, + async deactivate(ctx) { ctx.logger.info('Google OAuth deactivated'); }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js.map new file mode 100644 index 0000000..d3cb703 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/google-oauth/index.ts"],"names":[],"mappings":";;;;;AACA,6CAA2D;AAC3D,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,kBAAkB,EAAE,IAAA,gCAAuB,EAAC,GAAG,CAAC,CAAC,CAAC;QAC9D,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;IACtD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC,CAAC,CAAC;CACvG,CAAC;AACF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.ts new file mode 100644 index 0000000..d1e99ea --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/index.ts @@ -0,0 +1,13 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createGoogleOAuthRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + ctx.app.use('/api/auth/google', createGoogleOAuthRoutes(ctx)); + ctx.logger.info('Google OAuth routes registered'); + }, + async deactivate(ctx: PluginContext): Promise { ctx.logger.info('Google OAuth deactivated'); }, +}; +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/manifest.json new file mode 100644 index 0000000..dfcc09a --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/google-oauth/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "google-oauth", + "displayName": "Google OAuth", + "version": "1.0.0", + "description": "Login social via Google OAuth 2.0.", + "author": "Clube67", + "category": "integration", + "enabled": true, + "canDisable": true, + "dependencies": [ + "core-auth" + ], + "backend": { + "routePrefix": "/api/auth/google", + "hasMigrations": false + }, + "frontend": { + "menuItems": [] + }, + "hooks": { + "subscribes": [], + "emits": [ + "user:google_login" + ] + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js new file mode 100644 index 0000000..efaee69 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js @@ -0,0 +1,235 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createLeadsRoutes = createLeadsRoutes; +const express_1 = require("express"); +const auth_1 = require("../../../backend/src/middleware/auth"); +const rbac_1 = require("../../../backend/src/middleware/rbac"); +const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost']; +const LEAD_TYPE_VALUES = ['cold', 'member']; +function translateLead(lead) { + if (!lead) + return lead; + return { + ...lead, + source: lead.source || 'manual', + partner_id: lead.partner_id || null, + benefit_id: lead.benefit_id || null, + user_id: lead.user_id || null, + lead_type: lead.lead_type || 'cold', + }; +} +function createLeadsRoutes(ctx) { + const router = (0, express_1.Router)(); + const { db } = ctx; + const protectedRoutes = [auth_1.authenticate, (0, rbac_1.authorize)(['super_admin', 'admin'])]; + // GET /api/leads/metrics — Global metrics for super admin + router.get('/metrics', ...protectedRoutes, async (req, res) => { + try { + const [totalRow] = await db('leads').count('id as total'); + const total = Number(totalRow.total); + // Today + const [todayRow] = await db('leads') + .count('id as total') + .whereRaw('DATE(created_at) = CURDATE()'); + const today = Number(todayRow.total); + // By status + const byStatus = await db('leads') + .select('status') + .count('id as total') + .groupBy('status'); + // By lead type + const byType = await db('leads') + .select('lead_type') + .count('id as total') + .groupBy('lead_type'); + // Converted count for conversion rate + const converted = byStatus.find((r) => r.status === 'converted'); + const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0'; + // By city (via users join) + const byCity = await db('leads') + .join('users', 'leads.user_id', 'users.id') + .select('users.city', 'users.state') + .count('leads.id as total') + .whereNotNull('users.city') + .groupBy('users.city', 'users.state') + .orderBy('total', 'desc') + .limit(10); + // By neighborhood (via users join) + const byNeighborhood = await db('leads') + .join('users', 'leads.user_id', 'users.id') + .select('users.neighborhood', 'users.city', 'users.state') + .count('leads.id as total') + .whereNotNull('users.neighborhood') + .groupBy('users.neighborhood', 'users.city', 'users.state') + .orderBy('total', 'desc') + .limit(10); + // By partner + const byPartner = await db('leads') + .join('partners', 'leads.partner_id', 'partners.id') + .select('partners.id as partner_id', 'partners.company_name') + .count('leads.id as total') + .groupBy('partners.id', 'partners.company_name') + .orderBy('total', 'desc') + .limit(10); + // Recent leads + const recent = await db('leads') + .leftJoin('users', 'leads.user_id', 'users.id') + .leftJoin('partners', 'leads.partner_id', 'partners.id') + .select('leads.id', 'leads.name', 'leads.email', 'leads.status', 'leads.lead_type', 'leads.source', 'leads.created_at', 'partners.company_name as partner_name', 'users.city', 'users.neighborhood') + .orderBy('leads.created_at', 'desc') + .limit(15); + res.json({ + total, + today, + conversion_rate: conversionRate, + by_status: byStatus, + by_type: byType, + by_city: byCity, + by_neighborhood: byNeighborhood, + by_partner: byPartner, + recent, + }); + } + catch (err) { + console.error('Error fetching lead metrics:', err); + res.status(500).json({ error: 'Erro ao buscar métricas' }); + } + }); + router.get('/', auth_1.authenticate, async (req, res) => { + try { + const { partner_id, status, type, page = '1', limit = '20' } = req.query; + const offset = (parseInt(page) - 1) * parseInt(limit); + const userRole = req.user?.role; + const userPartner = req.user?.partner_id; + let query = db('leads').orderBy('created_at', 'desc'); + if (partner_id) + query = query.where({ partner_id }); + if (userRole === 'partner' && userPartner) { + query = query.where({ partner_id: userPartner }); + } + if (type && LEAD_TYPE_VALUES.includes(type)) { + query = query.where({ lead_type: type }); + } + if (status && STATUS_VALUES.includes(status)) { + query = query.where({ status: status }); + } + else { + query = query.where({ status: 'new' }); + } + const countResult = await query.clone().count('id as count'); + const leads = await query.limit(parseInt(limit)).offset(offset); + res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page) }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao listar leads' }); + } + }); + router.get('/:id', auth_1.authenticate, async (req, res) => { + try { + const id = Number(req.params.id); + const lead = await db('leads').where({ id }).first(); + if (!lead) + return res.status(404).json({ error: 'Lead não encontrado' }); + if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) { + return res.status(403).json({ error: 'Acesso negado' }); + } + const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc'); + res.json({ lead: translateLead(lead), history }); + } + catch (err) { + res.status(500).json({ error: 'Erro ao buscar lead' }); + } + }); + router.post('/', ...protectedRoutes, async (req, res) => { + try { + const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body; + const numericUserId = user_id ? Number(user_id) : null; + const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null; + const leadType = parsedUserId ? 'member' : 'cold'; + const [id] = await db('leads').insert({ + name, + email, + phone, + partner_id: partner_id || null, + benefit_id: benefit_id || null, + user_id: parsedUserId, + lead_type: leadType, + source: source || 'manual', + notes: notes || null, + assigned_to: assigned_to || null, + status: 'new', + owner: 'clube67', + }); + await db('lead_history').insert({ + lead_id: id, + action: 'created', + new_value: 'new', + created_by: req.user?.id || null, + }); + await ctx.hooks.emit('lead:created', { id, email, partner_id }); + const lead = await db('leads').where({ id }).first(); + res.status(201).json(translateLead(lead)); + } + catch (err) { + res.status(500).json({ error: 'Erro ao criar lead' }); + } + }); + router.patch('/:id/status', ...protectedRoutes, async (req, res) => { + try { + const id = Number(req.params.id); + const { status } = req.body; + if (!STATUS_VALUES.includes(status)) + return res.status(400).json({ error: 'Status inválido' }); + const lead = await db('leads').where({ id }).first(); + if (!lead) + return res.status(404).json({ error: 'Lead não encontrado' }); + await db('leads').where({ id }).update({ status, updated_at: db.fn.now() }); + await db('lead_history').insert({ + lead_id: id, + action: 'status', + old_value: lead.status, + new_value: status, + created_by: req.user?.id || null, + }); + if (status === 'converted') + await ctx.hooks.emit('lead:converted', { id }); + if (status === 'lost') + await ctx.hooks.emit('lead:lost', { id }); + const updated = await db('leads').where({ id }).first(); + res.json(translateLead(updated)); + } + catch (err) { + res.status(500).json({ error: 'Erro ao atualizar status' }); + } + }); + router.post('/:id/notes', ...protectedRoutes, async (req, res) => { + try { + const id = Number(req.params.id); + const { note } = req.body; + if (!note) + return res.status(400).json({ error: 'Nota obrigatória' }); + const lead = await db('leads').where({ id }).first(); + if (!lead) + return res.status(404).json({ error: 'Lead não encontrado' }); + await db('lead_history').insert({ + lead_id: id, + action: 'note', + old_value: lead.notes, + new_value: lead.notes ? `${lead.notes}\n${note}` : note, + note, + created_by: req.user?.id || null, + }); + await db('leads').where({ id }).update({ + notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]), + updated_at: db.fn.now(), + }); + const updated = await db('leads').where({ id }).first(); + res.json(translateLead(updated)); + } + catch (err) { + res.status(500).json({ error: 'Erro ao salvar nota' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js.map new file mode 100644 index 0000000..f5e710c --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/leads/backend/routes.ts"],"names":[],"mappings":";;AAoBA,8CAkOC;AAtPD,qCAAoD;AAEpD,+DAAoE;AACpE,+DAAiE;AAEjE,MAAM,aAAa,GAAG,CAAC,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC;AAC7E,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AAE5C,SAAS,aAAa,CAAC,IAAS;IAC5B,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,OAAO;QACH,GAAG,IAAI;QACP,MAAM,EAAE,IAAI,CAAC,MAAM,IAAI,QAAQ;QAC/B,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,IAAI;QACnC,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,IAAI;QACnC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,IAAI;QAC7B,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,MAAM;KACtC,CAAC;AACN,CAAC;AAED,SAAgB,iBAAiB,CAAC,GAAkB;IAChD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IACxB,MAAM,EAAE,EAAE,EAAE,GAAG,GAAG,CAAC;IACnB,MAAM,eAAe,GAAG,CAAC,mBAAY,EAAE,IAAA,gBAAS,EAAC,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAE5E,0DAA0D;IAC1D,MAAM,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,eAAe,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAC7E,IAAI,CAAC;YACD,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;YAC1D,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YAErC,QAAQ;YACR,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC/B,KAAK,CAAC,aAAa,CAAC;iBACpB,QAAQ,CAAC,8BAA8B,CAAC,CAAC;YAC9C,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YAErC,YAAY;YACZ,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC7B,MAAM,CAAC,QAAQ,CAAC;iBAChB,KAAK,CAAC,aAAa,CAAC;iBACpB,OAAO,CAAC,QAAQ,CAAC,CAAC;YAEvB,eAAe;YACf,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC3B,MAAM,CAAC,WAAW,CAAC;iBACnB,KAAK,CAAC,aAAa,CAAC;iBACpB,OAAO,CAAC,WAAW,CAAC,CAAC;YAE1B,sCAAsC;YACtC,MAAM,SAAS,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC;YACtE,MAAM,cAAc,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,EAAE,KAAK,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YAEtG,2BAA2B;YAC3B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC3B,IAAI,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,CAAC;iBAC1C,MAAM,CAAC,YAAY,EAAE,aAAa,CAAC;iBACnC,KAAK,CAAC,mBAAmB,CAAC;iBAC1B,YAAY,CAAC,YAAY,CAAC;iBAC1B,OAAO,CAAC,YAAY,EAAE,aAAa,CAAC;iBACpC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;iBACxB,KAAK,CAAC,EAAE,CAAC,CAAC;YAEf,mCAAmC;YACnC,MAAM,cAAc,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBACnC,IAAI,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,CAAC;iBAC1C,MAAM,CAAC,oBAAoB,EAAE,YAAY,EAAE,aAAa,CAAC;iBACzD,KAAK,CAAC,mBAAmB,CAAC;iBAC1B,YAAY,CAAC,oBAAoB,CAAC;iBAClC,OAAO,CAAC,oBAAoB,EAAE,YAAY,EAAE,aAAa,CAAC;iBAC1D,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;iBACxB,KAAK,CAAC,EAAE,CAAC,CAAC;YAEf,aAAa;YACb,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC9B,IAAI,CAAC,UAAU,EAAE,kBAAkB,EAAE,aAAa,CAAC;iBACnD,MAAM,CAAC,2BAA2B,EAAE,uBAAuB,CAAC;iBAC5D,KAAK,CAAC,mBAAmB,CAAC;iBAC1B,OAAO,CAAC,aAAa,EAAE,uBAAuB,CAAC;iBAC/C,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC;iBACxB,KAAK,CAAC,EAAE,CAAC,CAAC;YAEf,eAAe;YACf,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC;iBAC3B,QAAQ,CAAC,OAAO,EAAE,eAAe,EAAE,UAAU,CAAC;iBAC9C,QAAQ,CAAC,UAAU,EAAE,kBAAkB,EAAE,aAAa,CAAC;iBACvD,MAAM,CACH,UAAU,EACV,YAAY,EACZ,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,uCAAuC,EACvC,YAAY,EACZ,oBAAoB,CACvB;iBACA,OAAO,CAAC,kBAAkB,EAAE,MAAM,CAAC;iBACnC,KAAK,CAAC,EAAE,CAAC,CAAC;YAEf,GAAG,CAAC,IAAI,CAAC;gBACL,KAAK;gBACL,KAAK;gBACL,eAAe,EAAE,cAAc;gBAC/B,SAAS,EAAE,QAAQ;gBACnB,OAAO,EAAE,MAAM;gBACf,OAAO,EAAE,MAAM;gBACf,eAAe,EAAE,cAAc;gBAC/B,UAAU,EAAE,SAAS;gBACrB,MAAM;aACT,CAAC,CAAC;QACP,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,GAAG,CAAC,CAAC;YACnD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC,CAAC;QAC/D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,mBAAY,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAChE,IAAI,CAAC;YACD,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,GAAG,EAAE,KAAK,GAAG,IAAI,EAAE,GAAG,GAAG,CAAC,KAAK,CAAC;YACzE,MAAM,MAAM,GAAG,CAAC,QAAQ,CAAC,IAAc,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,KAAe,CAAC,CAAC;YAC1E,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC;YAChC,MAAM,WAAW,GAAG,GAAG,CAAC,IAAI,EAAE,UAAU,CAAC;YACzC,IAAI,KAAK,GAAG,EAAE,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;YACtD,IAAI,UAAU;gBAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC;YACpD,IAAI,QAAQ,KAAK,SAAS,IAAI,WAAW,EAAE,CAAC;gBACxC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,CAAC;YACrD,CAAC;YACD,IAAI,IAAI,IAAI,gBAAgB,CAAC,QAAQ,CAAC,IAAc,CAAC,EAAE,CAAC;gBACpD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,IAAc,EAAE,CAAC,CAAC;YACvD,CAAC;YACD,IAAI,MAAM,IAAI,aAAa,CAAC,QAAQ,CAAC,MAAgB,CAAC,EAAE,CAAC;gBACrD,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAgB,EAAE,CAAC,CAAC;YACtD,CAAC;iBAAM,CAAC;gBACJ,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;YAC3C,CAAC;YAED,MAAM,WAAW,GAAG,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;YAC7D,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAe,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC1E,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAc,CAAC,EAAE,CAAC,CAAC;QACvH,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sBAAsB,EAAE,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,mBAAY,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QACnE,IAAI,CAAC;YACD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACjC,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACrD,IAAI,CAAC,IAAI;gBAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;YACzE,IAAI,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,UAAU,KAAK,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC;gBACjG,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;YAC5D,CAAC;YACD,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,CAAC,KAAK,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;YAC9F,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;QAC3D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,eAAe,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QACvE,IAAI,CAAC;YACD,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC;YACrG,MAAM,aAAa,GAAG,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YACvD,MAAM,YAAY,GAAG,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC;YAC5E,MAAM,QAAQ,GAAG,YAAY,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC;YAClD,MAAM,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;gBAClC,IAAI;gBACJ,KAAK;gBACL,KAAK;gBACL,UAAU,EAAE,UAAU,IAAI,IAAI;gBAC9B,UAAU,EAAE,UAAU,IAAI,IAAI;gBAC9B,OAAO,EAAE,YAAY;gBACrB,SAAS,EAAE,QAAQ;gBACnB,MAAM,EAAE,MAAM,IAAI,QAAQ;gBAC1B,KAAK,EAAE,KAAK,IAAI,IAAI;gBACpB,WAAW,EAAE,WAAW,IAAI,IAAI;gBAChC,MAAM,EAAE,KAAK;gBACb,KAAK,EAAE,SAAS;aACnB,CAAC,CAAC;YACH,MAAM,EAAE,CAAC,cAAc,CAAC,CAAC,MAAM,CAAC;gBAC5B,OAAO,EAAE,EAAE;gBACX,MAAM,EAAE,SAAS;gBACjB,SAAS,EAAE,KAAK;gBAChB,UAAU,EAAG,GAAW,CAAC,IAAI,EAAE,EAAE,IAAI,IAAI;aAC5C,CAAC,CAAC;YACH,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;YAChE,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;QAC9C,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC,CAAC;QAC1D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,KAAK,CAAC,aAAa,EAAE,GAAG,eAAe,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAClF,IAAI,CAAC;YACD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACjC,MAAM,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC;YAC5B,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC;gBAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC,CAAC;YAC/F,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACrD,IAAI,CAAC,IAAI;gBAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;YACzE,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YAC5E,MAAM,EAAE,CAAC,cAAc,CAAC,CAAC,MAAM,CAAC;gBAC5B,OAAO,EAAE,EAAE;gBACX,MAAM,EAAE,QAAQ;gBAChB,SAAS,EAAE,IAAI,CAAC,MAAM;gBACtB,SAAS,EAAE,MAAM;gBACjB,UAAU,EAAG,GAAW,CAAC,IAAI,EAAE,EAAE,IAAI,IAAI;aAC5C,CAAC,CAAC;YACH,IAAI,MAAM,KAAK,WAAW;gBAAE,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;YAC3E,IAAI,MAAM,KAAK,MAAM;gBAAE,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC;YACjE,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACxD,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC;QACrC,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAC,CAAC;QAChE,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,GAAG,eAAe,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAChF,IAAI,CAAC;YACD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACjC,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC;YAC1B,IAAI,CAAC,IAAI;gBAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC,CAAC;YACtE,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACrD,IAAI,CAAC,IAAI;gBAAE,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;YACzE,MAAM,EAAE,CAAC,cAAc,CAAC,CAAC,MAAM,CAAC;gBAC5B,OAAO,EAAE,EAAE;gBACX,MAAM,EAAE,MAAM;gBACd,SAAS,EAAE,IAAI,CAAC,KAAK;gBACrB,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI;gBACvD,IAAI;gBACJ,UAAU,EAAG,GAAW,CAAC,IAAI,EAAE,EAAE,IAAI,IAAI;aAC5C,CAAC,CAAC;YACH,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC;gBACnC,KAAK,EAAE,EAAE,CAAC,GAAG,CAAC,qCAAqC,EAAE,CAAC,IAAI,CAAC,CAAC;gBAC5D,UAAU,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE;aAC1B,CAAC,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YACxD,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC;QACrC,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC,CAAC;QAC3D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAClB,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.ts new file mode 100644 index 0000000..2d6a54f --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/backend/routes.ts @@ -0,0 +1,247 @@ +import { Router, Request, Response } from 'express'; +import { PluginContext } from '../../../backend/src/core/types'; +import { authenticate } from '../../../backend/src/middleware/auth'; +import { authorize } from '../../../backend/src/middleware/rbac'; + +const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost']; +const LEAD_TYPE_VALUES = ['cold', 'member']; + +function translateLead(lead: any) { + if (!lead) return lead; + return { + ...lead, + source: lead.source || 'manual', + partner_id: lead.partner_id || null, + benefit_id: lead.benefit_id || null, + user_id: lead.user_id || null, + lead_type: lead.lead_type || 'cold', + }; +} + +export function createLeadsRoutes(ctx: PluginContext): Router { + const router = Router(); + const { db } = ctx; + const protectedRoutes = [authenticate, authorize(['super_admin', 'admin'])]; + + // GET /api/leads/metrics — Global metrics for super admin + router.get('/metrics', ...protectedRoutes, async (req: Request, res: Response) => { + try { + const [totalRow] = await db('leads').count('id as total'); + const total = Number(totalRow.total); + + // Today + const [todayRow] = await db('leads') + .count('id as total') + .whereRaw('DATE(created_at) = CURDATE()'); + const today = Number(todayRow.total); + + // By status + const byStatus = await db('leads') + .select('status') + .count('id as total') + .groupBy('status'); + + // By lead type + const byType = await db('leads') + .select('lead_type') + .count('id as total') + .groupBy('lead_type'); + + // Converted count for conversion rate + const converted = byStatus.find((r: any) => r.status === 'converted'); + const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0'; + + // By city (via users join) + const byCity = await db('leads') + .join('users', 'leads.user_id', 'users.id') + .select('users.city', 'users.state') + .count('leads.id as total') + .whereNotNull('users.city') + .groupBy('users.city', 'users.state') + .orderBy('total', 'desc') + .limit(10); + + // By neighborhood (via users join) + const byNeighborhood = await db('leads') + .join('users', 'leads.user_id', 'users.id') + .select('users.neighborhood', 'users.city', 'users.state') + .count('leads.id as total') + .whereNotNull('users.neighborhood') + .groupBy('users.neighborhood', 'users.city', 'users.state') + .orderBy('total', 'desc') + .limit(10); + + // By partner + const byPartner = await db('leads') + .join('partners', 'leads.partner_id', 'partners.id') + .select('partners.id as partner_id', 'partners.company_name') + .count('leads.id as total') + .groupBy('partners.id', 'partners.company_name') + .orderBy('total', 'desc') + .limit(10); + + // Recent leads + const recent = await db('leads') + .leftJoin('users', 'leads.user_id', 'users.id') + .leftJoin('partners', 'leads.partner_id', 'partners.id') + .select( + 'leads.id', + 'leads.name', + 'leads.email', + 'leads.status', + 'leads.lead_type', + 'leads.source', + 'leads.created_at', + 'partners.company_name as partner_name', + 'users.city', + 'users.neighborhood', + ) + .orderBy('leads.created_at', 'desc') + .limit(15); + + res.json({ + total, + today, + conversion_rate: conversionRate, + by_status: byStatus, + by_type: byType, + by_city: byCity, + by_neighborhood: byNeighborhood, + by_partner: byPartner, + recent, + }); + } catch (err: any) { + console.error('Error fetching lead metrics:', err); + res.status(500).json({ error: 'Erro ao buscar métricas' }); + } + }); + + router.get('/', authenticate, async (req: Request, res: Response) => { + try { + const { partner_id, status, type, page = '1', limit = '20' } = req.query; + const offset = (parseInt(page as string) - 1) * parseInt(limit as string); + const userRole = req.user?.role; + const userPartner = req.user?.partner_id; + let query = db('leads').orderBy('created_at', 'desc'); + if (partner_id) query = query.where({ partner_id }); + if (userRole === 'partner' && userPartner) { + query = query.where({ partner_id: userPartner }); + } + if (type && LEAD_TYPE_VALUES.includes(type as string)) { + query = query.where({ lead_type: type as string }); + } + if (status && STATUS_VALUES.includes(status as string)) { + query = query.where({ status: status as string }); + } else { + query = query.where({ status: 'new' }); + } + + const countResult = await query.clone().count('id as count'); + const leads = await query.limit(parseInt(limit as string)).offset(offset); + res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page as string) }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao listar leads' }); + } + }); + + router.get('/:id', authenticate, async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const lead = await db('leads').where({ id }).first(); + if (!lead) return res.status(404).json({ error: 'Lead não encontrado' }); + if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) { + return res.status(403).json({ error: 'Acesso negado' }); + } + const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc'); + res.json({ lead: translateLead(lead), history }); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao buscar lead' }); + } + }); + + router.post('/', ...protectedRoutes, async (req: Request, res: Response) => { + try { + const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body; + const numericUserId = user_id ? Number(user_id) : null; + const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null; + const leadType = parsedUserId ? 'member' : 'cold'; + const [id] = await db('leads').insert({ + name, + email, + phone, + partner_id: partner_id || null, + benefit_id: benefit_id || null, + user_id: parsedUserId, + lead_type: leadType, + source: source || 'manual', + notes: notes || null, + assigned_to: assigned_to || null, + status: 'new', + owner: 'clube67', + }); + await db('lead_history').insert({ + lead_id: id, + action: 'created', + new_value: 'new', + created_by: (req as any).user?.id || null, + }); + await ctx.hooks.emit('lead:created', { id, email, partner_id }); + const lead = await db('leads').where({ id }).first(); + res.status(201).json(translateLead(lead)); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao criar lead' }); + } + }); + + router.patch('/:id/status', ...protectedRoutes, async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const { status } = req.body; + if (!STATUS_VALUES.includes(status)) return res.status(400).json({ error: 'Status inválido' }); + const lead = await db('leads').where({ id }).first(); + if (!lead) return res.status(404).json({ error: 'Lead não encontrado' }); + await db('leads').where({ id }).update({ status, updated_at: db.fn.now() }); + await db('lead_history').insert({ + lead_id: id, + action: 'status', + old_value: lead.status, + new_value: status, + created_by: (req as any).user?.id || null, + }); + if (status === 'converted') await ctx.hooks.emit('lead:converted', { id }); + if (status === 'lost') await ctx.hooks.emit('lead:lost', { id }); + const updated = await db('leads').where({ id }).first(); + res.json(translateLead(updated)); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao atualizar status' }); + } + }); + + router.post('/:id/notes', ...protectedRoutes, async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const { note } = req.body; + if (!note) return res.status(400).json({ error: 'Nota obrigatória' }); + const lead = await db('leads').where({ id }).first(); + if (!lead) return res.status(404).json({ error: 'Lead não encontrado' }); + await db('lead_history').insert({ + lead_id: id, + action: 'note', + old_value: lead.notes, + new_value: lead.notes ? `${lead.notes}\n${note}` : note, + note, + created_by: (req as any).user?.id || null, + }); + await db('leads').where({ id }).update({ + notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]), + updated_at: db.fn.now(), + }); + const updated = await db('leads').where({ id }).first(); + res.json(translateLead(updated)); + } catch (err: any) { + res.status(500).json({ error: 'Erro ao salvar nota' }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js new file mode 100644 index 0000000..07d4be7 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js @@ -0,0 +1,26 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + const router = (0, routes_1.createLeadsRoutes)(ctx); + ctx.app.use('/api/leads', router); + ctx.hooks.register('user:register', async (data) => { + await ctx.db('leads').insert({ + user_id: data.userId, + source: 'registration', + status: 'new', + lead_type: 'member', + }).catch(() => { }); + }); + ctx.logger.info('Leads routes registered'); + }, + async deactivate(ctx) { ctx.logger.info('Leads deactivated'); }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js.map new file mode 100644 index 0000000..5e68b5d --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/leads/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,0BAAiB,EAAC,GAAG,CAAC,CAAC;QACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QAClC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,eAAe,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;YACpD,MAAM,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;gBACzB,OAAO,EAAE,IAAI,CAAC,MAAM;gBACpB,MAAM,EAAE,cAAc;gBACtB,MAAM,EAAE,KAAK;gBACb,SAAS,EAAE,QAAQ;aACtB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC;CAChG,CAAC;AACF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.ts new file mode 100644 index 0000000..b97fc56 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/index.ts @@ -0,0 +1,22 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createLeadsRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + const router = createLeadsRoutes(ctx); + ctx.app.use('/api/leads', router); + ctx.hooks.register('user:register', async (data: any) => { + await ctx.db('leads').insert({ + user_id: data.userId, + source: 'registration', + status: 'new', + lead_type: 'member', + }).catch(() => { }); + }); + ctx.logger.info('Leads routes registered'); + }, + async deactivate(ctx: PluginContext): Promise { ctx.logger.info('Leads deactivated'); }, +}; +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/manifest.json new file mode 100644 index 0000000..9352753 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/leads/manifest.json @@ -0,0 +1,52 @@ +{ + "name": "leads", + "displayName": "Leads & CRM", + "version": "1.0.0", + "description": "Captura, qualificação e acompanhamento de leads.", + "author": "Clube67", + "category": "business", + "enabled": true, + "canDisable": true, + "dependencies": [ + "core-auth", + "partners" + ], + "backend": { + "routePrefix": "/api/leads", + "hasMigrations": true + }, + "frontend": { + "menuItems": [ + { + "id": "leads-list", + "label": "Leads", + "icon": "UserPlus", + "href": "/admin/leads", + "roles": [ + "super_admin" + ], + "order": 35 + }, + { + "id": "leads-partner", + "label": "Meus Leads", + "icon": "UserPlus", + "href": "/partner/leads", + "roles": [ + "partner_admin" + ], + "order": 35 + } + ] + }, + "hooks": { + "subscribes": [ + "user:register" + ], + "emits": [ + "lead:created", + "lead:converted", + "lead:lost" + ] + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js new file mode 100644 index 0000000..58ed60f --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js @@ -0,0 +1,165 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createRoutes = createRoutes; +const express_1 = require("express"); +const plugin_config_1 = require("../../../backend/src/core/plugin-config"); +const openai_1 = __importDefault(require("openai")); +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +const https_1 = __importDefault(require("https")); +const uuid_1 = require("uuid"); +const config_1 = require("../../../backend/src/config"); +function createRoutes(ctx) { + const router = (0, express_1.Router)(); + router.get('/', (req, res) => { + res.json({ + message: 'Nanobana plugin active', + timestamp: new Date().toISOString(), + service: 'Clube67 System' + }); + }); + router.post('/generate-image', async (req, res) => { + try { + const { type, prompt, partnerName, city, description, partnerId } = req.body; + if (!partnerId) { + return res.status(400).json({ error: 'Partner ID is required' }); + } + // Check Usage Limit (1 per type) + // FUTURE UPDATE: Add monetization logic here to allow more generations + // Check for Unlimited User (ruibto@gmail.com) + const user = await ctx.db('users').where({ partner_id: partnerId }).first(); + const isUnlimited = user && (user.email === 'ruibto@gmail.com'); + if (!isUnlimited) { + const usageCount = await ctx.db('nanobana_usage') + .where({ partner_id: partnerId, type }) + .count('id as count') + .first(); + const count = usageCount ? Number(usageCount.count) : 0; + if (count >= 1) { + return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` }); + } + } + // Get Config + const nanobanaConfig = plugin_config_1.pluginConfig.get('nanobana'); + const apiKey = nanobanaConfig?.apiKey; + if (!apiKey) { + return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' }); + } + const openai = new openai_1.default({ apiKey }); + // Vision Analysis (if enabled) + let visionDescription = ''; + if (req.body.useImageRef && req.body.referenceImageUrl) { + try { + const refUrl = req.body.referenceImageUrl; + let imagePath = ''; + // Resolve local path + if (refUrl.includes('/uploads/')) { + const filename = path_1.default.basename(refUrl); + imagePath = path_1.default.join(config_1.config.upload.dir, filename); + } + if (imagePath && fs_1.default.existsSync(imagePath)) { + const imageBuffer = fs_1.default.readFileSync(imagePath); + const base64Image = imageBuffer.toString('base64'); + const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg'; + const dataUrl = `data:${mimeType};base64,${base64Image}`; + const visionResponse = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." }, + { type: "image_url", image_url: { url: dataUrl } } + ], + }, + ], + max_tokens: 300, + }); + visionDescription = visionResponse.choices[0].message.content || ''; + console.log('[Nanobana] Vision Description:', visionDescription); + } + else { + console.warn('[Nanobana] Reference image not found locally:', refUrl); + } + } + catch (visionErr) { + console.error('[Nanobana] Vision Error:', visionErr); + // Continue without vision + } + } + // Construct Prompt + let finalPrompt = ''; + if (visionDescription) { + finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`; + } + else if (prompt) { + finalPrompt = prompt; + } + else { + if (type === 'logo') { + finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`; + } + else { + finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`; + } + } + // Generate Image + const response = await openai.images.generate({ + model: "dall-e-3", + prompt: finalPrompt, + n: 1, + size: "1024x1024", + quality: "standard", + response_format: "url", + }); + const imageUrl = response.data[0].url; + if (!imageUrl) + throw new Error('Falha ao gerar imagem na OpenAI'); + // Download Image to Local Storage + const ext = '.png'; // DALL-E 3 usually png + const filename = `${(0, uuid_1.v4)()}${ext}`; + const localPath = path_1.default.join(config_1.config.upload.dir, filename); + const publicUrl = `/uploads/${filename}`; + const file = fs_1.default.createWriteStream(localPath); + await new Promise((resolve, reject) => { + https_1.default.get(imageUrl, function (response) { + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(true); + }); + }).on('error', function (err) { + fs_1.default.unlink(localPath, () => { }); + reject(err); + }); + }); + // Record Usage + await ctx.db('nanobana_usage').insert({ + partner_id: partnerId, + type + }); + res.json({ + success: true, + url: publicUrl, + originalPrompt: finalPrompt + }); + } + catch (error) { + console.error('Nanobana Error:', error); + let errorMessage = error.message || 'Erro ao gerar imagem'; + // Translate Safety Error + if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) { + errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.'; + } + else if (errorMessage.includes('billing') || errorMessage.includes('quota')) { + errorMessage = 'Erro de faturamento na IA. Contate o suporte.'; + } + res.status(500).json({ error: errorMessage }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js.map new file mode 100644 index 0000000..16a03ca --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/nanobana/backend/routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.ts new file mode 100644 index 0000000..ec2d600 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/backend/routes.ts @@ -0,0 +1,183 @@ +import { Router } from 'express'; +import { PluginContext } from '../../../backend/src/core/types'; + +import { pluginConfig } from '../../../backend/src/core/plugin-config'; +import OpenAI from 'openai'; +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import { v4 as uuidv4 } from 'uuid'; +import { config } from '../../../backend/src/config'; + +export function createRoutes(ctx: PluginContext): Router { + const router = Router(); + + router.get('/', (req, res) => { + res.json({ + message: 'Nanobana plugin active', + timestamp: new Date().toISOString(), + service: 'Clube67 System' + }); + }); + + router.post('/generate-image', async (req, res) => { + try { + const { type, prompt, partnerName, city, description, partnerId } = req.body; + + if (!partnerId) { + return res.status(400).json({ error: 'Partner ID is required' }); + } + + // Check Usage Limit (1 per type) + // FUTURE UPDATE: Add monetization logic here to allow more generations + + // Check for Unlimited User (ruibto@gmail.com) + const user = await ctx.db('users').where({ partner_id: partnerId }).first(); + const isUnlimited = user && (user.email === 'ruibto@gmail.com'); + + if (!isUnlimited) { + const usageCount = await ctx.db('nanobana_usage') + .where({ partner_id: partnerId, type }) + .count('id as count') + .first(); + + const count = usageCount ? Number(usageCount.count) : 0; + if (count >= 1) { + return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` }); + } + } + + // Get Config + const nanobanaConfig = pluginConfig.get('nanobana'); + const apiKey = nanobanaConfig?.apiKey; + + if (!apiKey) { + return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' }); + } + + const openai = new OpenAI({ apiKey }); + + // Vision Analysis (if enabled) + let visionDescription = ''; + if (req.body.useImageRef && req.body.referenceImageUrl) { + try { + const refUrl = req.body.referenceImageUrl; + let imagePath = ''; + + // Resolve local path + if (refUrl.includes('/uploads/')) { + const filename = path.basename(refUrl); + imagePath = path.join(config.upload.dir, filename); + } + + if (imagePath && fs.existsSync(imagePath)) { + const imageBuffer = fs.readFileSync(imagePath); + const base64Image = imageBuffer.toString('base64'); + const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg'; + const dataUrl = `data:${mimeType};base64,${base64Image}`; + + const visionResponse = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." }, + { type: "image_url", image_url: { url: dataUrl } } + ], + }, + ], + max_tokens: 300, + }); + + visionDescription = visionResponse.choices[0].message.content || ''; + console.log('[Nanobana] Vision Description:', visionDescription); + } else { + console.warn('[Nanobana] Reference image not found locally:', refUrl); + } + } catch (visionErr) { + console.error('[Nanobana] Vision Error:', visionErr); + // Continue without vision + } + } + + // Construct Prompt + let finalPrompt = ''; + + if (visionDescription) { + finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`; + } else if (prompt) { + finalPrompt = prompt; + } else { + if (type === 'logo') { + finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`; + } else { + finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`; + } + } + + // Generate Image + const response = await openai.images.generate({ + model: "dall-e-3", + prompt: finalPrompt, + n: 1, + size: "1024x1024", + quality: "standard", + response_format: "url", + }); + + const imageUrl = response.data[0].url; + + if (!imageUrl) throw new Error('Falha ao gerar imagem na OpenAI'); + + // Download Image to Local Storage + const ext = '.png'; // DALL-E 3 usually png + const filename = `${uuidv4()}${ext}`; + const localPath = path.join(config.upload.dir, filename); + const publicUrl = `/uploads/${filename}`; + + const file = fs.createWriteStream(localPath); + + await new Promise((resolve, reject) => { + https.get(imageUrl, function (response) { + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(true); + }); + }).on('error', function (err) { + fs.unlink(localPath, () => { }); + reject(err); + }); + }); + + // Record Usage + await ctx.db('nanobana_usage').insert({ + partner_id: partnerId, + type + }); + + res.json({ + success: true, + url: publicUrl, + originalPrompt: finalPrompt + }); + + } catch (error: any) { + console.error('Nanobana Error:', error); + + let errorMessage = error.message || 'Erro ao gerar imagem'; + + // Translate Safety Error + if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) { + errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.'; + } else if (errorMessage.includes('billing') || errorMessage.includes('quota')) { + errorMessage = 'Erro de faturamento na IA. Contate o suporte.'; + } + + res.status(500).json({ error: errorMessage }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js new file mode 100644 index 0000000..d9f14fd --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js @@ -0,0 +1,20 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + ctx.logger.info('Registering Nanobana routes...'); + ctx.app.use(manifest_json_1.default.backend.routePrefix, (0, routes_1.createRoutes)(ctx)); + ctx.logger.info('Nanobana activated!'); + }, + async deactivate(ctx) { + ctx.logger.info('Nanobana deactivated'); + }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js.map new file mode 100644 index 0000000..270986c --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/nanobana/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAgD;AAChD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;QAElD,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,uBAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,IAAA,qBAAY,EAAC,GAAG,CAAC,CAAC,CAAC;QAE7D,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IAC3C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;IAC5C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.ts new file mode 100644 index 0000000..8dbc099 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/index.ts @@ -0,0 +1,19 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + ctx.logger.info('Registering Nanobana routes...'); + + ctx.app.use(manifest.backend.routePrefix, createRoutes(ctx)); + + ctx.logger.info('Nanobana activated!'); + }, + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('Nanobana deactivated'); + }, +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/manifest.json new file mode 100644 index 0000000..a208d49 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/nanobana/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "nanobana", + "displayName": "Nanobana", + "version": "1.0.0", + "description": "Exposes a global API endpoint for system consumption.", + "author": "Clube67", + "category": "utility", + "enabled": true, + "canDisable": true, + "dependencies": [], + "backend": { + "routePrefix": "/api/plugins/nanobana", + "hasMigrations": true + }, + "frontend": { + "menuItems": [] + } +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/tsconfig.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/tsconfig.json new file mode 100644 index 0000000..338436f --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "..", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@core/*": [ + "../backend/src/core/*" + ] + } + }, + "include": [ + "./**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/migrations/**" + ] +} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js new file mode 100644 index 0000000..8bbddf5 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js @@ -0,0 +1,65 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createUploadRoutes = createUploadRoutes; +const express_1 = require("express"); +const multer_1 = __importDefault(require("multer")); +const path_1 = __importDefault(require("path")); +const fs_1 = __importDefault(require("fs")); +function createUploadRoutes(ctx) { + const router = (0, express_1.Router)(); + const uploadDir = process.env.UPLOAD_DIR || path_1.default.join(process.cwd(), 'uploads'); + if (!fs_1.default.existsSync(uploadDir)) + fs_1.default.mkdirSync(uploadDir, { recursive: true }); + const storage = multer_1.default.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`), + }); + const upload = (0, multer_1.default)({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); + // POST / — Upload file + router.post('/', upload.single('file'), async (req, res) => { + try { + if (!req.file) + return res.status(400).json({ error: 'Nenhum arquivo enviado' }); + const url = `/uploads/${req.file.filename}`; + await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url }); + res.json({ url, filename: req.file.filename, size: req.file.size }); + } + catch (err) { + res.status(500).json({ error: 'Erro no upload' }); + } + }); + // POST /logo — Upload partner logo + router.post('/logo', upload.single('logo'), async (req, res) => { + try { + if (!req.file) + return res.status(400).json({ error: 'Nenhum logo enviado' }); + const url = `/uploads/${req.file.filename}`; + res.json({ url, filename: req.file.filename }); + } + catch (err) { + res.status(500).json({ error: 'Erro no upload' }); + } + }); + // DELETE /:filename — Delete file + router.delete('/:filename', async (req, res) => { + try { + const filePath = path_1.default.join(uploadDir, req.params.filename); + if (fs_1.default.existsSync(filePath)) { + fs_1.default.unlinkSync(filePath); + await ctx.hooks.emit('upload:deleted', { filename: req.params.filename }); + res.json({ message: 'Arquivo removido' }); + } + else { + res.status(404).json({ error: 'Arquivo não encontrado' }); + } + } + catch (err) { + res.status(500).json({ error: 'Erro ao remover' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js.map new file mode 100644 index 0000000..27ec9d2 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../../../../plugins/uploads/backend/routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.ts new file mode 100644 index 0000000..093b0b1 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/backend/routes.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { PluginContext } from '../../../backend/src/core/types'; + +export function createUploadRoutes(ctx: PluginContext): Router { + const router = Router(); + const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`), + }); + const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); + + // POST / — Upload file + router.post('/', upload.single('file'), async (req: Request, res: Response) => { + try { + if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado' }); + + const destination = req.query.destination as string; + const category = req.query.category as any || 'media'; + + let fileBuffer = fs.readFileSync(req.file.path); + let mimetype = req.file.mimetype; + + // Emit transformation hook + const transformResults = await ctx.hooks.emit('upload:transform', { + buffer: fileBuffer, + mimetype: mimetype, + category: category, + originalname: req.file.originalname, + partnerId: req.body.partnerId + }); + + // If a plugin transformed the file, use the last result + if (transformResults && transformResults.length > 0) { + const lastResult = transformResults[transformResults.length - 1]; + if (lastResult && lastResult.buffer) { + fileBuffer = lastResult.buffer; + mimetype = lastResult.mimetype || mimetype; + } + } + + if (destination === 'cloud') { + if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); + + // Use a more robust import for the singleton + const storageCore = require('../../../../backend/src/core/StorageProvider'); + const provider = storageCore.storageProvider || (storageCore.default && storageCore.default.storageProvider); + + if (!provider) { + ctx.logger.error('[Uploads] StorageProvider not found in core'); + return res.status(500).json({ error: 'Storage core unavailable' }); + } + + const cloudResult = await provider.uploadFile({ + category, + partnerId: req.body.partnerId, + benefitId: req.body.benefitId, + postId: req.body.postId, + whatsappId: req.body.whatsappId, + file: { + originalname: req.file.originalname, + buffer: fileBuffer, + mimetype: mimetype + } + }); + + return res.json({ + url: `https://${process.env.WASABI_BUCKET}.s3.wasabisys.com/${cloudResult.path}`, + filename: req.file.filename, + provider: 'wasabi' + }); + } + + // Local storage fallback: save the potentially transformed buffer + if (transformResults && transformResults.length > 0) { + fs.writeFileSync(req.file.path, fileBuffer); + } + + const url = `/uploads/${req.file.filename}`; + await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url }); + res.json({ url, filename: req.file.filename, size: fileBuffer.length }); + } catch (err: any) { + console.error('[Uploads] Error:', err); + res.status(500).json({ error: 'Erro no upload' }); + } + }); + + // POST /logo — Upload partner logo + router.post('/logo', upload.single('logo'), async (req: Request, res: Response) => { + try { + if (!req.file) return res.status(400).json({ error: 'Nenhum logo enviado' }); + + let fileBuffer = fs.readFileSync(req.file.path); + let mimetype = req.file.mimetype; + + // Emit transformation hook + const transformResults = await ctx.hooks.emit('upload:transform', { + buffer: fileBuffer, + mimetype: mimetype, + category: 'partners', + originalname: req.file.originalname + }); + + if (transformResults && transformResults.length > 0) { + const lastResult = transformResults[transformResults.length - 1]; + if (lastResult && lastResult.buffer) { + fileBuffer = lastResult.buffer; + mimetype = lastResult.mimetype || mimetype; + // Overwrite local file with optimized version + fs.writeFileSync(req.file.path, fileBuffer); + } + } + + const url = `/uploads/${req.file.filename}`; + res.json({ url, filename: req.file.filename }); + } catch (err: any) { + console.error('[Uploads] Logo Error:', err); + res.status(500).json({ error: 'Erro no upload do logo' }); + } + }); + + // DELETE /:filename — Delete file + router.delete('/:filename', async (req: Request, res: Response) => { + try { + const filePath = path.join(uploadDir, req.params.filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + await ctx.hooks.emit('upload:deleted', { filename: req.params.filename }); + res.json({ message: 'Arquivo removido' }); + } else { + res.status(404).json({ error: 'Arquivo não encontrado' }); + } + } catch (err: any) { res.status(500).json({ error: 'Erro ao remover' }); } + }); + + // GET /:filename — Legacy access with Washabi Fallback 🛡️ + router.get('/:filename', async (req: Request, res: Response) => { + const { filename } = req.params; + const localPath = path.join(uploadDir, filename); + + // 🛡️ If exists locally, serve normally + if (fs.existsSync(localPath)) { + return res.sendFile(localPath); + } + + // 🚀 If NOT local, redirect or proxy to UnifiedStorageProvider + // We know most legacy uploads go to the 'media' category in the proxy + const storageProxyUrl = `/api/storage/view/media/${filename}`; + ctx.logger.info(`[Uploads:Fallback] Redirecting legacy file ${filename} -> ${storageProxyUrl}`); + res.redirect(storageProxyUrl); + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js new file mode 100644 index 0000000..48ef519 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js @@ -0,0 +1,17 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + ctx.app.use('/api/uploads', (0, routes_1.createUploadRoutes)(ctx)); + ctx.logger.info('Upload routes registered'); + }, + async deactivate(ctx) { ctx.logger.info('Uploads deactivated'); }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js.map b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js.map new file mode 100644 index 0000000..cc539c7 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../plugins/uploads/index.ts"],"names":[],"mappings":";;;;;AACA,6CAAsD;AACtD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,IAAA,2BAAkB,EAAC,GAAG,CAAC,CAAC,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IAChD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;CAClG,CAAC;AACF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.ts b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.ts new file mode 100644 index 0000000..10dc89b --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/index.ts @@ -0,0 +1,13 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createUploadRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + ctx.app.use('/api/uploads', createUploadRoutes(ctx)); + ctx.logger.info('Upload routes registered'); + }, + async deactivate(ctx: PluginContext): Promise { ctx.logger.info('Uploads deactivated'); }, +}; +export default plugin; diff --git a/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/manifest.json b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/manifest.json new file mode 100644 index 0000000..5ebcc02 --- /dev/null +++ b/clube67/newwhats.local/plugins-modelos-criar-modificados/uploads/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "uploads", + "displayName": "Uploads & Mídia", + "version": "1.0.0", + "description": "Upload de logos, imagens e documentos com suporte a S3.", + "author": "Clube67", + "category": "core", + "enabled": true, + "canDisable": false, + "dependencies": [ + "core-auth" + ], + "backend": { + "routePrefix": "/api/uploads", + "hasMigrations": false + }, + "frontend": { + "menuItems": [] + }, + "hooks": { + "subscribes": [], + "emits": [ + "upload:completed", + "upload:deleted" + ] + } +} diff --git a/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js new file mode 100644 index 0000000..95491f9 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js @@ -0,0 +1,60 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ImageOptimizer = void 0; +const sharp_1 = __importDefault(require("sharp")); +class ImageOptimizer { + /** + * Optimizes an image: converts to WebP, strips metadata, + * and applies a 1:1 Smart Crop if needed. + */ + static async optimize(buffer, category = 'media', options = {}) { + try { + let pipeline = (0, sharp_1.default)(buffer); + const metadata = await pipeline.metadata(); + if (!metadata.format) + return { buffer, mimetype: 'application/octet-stream' }; + // 1. Identify Target Size + // Default sizes based on category + let targetSize = options.size || 1024; + if (category === 'partners' || category === 'users') + targetSize = 400; + if (category === 'benefits') + targetSize = 800; + // 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category + const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category); + if (shouldCrop) { + pipeline = pipeline.resize({ + width: targetSize, + height: targetSize, + fit: sharp_1.default.fit.cover, + position: sharp_1.default.strategy.entropy // Smart focal point detection + }); + } + else { + // Resize without cropping if it's too large + pipeline = pipeline.resize({ + width: targetSize, + withoutEnlargement: true, + fit: sharp_1.default.fit.inside + }); + } + // 3. Convert to WebP & Strip Metadata + const optimizedBuffer = await pipeline + .webp({ quality: 85, effort: 4 }) + .toBuffer(); + return { + buffer: optimizedBuffer, + mimetype: 'image/webp' + }; + } + catch (err) { + console.error('[SmartVision] Optimization failed:', err); + return { buffer, mimetype: 'image/jpeg' }; // Fallback + } + } +} +exports.ImageOptimizer = ImageOptimizer; +//# sourceMappingURL=Optimizer.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js.map b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js.map new file mode 100644 index 0000000..f613bb5 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.js.map @@ -0,0 +1 @@ +{"version":3,"file":"Optimizer.js","sourceRoot":"","sources":["Optimizer.ts"],"names":[],"mappings":";;;;;;AAAA,kDAA0B;AAO1B,MAAa,cAAc;IACvB;;;OAGG;IACH,MAAM,CAAC,KAAK,CAAC,QAAQ,CACjB,MAAc,EACd,WAAmB,OAAO,EAC1B,UAA8C,EAAE;QAEhD,IAAI,CAAC;YACD,IAAI,QAAQ,GAAG,IAAA,eAAK,EAAC,MAAM,CAAC,CAAC;YAC7B,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,CAAC;YAE3C,IAAI,CAAC,QAAQ,CAAC,MAAM;gBAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,0BAA0B,EAAE,CAAC;YAE9E,0BAA0B;YAC1B,kCAAkC;YAClC,IAAI,UAAU,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;YACtC,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ,KAAK,OAAO;gBAAE,UAAU,GAAG,GAAG,CAAC;YACtE,IAAI,QAAQ,KAAK,UAAU;gBAAE,UAAU,GAAG,GAAG,CAAC;YAE9C,sEAAsE;YACtE,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAExF,IAAI,UAAU,EAAE,CAAC;gBACb,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,MAAM,EAAE,UAAU;oBAClB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,KAAK;oBACpB,QAAQ,EAAE,eAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,8BAA8B;iBAClE,CAAC,CAAC;YACP,CAAC;iBAAM,CAAC;gBACJ,4CAA4C;gBAC5C,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,kBAAkB,EAAE,IAAI;oBACxB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,MAAM;iBACxB,CAAC,CAAC;YACP,CAAC;YAED,sCAAsC;YACtC,MAAM,eAAe,GAAG,MAAM,QAAQ;iBACjC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;iBAChC,QAAQ,EAAE,CAAC;YAEhB,OAAO;gBACH,MAAM,EAAE,eAAe;gBACvB,QAAQ,EAAE,YAAY;aACzB,CAAC;QACN,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,OAAO,CAAC,KAAK,CAAC,oCAAoC,EAAE,GAAG,CAAC,CAAC;YACzD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,CAAC,CAAC,WAAW;QAC1D,CAAC;IACL,CAAC;CACJ;AAvDD,wCAuDC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.ts b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.ts new file mode 100644 index 0000000..f072bf1 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/backend/Optimizer.ts @@ -0,0 +1,63 @@ +import sharp from 'sharp'; + +export interface OptimizationResult { + buffer: Buffer; + mimetype: string; +} + +export class ImageOptimizer { + /** + * Optimizes an image: converts to WebP, strips metadata, + * and applies a 1:1 Smart Crop if needed. + */ + static async optimize( + buffer: Buffer, + category: string = 'media', + options: { aspect?: string; size?: number } = {} + ): Promise { + try { + let pipeline = sharp(buffer); + const metadata = await pipeline.metadata(); + + if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' }; + + // 1. Identify Target Size + // Default sizes based on category + let targetSize = options.size || 1024; + if (category === 'partners' || category === 'users') targetSize = 400; + if (category === 'benefits') targetSize = 800; + + // 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category + const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category); + + if (shouldCrop) { + pipeline = pipeline.resize({ + width: targetSize, + height: targetSize, + fit: sharp.fit.cover, + position: sharp.strategy.entropy // Smart focal point detection + }); + } else { + // Resize without cropping if it's too large + pipeline = pipeline.resize({ + width: targetSize, + withoutEnlargement: true, + fit: sharp.fit.inside + }); + } + + // 3. Convert to WebP & Strip Metadata + const optimizedBuffer = await pipeline + .webp({ quality: 85, effort: 4 }) + .toBuffer(); + + return { + buffer: optimizedBuffer, + mimetype: 'image/webp' + }; + } catch (err) { + console.error('[SmartVision] Optimization failed:', err); + return { buffer, mimetype: 'image/jpeg' }; // Fallback + } + } +} diff --git a/clube67/newwhats.local/plugins/SmartVision/index.js b/clube67/newwhats.local/plugins/SmartVision/index.js new file mode 100644 index 0000000..26431e1 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/index.js @@ -0,0 +1,33 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const Optimizer_1 = require("./backend/Optimizer"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + // Register for the upload:transform hook + ctx.hooks.register('upload:transform', async (payload) => { + const { buffer, mimetype, category } = payload; + // Only process common image formats + const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']; + if (!imageTypes.includes(mimetype)) { + return null; // Skip non-images + } + ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`); + const result = await Optimizer_1.ImageOptimizer.optimize(buffer, category); + return { + buffer: result.buffer, + mimetype: result.mimetype + }; + }); + ctx.logger.info('SmartVision Optimizer activated'); + }, + async deactivate(ctx) { + ctx.logger.info('SmartVision Optimizer deactivated'); + } +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/SmartVision/index.js.map b/clube67/newwhats.local/plugins/SmartVision/index.js.map new file mode 100644 index 0000000..a973c6e --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,mDAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,yCAAyC;QACzC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,EAAE,KAAK,EAAE,OAAY,EAAE,EAAE;YAC1D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;YAE/C,oCAAoC;YACpC,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC;YAC3E,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACjC,OAAO,IAAI,CAAC,CAAC,kBAAkB;YACnC,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gDAAgD,QAAQ,EAAE,CAAC,CAAC;YAE5E,MAAM,MAAM,GAAG,MAAM,0BAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAE/D,OAAO;gBACH,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;aAC5B,CAAC;QACN,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;IACvD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IACzD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/SmartVision/index.ts b/clube67/newwhats.local/plugins/SmartVision/index.ts new file mode 100644 index 0000000..12f97c5 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/index.ts @@ -0,0 +1,35 @@ +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { ImageOptimizer } from './backend/Optimizer'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + async activate(ctx: PluginContext): Promise { + // Register for the upload:transform hook + ctx.hooks.register('upload:transform', async (payload: any) => { + const { buffer, mimetype, category } = payload; + + // Only process common image formats + const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']; + if (!imageTypes.includes(mimetype)) { + return null; // Skip non-images + } + + ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`); + + const result = await ImageOptimizer.optimize(buffer, category); + + return { + buffer: result.buffer, + mimetype: result.mimetype + }; + }); + + ctx.logger.info('SmartVision Optimizer activated'); + }, + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('SmartVision Optimizer deactivated'); + } +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins/SmartVision/manifest.json b/clube67/newwhats.local/plugins/SmartVision/manifest.json new file mode 100644 index 0000000..267d598 --- /dev/null +++ b/clube67/newwhats.local/plugins/SmartVision/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "SmartVision", + "displayName": "SmartVision Optimizer", + "version": "1.0.0", + "description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.", + "author": "Clube67 Team", + "category": "integration", + "enabled": true, + "canDisable": true, + "dependencies": [ + "uploads" + ], + "hooks": { + "subscribes": [ + "upload:transform" + ], + "emits": [] + } +} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js new file mode 100644 index 0000000..e241e14 --- /dev/null +++ b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js @@ -0,0 +1,361 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const client_s3_1 = require("@aws-sdk/client-s3"); +const lib_storage_1 = require("@aws-sdk/lib-storage"); +const StorageProvider_1 = require("../../backend/src/core/StorageProvider"); +const plugin_config_1 = require("../../backend/src/core/plugin-config"); +const sharp_1 = __importDefault(require("sharp")); +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media']; +// Mapa de extensão → mimetype para o proxy +const MIME_MAP = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', + gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', + pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg', + mp3: 'audio/mpeg', doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; +class UnifiedStorageProviderPlugin { + constructor() { + this.s3 = null; + this.bucket = ''; + this.region = 'us-east-1'; + this.localFallbackDir = path_1.default.resolve(process.cwd(), 'storage', 'local_fallback'); + this.routesRegistered = false; + } + async activate(ctx) { + this.ctx = ctx; + const cfg = plugin_config_1.pluginConfig.get('UnifiedStorageProvider') || {}; + if (!fs_1.default.existsSync(this.localFallbackDir)) { + fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true }); + } + const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY; + const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY; + this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1'); + this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || ''); + const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com'); + const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; + ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug'); + if (!accessKey || !secretKey || !this.bucket) { + ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin'); + } + else { + this.s3 = new client_s3_1.S3Client({ + region: this.region, + endpoint, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + StorageProvider_1.storageProvider.register(this); + ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`); + this.startSyncWorker(); + } + if (!this.routesRegistered) { + // ── Proxy: serve arquivos do Wasabi (ou fallback local) ──────────────── + ctx.app.get('/api/storage/view/*', async (req, res) => { + const filePath = req.params[0]; + if (!filePath) + return res.status(400).send('Path missing'); + try { + // 1. Fallback local + const localPath = path_1.default.join(this.localFallbackDir, filePath); + if (fs_1.default.existsSync(localPath)) { + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('X-Storage-Source', 'local_fallback'); + return res.send(fs_1.default.readFileSync(localPath)); + } + // 2. Wasabi + if (!this.s3 || !this.bucket) { + return res.status(503).send('Storage não inicializado'); + } + const result = await this.s3.send(new client_s3_1.GetObjectCommand({ + Bucket: this.bucket, + Key: filePath, + })); + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('X-Storage-Source', 'wasabi'); + if (result.Body) { + const bytes = await result.Body.transformToByteArray(); + return res.send(Buffer.from(bytes)); + } + return res.status(404).send('Body vazio'); + } + catch { + return res.status(404).send('Arquivo não encontrado'); + } + }); + // ── POST /api/storage/wasabi/list-buckets ────────────────────────────── + // Lista buckets usando credenciais do body (sem exigir S3 pré-configurado) + ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => { + const { accessKey, secretKey, region, endpoint } = req.body; + if (!accessKey || !secretKey) { + return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' }); + } + try { + const ep = (endpoint || 's3.wasabisys.com').startsWith('http') + ? endpoint || 's3.wasabisys.com' + : `https://${endpoint || 's3.wasabisys.com'}`; + const s3t = new client_s3_1.S3Client({ + region: region || 'us-east-1', + endpoint: ep, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + const data = await s3t.send(new client_s3_1.ListBucketsCommand({})); + const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate })); + return res.json({ buckets }); + } + catch (err) { + return res.status(400).json({ error: err.message }); + } + }); + // ── POST /api/storage/wasabi/create-bucket ───────────────────────────── + ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => { + const { accessKey, secretKey, region, endpoint, name } = req.body; + if (!accessKey || !secretKey) + return res.status(400).json({ error: 'Credenciais obrigatórias' }); + if (!name) + return res.status(400).json({ error: 'Campo "name" obrigatório' }); + const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); + const effectiveRegion = region || 'us-east-1'; + const ep = (endpoint || 's3.wasabisys.com').startsWith('http') + ? endpoint || 's3.wasabisys.com' + : `https://${endpoint || 's3.wasabisys.com'}`; + try { + const s3t = new client_s3_1.S3Client({ + region: effectiveRegion, + endpoint: ep, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + const params = { Bucket: safeName }; + if (effectiveRegion !== 'us-east-1') { + params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion }; + } + await s3t.send(new client_s3_1.CreateBucketCommand(params)); + ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`); + return res.status(201).json({ ok: true, name: safeName }); + } + catch (err) { + return res.status(500).json({ error: err.message }); + } + }); + this.routesRegistered = true; + } + setInterval(() => { StorageProvider_1.storageProvider.updateHealth(); }, 30000); + } + async deactivate(ctx) { + this.s3 = null; + ctx.logger.info('UnifiedStorageProvider desativado'); + } + async checkHealth() { + return (await this.checkDetailedHealth()).healthy; + } + async checkDetailedHealth() { + if (!this.s3 || !this.bucket) { + return { + healthy: false, + configured: false, + reason: 'not_configured', + message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.', + }; + } + try { + await this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket })); + return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` }; + } + catch (err) { + const code = err?.Code || err?.code || err?.name || ''; + const httpStatus = err?.$metadata?.httpStatusCode ?? 0; + if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') { + return { + healthy: false, configured: true, reason: 'auth_error', + message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.', + }; + } + if (httpStatus === 403 || code === 'AccessDenied') { + return { + healthy: false, configured: true, reason: 'billing_error', + message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.', + }; + } + if (httpStatus === 404 || code === 'NoSuchBucket') { + return { + healthy: false, configured: true, reason: 'bucket_not_found', + message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`, + }; + } + if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') { + return { + healthy: false, configured: true, reason: 'network_error', + message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.', + }; + } + return { + healthy: false, configured: true, reason: 'unknown', + message: `Erro ao verificar Wasabi: ${err?.message ?? code}`, + }; + } + } + async deletePrefix(prefix) { + let deletedCount = 0; + // 1. Limpa do Wasabi + if (this.s3 && this.bucket) { + try { + let isTruncated = true; + let continuationToken; + while (isTruncated) { + const listParams = { + Bucket: this.bucket, + Prefix: prefix, + MaxKeys: 1000, + }; + if (continuationToken) { + listParams.ContinuationToken = continuationToken; + } + const listResult = await this.s3.send(new client_s3_1.ListObjectsV2Command(listParams)); + const objects = listResult.Contents ?? []; + if (objects.length > 0) { + const deleteParams = { + Bucket: this.bucket, + Delete: { + Objects: objects.map((obj) => ({ Key: obj.Key })), + Quiet: true, + }, + }; + await this.s3.send(new client_s3_1.DeleteObjectsCommand(deleteParams)); + deletedCount += objects.length; + this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`); + } + isTruncated = listResult.IsTruncated ?? false; + continuationToken = listResult.NextContinuationToken; + } + } + catch (err) { + this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`); + } + } + // 2. Limpa do fallback local + const localPath = path_1.default.join(this.localFallbackDir, prefix); + if (fs_1.default.existsSync(localPath)) { + try { + const stat = fs_1.default.statSync(localPath); + if (stat.isDirectory()) { + fs_1.default.rmSync(localPath, { recursive: true, force: true }); + this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`); + } + else { + fs_1.default.unlinkSync(localPath); + this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`); + } + } + catch (err) { + this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`); + } + } + return { deleted: deletedCount }; + } + async upload(payload) { + const { category, file } = payload; + if (!VALID_CATEGORIES.includes(category)) + throw new Error(`Categoria inválida: ${category}`); + let buffer = file.buffer; + let originalName = file.originalname; + let mimetype = file.mimetype; + // Otimiza imagens grandes (>150 KB) para WebP + if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) { + try { + buffer = await (0, sharp_1.default)(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer(); + mimetype = 'image/webp'; + originalName = originalName.replace(/\.[^.]+$/, '.webp'); + this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`); + } + catch (err) { + this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`); + } + } + const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fileName = `${Date.now()}-${safeBase}`; + // Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file} + const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) + ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` + : `${category}/${fileName}`; + if (!this.s3 || !this.bucket) { + return this.saveToLocalFallback(relativePath, buffer, originalName, category); + } + try { + await new lib_storage_1.Upload({ + client: this.s3, + params: { + Bucket: this.bucket, + Key: relativePath, + Body: buffer, + ContentType: mimetype, + }, + }).done(); + this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`); + return { provider: 'wasabi', path: relativePath }; + } + catch (err) { + this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`); + return this.saveToLocalFallback(relativePath, buffer, originalName, category); + } + } + /** Salva buffer no diretório de fallback local, criando subpastas se necessário */ + saveToLocalFallback(relativePath, buffer, originalName, _category) { + const dest = path_1.default.join(this.localFallbackDir, relativePath); + fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true }); + fs_1.default.writeFileSync(dest, buffer); + this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`); + return { provider: 'local_fallback', path: relativePath }; + } + /** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */ + startSyncWorker() { + setInterval(async () => { + if (!this.s3 || !this.bucket) + return; + await this.syncDir(this.localFallbackDir); + }, 120000); + } + async syncDir(dir) { + if (!fs_1.default.existsSync(dir)) + return; + for (const entry of fs_1.default.readdirSync(dir)) { + const fullPath = path_1.default.join(dir, entry); + const stat = fs_1.default.statSync(fullPath); + if (stat.isDirectory()) { + await this.syncDir(fullPath); + continue; + } + const relativePath = path_1.default.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/'); + try { + await new lib_storage_1.Upload({ + client: this.s3, + params: { + Bucket: this.bucket, + Key: relativePath, + Body: fs_1.default.readFileSync(fullPath), + }, + }).done(); + fs_1.default.unlinkSync(fullPath); + this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`); + } + catch (err) { + this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`); + } + } + } +} +const instance = new UnifiedStorageProviderPlugin(); +exports.default = instance; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js.map b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js.map new file mode 100644 index 0000000..050652d --- /dev/null +++ b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts new file mode 100644 index 0000000..769faa2 --- /dev/null +++ b/clube67/newwhats.local/plugins/UnifiedStorageProvider/index.ts @@ -0,0 +1,401 @@ +import { + S3Client, + GetObjectCommand, + HeadBucketCommand, + ListObjectsV2Command, + ListBucketsCommand, + CreateBucketCommand, + DeleteObjectsCommand, +} from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { storageProvider, StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider'; +import { pluginConfig } from '../../backend/src/core/plugin-config'; +import sharp from 'sharp'; +import fs from 'fs'; +import path from 'path'; + +const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media']; + +// Mapa de extensão → mimetype para o proxy +const MIME_MAP: Record = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', + gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', + pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg', + mp3: 'audio/mpeg', doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; + +class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface { + manifest: any; + private s3: S3Client | null = null; + private bucket: string = ''; + private region: string = 'us-east-1'; + private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback'); + private ctx!: PluginContext; + private routesRegistered = false; + + async activate(ctx: PluginContext): Promise { + this.ctx = ctx; + const cfg = pluginConfig.get('UnifiedStorageProvider') || {}; + + if (!fs.existsSync(this.localFallbackDir)) { + fs.mkdirSync(this.localFallbackDir, { recursive: true }); + } + + const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY; + const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY; + this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1') as string; + this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '') as string; + + const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com') as string; + const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`; + + ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug'); + + if (!accessKey || !secretKey || !this.bucket) { + ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin'); + } else { + this.s3 = new S3Client({ + region: this.region, + endpoint, + credentials: { accessKeyId: accessKey as string, secretAccessKey: secretKey as string }, + forcePathStyle: true, + }); + + storageProvider.register(this); + ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`); + + this.startSyncWorker(); + } + + if (!this.routesRegistered) { + // ── Proxy: serve arquivos do Wasabi (ou fallback local) ──────────────── + ctx.app.get('/api/storage/view/*', async (req, res) => { + const filePath = req.params[0]; + if (!filePath) return res.status(400).send('Path missing'); + + try { + // 1. Fallback local + const localPath = path.join(this.localFallbackDir, filePath); + if (fs.existsSync(localPath)) { + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('X-Storage-Source', 'local_fallback'); + return res.send(fs.readFileSync(localPath)); + } + + // 2. Wasabi + if (!this.s3 || !this.bucket) { + return res.status(503).send('Storage não inicializado'); + } + + const result = await this.s3.send(new GetObjectCommand({ + Bucket: this.bucket, + Key: filePath, + })); + + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('X-Storage-Source', 'wasabi'); + + if (result.Body) { + const bytes = await (result.Body as any).transformToByteArray(); + return res.send(Buffer.from(bytes)); + } + return res.status(404).send('Body vazio'); + } catch { + return res.status(404).send('Arquivo não encontrado'); + } + }); + + // ── POST /api/storage/wasabi/list-buckets ────────────────────────────── + // Lista buckets usando credenciais do body (sem exigir S3 pré-configurado) + ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => { + const { accessKey, secretKey, region, endpoint } = req.body as Record; + if (!accessKey || !secretKey) { + return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' }); + } + try { + const ep = (endpoint || 's3.wasabisys.com').startsWith('http') + ? endpoint || 's3.wasabisys.com' + : `https://${endpoint || 's3.wasabisys.com'}`; + const s3t = new S3Client({ + region: region || 'us-east-1', + endpoint: ep, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + const data = await s3t.send(new ListBucketsCommand({})); + const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate })); + return res.json({ buckets }); + } catch (err: any) { + return res.status(400).json({ error: err.message }); + } + }); + + // ── POST /api/storage/wasabi/create-bucket ───────────────────────────── + ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => { + const { accessKey, secretKey, region, endpoint, name } = req.body as Record; + if (!accessKey || !secretKey) return res.status(400).json({ error: 'Credenciais obrigatórias' }); + if (!name) return res.status(400).json({ error: 'Campo "name" obrigatório' }); + + const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'); + const effectiveRegion = region || 'us-east-1'; + const ep = (endpoint || 's3.wasabisys.com').startsWith('http') + ? endpoint || 's3.wasabisys.com' + : `https://${endpoint || 's3.wasabisys.com'}`; + + try { + const s3t = new S3Client({ + region: effectiveRegion, + endpoint: ep, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + forcePathStyle: true, + }); + const params: any = { Bucket: safeName }; + if (effectiveRegion !== 'us-east-1') { + params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion }; + } + await s3t.send(new CreateBucketCommand(params)); + ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`); + return res.status(201).json({ ok: true, name: safeName }); + } catch (err: any) { + return res.status(500).json({ error: err.message }); + } + }); + + this.routesRegistered = true; + } + + setInterval(() => { storageProvider.updateHealth(); }, 30000); + } + + async deactivate(ctx: PluginContext): Promise { + this.s3 = null; + ctx.logger.info('UnifiedStorageProvider desativado'); + } + + async checkHealth(): Promise { + return (await this.checkDetailedHealth()).healthy; + } + + async checkDetailedHealth(): Promise { + if (!this.s3 || !this.bucket) { + return { + healthy: false, + configured: false, + reason: 'not_configured', + message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.', + }; + } + try { + await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); + return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` }; + } catch (err: any) { + const code: string = err?.Code || err?.code || err?.name || ''; + const httpStatus: number = err?.$metadata?.httpStatusCode ?? 0; + + if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') { + return { + healthy: false, configured: true, reason: 'auth_error', + message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.', + }; + } + if (httpStatus === 403 || code === 'AccessDenied') { + // 403 pode ser pagamento atrasado ou permissão negada + return { + healthy: false, configured: true, reason: 'billing_error', + message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.', + }; + } + if (httpStatus === 404 || code === 'NoSuchBucket') { + return { + healthy: false, configured: true, reason: 'bucket_not_found', + message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`, + }; + } + if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') { + return { + healthy: false, configured: true, reason: 'network_error', + message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.', + }; + } + return { + healthy: false, configured: true, reason: 'unknown', + message: `Erro ao verificar Wasabi: ${err?.message ?? code}`, + }; + } + } + + async deletePrefix(prefix: string): Promise<{ deleted: number }> { + let deletedCount = 0; + + // 1. Limpa do Wasabi + if (this.s3 && this.bucket) { + try { + let isTruncated = true; + let continuationToken: string | undefined; + + while (isTruncated) { + const listParams: any = { + Bucket: this.bucket, + Prefix: prefix, + MaxKeys: 1000, + }; + if (continuationToken) { + listParams.ContinuationToken = continuationToken; + } + + const listResult = await this.s3.send(new ListObjectsV2Command(listParams)); + const objects = listResult.Contents ?? []; + + if (objects.length > 0) { + const deleteParams = { + Bucket: this.bucket, + Delete: { + Objects: objects.map((obj) => ({ Key: obj.Key! })), + Quiet: true, + }, + }; + await this.s3.send(new DeleteObjectsCommand(deleteParams)); + deletedCount += objects.length; + this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`); + } + + isTruncated = listResult.IsTruncated ?? false; + continuationToken = listResult.NextContinuationToken; + } + } catch (err: any) { + this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`); + } + } + + // 2. Limpa do fallback local + const localPath = path.join(this.localFallbackDir, prefix); + if (fs.existsSync(localPath)) { + try { + const stat = fs.statSync(localPath); + if (stat.isDirectory()) { + fs.rmSync(localPath, { recursive: true, force: true }); + this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`); + } else { + fs.unlinkSync(localPath); + this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`); + } + } catch (err: any) { + this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`); + } + } + + return { deleted: deletedCount }; + } + + async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> { + const { category, file } = payload; + if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`); + + let buffer = file.buffer; + let originalName = file.originalname; + let mimetype = file.mimetype; + + // Otimiza imagens grandes (>150 KB) para WebP + if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) { + try { + buffer = await sharp(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer(); + mimetype = 'image/webp'; + originalName = originalName.replace(/\.[^.]+$/, '.webp'); + this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`); + } catch (err: any) { + this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`); + } + } + + const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fileName = `${Date.now()}-${safeBase}`; + + // Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file} + const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) + ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` + : `${category}/${fileName}`; + + if (!this.s3 || !this.bucket) { + return this.saveToLocalFallback(relativePath, buffer, originalName, category); + } + + try { + await new Upload({ + client: this.s3, + params: { + Bucket: this.bucket, + Key: relativePath, + Body: buffer, + ContentType: mimetype, + }, + }).done(); + + this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`); + return { provider: 'wasabi', path: relativePath }; + + } catch (err: any) { + this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`); + return this.saveToLocalFallback(relativePath, buffer, originalName, category); + } + } + + /** Salva buffer no diretório de fallback local, criando subpastas se necessário */ + private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } { + const dest = path.join(this.localFallbackDir, relativePath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, buffer); + this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`); + return { provider: 'local_fallback', path: relativePath }; + } + + /** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */ + private startSyncWorker(): void { + setInterval(async () => { + if (!this.s3 || !this.bucket) return; + await this.syncDir(this.localFallbackDir); + }, 120_000); + } + + private async syncDir(dir: string): Promise { + if (!fs.existsSync(dir)) return; + + for (const entry of fs.readdirSync(dir)) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + await this.syncDir(fullPath); + continue; + } + + const relativePath = path.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/'); + + try { + await new Upload({ + client: this.s3!, + params: { + Bucket: this.bucket, + Key: relativePath, + Body: fs.readFileSync(fullPath), + }, + }).done(); + + fs.unlinkSync(fullPath); + this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`); + } catch (err: any) { + this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`); + } + } + } +} + +const instance = new UnifiedStorageProviderPlugin(); +export default instance; diff --git a/clube67/newwhats.local/plugins/UnifiedStorageProvider/manifest.json b/clube67/newwhats.local/plugins/UnifiedStorageProvider/manifest.json new file mode 100644 index 0000000..50d9af6 --- /dev/null +++ b/clube67/newwhats.local/plugins/UnifiedStorageProvider/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "UnifiedStorageProvider", + "displayName": "Wasabi Storage", + "version": "1.1.0", + "description": "Armazenamento de mídias do WhatsApp via Wasabi (S3 compatível)", + "author": "NewWhats", + "category": "core", + "enabled": true, + "canDisable": false, + "dependencies": [], + "backend": { + "routePrefix": "/api/storage", + "hasMigrations": false + }, + "configSchema": [ + { "key": "wasabiAccessKey", "label": "Access Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave de acesso gerada em Account → Access Keys no painel Wasabi." }, + { "key": "wasabiSecretKey", "label": "Secret Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave secreta correspondente à Access Key — exibida só uma vez no momento da criação." }, + { "key": "wasabiBucket", "label": "Bucket Ativo", "type": "text", "required": true, "group": "Bucket", "tooltip": "Nome do bucket onde as mídias do WhatsApp serão armazenadas." }, + { "key": "wasabiRegion", "label": "Region", "type": "text", "required": false, "group": "Avançado", "tooltip": "Região do bucket. Padrão: us-east-1. Exemplos: us-east-2, eu-central-1." }, + { "key": "wasabiEndpoint", "label": "Endpoint", "type": "text", "required": false, "group": "Avançado", "tooltip": "Endpoint S3. Padrão: s3.wasabisys.com. Só altere se usar região diferente." } + ] +} diff --git a/clube67/newwhats.local/plugins/core-auth/backend/routes.js b/clube67/newwhats.local/plugins/core-auth/backend/routes.js new file mode 100644 index 0000000..7ea96c7 --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/backend/routes.js @@ -0,0 +1,148 @@ +"use strict"; +// ============================================================ +// Plugin: core-auth — Routes +// ============================================================ +// Migrated from: backend/src/routes/auth.routes.ts +// backend/src/controllers/auth.controller.ts +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAuthRoutes = createAuthRoutes; +const express_1 = require("express"); +const bcryptjs_1 = __importDefault(require("bcryptjs")); +const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); +function createAuthRoutes(ctx) { + const router = (0, express_1.Router)(); + const { db, hooks } = ctx; + // ── POST /login ─────────────────────────────────────── + router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'Email e senha são obrigatórios' }); + } + const user = await db('users').where({ email }).first(); + if (!user) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + const isValid = await bcryptjs_1.default.compare(password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + const accessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') }); + const refreshToken = jsonwebtoken_1.default.sign({ id: user.id, type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') }); + // Emit hook event + await hooks.emit('user:login', { userId: user.id, ip: req.ip }); + // Audit log + await db('audit_log').insert({ + user_id: user.id, + action: 'LOGIN', + entity_type: 'user', + entity_id: user.id, + details: 'Usuário logou no sistema', + ip_address: req.ip, + }).catch(() => { }); // non-critical + const { password_hash, ...safeUser } = user; + res.json({ user: safeUser, accessToken, refreshToken }); + } + catch (err) { + ctx.logger.error(`Login error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + // ── POST /register ──────────────────────────────────── + router.post('/register', async (req, res) => { + try { + const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body; + if (!name || !email || !password) { + return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' }); + } + const existing = await db('users').where({ email }).first(); + if (existing) { + return res.status(409).json({ error: 'Email já cadastrado' }); + } + const password_hash = await bcryptjs_1.default.hash(password, 12); + const [id] = await db('users').insert({ + name, + email, + password_hash, + cpf: cpf || null, + whatsapp: whatsapp || null, + city: city || null, + neighborhood: neighborhood || null, + state: state || null, + role: 'user', + status: 'active', + }); + await hooks.emit('user:register', { userId: id, email }); + res.status(201).json({ id, name, email, role: 'user' }); + } + catch (err) { + ctx.logger.error(`Register error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + // ── GET /me ─────────────────────────────────────────── + router.get('/me', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Token não fornecido' }); + } + const token = authHeader.split(' ')[1]; + const payload = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET); + const user = await db('users') + .where({ id: payload.id }) + .select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at') + .first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + let partnerConsent = false; + let partnerConsentAt = null; + let partnerConsentIp = null; + if (user?.partner_id) { + const partner = await db('partners') + .where({ id: user.partner_id }) + .select('data_consent', 'consent_at', 'consent_ip') + .first(); + partnerConsent = Boolean(partner?.data_consent); + partnerConsentAt = partner?.consent_at || null; + partnerConsentIp = partner?.consent_ip || null; + } + res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp }); + } + catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expirado' }); + } + res.status(401).json({ error: 'Token inválido' }); + } + }); + // ── POST /refresh ───────────────────────────────────── + router.post('/refresh', async (req, res) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token obrigatório' }); + } + const payload = jsonwebtoken_1.default.verify(refreshToken, process.env.JWT_REFRESH_SECRET); + if (payload.type !== 'refresh') { + return res.status(401).json({ error: 'Token inválido' }); + } + const user = await db('users').where({ id: payload.id }).first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + const newAccessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') }); + await hooks.emit('token:refresh', { userId: user.id }); + res.json({ accessToken: newAccessToken }); + } + catch (err) { + res.status(401).json({ error: 'Refresh token inválido ou expirado' }); + } + }); + return router; +} +//# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/core-auth/backend/routes.js.map b/clube67/newwhats.local/plugins/core-auth/backend/routes.js.map new file mode 100644 index 0000000..e6fbcee --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/backend/routes.js.map @@ -0,0 +1 @@ +{"version":3,"file":"routes.js","sourceRoot":"","sources":["routes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/core-auth/backend/routes.ts b/clube67/newwhats.local/plugins/core-auth/backend/routes.ts new file mode 100644 index 0000000..1f612a4 --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/backend/routes.ts @@ -0,0 +1,180 @@ +// ============================================================ +// Plugin: core-auth — Routes +// ============================================================ +// Migrated from: backend/src/routes/auth.routes.ts +// backend/src/controllers/auth.controller.ts + +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { PluginContext } from '../../../backend/src/core/types'; + +export function createAuthRoutes(ctx: PluginContext): Router { + const router = Router(); + const { db, hooks } = ctx; + + // ── POST /login ─────────────────────────────────────── + router.post('/login', async (req: Request, res: Response) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email e senha são obrigatórios' }); + } + + const user = await db('users').where({ email }).first(); + if (!user) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: 'Credenciais inválidas' }); + } + + const accessToken = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET!, + { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any } + ); + + const refreshToken = jwt.sign( + { id: user.id, type: 'refresh' }, + process.env.JWT_REFRESH_SECRET!, + { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any } + ); + + // Emit hook event + await hooks.emit('user:login', { userId: user.id, ip: req.ip }); + + // Audit log + await db('audit_log').insert({ + user_id: user.id, + action: 'LOGIN', + entity_type: 'user', + entity_id: user.id, + details: 'Usuário logou no sistema', + ip_address: req.ip, + }).catch(() => { }); // non-critical + + const { password_hash, ...safeUser } = user; + res.json({ user: safeUser, accessToken, refreshToken }); + } catch (err: any) { + ctx.logger.error(`Login error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + + // ── POST /register ──────────────────────────────────── + router.post('/register', async (req: Request, res: Response) => { + try { + const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body; + + if (!name || !email || !password) { + return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' }); + } + + const existing = await db('users').where({ email }).first(); + if (existing) { + return res.status(409).json({ error: 'Email já cadastrado' }); + } + + const password_hash = await bcrypt.hash(password, 12); + + const [id] = await db('users').insert({ + name, + email, + password_hash, + cpf: cpf || null, + whatsapp: whatsapp || null, + city: city || null, + neighborhood: neighborhood || null, + state: state || null, + role: 'user', + status: 'active', + }); + + await hooks.emit('user:register', { userId: id, email }); + + res.status(201).json({ id, name, email, role: 'user' }); + } catch (err: any) { + ctx.logger.error(`Register error: ${err.message}`); + res.status(500).json({ error: 'Erro interno no servidor' }); + } + }); + + // ── GET /me ─────────────────────────────────────────── + router.get('/me', async (req: Request, res: Response) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Token não fornecido' }); + } + + const token = authHeader.split(' ')[1]; + const payload = jwt.verify(token, process.env.JWT_SECRET!) as any; + + const user = await db('users') + .where({ id: payload.id }) + .select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at') + .first(); + + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + + let partnerConsent = false; + let partnerConsentAt = null; + let partnerConsentIp = null; + if (user?.partner_id) { + const partner = await db('partners') + .where({ id: user.partner_id }) + .select('data_consent', 'consent_at', 'consent_ip') + .first(); + partnerConsent = Boolean(partner?.data_consent); + partnerConsentAt = partner?.consent_at || null; + partnerConsentIp = partner?.consent_ip || null; + } + res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp }); + } catch (err: any) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expirado' }); + } + res.status(401).json({ error: 'Token inválido' }); + } + }); + + // ── POST /refresh ───────────────────────────────────── + router.post('/refresh', async (req: Request, res: Response) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token obrigatório' }); + } + + const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as any; + if (payload.type !== 'refresh') { + return res.status(401).json({ error: 'Token inválido' }); + } + + const user = await db('users').where({ id: payload.id }).first(); + if (!user) { + return res.status(404).json({ error: 'Usuário não encontrado' }); + } + + const newAccessToken = jwt.sign( + { id: user.id, email: user.email, role: user.role }, + process.env.JWT_SECRET!, + { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any } + ); + + await hooks.emit('token:refresh', { userId: user.id }); + + res.json({ accessToken: newAccessToken }); + } catch (err: any) { + res.status(401).json({ error: 'Refresh token inválido ou expirado' }); + } + }); + + return router; +} diff --git a/clube67/newwhats.local/plugins/core-auth/index.js b/clube67/newwhats.local/plugins/core-auth/index.js new file mode 100644 index 0000000..ee1e4c4 --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/index.js @@ -0,0 +1,23 @@ +"use strict"; +// ============================================================ +// Plugin: core-auth — Entry Point +// ============================================================ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const routes_1 = require("./backend/routes"); +const manifest_json_1 = __importDefault(require("./manifest.json")); +const plugin = { + manifest: manifest_json_1.default, + async activate(ctx) { + const router = (0, routes_1.createAuthRoutes)(ctx); + ctx.app.use('/api/auth', router); + ctx.logger.info('Auth routes registered at /api/auth'); + }, + async deactivate(ctx) { + ctx.logger.info('Auth plugin deactivated'); + }, +}; +exports.default = plugin; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/core-auth/index.js.map b/clube67/newwhats.local/plugins/core-auth/index.js.map new file mode 100644 index 0000000..dc38631 --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kCAAkC;AAClC,+DAA+D;;;;;AAG/D,6CAAoD;AACpD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,yBAAgB,EAAC,GAAG,CAAC,CAAC;QACrC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/core-auth/index.ts b/clube67/newwhats.local/plugins/core-auth/index.ts new file mode 100644 index 0000000..9d2e49b --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/index.ts @@ -0,0 +1,23 @@ +// ============================================================ +// Plugin: core-auth — Entry Point +// ============================================================ + +import { PluginInstance, PluginContext } from '../../backend/src/core/types'; +import { createAuthRoutes } from './backend/routes'; +import manifest from './manifest.json'; + +const plugin: PluginInstance = { + manifest: manifest as any, + + async activate(ctx: PluginContext): Promise { + const router = createAuthRoutes(ctx); + ctx.app.use('/api/auth', router); + ctx.logger.info('Auth routes registered at /api/auth'); + }, + + async deactivate(ctx: PluginContext): Promise { + ctx.logger.info('Auth plugin deactivated'); + }, +}; + +export default plugin; diff --git a/clube67/newwhats.local/plugins/core-auth/manifest.json b/clube67/newwhats.local/plugins/core-auth/manifest.json new file mode 100644 index 0000000..57b2d25 --- /dev/null +++ b/clube67/newwhats.local/plugins/core-auth/manifest.json @@ -0,0 +1,46 @@ +{ + "name": "core-auth", + "displayName": "Autenticação", + "version": "1.0.0", + "description": "Login, JWT, refresh tokens, sessões, Google OAuth e RBAC.", + "author": "Clube67", + "category": "core", + "enabled": true, + "canDisable": false, + "dependencies": [], + "backend": { + "routePrefix": "/api/auth", + "hasMigrations": true, + "globalMiddleware": [ + "authenticate" + ] + }, + "frontend": { + "menuItems": [], + "pages": [ + { + "path": "/login", + "component": "LoginPage", + "roles": [ + "*" + ] + }, + { + "path": "/register", + "component": "RegisterPage", + "roles": [ + "*" + ] + } + ] + }, + "hooks": { + "subscribes": [], + "emits": [ + "user:login", + "user:logout", + "user:register", + "token:refresh" + ] + } +} diff --git a/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js new file mode 100644 index 0000000..7fe01a0 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js @@ -0,0 +1,103 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildApiKeyAuth = buildApiKeyAuth; +exports.resolveApiKey = resolveApiKey; +// ─── Cache de API key (evita Prisma query em cada requisição) ──────────────── +// Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente. +const KEY_CACHE_TTL = 5 * 60000; +const keyCache = new Map(); +setInterval(() => { + const now = Date.now(); + for (const [k, v] of keyCache.entries()) { + if (now - v.cachedAt > KEY_CACHE_TTL) + keyCache.delete(k); + } +}, KEY_CACHE_TTL); +// ─── Rate limiter simples em memória (por chave) ───────────────────────────── +const RATE_WINDOW_MS = 60000; +const RATE_MAX = 120; +const rateMap = new Map(); +function checkRateLimit(key) { + const now = Date.now(); + let entry = rateMap.get(key); + if (!entry || now > entry.resetAt) { + entry = { count: 1, resetAt: now + RATE_WINDOW_MS }; + rateMap.set(key, entry); + return true; + } + entry.count++; + return entry.count <= RATE_MAX; +} +// Limpeza periódica para não vazar memória +setInterval(() => { + const now = Date.now(); + for (const [k, v] of rateMap.entries()) { + if (now > v.resetAt) + rateMap.delete(k); + } +}, RATE_WINDOW_MS * 2); +// ─── Factory ───────────────────────────────────────────────────────────────── +function buildApiKeyAuth(prisma) { + return async function apiKeyAuth(req, res, next) { + const key = req.headers['x-nw-key']?.trim(); + if (!key) { + res.status(401).json({ error: 'Header x-nw-key ausente' }); + return; + } + if (!checkRateLimit(key)) { + res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' }); + return; + } + // Verifica cache antes de ir ao banco + const cached = keyCache.get(key); + if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { + if (cached.expiresAt && cached.expiresAt < new Date()) { + res.status(401).json({ error: 'Chave expirada' }); + return; + } + req.extTenantId = cached.tenantId; + next(); + return; + } + const apiKey = await prisma.apiKey.findUnique({ + where: { key }, + select: { tenantId: true, isActive: true, expiresAt: true }, + }); + if (!apiKey || !apiKey.isActive) { + res.status(401).json({ error: 'Chave inválida ou inativa' }); + return; + } + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { + res.status(401).json({ error: 'Chave expirada' }); + return; + } + keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }); + req.extTenantId = apiKey.tenantId; + next(); + }; +} +/** + * Versão standalone que valida a chave sem passar pelo Express. + * Usada no handshake do WS (upgrade request). + */ +async function resolveApiKey(prisma, key) { + if (!key) + return null; + const cached = keyCache.get(key); + if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { + if (cached.expiresAt && cached.expiresAt < new Date()) + return null; + return cached.tenantId; + } + const apiKey = await prisma.apiKey.findUnique({ + where: { key }, + select: { tenantId: true, isActive: true, expiresAt: true }, + }); + if (!apiKey || !apiKey.isActive) + return null; + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) + return null; + keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }); + return apiKey.tenantId; +} +//# sourceMappingURL=apikey-auth.js.map \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js.map b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js.map new file mode 100644 index 0000000..79d624b --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.js.map @@ -0,0 +1 @@ +{"version":3,"file":"apikey-auth.js","sourceRoot":"","sources":["apikey-auth.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.ts b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.ts new file mode 100644 index 0000000..a38a4c0 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/apikey-auth.ts @@ -0,0 +1,133 @@ +/** + * Middleware de autenticação para a External API. + * + * Aceita a integration_key via: + * - Header: x-nw-key: nw_... + * + * Resolve a chave para tenantId e injeta em req.extTenantId. + * Rate-limiting básico: max 120 req/min por chave (janela deslizante simples em memória). + */ +import type { Request, Response, NextFunction } from 'express' +import type { PrismaClient } from '@prisma/client' + +// ─── Cache de API key (evita Prisma query em cada requisição) ──────────────── +// Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente. +const KEY_CACHE_TTL = 5 * 60_000 +interface KeyCacheEntry { tenantId: string; expiresAt: Date | null; cachedAt: number } +const keyCache = new Map() + +setInterval(() => { + const now = Date.now() + for (const [k, v] of keyCache.entries()) { + if (now - v.cachedAt > KEY_CACHE_TTL) keyCache.delete(k) + } +}, KEY_CACHE_TTL) + +// ─── Rate limiter simples em memória (por chave) ───────────────────────────── +const RATE_WINDOW_MS = 60_000 +const RATE_MAX = 120 + +interface RateEntry { count: number; resetAt: number } +const rateMap = new Map() + +function checkRateLimit(key: string): boolean { + const now = Date.now() + let entry = rateMap.get(key) + if (!entry || now > entry.resetAt) { + entry = { count: 1, resetAt: now + RATE_WINDOW_MS } + rateMap.set(key, entry) + return true + } + entry.count++ + return entry.count <= RATE_MAX +} + +// Limpeza periódica para não vazar memória +setInterval(() => { + const now = Date.now() + for (const [k, v] of rateMap.entries()) { + if (now > v.resetAt) rateMap.delete(k) + } +}, RATE_WINDOW_MS * 2) + +// ─── Extend Express Request ─────────────────────────────────────────────────── +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + extTenantId?: string + } + } +} + +// ─── Factory ───────────────────────────────────────────────────────────────── +export function buildApiKeyAuth(prisma: PrismaClient) { + return async function apiKeyAuth(req: Request, res: Response, next: NextFunction) { + const key = (req.headers['x-nw-key'] as string | undefined)?.trim() + + if (!key) { + res.status(401).json({ error: 'Header x-nw-key ausente' }) + return + } + + if (!checkRateLimit(key)) { + res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' }) + return + } + + // Verifica cache antes de ir ao banco + const cached = keyCache.get(key) + if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { + if (cached.expiresAt && cached.expiresAt < new Date()) { + res.status(401).json({ error: 'Chave expirada' }) + return + } + req.extTenantId = cached.tenantId + next() + return + } + + const apiKey = await prisma.apiKey.findUnique({ + where: { key }, + select: { tenantId: true, isActive: true, expiresAt: true }, + }) + + if (!apiKey || !apiKey.isActive) { + res.status(401).json({ error: 'Chave inválida ou inativa' }) + return + } + + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { + res.status(401).json({ error: 'Chave expirada' }) + return + } + + keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }) + req.extTenantId = apiKey.tenantId + next() + } +} + +/** + * Versão standalone que valida a chave sem passar pelo Express. + * Usada no handshake do WS (upgrade request). + */ +export async function resolveApiKey( + prisma: PrismaClient, + key: string, +): Promise { + if (!key) return null + const cached = keyCache.get(key) + if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) { + if (cached.expiresAt && cached.expiresAt < new Date()) return null + return cached.tenantId + } + const apiKey = await prisma.apiKey.findUnique({ + where: { key }, + select: { tenantId: true, isActive: true, expiresAt: true }, + }) + if (!apiKey || !apiKey.isActive) return null + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null + keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() }) + return apiKey.tenantId +} diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/env.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/env.js new file mode 100644 index 0000000..e6c2ea2 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/env.js @@ -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; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/logger.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/logger.js new file mode 100644 index 0000000..95c3a9e --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/config/logger.js @@ -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, +}); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/StorageProvider.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/StorageProvider.js new file mode 100644 index 0000000..a02b80e --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/StorageProvider.js @@ -0,0 +1,50 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.storageProvider = void 0; +const logger_1 = require("../config/logger"); +/** + * Singleton registry para o storage provider ativo. + * O plugin UnifiedStorageProvider se registra aqui durante o activate(). + * O plugin uploads chama uploadFile() para enviar ao storage registrado. + */ +class StorageProviderRegistry { + constructor() { + this.provider = null; + this.healthy = false; + } + register(provider) { + this.provider = provider; + logger_1.logger.info('[StorageProvider] Provider registrado'); + } + isRegistered() { + return this.provider !== null; + } + isHealthy() { + return this.healthy; + } + async uploadFile(payload) { + if (!this.provider) { + throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.'); + } + return this.provider.upload(payload); + } + /** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */ + async updateHealth() { + if (!this.provider) { + this.healthy = false; + return; + } + try { + this.healthy = await this.provider.checkHealth(); + } + catch { + this.healthy = false; + } + } + unregister() { + this.provider = null; + this.healthy = false; + logger_1.logger.info('[StorageProvider] Provider removido'); + } +} +exports.storageProvider = new StorageProviderRegistry(); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/hook-bus.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/hook-bus.js new file mode 100644 index 0000000..3ad6421 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/hook-bus.js @@ -0,0 +1,49 @@ +"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(); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/plugin-config.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/plugin-config.js new file mode 100644 index 0000000..dc092fa --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/plugin-config.js @@ -0,0 +1,51 @@ +"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(); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/types.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/types.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/core/types.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/cache/dragonfly.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/cache/dragonfly.js new file mode 100644 index 0000000..9ea435b --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/cache/dragonfly.js @@ -0,0 +1,54 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.dragonfly = void 0; +const ioredis_1 = __importDefault(require("ioredis")); +const env_1 = require("../../config/env"); +const logger_1 = require("../../config/logger"); +class DragonflyClient { + constructor() { + this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10); + this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: true, + }); + this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado')); + this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro')); + } + async connect() { + await this.client.connect(); + } + async get(key) { + return this.client.get(key); + } + async getJson(key) { + const raw = await this.client.get(key); + if (!raw) + return null; + return JSON.parse(raw); + } + async set(key, value, ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl); + } + async setJson(key, value, ttlSeconds) { + await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl); + } + async del(key) { + await this.client.del(key); + } + async exists(key) { + const count = await this.client.exists(key); + return count > 0; + } + /** Publica em um canal pub/sub (usado para broadcasting de QR/status) */ + async publish(channel, message) { + await this.client.publish(channel, message); + } + raw() { + return this.client; + } +} +exports.dragonfly = new DragonflyClient(); diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/database/prisma.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/database/prisma.js new file mode 100644 index 0000000..17038f6 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/infra/database/prisma.js @@ -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; +} diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.repository.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.repository.js new file mode 100644 index 0000000..5a2b8f6 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.repository.js @@ -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 }, + }), +}; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.service.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.service.js new file mode 100644 index 0000000..6553a2e --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/chatbot/chatbot.service.js @@ -0,0 +1,164 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ChatbotService = void 0; +/** + * chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash. + * + * Padrão Cérebro (instrucoes.md §5): + * - Não envia histórico completo para economizar tokens + * - Mantém resumo de 2 frases por chat (botSummary no banco) + * - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar + */ +const generative_ai_1 = require("@google/generative-ai"); +const prisma_1 = require("../../infra/database/prisma"); +const logger_1 = require("../../config/logger"); +const chatbot_repository_1 = require("./chatbot.repository"); +// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem) +const geminiClients = new Map(); +function getGeminiClient(apiKey) { + if (!geminiClients.has(apiKey)) { + geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey)); + } + return geminiClients.get(apiKey); +} +function getModel(apiKey, modelName) { + return getGeminiClient(apiKey).getGenerativeModel({ model: modelName }); +} +// ─── Micro-prompt: classificação de intenção (1 token) ─────────────────────── +async function classifyIntent(userMsg, summary, systemPrompt, model) { + const prompt = [ + `Você é um classificador de intenção para um chatbot de atendimento.`, + `Prompt do atendente: ${systemPrompt}`, + summary ? `Contexto da conversa até agora: ${summary}` : '', + `Nova mensagem do cliente: "${userMsg}"`, + `Responda APENAS com o número:`, + `1 = Consigo responder essa pergunta dentro do meu papel`, + `2 = Precisa de um agente humano`, + ].filter(Boolean).join('\n'); + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 2, temperature: 0 }, + }); + const text = result.response.text().trim(); + return text.startsWith('2') ? 'escalate' : 'resolve'; + } + catch (err) { + logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve'); + return 'resolve'; + } +} +// ─── Geração de resposta ────────────────────────────────────────────────────── +async function generateResponse(userMsg, summary, systemPrompt, model) { + const prompt = [ + systemPrompt, + summary ? `\nContexto da conversa até agora:\n${summary}` : '', + `\nMensagem do cliente: "${userMsg}"`, + `\nResponda de forma natural, breve e direta. Não use markdown.`, + ].filter(Boolean).join('\n'); + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 300, temperature: 0.7 }, + }); + return result.response.text().trim(); +} +// ─── Atualização do Cérebro (resumo de 2 frases) ───────────────────────────── +async function updateSummary(currentSummary, userMsg, botMsg, model) { + const prompt = [ + currentSummary + ? `Resumo atual da conversa: ${currentSummary}` + : 'Esta é a primeira troca da conversa.', + `Nova troca:`, + `Cliente: "${userMsg}"`, + `Assistente: "${botMsg}"`, + `Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`, + `Responda apenas com o resumo, sem introdução.`, + ].join('\n'); + try { + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 80, temperature: 0.3 }, + }); + return result.response.text().trim(); + } + catch { + // Em caso de erro, mantém o resumo anterior + return currentSummary ?? ''; + } +} +// ─── Interface pública ──────────────────────────────────────────────────────── +class ChatbotService { + constructor(io) { + this.io = io; + } + /** + * Ponto de entrada para cada mensagem recebida. + * Chamado pelo MessageHandler após persistir a mensagem. + */ + async handleIncoming(opts) { + const { tenantId, instanceId, chatId, jid, text, sock } = opts; + // 1. Verifica se há bot ativo para esta instância + const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId); + if (!bot) + return; + // 2. Verifica o estado do chat (human takeover) + const chatState = await chatbot_repository_1.chatBotState.getState(chatId); + if (!chatState || chatState.botPaused) + return; + // 3. Aplica modo de gatilho + if (bot.triggerMode === 'KEYWORD') { + const lowerText = text.toLowerCase(); + const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase())); + if (!hasKeyword) + return; + } + try { + const model = getModel(bot.credential.apiKey, bot.model); + const summary = chatState.botSummary ?? null; + // 4. Micro-prompt: intenção + const intent = await classifyIntent(text, summary, bot.systemPrompt, model); + if (intent === 'escalate') { + // Pausa o bot e notifica via Socket.IO + await chatbot_repository_1.chatBotState.pauseBot(chatId); + this.io.to(`chat:${chatId}`).emit('bot:escalated', { + chatId, + message: 'Bot pausado — atendente humano necessário', + }); + logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano'); + return; + } + // 5. Gera resposta + const responseText = await generateResponse(text, summary, bot.systemPrompt, model); + // 6. Envia via Baileys + const sent = await sock.sendMessage(jid, { text: responseText }); + // 7. Persiste mensagem do bot no banco + const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } }); + if (chat) { + const botMsg = await prisma_1.prisma.message.create({ + data: { + tenantId, + instanceId, + chatId, + remoteJid: jid, + messageId: sent?.key.id ?? `bot-${Date.now()}`, + fromMe: true, + type: 'TEXT', + body: responseText, + status: 'SENT', + timestamp: new Date(), + }, + }); + // Notifica frontend + this.io.to(`chat:${chatId}`).emit('message:new', botMsg); + } + // 8. Atualiza Cérebro + const newSummary = await updateSummary(summary, text, responseText, model); + await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary); + logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada'); + } + catch (err) { + logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem'); + } + } +} +exports.ChatbotService = ChatbotService; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js new file mode 100644 index 0000000..ba7485d --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.js @@ -0,0 +1,1113 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.WhatsAppConnectionManager = void 0; +/** + * WhatsAppConnectionManager — Orquestra conexões Baileys para todas as instâncias. + * + * Esta é a classe central do módulo WhatsApp. Ela gerencia: + * 1. Ciclo de vida das conexões (connect, disconnect, reconnect) + * 2. Registro de TODOS os event listeners do Baileys + * 3. Reconexão automática com backoff exponencial + * 4. Watchdog para detectar conexões mortas silenciosamente + * 5. Sincronização de chats, contatos e histórico + * 6. Mapeamento LID → phone JID (via chats.phoneNumberShare) + * 7. Emissão de eventos Socket.IO para o frontend + * + * ARQUITETURA: + * - Uma instância de WhatsAppConnectionManager por servidor (singleton prático) + * - Cada instância WhatsApp (número conectado) tem seu próprio WASocket + * - Os Maps internos indexam por instanceId para acesso O(1) + * - Multi-tenant: tenantByInstance mapeia instância → tenant para isolamento + * + * EVENTOS BAILEYS REGISTRADOS: + * - creds.update → salva credenciais (auth state) + * - connection.update → gerencia QR, conexão, desconexão, reconexão + * - messages.upsert → delega para MessageHandler (tempo real + histórico) + * - messages.update → atualiza status (sent→delivered→read) + * - contacts.upsert/update → delega para ContactHandler + * - chats.upsert/update → sincroniza lista de chats no banco + * - chats.phoneNumberShare → captura mapeamento LID → phone JID + * - groups.upsert/update → sincroniza grupos (subject, metadata) + * - messaging-history.set → history sync (contatos, chats, mensagens em lote) + * + * @see MessageHandler — processa mensagens individuais + * @see ContactHandler — gerencia contatos e avatares + */ +const path_1 = __importDefault(require("path")); +const promises_1 = __importDefault(require("fs/promises")); +const promises_2 = __importDefault(require("dns/promises")); +const baileys_1 = require("@whiskeysockets/baileys"); +const qrcode_1 = __importDefault(require("qrcode")); +const env_1 = require("../../../config/env"); +const logger_1 = require("../../../config/logger"); +const dragonfly_1 = require("../../../infra/cache/dragonfly"); +const prisma_1 = require("../../../infra/database/prisma"); +const client_1 = require("@prisma/client"); +const MessageHandler_1 = require("../handlers/MessageHandler"); +const ContactHandler_1 = require("../handlers/ContactHandler"); +const chatbot_service_1 = require("../../chatbot/chatbot.service"); +const hook_bus_1 = require("../../../core/hook-bus"); +// ─── Chaves de cache no DragonflyDB (Redis-compatible) ────────────────────── +const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`; +const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`; +// ─── Configuração de reconexão automática ─────────────────────────────────── +// Backoff exponencial: 3s → 6s → 12s → 24s → 48s → ... → max 5min +// Nunca desiste, exceto loggedOut (usuário deslogou) ou forbidden (ban) +const RECONNECT_BASE_DELAY_MS = 3000; // delay inicial +const RECONNECT_MAX_DELAY_MS = 300000; // teto geral: 5 minutos +// Teto MENOR para erros transientes de rede (DNS, ECONN, ETIMEDOUT). +// Esses erros costumam resolver em segundos; um teto de 5min deixa a +// sessão "dormindo" num setTimeout longo enquanto a rede já voltou. +const RECONNECT_NETWORK_MAX_MS = 60000; // teto rede: 60s +const RECONNECT_MAX_ATTEMPTS = Infinity; +// ─── Network probe — acorda reconexões pendentes quando a rede volta ──────── +// Enquanto uma instância está aguardando backoff, um DNS lookup leve é +// disparado em intervalo fixo. Se o lookup funciona E existe um retry +// agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o timer +// e dispara retry imediato. Zero tráfego para o WhatsApp — só DNS. +const NETWORK_PROBE_INTERVAL_MS = 30000; // a cada 30s +const PROBE_ADVANCE_THRESHOLD_MS = 15000; // só adianta se faltar > 15s +const PROBE_DNS_HOST = 'web.whatsapp.com'; +// ─── Watchdog: detecta conexão morta silenciosamente ──────────────────────── +// Conservador por design: reconexões frequentes sinalizam "cliente instável" +// para o WhatsApp e aumentam risco de banimento do número. Por isso: +// - Timeout alto (10 min): conversas calmas são normais, não disparam +// - Intervalo baixo de verificação (3 min): menos overhead +// - Ping leve antes de matar: tenta reviver sem derrubar o socket +// - Cool-down entre kills: evita loop de reconexão se a rede oscila +const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min +const WATCHDOG_TIMEOUT_MS = 600000; // 10 min sem atividade antes de agir +const WATCHDOG_PING_GRACE_MS = 30000; // aguarda 30s após ping antes de matar +const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos +class WhatsAppConnectionManager { + constructor(io) { + // ─── Maps indexados por instanceId (UUID) ────────────────────────────── + /** Socket Baileys ativo por instância */ + this.sockets = new Map(); + /** Handler de contatos por instância */ + this.contactHandlers = new Map(); + /** Handler de mensagens por instância */ + this.msgHandlers = new Map(); + /** Contador de tentativas de reconexão (reseta ao conectar com sucesso) */ + this.reconnectAttempts = new Map(); + /** Timer de reconexão pendente (clearTimeout para cancelar) */ + this.reconnectTimers = new Map(); + /** + * Timestamp absoluto (ms) em que o retry pendente vai disparar. + * Usado pelo network probe para decidir se vale adiantar um retry + * que ainda está dormindo num backoff longo. + */ + this.reconnectScheduledAt = new Map(); + /** Timer global do network probe (singleton) */ + this.networkProbeTimer = null; + /** Timer do watchdog (clearInterval para parar) */ + this.watchdogTimers = new Map(); + /** Timestamp da última atividade (qualquer evento Baileys) */ + this.lastActivity = new Map(); + /** Timestamp do último force-kill do watchdog (cool-down anti-loop) */ + this.lastWatchdogKill = new Map(); + /** Timestamp do último ping do watchdog (aguarda resposta antes de matar) */ + this.lastWatchdogPing = new Map(); + /** Mapeamento instanceId → tenantId (necessário para reconexões) */ + this.tenantByInstance = new Map(); + this.io = io; + this.chatbotService = new chatbot_service_1.ChatbotService(io); + this.startNetworkProbe(); + } + // ═══════════════════════════════════════════════════════════════════════════ + // CONEXÃO PRINCIPAL + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Conecta (ou reconecta) uma instância WhatsApp. + * + * Fluxo: + * 1. Carrega auth state do disco (useMultiFileAuthState) + * 2. Cria socket Baileys com configuração padrão + * 3. Registra TODOS os event listeners + * 4. Baileys inicia handshake com WhatsApp Web + * 5. Se precisar de QR: emite evento 'qr' via Socket.IO + * 6. Se já autenticado: connection 'open' dispara sync + * + * IMPORTANTE: Este método é idempotente — se a instância já está + * conectada, retorna sem fazer nada (evita sockets duplicados). + */ + async connect(instanceId, tenantId) { + if (this.sockets.has(instanceId)) { + // Socket já existe — re-emite o status atual para sincronizar o frontend + // (acontece quando o backend reconectou em background antes do frontend abrir) + const cachedStatus = await dragonfly_1.dragonfly.get(CACHE_KEY_STATUS(instanceId)); + const currentStatus = cachedStatus ?? client_1.InstanceStatus.CONNECTED; + logger_1.logger.info({ instanceId, currentStatus }, 'Instância já conectada — re-emitindo status para o frontend'); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: currentStatus, + }); + return; + } + // Limpa timers pendentes de reconexão anterior + this.clearReconnectTimer(instanceId); + this.tenantByInstance.set(instanceId, tenantId); + // ── Auth state: credenciais persistidas em disco ────────────────────── + // Cada instância tem seu diretório em BAILEYS_SESSIONS_PATH/{instanceId}/ + // Contém: creds.json, app-state-sync-key-*, pre-key-*, etc. + const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId); + try { + await promises_1.default.mkdir(sessionDir, { recursive: true }); + } + catch (err) { + logger_1.logger.error({ err, instanceId, sessionDir }, 'Falha ao criar diretório de sessão'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED }); + return; + } + const { state, saveCreds } = await (0, baileys_1.useMultiFileAuthState)(sessionDir); + // fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar + // se não houver internet. Usa versão embutida como fallback seguro. + let version = [2, 3000, 1023504162]; + try { + const fetched = await (0, baileys_1.fetchLatestBaileysVersion)(); + version = fetched.version; + } + catch (err) { + logger_1.logger.warn({ instanceId }, 'fetchLatestBaileysVersion falhou — usando versão embutida como fallback'); + } + logger_1.logger.info({ instanceId, version }, 'Iniciando socket Baileys'); + // ── Cria socket Baileys ────────────────────────────────────────────── + const sock = (0, baileys_1.makeWASocket)({ + version, + auth: { + creds: state.creds, + // Cache de chaves Signal para performance (evita leituras de disco) + keys: (0, baileys_1.makeCacheableSignalKeyStore)(state.keys, logger_1.logger), + }, + printQRInTerminal: false, + qrTimeout: 60000, + logger: logger_1.logger.child({ instanceId }), + browser: ['NewWhats', 'Chrome', '124.0.0'], + syncFullHistory: true, // Solicita histórico completo ao telefone + markOnlineOnConnect: true, // Marca como "online" ao conectar + // getMessage: callback para reenvio de mensagens (retry) + // O Baileys chama quando precisa reenviar uma mensagem que falhou + getMessage: async (key) => { + if (!key.id) + return undefined; + const msg = await prisma_1.prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId, messageId: key.id } }, + }); + if (!msg?.body) + return undefined; + return { conversation: msg.body }; + }, + }); + this.sockets.set(instanceId, sock); + this.touchActivity(instanceId); + // ═════════════════════════════════════════════════════════════════════ + // REGISTRO DE EVENT LISTENERS DO BAILEYS + // ═════════════════════════════════════════════════════════════════════ + // ─── Credenciais: salvar ao atualizar ──────────────────────────────── + // O Baileys atualiza credenciais periodicamente (rotação de chaves Signal) + sock.ev.on('creds.update', saveCreds); + // ─── Estado de conexão ─────────────────────────────────────────────── + // Eventos: qr (novo QR gerado), open (conectado), close (desconectado), + // connecting (tentando conectar) + sock.ev.on('connection.update', (update) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'connection.update', { + connection: update.connection, + qr: update.qr ? '(qr gerado)' : undefined, + lastDisconnect: update.lastDisconnect + ? { statusCode: update.lastDisconnect.error?.output?.statusCode } + : undefined, + }); + this.handleConnectionUpdate(instanceId, tenantId, update, sock); + }); + // ─── Mensagens recebidas (tempo real + histórico) ──────────────────── + // type 'notify': mensagem em tempo real → processMessage completo + // type 'append': histórico sync → handleHistory (bulk, sem socket) + const msgHandler = new MessageHandler_1.MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService); + this.msgHandlers.set(instanceId, msgHandler); + sock.ws.on('CB:message', (node) => { + const msgId = node?.attrs?.id; + const peerRecipientPn = node?.attrs?.peer_recipient_pn; + if (msgId && peerRecipientPn) { + msgHandler.setPeerRecipientHint(msgId, peerRecipientPn); + } + }); + sock.ev.on('messages.upsert', (m) => { + this.touchActivity(instanceId); + // Log de debug para monitoramento via página /baileys + for (const msg of m.messages ?? []) { + const text = msg.message?.conversation ?? + msg.message?.extendedTextMessage?.text ?? + msg.message?.imageMessage?.caption ?? + msg.message?.videoMessage?.caption ?? + null; + this.baileysLog(tenantId, instanceId, 'message', { + fromMe: msg.key?.fromMe, + remoteJid: msg.key?.remoteJid, + body: text, + type: Object.keys(msg.message ?? {}).find(k => k !== 'messageContextInfo') ?? 'unknown', + status: msg.status, + }); + // Log de monitoramento para mensagens LID + // Ajuda a verificar se key.senderPn está presente para resolução + if (msg.key?.remoteJid?.endsWith('@lid')) { + logger_1.logger.debug({ + instanceId, + remoteJid: msg.key.remoteJid, + senderPn: msg.key.senderPn, + pushName: msg.pushName, + }, '[LID] Mensagem @lid recebida — será resolvida pelo MessageHandler'); + } + } + // A persistência do mapeamento LID→phone vive dentro do + // MessageHandler.processMessage (no upsert do Contact), porque + // updateMany aqui afetava 0 rows em contatos inexistentes — bug + // silencioso que descartava o mapeamento na primeira mensagem LID. + // Delega processamento completo para o MessageHandler + msgHandler.handle(m); + }); + // ─── Status de mensagens (sent → delivered → read) ─────────────────── + // O WhatsApp envia atualizações de status conforme a mensagem é entregue + // e lida. Mapeamos os códigos numéricos para strings legíveis. + sock.ev.on('messages.update', (updates) => { + this.touchActivity(instanceId); + for (const { key, update } of updates) { + if (!key.id || !update.status) + continue; + const statusMap = { + 0: 'PENDING', 1: 'PENDING', 2: 'SENT', 3: 'DELIVERED', 4: 'READ', 5: 'READ', + }; + const status = statusMap[update.status]; + if (!status) + continue; + // Atualiza no banco e emite para o frontend (ticks azuis/cinza) + prisma_1.prisma.message.updateMany({ + where: { instanceId, messageId: key.id }, + data: { status: status }, + }).then((r) => { + if (r.count === 0) + return; + this.io.to(`tenant:${tenantId}`).emit('message:status', { + messageId: key.id, + status, + }); + hook_bus_1.hookBus.emit('ext:message.update', { + instanceId, + tenantId, + messageId: key.id, + status, + timestamp: Date.now(), + }).catch(() => { }); + }).catch(() => { }); + } + }); + // ─── Contatos ──────────────────────────────────────────────────────── + // contacts.upsert: lista completa de contatos (conexão inicial/sync) + // contacts.update: atualizações parciais (nome alterado, etc) + const contactHandler = new ContactHandler_1.ContactHandler(instanceId, tenantId, sock, this.io); + this.contactHandlers.set(instanceId, contactHandler); + sock.ev.on('contacts.upsert', (contacts) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'contacts.upsert', { count: contacts.length }); + logger_1.logger.info({ instanceId, count: contacts.length }, '[contacts.upsert] Recebido'); + contactHandler.upsert(contacts); + }); + sock.ev.on('contacts.update', (contacts) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length }); + contactHandler.update(contacts); + }); + // ─── LID → Phone mapping (chats.phoneNumberShare) ──────────────────── + // Evento emitido quando um contato envia uma mensagem de protocolo + // SHARE_PHONE_NUMBER, revelando o telefone real por trás do LID. + // + // Este é um dos mecanismos para construir o mapeamento LID → phone. + // Outros mecanismos: + // - key.senderPn nas mensagens (MessageHandler.resolveLidToPhoneJid) + // - campo `lid` nos contatos do history sync (ContactHandler.upsert) + // + // @see MessageHandler.resolveLidToPhoneJid() — consome este mapeamento + sock.ev.on('chats.phoneNumberShare', ({ lid, jid: phoneJid }) => { + this.touchActivity(instanceId); + logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido'); + if (lid && phoneJid) { + // Salva o mapeamento LID no contato que já existe com o phone JID + prisma_1.prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: phoneJid }, + data: { lidJid: lid }, + }).catch(() => { }); + // Tenta corrigir contatos que foram criados com JID = LID + // (pode acontecer se o contato chegou antes do phoneNumberShare) + prisma_1.prisma.contact.updateMany({ + where: { tenantId, instanceId, jid: lid }, + data: { jid: phoneJid, phone: phoneJid.split('@')[0], lidJid: lid }, + }).catch(() => { }); + } + }); + // ─── Chats ─────────────────────────────────────────────────────────── + // chats.upsert: novos chats (ou chats que voltaram da lista) + // chats.update: atualizações (unread count, pin, archive, etc) + sock.ev.on('chats.upsert', (chats) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length }); + this.syncChats(instanceId, tenantId, chats).catch(() => { }); + }); + sock.ev.on('chats.update', (chats) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length }); + this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => { }); + }); + // ─── Grupos ────────────────────────────────────────────────────────── + // groups.upsert: grupo criado ou entrou em novo grupo + // groups.update: subject (nome) ou metadata alterados + // Convertemos para o formato de chat e sincronizamos via syncChats() + sock.ev.on('groups.upsert', (groups) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length }); + const chatsFromGroups = groups.map((g) => ({ + id: g.id, + name: g.subject, + conversationTimestamp: g.creation, + unreadCount: 0, + pinned: false, + archived: false, + })); + this.syncChats(instanceId, tenantId, chatsFromGroups).catch(() => { }); + }); + sock.ev.on('groups.update', (updates) => { + this.touchActivity(instanceId); + this.baileysLog(tenantId, instanceId, 'groups.update', { count: updates.length }); + const chatsFromUpdates = updates + .filter((g) => g.id && g.subject) + .map((g) => ({ id: g.id, name: g.subject })); + this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => { }); + }); + // ─── Histórico inicial (messaging-history.set) ─────────────────────── + // Evento principal de sincronização: o telefone envia chats, contatos + // e mensagens em lotes. Pode disparar múltiplas vezes. + // Quando isLatest=true, o sync terminou → dispara reconciliação de nomes. + sock.ev.on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }) => { + this.touchActivity(instanceId); + logger_1.logger.info({ + instanceId, + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }, '[History] messaging-history.set recebido'); + this.baileysLog(tenantId, instanceId, 'messaging-history.set', { + chats: histChats.length, + contacts: histContacts.length, + messages: histMessages.length, + isLatest, + }); + // Processa na ordem: contatos → chats → mensagens + // (contatos primeiro para que o vínculo Chat→Contact funcione) + if (histContacts.length > 0) { + await contactHandler.upsert(histContacts).catch(() => { }); + } + if (histChats.length > 0) { + await this.syncChats(instanceId, tenantId, histChats).catch(() => { }); + } + if (histMessages.length > 0) { + await msgHandler.handleHistory(histMessages).catch(() => { }); + } + // Quando isLatest=true, o sync do histórico terminou completamente. + // Inicia reconciliação: busca contatos sem nome e preenche via pushName + // das mensagens recebidas (o history sync não traz pushName nos contatos). + if (isLatest) { + logger_1.logger.info({ instanceId }, '[History] Sync completo (isLatest=true) — iniciando reconciliação de nomes'); + contactHandler.reconcileNamesFromMessages().catch(() => { }); + } + }); + // ── Guard: emite DISCONNECTED se o Baileys lançar erro síncrono ───────── + // makeWASocket pode lançar em casos raros (OpenSSL, auth corrompida, etc.) + // Não há como capturar erros async do Baileys aqui além dos event listeners, + // mas garantimos que o socket foi registrado antes de prosseguir. + if (!this.sockets.has(instanceId)) { + logger_1.logger.error({ instanceId }, 'Socket Baileys não foi registrado após inicialização — marcando como DISCONNECTED'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED }); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÃO DE CONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa eventos de mudança de estado da conexão Baileys. + * + * Estados possíveis: + * - qr: QR Code gerado → emite para o frontend escanear + * - open: conectado com sucesso → inicia sync, watchdog, etc. + * - close: desconectado → decide se reconecta ou para + * - connecting: tentando conectar → atualiza status + * + * DECISÃO DE RECONEXÃO: + * - loggedOut (401) → sessão expirada, limpa arquivos, exige novo QR + * - forbidden (403) → conta banida, para completamente + * - Qualquer outro código → reconecta com backoff exponencial + */ + async handleConnectionUpdate(instanceId, tenantId, update, sock) { + const { connection, lastDisconnect, qr } = update; + // ── QR Code emitido ─────────────────────────────────────────────────── + if (qr) { + logger_1.logger.info({ instanceId }, 'QR Code gerado — aguardando escaneamento'); + const qrBase64 = await qrcode_1.default.toDataURL(qr); + // Salva no cache com TTL de 60s (QR expira rapidamente) + await dragonfly_1.dragonfly.set(CACHE_KEY_QR(instanceId), qrBase64, 60); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.QR_PENDING); + // Emite para a sala da instância (página de conexão) E para o tenant (dashboard) + this.io.to(`instance:${instanceId}`).emit('qr', { instanceId, qrBase64 }); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.QR_PENDING, + qrBase64, + }); + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hook_bus_1.hookBus.emit('ext:session.qr', { instanceId, tenantId, qrBase64, timestamp: Date.now() }).catch(() => { }); + } + // ── Conectado com sucesso ───────────────────────────────────────────── + if (connection === 'open') { + logger_1.logger.info({ instanceId }, 'WhatsApp conectado com sucesso'); + // Reseta contadores de reconexão + this.reconnectAttempts.set(instanceId, 0); + this.clearReconnectTimer(instanceId); + // Inicia watchdog para detectar conexão morta + this.startWatchdog(instanceId); + await dragonfly_1.dragonfly.del(CACHE_KEY_QR(instanceId)); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTED); + // Salva o número de telefone da sessão no banco + // (sock.user.id vem no formato "5511999999999:1@s.whatsapp.net") + const phoneJid = sock.user?.id; + if (phoneJid) { + const phone = phoneJid.split(':')[0].split('@')[0]; + await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { phone } }) + .catch(() => { }); + } + // Pós-conexão: sincroniza avatar da instância e busca avatares ausentes + const handler = this.contactHandlers.get(instanceId); + if (handler) { + // Avatar da própria instância (foto de perfil do número) + handler.syncInstanceAvatar(instanceId) + .then((avatar) => { + if (avatar) { + this.io.to(`tenant:${tenantId}`).emit('instance:avatar', { instanceId, avatar }); + } + }) + .catch(() => { }); + // Busca avatares ausentes após 5s (dá tempo para o sync inicial) + setTimeout(() => handler.syncMissingAvatars().catch(() => { }), 5000); + // Renova avatares próximos de expirar após 30s (não compete com syncMissing) + setTimeout(() => handler.syncExpiredAvatars().catch(() => { }), 30000); + } + // Sync incremental: verifica gap de mensagens durante downtime + this.syncMissedMessages(instanceId, tenantId).catch(() => { }); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTED, + }); + hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'connected', timestamp: Date.now() }).catch(() => { }); + } + // ── Desconectado ────────────────────────────────────────────────────── + if (connection === 'close') { + const rawErr = lastDisconnect?.error; + const error = rawErr?.output; + const statusCode = error?.statusCode; + const isNetworkErr = this.isTransientNetworkError(rawErr); + logger_1.logger.warn({ instanceId, statusCode, isNetworkErr }, 'Conexao encerrada'); + // Limpa referências da instância desconectada + this.sockets.delete(instanceId); + this.contactHandlers.delete(instanceId); + this.msgHandlers.delete(instanceId); + this.stopWatchdog(instanceId); + // Decide se deve reconectar baseado no código de erro + const shouldReconnect = statusCode !== baileys_1.DisconnectReason.loggedOut && + statusCode !== baileys_1.DisconnectReason.forbidden; + if (shouldReconnect) { + const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1; + this.reconnectAttempts.set(instanceId, attempts); + // Backoff exponencial: 3s, 6s, 12s, 24s, 48s… + // Teto menor (60s) para erros de rede transientes: DNS/ECONN se + // resolvem em segundos, não faz sentido dormir 5min. Teto geral + // (5min) fica para erros de servidor/protocolo do WhatsApp, onde + // paciência é melhor do que pressa. + const maxDelay = isNetworkErr ? RECONNECT_NETWORK_MAX_MS : RECONNECT_MAX_DELAY_MS; + const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempts - 1), maxDelay); + const scheduledAt = Date.now() + delay; + const nextAt = new Date(scheduledAt).toISOString(); + logger_1.logger.info({ instanceId, attempts, delayMs: delay, nextAt, isNetworkErr }, '[Reconexao] Agendando reconexao com backoff exponencial'); + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTING, + reconnectIn: delay, + attempt: attempts, + }); + this.scheduleReconnect(instanceId, tenantId, delay); + } + else { + // loggedOut → limpa sessão e exige novo QR + if (statusCode === baileys_1.DisconnectReason.loggedOut) { + logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao'); + await this.deleteSessionFiles(instanceId); + } + // forbidden → conta banida, apenas atualiza status + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.DISCONNECTED); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.DISCONNECTED, + reason: baileys_1.DisconnectReason[statusCode] ?? statusCode, + }); + hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { }); + } + } + // ── Conectando ──────────────────────────────────────────────────────── + if (connection === 'connecting') { + await this.updateInstanceStatus(instanceId, client_1.InstanceStatus.CONNECTING); + this.io.to(`tenant:${tenantId}`).emit('instance:status', { + instanceId, + status: client_1.InstanceStatus.CONNECTING, + }); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC INCREMENTAL — Mensagens perdidas durante downtime + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Verifica e notifica sobre gap de mensagens durante downtime. + * + * Consulta a última mensagem no banco e calcula quanto tempo o servidor + * ficou offline. O Baileys com syncFullHistory=true já solicita as + * mensagens pendentes automaticamente — este método apenas: + * 1. Loga o gap para visibilidade (monitoramento) + * 2. Emite 'instance:syncing' para o frontend mostrar indicador de sync + */ + async syncMissedMessages(instanceId, tenantId) { + try { + const lastMsg = await prisma_1.prisma.message.findFirst({ + where: { instanceId }, + orderBy: { timestamp: 'desc' }, + select: { timestamp: true, messageId: true }, + }); + if (!lastMsg) { + logger_1.logger.info({ instanceId }, '[Sync] Nenhuma mensagem no DB — history sync completo via Baileys'); + return; + } + const gap = Date.now() - lastMsg.timestamp.getTime(); + const gapMinutes = Math.round(gap / 60000); + logger_1.logger.info({ instanceId, lastMsgAt: lastMsg.timestamp, gapMinutes }, '[Sync] Reconectado — gap de mensagens detectado'); + this.baileysLog(tenantId, instanceId, 'sync:gap', { + lastMsgAt: lastMsg.timestamp.toISOString(), + gapMinutes, + }); + // Notifica o frontend que está sincronizando + this.io.to(`tenant:${tenantId}`).emit('instance:syncing', { + instanceId, + gapMinutes, + since: lastMsg.timestamp.toISOString(), + }); + } + catch (err) { + logger_1.logger.error({ err, instanceId }, '[Sync] Erro ao verificar gap de mensagens'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // WATCHDOG — Detecta conexão morta silenciosamente + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Registra timestamp da última atividade para o watchdog. + * Chamado por TODOS os event listeners do Baileys. + */ + touchActivity(instanceId) { + this.lastActivity.set(instanceId, Date.now()); + } + /** + * Inicia o watchdog para uma instância. + * + * Fluxo conservador em 3 etapas — escolhido pra minimizar reconexões: + * 1. Se inativo > TIMEOUT (10 min): envia um presence update como + * ping leve e marca timestamp. Se o socket estiver vivo, Baileys + * responde e touchActivity é chamado no próximo evento. + * 2. Se continua inativo GRACE (+30s) após o ping: confirma que a + * conexão está de fato morta. Procede pro kill. + * 3. Antes de matar: verifica cool-down (15 min desde último kill) + * pra não entrar em loop quando a rede está oscilando. Se estiver + * no cool-down, só loga e espera o próximo ciclo. + * + * Esta sequência dá ~10-11 min de folga antes do primeiro kill, e no + * mínimo 15 min entre kills consecutivos — 7-8× mais conservador que + * a versão anterior (2 min sem verificação de estado). + */ + startWatchdog(instanceId) { + this.stopWatchdog(instanceId); + const timer = setInterval(async () => { + const now = Date.now(); + const lastTs = this.lastActivity.get(instanceId) ?? 0; + const elapsed = now - lastTs; + const tenantId = this.tenantByInstance.get(instanceId); + if (elapsed <= WATCHDOG_TIMEOUT_MS) { + // Conexão com atividade recente — reseta estado de ping/cool-down + this.lastWatchdogPing.delete(instanceId); + return; + } + const sock = this.sockets.get(instanceId); + // Caso 1: socket sumiu sem connection.close — reconecta imediatamente + if (!sock) { + if (tenantId) { + logger_1.logger.warn({ instanceId }, '[Watchdog] Socket ausente — reagendando conexao'); + this.stopWatchdog(instanceId); + this.connect(instanceId, tenantId).catch(() => { }); + } + return; + } + const pingedAt = this.lastWatchdogPing.get(instanceId) ?? 0; + const sincePing = now - pingedAt; + // Etapa 1: envia ping leve (presence update) se ainda não pingou + // neste ciclo de inatividade. Não conta como atividade — só estímulo. + if (pingedAt === 0 || sincePing > WATCHDOG_TIMEOUT_MS) { + try { + await sock.sendPresenceUpdate('available'); + this.lastWatchdogPing.set(instanceId, now); + logger_1.logger.debug({ instanceId, elapsedMs: elapsed }, '[Watchdog] Ping leve enviado — aguardando resposta'); + } + catch (err) { + // Ping falhou → socket já está morto, pula pra etapa de kill + logger_1.logger.warn({ instanceId, err }, '[Watchdog] Ping falhou — socket morto'); + this.forceKillSocket(instanceId, sock, tenantId, elapsed); + } + return; + } + // Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE. + // Confirma que o socket está zumbi e força reconexão. + if (sincePing > WATCHDOG_PING_GRACE_MS) { + this.forceKillSocket(instanceId, sock, tenantId, elapsed); + } + }, WATCHDOG_INTERVAL_MS); + this.watchdogTimers.set(instanceId, timer); + } + /** + * Força o fechamento do socket, respeitando cool-down para evitar loop. + * Só mata se não houve outro kill nos últimos WATCHDOG_COOLDOWN_MS. + */ + forceKillSocket(instanceId, sock, tenantId, elapsedMs) { + const now = Date.now(); + const lastKill = this.lastWatchdogKill.get(instanceId) ?? 0; + const sinceKill = now - lastKill; + if (sinceKill < WATCHDOG_COOLDOWN_MS) { + logger_1.logger.warn({ instanceId, elapsedMs, sinceLastKillMs: sinceKill }, '[Watchdog] Em cool-down — adiando kill para proteger numero de ban'); + return; + } + logger_1.logger.warn({ instanceId, elapsedMs }, '[Watchdog] Conexao morta confirmada — forcando reconexao'); + this.lastWatchdogKill.set(instanceId, now); + this.lastWatchdogPing.delete(instanceId); + try { + sock.end(new Error('Watchdog timeout')); + } + catch { /* ignore */ } + // Se o sock.end não disparar connection.close (raro), reconecta manualmente + if (!this.sockets.has(instanceId) && tenantId) { + this.stopWatchdog(instanceId); + this.connect(instanceId, tenantId).catch(() => { }); + } + } + /** Para o watchdog de uma instância */ + stopWatchdog(instanceId) { + const timer = this.watchdogTimers.get(instanceId); + if (timer) { + clearInterval(timer); + this.watchdogTimers.delete(instanceId); + } + // Limpa estado de ping pendente — novo ciclo começa limpo. + // NÃO limpa lastWatchdogKill aqui: o cool-down deve sobreviver a + // stop/start para bloquear reconexões em cascata. + this.lastWatchdogPing.delete(instanceId); + } + // ═══════════════════════════════════════════════════════════════════════════ + // TIMER DE RECONEXÃO + // ═══════════════════════════════════════════════════════════════════════════ + /** Cancela timer de reconexão pendente (evita reconexão duplicada) */ + clearReconnectTimer(instanceId) { + const timer = this.reconnectTimers.get(instanceId); + if (timer) { + clearTimeout(timer); + this.reconnectTimers.delete(instanceId); + } + this.reconnectScheduledAt.delete(instanceId); + } + /** + * Agenda uma reconexão após `delay` ms. + * + * Centraliza o setTimeout + tracking do scheduledAt (usado pelo network + * probe pra saber se vale adiantar). Cancela qualquer timer anterior. + */ + scheduleReconnect(instanceId, tenantId, delay) { + this.clearReconnectTimer(instanceId); + const scheduledAt = Date.now() + delay; + this.reconnectScheduledAt.set(instanceId, scheduledAt); + const timer = setTimeout(() => { + this.reconnectScheduledAt.delete(instanceId); + this.connect(instanceId, tenantId); + }, delay); + this.reconnectTimers.set(instanceId, timer); + } + /** + * Classifica um erro de conexão como "rede transiente". + * + * Erros de DNS/TCP costumam resolver em segundos. Erros de status do + * WhatsApp (loggedOut, conflict, restartRequired) são categoria diferente + * e devem usar o backoff longo tradicional. + */ + isTransientNetworkError(err) { + if (!err) + return false; + const code = err.code || err.cause?.code || err.errno; + if (code && typeof code === 'string') { + const transient = [ + 'EAI_AGAIN', // DNS retry + 'ENOTFOUND', // DNS não resolveu + 'ECONNRESET', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENETDOWN', + 'EPIPE', + ]; + if (transient.includes(code)) + return true; + } + // Fallback: mensagem contém nome do host — é quase certo que é DNS/WS + const msg = (err.message || '').toString(); + if (/getaddrinfo|web\.whatsapp\.com|WebSocket Error/i.test(msg)) + return true; + return false; + } + // ═══════════════════════════════════════════════════════════════════════════ + // NETWORK PROBE — Acorda reconexões quando a rede volta + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Inicia o network probe global. + * + * A cada NETWORK_PROBE_INTERVAL_MS, testa se o DNS resolve + * `web.whatsapp.com`. Se resolver E houver alguma instância com retry + * agendado para daqui a mais de PROBE_ADVANCE_THRESHOLD_MS, cancela o + * timer dessa instância e dispara o retry imediatamente. + * + * Por quê DNS em vez de HTTP? Zero bytes para o WhatsApp. DNS falha com + * EAI_AGAIN quando a rede está caída, e resolve rápido quando volta — + * é um sinal confiável de disponibilidade de rede sem tocar no WA. + */ + startNetworkProbe() { + if (this.networkProbeTimer) + return; + this.networkProbeTimer = setInterval(async () => { + // Curto-circuito: se não há retries pendentes, nem faz o DNS. + if (this.reconnectScheduledAt.size === 0) + return; + try { + await promises_2.default.lookup(PROBE_DNS_HOST); + } + catch { + return; // Rede ainda caída — não adianta nada, espera o tick seguinte. + } + // DNS OK → adianta qualquer retry que ainda falte > threshold + const now = Date.now(); + for (const [instanceId, scheduledAt] of this.reconnectScheduledAt) { + const remaining = scheduledAt - now; + if (remaining <= PROBE_ADVANCE_THRESHOLD_MS) + continue; + const tenantId = this.tenantByInstance.get(instanceId); + if (!tenantId) + continue; + logger_1.logger.info({ instanceId, remainingMs: remaining }, '[NetworkProbe] Rede detectada — adiantando retry pendente'); + this.clearReconnectTimer(instanceId); + // Dispara sem await — probe roda em interval e não deve bloquear + this.connect(instanceId, tenantId).catch((err) => logger_1.logger.error({ err, instanceId }, '[NetworkProbe] Falha ao adiantar retry')); + } + }, NETWORK_PROBE_INTERVAL_MS); + // Não segura o processo vivo só por causa do probe + this.networkProbeTimer.unref?.(); + } + // ═══════════════════════════════════════════════════════════════════════════ + // DESCONEXÃO MANUAL + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Desconecta uma instância manualmente (logout). + * + * Diferente de uma desconexão por erro — aqui o usuário está + * intencionalmente desconectando. Limpa todos os timers e + * faz logout no WhatsApp (invalidando a sessão). + */ + async disconnect(instanceId) { + const sock = this.sockets.get(instanceId); + if (!sock) + return; + this.clearReconnectTimer(instanceId); + this.stopWatchdog(instanceId); + this.reconnectAttempts.delete(instanceId); + // Logout manual zera o estado de cool-down: reconexão futura é um + // novo ciclo e não deve herdar o bloqueio do watchdog. + this.lastWatchdogKill.delete(instanceId); + this.lastActivity.delete(instanceId); + await sock.logout(); + this.sockets.delete(instanceId); + this.contactHandlers.delete(instanceId); + this.msgHandlers.delete(instanceId); + logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente'); + } + // ═══════════════════════════════════════════════════════════════════════════ + // ACESSORES PÚBLICOS + // ═══════════════════════════════════════════════════════════════════════════ + /** Retorna o socket Baileys ativo para uma instância (usado pelas rotas de envio) */ + getSocket(instanceId) { + return this.sockets.get(instanceId); + } + /** Retorna o Socket.IO server (usado pelas rotas para emitir eventos) */ + getIO() { + return this.io; + } + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS INTERNOS + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Atualiza o status da instância no banco E no cache. + * Dupla escrita para que o frontend possa consultar status tanto + * via API (banco) quanto via polling rápido (cache DragonflyDB). + */ + async updateInstanceStatus(instanceId, status) { + await Promise.all([ + prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { status } }) + .catch((err) => { if (err?.code !== 'P2025') + throw err; }), + dragonfly_1.dragonfly.set(CACHE_KEY_STATUS(instanceId), status), + ]); + } + /** Remove arquivos de sessão do disco (após loggedOut) */ + async deleteSessionFiles(instanceId) { + const sessionDir = path_1.default.resolve(env_1.env.BAILEYS_SESSIONS_PATH, instanceId); + await promises_1.default.rm(sessionDir, { recursive: true, force: true }); + } + // ═══════════════════════════════════════════════════════════════════════════ + // SINCRONIZAÇÃO DE CHATS + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Sincroniza lista de chats no banco. + * + * Chamado por: chats.upsert, chats.update, groups.upsert, groups.update, + * messaging-history.set + * + * Para cada chat: + * - Cria ou atualiza no banco com JID, nome (grupo), timestamps + * - Vincula ao Contact existente (se houver) + * - Sincroniza estado de pin/archive/unread do WhatsApp + * + * Otimização: busca todos os contatos existentes em uma única query + * e usa Map para lookup O(1) por JID. + */ + async syncChats(instanceId, tenantId, chats) { + if (chats.length === 0) + return; + // Busca todos os contatos existentes para os JIDs (1 query) + const jids = chats.map((c) => c.id).filter(Boolean); + const existingContacts = await prisma_1.prisma.contact.findMany({ + where: { tenantId, instanceId, jid: { in: jids } }, + select: { id: true, jid: true }, + }); + const contactIdByJid = new Map(existingContacts.map((c) => [c.jid, c.id])); + // Pré-carrega mapeamento LID → phone JID para chats @lid. + // ContactHandler.upsert já rodou antes (messaging-history.set processa + // contacts → chats → messages nessa ordem), então lidJid já está populado. + const lidJids = jids.filter((j) => j.endsWith('@lid')); + const lidToPhoneJid = new Map(); // @lid → @s.whatsapp.net + const lidToContactId = new Map(); // @lid → contactId + if (lidJids.length > 0) { + const resolvedByLid = await prisma_1.prisma.contact.findMany({ + where: { + tenantId, + instanceId, + lidJid: { in: lidJids }, + NOT: { jid: { endsWith: '@lid' } }, // só aceita se resolveu para phone JID + }, + select: { id: true, jid: true, lidJid: true }, + }); + for (const rc of resolvedByLid) { + if (rc.lidJid) { + lidToPhoneJid.set(rc.lidJid, rc.jid); + lidToContactId.set(rc.lidJid, rc.id); + } + } + } + for (const chat of chats) { + const rawJid = chat.id; + if (!rawJid) + continue; + // Descarta JIDs de sistema: broadcasts, newsletters e conta PSA (0@s.whatsapp.net) + if (rawJid.includes('@broadcast') || rawJid.endsWith('@newsletter') || rawJid.startsWith('0@')) + continue; + // ── Resolução LID → phone JID ───────────────────────────────────── + // Chats @lid são artefatos do protocolo WhatsApp multi-device: + // o JID real do contato é @s.whatsapp.net. + // Se já temos o mapeamento (ContactHandler rodou antes), usa o phone JID. + // Se não temos resolução, descarta — seria filtrado pelo ChatRepository. + let jid = rawJid; + if (rawJid.endsWith('@lid')) { + const phoneJid = lidToPhoneJid.get(rawJid); + if (phoneJid) { + jid = phoneJid; + // Garante que o contactId mapeado seja usado abaixo + const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid); + if (resolvedContactId) + contactIdByJid.set(jid, resolvedContactId); + } + else { + // Sem resolução disponível: chat será criado via handleHistory quando + // a mensagem chegar com senderPn resolvível. + logger_1.logger.debug({ rawJid, instanceId }, '[syncChats] @lid sem resolução — descartando'); + continue; + } + } + try { + // Converte timestamp Unix (segundos) para Date + const lastAt = chat.conversationTimestamp + ? new Date(Number(chat.conversationTimestamp) * 1000) + : undefined; + const isGroup = jid.endsWith('@g.us'); + // Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name + const groupName = isGroup ? (chat.name ?? null) : null; + const contactId = contactIdByJid.get(jid) ?? null; + await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + create: { + tenantId, + instanceId, + jid, + name: groupName, + unreadCount: chat.unreadCount ?? 0, + lastMessageAt: lastAt ?? null, + isPinned: !!chat.pinned, + isArchived: chat.archived ?? false, + ...(contactId && { contactId }), + }, + update: { + ...(groupName && { name: groupName }), + ...(contactId && { contactId }), + // Só aplica campos que vieram explicitamente no evento. + // chats.upsert pode ser parcial (nova mensagem, metadata) — se + // pinned/archived/unreadCount estiverem ausentes, não sobrescreve + // o valor salvo no banco. + ...(chat.unreadCount !== undefined && { unreadCount: chat.unreadCount }), + ...(lastAt && { lastMessageAt: lastAt }), + ...(chat.pinned !== undefined && { isPinned: !!chat.pinned }), + ...(chat.archived !== undefined && { isArchived: !!chat.archived }), + }, + }); + } + catch (err) { + logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa atualizações parciais de chats (pin, archive, unread). + * + * Diferente de syncChats (usado para bulk/history), este método: + * 1. Só atualiza campos presentes na atualização (sem sobrescrever com defaults) + * 2. Emite chat:upsert via Socket.IO para atualizar o frontend em tempo real + * + * O evento chats.update do Baileys traz objetos parciais — apenas os campos + * que mudaram. Por isso não podemos usar ?? 0 para unreadCount, pois isso + * sobrescreveria o valor correto com zero. + */ + async handleChatsUpdate(instanceId, tenantId, updates) { + for (const update of updates) { + const jid = update.id; + if (!jid || jid.endsWith('@lid')) + continue; + try { + // Monta patch com apenas os campos presentes na atualização + const patch = {}; + if (update.pinned !== undefined) + patch.isPinned = !!update.pinned; + if (update.archived !== undefined) + patch.isArchived = !!update.archived; + if (update.unreadCount !== undefined) + patch.unreadCount = update.unreadCount; + if (Object.keys(patch).length === 0) + continue; + const chat = await prisma_1.prisma.chat.update({ + where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } }, + data: patch, + include: { + contact: { + select: { + name: true, verifiedName: true, notify: true, + phone: true, avatarUrl: true, + scoreReputacao: true, flagRestricao: true, + }, + }, + }, + }).catch(() => null); + if (!chat) + continue; + // Emite para o frontend atualizar em tempo real + const c = chat.contact; + const isGroup = jid.endsWith('@g.us'); + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null); + this.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: 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: null, + }); + } + catch (err) { + logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // DEBUG LOG (página /baileys no frontend) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Emite evento de debug para a página /baileys do frontend. + * + * Permite monitorar em tempo real todos os eventos Baileys + * sem precisar acessar os logs do servidor. + */ + baileysLog(tenantId, instanceId, event, data) { + this.io.to(`tenant:${tenantId}`).emit('baileys:log', { + timestamp: new Date().toISOString(), + sessionId: instanceId, + event, + data, + }); + } +} +exports.WhatsAppConnectionManager = WhatsAppConnectionManager; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/ContactHandler.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/ContactHandler.js new file mode 100644 index 0000000..39dec1d --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/ContactHandler.js @@ -0,0 +1,494 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ContactHandler = void 0; +const prisma_1 = require("../../../infra/database/prisma"); +const logger_1 = require("../../../config/logger"); +// ─── Configuração de rate-limit para busca de avatares ────────────────────── +// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl +// forem feitas em curto período. Estas constantes controlam o throttling. +/** Quantos avatares buscar em paralelo por lote */ +const AVATAR_BATCH_SIZE = 5; +/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */ +const AVATAR_DELAY_MS = 900; +/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */ +const MAX_MISSING_AVATAR_SYNC = 80; +class ContactHandler { + constructor( + /** UUID da instância WhatsApp */ + instanceId, + /** UUID do tenant (isolamento multi-tenant) */ + tenantId, + /** Socket Baileys — usado para profilePictureUrl e resyncAppState */ + sock, + /** Socket.IO — emite eventos de avatar/reconciliação para o frontend */ + io) { + this.instanceId = instanceId; + this.tenantId = tenantId; + this.sock = sock; + this.io = io; + /** Fila de JIDs aguardando busca de avatar */ + this.avatarQueue = []; + /** Flag de mutex simples — evita processamento concorrente da fila */ + this.processingAvatars = false; + } + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO DE NOME + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Regra de ouro para resolução de nome de contato: + * name (agenda do telefone) > verifiedName (conta business) > notify (pushname) + * + * Retorna undefined se nenhum campo estiver disponível. + */ + resolveName(c) { + return c.name ?? c.verifiedName ?? c.notify ?? undefined; + } + // ═══════════════════════════════════════════════════════════════════════════ + // UPSERT EM MASSA (contacts.upsert + messaging-history.set) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Cria ou atualiza contatos em massa. + * + * Dispara em dois cenários: + * 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão) + * 2. Campo contacts[] do messaging-history.set (history sync) + * + * Para cada contato: + * - Cria ou atualiza no banco com nome resolvido + * - Vincula chats órfãos (Chat sem contactId) ao contato + * - Enfileira busca de avatar se imgUrl não disponível + * - Captura mapeamento LID → phone JID (campo `lid` do history sync) + * + * TRATAMENTO DE LID: + * O Baileys pode enviar contatos com c.id no formato @lid. + * Quando o contato do history sync inclui campo `jid` (telefone real), + * usamos esse JID como chave primária e salvamos o LID em lidJid. + * Quando não há campo `jid`, o contato é criado com o LID como JID + * (será corrigido quando phoneNumberShare ou senderPn revelar o telefone). + */ + async upsert(contacts) { + /** JIDs que precisam buscar avatar via profilePictureUrl */ + const needFetchAvatar = []; + /** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */ + let directAvatarCount = 0; + for (const c of contacts) { + if (!c.id) + continue; + const isLid = c.id.endsWith('@lid'); + const isBroadcast = c.id.includes('@broadcast'); + if (isBroadcast) + continue; + // imgUrl pode vir do Baileys com valores especiais: + // - URL string: avatar disponível (salva direto) + // - 'changed': avatar foi alterado (precisa buscar novamente) + // - null/undefined: não disponível (precisa buscar via API) + const imgUrl = c.imgUrl; + // LID mapping: o history sync do Baileys traz campo `lid` nos contatos + // que associa o LID ao contato com JID real (@s.whatsapp.net) + // Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" } + const lidFromContact = c.lid; + try { + // Para contatos @lid que possuem campo `jid` (vem do history sync), + // usa o JID real como chave primária. Caso contrário, usa c.id como está. + // Isso evita criar contatos duplicados (um com LID, outro com phone). + const contactJid = isLid && c.jid ? c.jid : c.id; + const contact = await prisma_1.prisma.contact.upsert({ + where: { + tenantId_instanceId_jid: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + }, + }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: contactJid, + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0], + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + // Salva mapeamento LID: + // Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid + // Se o contato TEM campo lid (history sync): salva lid como lidJid + ...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}), + }, + update: { + name: this.resolveName(c) ?? null, + verifiedName: c.verifiedName ?? null, + notify: c.notify ?? null, + ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), + ...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}), + }, + }); + // Garante vínculo Chat ↔ Contact para chats órfãos + // (chats criados antes do contato existir ficam com contactId=null) + await prisma_1.prisma.chat.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: c.id, + contactId: null, + }, + data: { contactId: contact.id }, + }); + // ── Lógica de avatar ────────────────────────────────────────────── + // JIDs @lid não suportam profilePictureUrl (retorna 404 no WA) + if (!isLid) { + if (imgUrl && imgUrl !== 'changed') { + // Avatar veio pronto do Baileys — notifica frontend imediatamente + directAvatarCount++; + this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { + instanceId: this.instanceId, + jid: c.id, + avatarUrl: imgUrl, + }); + } + else if (imgUrl === 'changed' || imgUrl === undefined) { + // Precisa buscar via API — adiciona à fila com rate-limit + needFetchAvatar.push(c.id); + } + } + } + catch (err) { + logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato'); + } + } + logger_1.logger.debug({ + instanceId: this.instanceId, + direct: directAvatarCount, + toFetch: needFetchAvatar.length, + }, '[ContactHandler] Avatares: diretos vs a buscar via API'); + // Enfileira busca de avatares com cap de 50 por batch + // (randomiza para priorizar contatos diferentes a cada sync) + if (this.sock && needFetchAvatar.length > 0) { + const toQueue = needFetchAvatar.length > 50 + ? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50) + : needFetchAvatar; + this.enqueueAvatars(toQueue); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // UPDATE PARCIAL (contacts.update) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Atualiza campos parciais de contatos existentes. + * + * Dispara no evento contacts.update do Baileys — diferente do upsert, + * aqui só atualizamos os campos que foram explicitamente alterados. + * Não cria contatos novos (se não existir, ignora silenciosamente). + */ + async update(contacts) { + for (const c of contacts) { + if (!c.id) + continue; + const resolvedName = this.resolveName(c); + // Se nenhum campo relevante mudou, pula + if (!resolvedName && c.verifiedName === undefined && c.notify === undefined) + continue; + try { + await prisma_1.prisma.contact.updateMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: c.id, + }, + data: { + ...(resolvedName !== undefined && { name: resolvedName }), + ...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }), + ...(c.notify !== undefined && { notify: c.notify }), + }, + }); + } + catch (err) { + logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato'); + } + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // AVATAR DA INSTÂNCIA (foto de perfil do número da sessão) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Busca e salva a foto de perfil do número WhatsApp da instância. + * Chamado pelo WhatsAppConnectionManager após connection 'open'. + */ + async syncInstanceAvatar(instanceId) { + if (!this.sock?.user?.id) + return null; + try { + const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image'); + if (url) { + await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } }); + logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado'); + return url; + } + } + catch { + // Avatar privado ou não disponível — normal para contas com privacidade restrita + } + return null; + } + // ═══════════════════════════════════════════════════════════════════════════ + // RECONCILIAÇÃO DE NOMES VIA PUSHNAME + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Reconcilia nomes de contatos após sync do histórico. + * + * PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos + * sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName. + * Os pushNames só aparecem nas mensagens, não nos contatos do sync. + * + * SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com + * name=null E notify=null, e preenche usando o pushName da mensagem + * recebida mais recente para cada JID. + * + * FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject) + * + * Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats. + */ + async reconcileNamesFromMessages() { + try { + // Busca contatos "sem nome" — candidatos à reconciliação + const unnamed = await prisma_1.prisma.contact.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + name: null, + notify: null, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + { jid: { endsWith: '@newsletter' } }, + { jid: { endsWith: '@g.us' } }, + ], + }, + select: { id: true, jid: true }, + }); + if (unnamed.length === 0) { + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome'); + return; + } + logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens'); + let updated = 0; + for (const contact of unnamed) { + // Busca a mensagem recebida mais recente com pushName para este JID + const msg = await prisma_1.prisma.message.findFirst({ + where: { + instanceId: this.instanceId, + remoteJid: contact.jid, + fromMe: false, + pushName: { not: null }, + }, + orderBy: { timestamp: 'desc' }, + select: { pushName: true }, + }); + if (msg?.pushName) { + await prisma_1.prisma.contact.update({ + where: { id: contact.id }, + data: { name: msg.pushName, notify: msg.pushName }, + }); + updated++; + } + } + logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída'); + // Notifica o frontend para atualizar a lista de chats + if (updated > 0) { + this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', { + instanceId: this.instanceId, + count: updated, + }); + } + } + catch (err) { + logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // FORCE CONTACT SYNC (resyncAppState) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Força re-sync dos contatos via app-state do Baileys. + * + * NOTA: Este método existe mas NÃO é chamado automaticamente. + * Em reconexões, o Baileys não re-envia contacts.upsert. Este método + * força o resync das app-state collections que contêm contatos. + * + * PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com + * "tried remove, but no previous op" — erro de estado corrompido + * no Baileys. Por isso foi removido do trigger automático pós-conexão. + * + * Pode ser chamado manualmente via API se necessário. + */ + async forceContactSync() { + if (!this.sock) + return; + try { + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state'); + await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false); + logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído'); + } + catch (err) { + logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC PÓS-CONEXÃO: AVATARES AUSENTES + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Busca avatares ausentes para chats ativos após reconexão. + * + * Chamado pelo WhatsAppConnectionManager 5s após connection 'open'. + * Cobre o caso em que contacts.upsert não disparou (history sync timeout) + * ou contatos vieram sem imgUrl. + * + * Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro). + * Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA. + */ + async syncMissingAvatars() { + try { + const missing = await prisma_1.prisma.chat.findMany({ + where: { + tenantId: this.tenantId, + instanceId: this.instanceId, + isArchived: false, + NOT: [ + { jid: { endsWith: '@lid' } }, + { jid: { contains: '@broadcast' } }, + ], + contact: { avatarUrl: null }, + }, + select: { jid: true }, + orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } }, + take: MAX_MISSING_AVATAR_SYNC, + }); + if (missing.length === 0) + return; + const jids = missing.map((c) => c.jid); + logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes'); + this.enqueueAvatars(jids); + } + catch (err) { + logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes'); + } + } + /** + * Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira + * em menos de 48 horas ou já expirou. + * + * URLs do WA têm parâmetro `oe=` que indica a expiração. + * Sem renovação, 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; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/MessageHandler.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/MessageHandler.js new file mode 100644 index 0000000..b4b69a8 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/modules/whatsapp/handlers/MessageHandler.js @@ -0,0 +1,758 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MessageHandler = void 0; +/** + * MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico). + * + * Responsabilidades: + * 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append') + * 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net) + * 3. Criar/atualizar Contact, Chat e Message no banco + * 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert) + * 5. Download assíncrono de mídia (imagens, vídeos, docs) + * 6. Disparar chatbot para mensagens recebidas com texto + * + * CONTEXTO LID (Linked ID): + * A partir de 2024, o WhatsApp passou a usar LID como identificador primário + * nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez + * de "5511999999999@s.whatsapp.net". O telefone real está disponível em: + * - key.senderPn: campo do protocolo WA contendo o JID com telefone + * - key.participantPn: idem, usado em contextos de grupo + * - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente) + * + * @see WhatsAppConnectionManager — registra os event listeners do Baileys + * @see ContactHandler — gerencia contatos e avatares + */ +const path_1 = __importDefault(require("path")); +const promises_1 = __importDefault(require("fs/promises")); +const baileys_1 = require("@whiskeysockets/baileys"); +const client_1 = require("@prisma/client"); +const prisma_1 = require("../../../infra/database/prisma"); +const logger_1 = require("../../../config/logger"); +const whatsapp_1 = require("../../../shared/utils/whatsapp"); +const StorageProvider_1 = require("../../../core/StorageProvider"); +const hook_bus_1 = require("../../../core/hook-bus"); +/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ +const MEDIA_DIR = path_1.default.resolve('./media'); +/** + * Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário. + * Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message. + */ +const PROTOCOL_CONTENT_TYPES = new Set([ + 'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo) + 'protocolMessage', // revogações, timers de desaparecimento (protocolo) + 'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro) + 'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo) + 'messageContextInfo', // só metadados, sem conteúdo + 'appStateSyncKeyShare', // sync de estado do app (protocolo) + 'appStateFatalExceptionNotification', // notificação interna (protocolo) + 'keepInChatMessage', // manter no chat (protocolo) +]); +class MessageHandler { + constructor( + /** Socket Baileys ativo — usado para download de mídia e reupload */ + sock, + /** UUID da instância WhatsApp (1 instância = 1 número conectado) */ + instanceId, + /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */ + tenantId, + /** Socket.IO server — emite eventos em tempo real para o frontend */ + io, + /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */ + chatbotService) { + this.sock = sock; + this.instanceId = instanceId; + this.tenantId = tenantId; + this.io = io; + this.chatbotService = chatbotService; + /** + * Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas. + * + * Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net"). + * + * Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true) + * para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa + * sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da + * sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do + * telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o + * Baileys NÃO expõe em `decode-wa-message.js`. + * + * Por isso, o WhatsAppConnectionManager registra um listener extra em + * `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa + * ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid` + * consome a dica e remove a entrada (mapa efêmero, não cresce). + */ + this.peerRecipientHints = new Map(); + } + /** + * Registra uma dica de destinatário extraída da stanza bruta. + * + * Chamado pelo listener `sock.ws.on('CB:message')` no + * WhatsAppConnectionManager. Entradas expiram em 60s como garantia + * contra vazamento caso o `messages.upsert` correspondente nunca chegue. + */ + setPeerRecipientHint(msgId, peerRecipientPn) { + this.peerRecipientHints.set(msgId, peerRecipientPn); + setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).unref(); + } + /** + * Ponto de entrada principal — chamado pelo event listener `messages.upsert`. + * + * @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado + * + * Diferença entre os modos: + * - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia) + * - append: apenas salva no banco em lote (bulk insert), sem emitir eventos + */ + async handle({ messages, type }) { + if (type === 'notify') { + for (const msg of messages) { + try { + await this.processMessage(msg); + } + catch (err) { + logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem'); + } + } + } + else if (type === 'append') { + await this.handleHistory(messages); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa mensagens históricas (history sync / append). + * + * Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany + * por grupo, minimizando round-trips ao banco. + * + * Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup. + * Se não for possível resolver, a mensagem é descartada silenciosamente + * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare). + */ + async handleHistory(messages) { + if (messages.length === 0) + return; + // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat + const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? ''); + // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID) + const byJid = new Map(); + for (const msg of messages) { + let jidRaw = msg.key.remoteJid; + if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) + continue; + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw); + if (ownJid && normalizedRaw === ownJid) + continue; + // ── Resolução LID → phone JID para mensagens históricas ────────── + // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid. + // Precisamos converter para @s.whatsapp.net antes de persistir, + // caso contrário o chat ficaria duplicado (um com LID, outro com phone). + if (jidRaw.endsWith('@lid')) { + // Tenta key.senderPn (disponível quando o protocolo WA inclui sender_pn) + const senderPn = msg.key.senderPn || msg.key.participantPn; + if (senderPn) { + jidRaw = (0, whatsapp_1.normalizeJid)(senderPn); + if (!jidRaw.endsWith('@s.whatsapp.net')) + continue; + } + else { + // Fallback: busca mapeamento LID → phone já salvo no banco + // (populado por phoneNumberShare ou processMessage anterior) + const contact = await prisma_1.prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw }, + select: { jid: true }, + }); + if (contact?.jid && !contact.jid.endsWith('@lid')) { + jidRaw = contact.jid; + } + else { + // Sem resolução possível, ou contato ainda armazenado com JID @lid + // (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn). + continue; + } + } + } + const jid = (0, whatsapp_1.normalizeJid)(jidRaw); + const list = byJid.get(jid) ?? []; + list.push(msg); + byJid.set(jid, list); + } + let totalSaved = 0; + for (const [jid, msgs] of byJid) { + try { + // Calcula o timestamp mais recente para atualizar lastMessageAt do chat + const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0); + const lastAt = latestTs ? new Date(latestTs * 1000) : null; + // 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação) + const contact = await prisma_1.prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + phone: jid.endsWith('@g.us') ? null : jid.split('@')[0], + }, + update: {}, + }); + // 2. Garantir que o Chat existe e está vinculado ao Contact + const chat = await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid, + contactId: contact.id, + lastMessageAt: lastAt, + unreadCount: 0 + }, + update: { + contactId: contact.id, + ...(lastAt && { lastMessageAt: lastAt }) + }, + }); + // 3. Prepara registros para bulk insert (createMany com skipDuplicates) + const rows = msgs.flatMap((msg) => { + const { key, message, messageTimestamp } = msg; + if (!message || !key.id) + return []; + const contentType = (0, baileys_1.getContentType)(message); + if (!contentType) + return []; + // Descarta tipos de protocolo/sistema que não representam conteúdo real + if (PROTOCOL_CONTENT_TYPES.has(contentType)) + return []; + const msgType = this.mapContentType(contentType); + if (msgType === client_1.MessageType.UNSUPPORTED) + return []; + const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date(); + // Extrai o corpo textual — tenta vários campos em ordem de prioridade + const body = message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + null; + const isGroup = jid.endsWith('@g.us'); + return [{ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid: jid, + messageId: key.id, + fromMe: key.fromMe ?? false, + type: msgType, + body, + // pushName: nome público do remetente (só confiável em msgs recebidas) + pushName: (key.fromMe ? null : msg.pushName) ?? null, + // senderJid: em grupos, identifica quem enviou (JID do participante) + senderJid: isGroup ? (key.participant ?? null) : null, + status: 'READ', // históricas são consideradas já lidas + timestamp: ts, + }]; + }); + if (rows.length > 0) { + const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true }); + totalSaved += count; + } + } + catch (err) { + logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat'); + } + } + if (totalSaved > 0) { + logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // TEMPO REAL — Processa uma única mensagem (notify) + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Processa uma mensagem recebida em tempo real. + * + * Fluxo completo: + * 1. Filtra broadcasts, newsletters + * 2. Resolve LID → telefone (se @lid) + * 3. Cria/atualiza Contact com pushName (se aplicável) + * 4. Cria/atualiza Chat vinculado ao Contact + * 5. Persiste a Message no banco + * 6. Emite socket events (message:new, chat:upsert) + * 7. Agenda download de mídia (assíncrono) + * 8. Dispara chatbot (assíncrono) + */ + async processMessage(msg) { + const { key, message, messageTimestamp, pushName } = msg; + if (!message || !key.remoteJid) + return; + // Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp + if (key.remoteJid === 'status@broadcast') + return; + if (key.remoteJid.endsWith('@newsletter')) + return; + if (key.remoteJid.startsWith('0@')) + return; + // ── Resolução LID → telefone ────────────────────────────────────────── + // WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net. + // O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB. + // + // SEM esta resolução, mensagens de contatos que usam LID seriam descartadas + // e o chat apareceria com a foto/número errado no frontend (bug crítico). + // + // Prioridade de resolução: + // 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos) + // 2. key.participantPn — idem, usado em contextos de grupo + // 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare) + /** + * Guarda o JID @lid original quando a resolução é bem-sucedida, para + * persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact). + * Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para + * contatos novos — bug silencioso que fazia o mapeamento ser perdido. + */ + let originalLidJid = null; + if (key.remoteJid.endsWith('@lid')) { + const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key); + if (!phoneJid) { + // Sem resolução: não há como saber o número real do contato. + // A mensagem será perdida, mas o mapeamento será capturado no futuro + // via chats.phoneNumberShare e as próximas mensagens serão processadas. + logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando'); + return; + } + originalLidJid = key.remoteJid; + key.remoteJid = phoneJid; + } + const contentType = (0, baileys_1.getContentType)(message); + if (!contentType) + return; + // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário) + if (PROTOCOL_CONTENT_TYPES.has(contentType)) + return; + const msgType = this.mapContentType(contentType); + // Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED + if (msgType === client_1.MessageType.UNSUPPORTED) + return; + const fromMe = key.fromMe ?? false; + const remoteJidRaw = key.remoteJid; + /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */ + const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw); + const messageId = key.id; + // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais) + const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? ''); + if (ownJid && remoteJid === ownJid) + return; + // ── 1. Contact: criar ou atualizar ──────────────────────────────────── + // REGRA IMPORTANTE sobre pushName: + // - pushName é o "nome público" que o remetente escolheu no WhatsApp + // - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável + // - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão + // → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho") + // - Para GRUPOS: pushName é do remetente individual, não do grupo + // → O nome do grupo vem de groups.upsert → Chat.name (subject) + const isGroupMessage = remoteJid.endsWith('@g.us'); + const participantJid = key.participant ?? msg.participant ?? null; + const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null; + const contact = await prisma_1.prisma.contact.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + name: safePushName, + notify: safePushName, + phone: isGroupMessage ? null : remoteJid.split('@')[0], + // Se resolvemos a partir de @lid, persiste o mapeamento já na criação. + // Sem isso, o próximo @lid do mesmo contato falharia o DB lookup. + ...(originalLidJid && { lidJid: originalLidJid }), + }, + update: { + // Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert + ...(safePushName && { notify: safePushName }), + // Garante que o lidJid fique salvo mesmo em contatos pré-existentes + // que ainda não tinham o mapeamento (ex: criados via history sync). + ...(originalLidJid && { lidJid: originalLidJid }), + }, + }); + // ── 2. Chat: criar ou atualizar, vinculando ao Contact ──────────────── + const chat = await prisma_1.prisma.chat.upsert({ + where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + jid: remoteJid, + contactId: contact.id, + unreadCount: fromMe ? 0 : 1, + lastMessageAt: new Date(messageTimestamp * 1000), + }, + update: { + contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos) + lastMessageAt: new Date(messageTimestamp * 1000), + // fromMe: zera unread (estamos respondendo); !fromMe: incrementa + unreadCount: fromMe ? { set: 0 } : { increment: 1 }, + }, + }); + // ── 3. Extrai corpo textual da mensagem ─────────────────────────────── + // nativeFlowResponseMessage: resposta ao clique de botão/lista + let nativeFlowBody = null; + if (message.interactiveResponseMessage?.nativeFlowResponseMessage) { + try { + const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}'); + nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null; + } + catch { /* ignora JSON inválido */ } + } + const body = message.conversation ?? + message.extendedTextMessage?.text ?? + message.imageMessage?.caption ?? + message.videoMessage?.caption ?? + message.documentMessage?.caption ?? + nativeFlowBody ?? + null; + // ── 4. Contexto de reply (se for resposta a outra mensagem) ─────────── + const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null; + let replyToId = null; + if (replyToWaId) { + const replyMsg = await prisma_1.prisma.message.findUnique({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } }, + }); + replyToId = replyMsg?.id ?? null; + } + // ── 5. Persiste a mensagem (mídia será baixada depois) ──────────────── + const saved = await prisma_1.prisma.message.upsert({ + where: { instanceId_messageId: { instanceId: this.instanceId, messageId } }, + create: { + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + remoteJid, + messageId, + fromMe, + type: msgType, + body, + pushName: fromMe ? null : (pushName ?? null), + senderJid: isGroupMessage ? participantJid : null, + replyToId, + status: fromMe ? 'SENT' : 'PENDING', + timestamp: new Date(messageTimestamp * 1000), + }, + update: {}, // Se já existe (duplicata), não sobrescreve + }); + // ── 6. Emite eventos Socket.IO ──────────────────────────────────────── + // 6a. Para quem está com o chat aberto (sala chat:{id}) + // Inclui replyTo quando é uma resposta — o frontend precisa para exibir + // a caixa de citação (quoted block) em tempo real, sem precisar recarregar. + const emitMsg = replyToId + ? await prisma_1.prisma.message.findUnique({ + where: { id: saved.id }, + include: { + replyTo: { + select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true }, + }, + }, + }) ?? saved + : saved; + this.io.to(`chat:${chat.id}`).emit('message:new', { + ...emitMsg, + pushName, + senderJid: isGroupMessage ? participantJid : null, + }); + // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream + hook_bus_1.hookBus.emit('ext:message.new', { + instanceId: this.instanceId, + tenantId: this.tenantId, + chatId: chat.id, + message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null }, + timestamp: Date.now(), + }).catch(() => { }); + // 6b. Para TODOS os clientes do tenant (atualiza a lista de chats) + // Necessário para: novos chats, chats não abertos, badge de unread + this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp }); + // ── 7. Download de mídia assíncrono (não bloqueia o event loop) ──────── + if (this.isMediaMessage(contentType)) { + setImmediate(() => this.downloadAndPersistMedia(msg, saved.id, chat.id, contentType)); + } + // ── 8. Dispara chatbot para mensagens recebidas com texto ───────────── + if (!fromMe && body && this.chatbotService) { + setImmediate(() => this.chatbotService.handleIncoming({ + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + jid: remoteJid, + text: body, + sock: this.sock, + }).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono'))); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // RESOLUÇÃO LID → TELEFONE + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Converte um JID LID (ex: "123456789@lid") para o JID com telefone + * (ex: "5511999999999@s.whatsapp.net"). + * + * O WhatsApp moderno usa LID (Linked ID) como identificador interno. + * Para exibir o contato corretamente, precisamos mapear para o telefone real. + * + * Fontes de resolução (em ordem de prioridade): + * 1. key.senderPn / key.participantPn — campo do protocolo WA. + * Útil para mensagens RECEBIDAS e contextos de grupo. + * 2. peerRecipientHints[msgId] — dica extraída da stanza bruta + * (atributo `peer_recipient_pn`, não exposto pelo Baileys). + * Única fonte confiável para mensagens fromMe=true enviadas pelo + * celular pra um contato @lid que ainda não temos mapeado. + * 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente). + * Populado por: processamento anterior, chats.phoneNumberShare, + * ou contacts.upsert do history sync. + * + * A persistência do mapeamento LID→phone foi movida para processMessage + * (após o upsert do Contact), para evitar o bug anterior onde o + * `updateMany` disparado aqui afetava 0 rows em contatos inexistentes. + * + * @param lidJid - O JID no formato @lid (ex: "123456789@lid") + * @param key - WAMessageKey (id, fromMe, senderPn, participantPn) + * @returns O JID @s.whatsapp.net correspondente, ou null se não resolver + */ + async resolveLidToPhoneJid(lidJid, key) { + // 1. senderPn vem direto do protocolo WhatsApp (mais confiável) + // OBS: em mensagens fromMe=true sincronizadas do celular, senderPn + // descreve o DONO da sessão, não o destinatário — inútil aqui. + const senderPn = key.senderPn || key.participantPn; + if (senderPn && !key.fromMe) { + const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn); + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid; + } + } + // 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para + // mensagens fromMe=true que chegam via sync de outro dispositivo. + if (key.fromMe && key.id) { + const hint = this.peerRecipientHints.get(key.id); + this.peerRecipientHints.delete(key.id); // uso único + if (hint) { + const phoneJid = (0, whatsapp_1.normalizeJid)(hint); + if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) { + return phoneJid; + } + } + } + // 3. Busca no DB por mapeamento LID salvo anteriormente + const contact = await prisma_1.prisma.contact.findFirst({ + where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid }, + select: { jid: true }, + }); + if (contact?.jid) + return contact.jid; + return null; + } + // ═══════════════════════════════════════════════════════════════════════════ + // EMISSÃO DE EVENTOS SOCKET.IO + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Emite evento `chat:upsert` para todos os clientes do tenant. + * + * Usado para atualizar a lista de chats no frontend em tempo real: + * - Novos chats aparecem instantaneamente + * - Chats existentes atualizam lastMessage, unreadCount, etc. + * - Chats @lid são filtrados (não devem aparecer no frontend) + * + * O frontend usa este evento para reordenar a lista por lastMessageAt + * e exibir o preview da última mensagem. + */ + async emitChatUpsert(chatId, lastMessage) { + try { + const chat = await prisma_1.prisma.chat.findUnique({ + where: { id: chatId }, + include: { + contact: { + select: { + name: true, + verifiedName: true, + notify: true, + phone: true, + avatarUrl: true, + scoreReputacao: true, + flagRestricao: true, + }, + }, + }, + }); + if (!chat) + return; + // Chats @lid são artefatos internos — nunca expor ao frontend + if (chat.jid.endsWith('@lid')) + return; + const c = chat.contact; + const isGroup = chat.jid.endsWith('@g.us'); + // Regra de resolução de nome: + // Grupos: usa Chat.name (subject do grupo, vem de groups.upsert) + // Individuais: name (agenda) > verifiedName (business) > notify (pushname) + // NUNCA usar pushName do remetente como nome de grupo + const displayName = isGroup + ? (chat.name ?? null) + : (c?.name ?? c?.verifiedName ?? c?.notify ?? null); + this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', { + id: chat.id, + tenantId: chat.tenantId, + instanceId: chat.instanceId, + jid: chat.jid, + name: chat.name ?? null, + unreadCount: chat.unreadCount, + lastMessageAt: chat.lastMessageAt, + isPinned: chat.isPinned, + isArchived: chat.isArchived, + botPaused: chat.botPaused, + contact: c + ? { + name: displayName, + phone: c.phone, + avatarUrl: c.avatarUrl, + scoreReputacao: c.scoreReputacao, + flagRestricao: c.flagRestricao, + } + : isGroup + ? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false } + : null, + lastMessage, + }); + } + catch (err) { + logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD DE MÍDIA + // ═══════════════════════════════════════════════════════════════════════════ + /** + * Baixa o conteúdo de mídia de uma mensagem e salva no disco. + * + * Executado via setImmediate() para não bloquear o event loop. + * Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl + * e emite `message:media_ready` para o frontend renderizar o preview. + * + * Organização: /media/{instanceId}/{messageId}.{ext} + */ + async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) { + try { + const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage }); + if (!buffer) + return; + // Para documentos: preserva nome e extensão originais do arquivo + const docMsg = msg.message?.documentMessage; + const originalFileName = docMsg?.fileName?.trim() || null; + const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType); + let ext; + if (contentType === 'documentMessage' && originalFileName) { + ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin'; + } + else { + ext = this.extensionForType(contentType); + } + const fileName = `${savedMsgId}.${ext}`; + const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName); + await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true }); + await promises_1.default.writeFile(filePath, buffer); + // Tenta upload para Wasabi; se falhar usa path local como fallback + let mediaUrl = `/media/${this.instanceId}/${fileName}`; + 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; + 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: filePath, + mediaUrl, + // Preserva o nome original do documento para exibição no frontend + ...(contentType === 'documentMessage' && originalFileName + ? { fileName: originalFileName } + : {}), + }, + }); + // Notifica frontend que a mídia está pronta para exibição + this.io.to(`chat:${chatId}`).emit('message:media_ready', { + messageId: savedMsgId, + mediaUrl, + }); + logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva'); + } + catch (err) { + logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia'); + } + } + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITÁRIOS + // ═══════════════════════════════════════════════════════════════════════════ + /** Verifica se o contentType é de mídia (precisa de download) */ + isMediaMessage(contentType) { + return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType); + } + /** + * Mapeia o contentType do Baileys para o enum MessageType do Prisma. + * Tipos não reconhecidos são mapeados para UNSUPPORTED. + */ + mapContentType(contentType) { + const map = { + conversation: client_1.MessageType.TEXT, + extendedTextMessage: client_1.MessageType.TEXT, + imageMessage: client_1.MessageType.IMAGE, + videoMessage: client_1.MessageType.VIDEO, + audioMessage: client_1.MessageType.AUDIO, + documentMessage: client_1.MessageType.DOCUMENT, + stickerMessage: client_1.MessageType.STICKER, + locationMessage: client_1.MessageType.LOCATION, + contactMessage: client_1.MessageType.CONTACT, + reactionMessage: client_1.MessageType.REACTION, + pollCreationMessage: client_1.MessageType.POLL, + pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos + pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select + // Resposta ao clique de botão / seleção de item de lista + interactiveResponseMessage: client_1.MessageType.INTERACTIVE, + // Mensagem interativa recebida de outro remetente (botões/lista/carrossel) + interactiveMessage: client_1.MessageType.BUTTONS, + }; + return map[contentType] ?? client_1.MessageType.UNSUPPORTED; + } + /** Retorna a extensão de arquivo adequada para o tipo de mídia */ + extensionForType(contentType) { + const map = { + imageMessage: 'jpg', + videoMessage: 'mp4', + audioMessage: 'ogg', + documentMessage: 'bin', + stickerMessage: 'webp', + }; + return map[contentType] ?? 'bin'; + } + /** Retorna o mimetype adequado para o tipo de mídia */ + mimetypeForType(contentType) { + const map = { + imageMessage: 'image/jpeg', + videoMessage: 'video/mp4', + audioMessage: 'audio/ogg', + documentMessage: 'application/octet-stream', + stickerMessage: 'image/webp', + }; + return map[contentType] ?? 'application/octet-stream'; + } +} +exports.MessageHandler = MessageHandler; diff --git a/clube67/newwhats.local/plugins/ext-api/backend/backend/src/shared/utils/whatsapp.js b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/shared/utils/whatsapp.js new file mode 100644 index 0000000..130a2b5 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/backend/src/shared/utils/whatsapp.js @@ -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; +} diff --git a/clube67/newwhats.local/plugins/ext-api/backend/plugins/ext-api/backend/routes.js b/clube67/newwhats.local/plugins/ext-api/backend/plugins/ext-api/backend/routes.js new file mode 100644 index 0000000..a13c9a7 --- /dev/null +++ b/clube67/newwhats.local/plugins/ext-api/backend/plugins/ext-api/backend/routes.js @@ -0,0 +1,1144 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildExtRoutes = buildExtRoutes; +/** + * External REST API — /api/ext/v1/ + * + * Endpoints implementados nesta fase: + * + * GET /api/ext/v1/sessions — lista instâncias do tenant + * POST /api/ext/v1/sessions — cria nova instância + * DELETE /api/ext/v1/sessions/:id — remove instância + * GET /api/ext/v1/sessions/:id/qr — QR base64 da instância + * GET /api/ext/v1/inbox?session=&search=&limit= — lista chats + * GET /api/ext/v1/inbox/:chatId/messages — mensagens paginadas + * POST /api/ext/v1/inbox/:chatId/send — envia mensagem de texto + * + * Envelope de resposta de erro: { error: string } + * Envelope de resposta de sucesso: dados diretos (sem wrapper extra) + */ +const fs_1 = require("fs"); +const promises_1 = require("fs/promises"); +const express_1 = require("express"); +const dragonfly_1 = require("../../../backend/src/infra/cache/dragonfly"); +const brain_1 = require("../../secretaria/brain"); +const rich_message_1 = require("../../../backend/dist/modules/whatsapp/rich-message"); +function uuid() { + try { + return crypto.randomUUID(); + } + catch { + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; + } +} +// ── Handoff timers (in-memory, por convId) ──────────────────────────────────── +const HANDOFF_TIMEOUT_MS = 15 * 60 * 1000; +const handoffTimers = new Map(); +function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) { + const existing = handoffTimers.get(convId); + if (existing) + clearTimeout(existing); + const timer = setTimeout(async () => { + handoffTimers.delete(convId); + try { + await db('sec_conversations').where({ id: convId }) + .update({ handoff_mode: 'ia', handoff_human_at: null }); + // chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId") + const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId; + hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' }); + } + catch { } + }, HANDOFF_TIMEOUT_MS); + handoffTimers.set(convId, timer); +} +function cancelHandoffTimeout(convId) { + const existing = handoffTimers.get(convId); + if (existing) { + clearTimeout(existing); + handoffTimers.delete(convId); + } +} +function buildExtRoutes(prisma, manager, db, config, hooks) { + const router = (0, express_1.Router)(); + // ── Escalation notification listener ────────────────────────────────────── + // When escalar_humano tool fires, send a WA message to the configured admin phone. + hooks.register('ext:escalated', async (data) => { + try { + const rawPhone = await config.get('admin_notify_phone'); + const adminPhone = typeof rawPhone === 'string' ? rawPhone.trim() : ''; + if (!adminPhone) + return; + // Normalise to JID + const digits = adminPhone.replace(/\D/g, ''); + const normalized = digits.startsWith('55') ? digits : '55' + digits; + const jid = `${normalized}@s.whatsapp.net`; + // Find first connected instance for this tenant + const instance = await prisma.instance.findFirst({ + where: { tenantId: data.tenantId }, + select: { id: true }, + }); + if (!instance) + return; + const sock = manager.getSocket(instance.id); + if (!sock) + return; + const proto = data.protocolNumber ? `#${data.protocolNumber}` : ''; + const motivo = data.motivo ? ` — ${data.motivo}` : ''; + const msg = `⚠️ *Escalação* ${proto}${motivo}\nUm cliente precisa de atendimento humano. Abra o painel para assumir.`; + await sock.sendMessage(jid, { text: msg }); + } + catch { /* notificação é best-effort */ } + }); + // ── GET /sessions ────────────────────────────────────────────────────────── + // Lista todas as instâncias do tenant com status e telefone. + router.get('/sessions', async (req, res) => { + const tenantId = req.extTenantId; + try { + const instances = await prisma.instance.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + name: true, + phone: true, + status: true, + avatar: true, + createdAt: true, + }, + }); + res.json(instances); + } + catch (err) { + res.status(500).json({ error: err.message ?? 'Erro interno' }); + } + }); + // ── POST /sessions ──────────────────────────────────────────────────────── + // Cria uma nova instância e dispara a conexão (QR chega via WS). + router.post('/sessions', async (req, res) => { + const tenantId = req.extTenantId; + const { name } = req.body; + if (!name?.trim()) { + res.status(400).json({ error: 'name é obrigatório' }); + return; + } + try { + const instance = await prisma.instance.create({ + data: { tenantId, name: name.trim() }, + select: { id: true, name: true, phone: true, status: true, avatar: true, createdAt: true }, + }); + manager.connect(instance.id, tenantId).catch((err) => { + console.error('[ext-api] Falha ao conectar instância após criação:', err); + }); + res.status(201).json(instance); + } + catch (err) { + res.status(500).json({ error: err.message ?? 'Erro interno' }); + } + }); + // ── DELETE /sessions/:id ────────────────────────────────────────────────── + // Desconecta e remove a instância do tenant. + router.delete('/sessions/:id', async (req, res) => { + const tenantId = req.extTenantId; + const instanceId = req.params['id']; + try { + const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } }); + if (!instance) { + res.status(404).json({ error: 'Instância não encontrada' }); + return; + } + await manager.disconnect(instanceId).catch(() => { }); + await prisma.instance.delete({ where: { id: instanceId } }); + res.json({ message: 'Instância removida' }); + } + catch (err) { + res.status(500).json({ error: err.message ?? 'Erro interno' }); + } + }); + // ── GET /sessions/:id/qr ────────────────────────────────────────────────── + // Retorna o QR code atual em base64 (poll antes de receber via WS). + router.get('/sessions/:id/qr', async (req, res) => { + const tenantId = req.extTenantId; + const instanceId = req.params['id']; + try { + const instance = await prisma.instance.findFirst({ + where: { id: instanceId, tenantId }, + select: { id: true }, + }); + if (!instance) { + res.status(404).json({ error: 'Instância não encontrada' }); + return; + } + const qrBase64 = await dragonfly_1.dragonfly.get(`instance:${instanceId}:qr`); + if (!qrBase64) { + res.status(404).json({ error: 'QR não disponível — inicie a conexão primeiro' }); + return; + } + res.json({ instanceId, qrBase64 }); + } + catch (err) { + res.status(500).json({ error: err.message ?? 'Erro interno' }); + } + }); + // ── GET /media/:messageId ───────────────────────────────────────────────── + // Serve o arquivo de mídia de uma mensagem. + // Se mediaUrl for URL externa (Wasabi etc.) redireciona 302. + // Se for arquivo local, faz stream com suporte a Range (áudio/vídeo). + router.get('/media/:messageId', async (req, res) => { + const tenantId = req.extTenantId; + const messageId = req.params['messageId']; + try { + const msg = await prisma.message.findFirst({ + where: { id: messageId, tenantId }, + select: { mediaUrl: true, mediaPath: true, mimeType: true, fileName: true }, + }); + if (!msg || (!msg.mediaPath && !msg.mediaUrl)) { + res.status(404).json({ error: 'Mídia não encontrada' }); + return; + } + // URL externa (Wasabi / CDN) → redirect + if (msg.mediaUrl && msg.mediaUrl.startsWith('http')) { + res.redirect(302, msg.mediaUrl); + return; + } + const filePath = msg.mediaPath; + if (!filePath) { + res.status(404).json({ error: 'Arquivo não disponível' }); + return; + } + const info = await (0, promises_1.stat)(filePath).catch(() => null); + if (!info) { + res.status(404).json({ error: 'Arquivo não encontrado no disco' }); + return; + } + const mime = msg.mimeType || 'application/octet-stream'; + const size = info.size; + // Content-Disposition: inline para imagens/áudio/vídeo, attachment para docs + const isInline = mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/'); + const disposition = isInline + ? 'inline' + : `attachment; filename="${msg.fileName ?? 'arquivo'}"`; + res.setHeader('Content-Disposition', disposition); + res.setHeader('Content-Type', mime); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Cache-Control', 'private, max-age=3600'); + // Suporte a Range (essencial para