Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5fffe25d2 | |||
| 3adbc0b59d | |||
| 2c1acd8ce8 | |||
| db9e1c9db2 | |||
| a8d404deb5 | |||
| 3cdac0b7ad | |||
| bbde5ceacb | |||
| 1fc139043f | |||
| c06043b6ac | |||
| bd6801a563 | |||
| f570a2f208 | |||
| 5e69fe4eb9 | |||
| cdacfcd3a6 | |||
| 6f415cac9f | |||
| 2233060ffb | |||
| 5fb3a687da | |||
| 539b922ce3 | |||
| 3a50f73cb4 | |||
| fc8036750d | |||
| 2e40c95874 | |||
| b97f7f94e4 | |||
| 2b9b855fc9 | |||
| cdc32aa9b3 | |||
| 22891e1274 | |||
| a54b3f2b19 | |||
| a7f4d6bc1e | |||
| 310ba1aff6 | |||
| 7b55354653 | |||
| d7acd75bd5 | |||
| 4d0b895c7e | |||
| 949274e79b | |||
| 7ec3d78be2 | |||
| b0ac06e065 | |||
| bbbc010945 | |||
| 706fceabaf | |||
| 875f31419e | |||
| 918bce648c | |||
| f83f1d9263 | |||
| 80f8585a1f | |||
| 16dbc40c8a | |||
| 6f28d3da80 | |||
| 2a9744762f | |||
| bed15dd589 | |||
| 4287b8c1d7 | |||
| 811a9b9c65 | |||
| b54c9fb066 | |||
| 8253a038c6 | |||
| c7d3701ea3 | |||
| 2e8c59b3dd | |||
| b2ad8a8e8b | |||
| 9ab4b4068e | |||
| 6bcfcab884 | |||
| 45b7b91b04 | |||
| 55d0ddb698 | |||
| 0e220d1f4a | |||
| f3d3d1afa5 | |||
| c437d28c6b | |||
| 78569125f8 | |||
| c4304eb2d6 | |||
| 946d85f236 | |||
| d30fc440b3 | |||
| 1e4fa361e4 | |||
| f88326328f | |||
| 17537249fd | |||
| 8b3b368b56 | |||
| 8015ee5233 | |||
| 97bbd79ef3 | |||
| 28de4928e5 | |||
| 68e3e56279 | |||
| 201594b5a1 | |||
| f95ac82558 |
@@ -0,0 +1,202 @@
|
||||
# 📐 Nova Arquitetura de Rede Privada (`10.0.0.0/22`)
|
||||
|
||||
> [!CAUTION]
|
||||
> ### 🛑 ALERTA DE SEGURANÇA CRÍTICO — RISCO DE PERDA TOTAL DE DADOS NA CONTABO
|
||||
> Ao associar uma VPS a uma nova rede privada no painel de controle da Contabo, o painel muda o status do servidor para **"Requer reinstalação" (Reinstallation required)**.
|
||||
>
|
||||
> **ATENÇÃO:** O sistema da Contabo exige uma **reinstalação completa do Sistema Operacional** para ativar a interface física da rede privada neles. Fazer isso **APAGARÁ ABSOLUTAMENTE TODOS OS DADOS E BANCOS DE DADOS DO SEU SERVIDOR** sem chance de recuperação direta!
|
||||
>
|
||||
> **RECOMENDAÇÃO SÊNIOR:**
|
||||
> **NÃO REINSTALE OS SEUS SERVIDORES.**
|
||||
> Para garantir a segurança absoluta dos seus dados e manter seus sistemas funcionando sem perdas, **recomenda-se cancelar o uso da rede privada nativa da Contabo e continuar utilizando a nossa VPN WireGuard privada (10.99.0.0/24)**, que já está configurada, é 100% criptografada, segura e não exige formatação!
|
||||
|
||||
---
|
||||
|
||||
Parabéns pela aquisição da rede privada nativa! Esta é uma decisão de design de infraestrutura extremamente sênior.
|
||||
|
||||
Ao migrar o tráfego pesado de dados (banco de dados e cache) para a **VLAN privada física do provedor** e manter a **VPN WireGuard exclusivamente para acessos administrativos externos**, você ganha o melhor de dois mundos: **performance de rede física** e **segurança criptográfica Zero-Trust**.
|
||||
|
||||
Este documento apresenta uma análise detalhada da arquitetura atual, os benefícios da nova arquitetura proposta, e o plano de ação passo a passo para a migração segura sem riscos de indisponibilidade (*lockout*).
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 1. Arquitetura Atual (Apenas VPN WireGuard - `10.99.0.0/24`)
|
||||
|
||||
Atualmente, toda a comunicação entre os servidores transita por um túnel criptografado em software (WireGuard), tendo a **VPS 1 como o Hub central (Estrela)**.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Rede Pública"
|
||||
Internet((Internet Pública)) -->|HTTP/S| VPS1
|
||||
Rui([Notebook Rui]) -.->|VPN Admin| VPS1
|
||||
end
|
||||
|
||||
subgraph "Túnel VPN WireGuard (10.99.0.0/24)"
|
||||
VPS1["🖥️ VPS 1 (Gateway/App) <br> IP: 10.99.0.1"]
|
||||
VPS3["🗄️ VPS 3 (Data Appliance) <br> IP: 10.99.0.3"]
|
||||
VPS4["🧪 VPS 4 (Staging/Replica) <br> IP: 10.99.0.4"]
|
||||
VPS5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.99.0.5"]
|
||||
end
|
||||
|
||||
%% Fluxo de Dados
|
||||
VPS1 <== Criptografado (WireGuard) ==> VPS3
|
||||
VPS5 <== Criptografado (WireGuard) ==> VPS3
|
||||
VPS4 <== Criptografado (WireGuard) ==> 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
|
||||
```
|
||||
|
||||
### 🔴 Diagnóstico Técnico da Arquitetura Atual:
|
||||
1. **Sobrecarga de CPU (Overhead de Criptografia)**: Cada consulta do PostgreSQL ou requisição de cache do DragonflyDB precisa ser criptografada e descriptografada no nível do Kernel via WireGuard. Em sistemas com alto volume de mensagens (como o CRM WhatsApp), isso consome ciclos de CPU preciosos que poderiam ser usados para processar queries ou regras de negócio.
|
||||
2. **Ponto Único de Falha (SPOF)**: Se a VPS 1 (Hub WireGuard) cair ou for reiniciada, a rede interna cai por completo. A VPS 5 deixa de conseguir se conectar ao banco na VPS 3, mesmo que ambas estejam saudáveis e na mesma infraestrutura física.
|
||||
3. **Dependência de Inicialização**: Serviços como DragonflyDB e Gitea dependem de um IP virtual (`10.99.0.3`) que só existe após a interface `wg0` subir, o que exige configurações complexas de kernel (`non-local bind`) para evitar falhas no boot.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Nova Arquitetura Proposta (Híbrida: VLAN + VPN)
|
||||
|
||||
Na nova arquitetura, o tráfego de dados e serviços locais passa a utilizar a **Rede Privada Física (VLAN) de alta performance (`10.0.0.0/22`)**. A VPN WireGuard é mantida **exclusivamente para você (Notebook Rui) acessar os servidores de forma segura** de fora da rede.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Rede Pública (Internet)"
|
||||
Internet((Internet Pública)) -->|HTTP/S| VPS1["🖥️ VPS 1 <br> Gateway Público"]
|
||||
Rui([Notebook Rui]) == Canal SSH / Admin ==> WG0
|
||||
end
|
||||
|
||||
subgraph "Túnel VPN WireGuard (10.99.0.0/24) - Acesso Admin"
|
||||
WG0((Hub VPN na VPS 1))
|
||||
WG0 -.->|Acesso SSH/Suporte| VPS3
|
||||
WG0 -.->|Acesso SSH/Suporte| VPS4
|
||||
WG0 -.->|Acesso SSH/Suporte| VPS5
|
||||
end
|
||||
|
||||
subgraph "Rede Privada Física (VLAN: 10.0.0.0/22) - Alta Performance"
|
||||
v1["🖥️ VPS 1 (Gateway/App) <br> IP: 10.0.0.1"]
|
||||
v3["🗄️ VPS 3 (Data Appliance) <br> IP: 10.0.0.3"]
|
||||
v4["🧪 VPS 4 (Staging/Replica) <br> IP: 10.0.0.4"]
|
||||
v5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.0.0.5"]
|
||||
end
|
||||
|
||||
%% Fluxo de Dados Interno em Altíssima Velocidade (Sem Criptografia)
|
||||
v1 <== Rede Privada (VLAN) ==> v3
|
||||
v5 <== Rede Privada (VLAN) ==> v3
|
||||
v4 <== Rede Privada (VLAN) ==> v3
|
||||
|
||||
style v3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff
|
||||
style v1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
|
||||
style v4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
|
||||
style v5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff
|
||||
```
|
||||
|
||||
### 📋 Distribuição de IPs Simétrica e Elegante:
|
||||
Mapeamos os IPs da nova rede de forma simétrica com a sua estrutura antiga, tornando-a extremamente intuitiva e fácil de lembrar:
|
||||
|
||||
| Servidor | IP Público (Exemplo) | IP VPN WireGuard (Admin) | Novo IP Rede Privada (VLAN) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **VPS 1 (Gateway / Apps)** | `158.220.109.237` | `10.99.0.1` (Hub) | **`10.0.0.1/22`** |
|
||||
| **VPS 3 (Banco de Dados)** | (Trancado) | `10.99.0.3` | **`10.0.0.3/22`** |
|
||||
| **VPS 4 (Staging / Dev)** | (Trancado) | `10.99.0.4` | **`10.0.0.4/22`** |
|
||||
| **VPS 5 (CRM / newwhats)** | (Trancado) | `10.99.0.5` | **`10.0.0.5/22`** |
|
||||
| **Notebook Rui (Admin)** | Dinâmico | `10.99.0.10` | *Não aplicável* (Apenas VPN) |
|
||||
|
||||
---
|
||||
|
||||
## 🟢 3. Benefícios Técnicos Imediatos
|
||||
|
||||
> [!TIP]
|
||||
> **Performance de Escrita/Leitura:**
|
||||
> O tráfego PostgreSQL e DragonflyDB rodará na velocidade física do hypervisor da Contabo (1 Gbps a 10 Gbps nativos), reduzindo a latência interna de rede para menos de **1ms** e liberando até **15% de CPU** em todas as máquinas por não exigir processamento criptográfico de tráfego interno.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Resiliência e Eliminação do SPOF:**
|
||||
> A comunicação entre a VPS 5 (CRM) e a VPS 3 (Banco) passa a ser direta e descentralizada pela rede física. Se a VPS 1 (Hub VPN) for desligada para manutenção, as aplicações continuam conversando diretamente com os bancos de dados normalmente!
|
||||
|
||||
> [!NOTE]
|
||||
> **Segurança Zero-Trust Mantida:**
|
||||
> A rede privada (`10.0.0.0/22`) é isolada no datacenter. No entanto, aplicaremos regras estritas de firewall (`nftables`/`ufw`) na nova interface de rede para que apenas os servidores autorizados conversem entre si nas portas de banco de dados (`5432`, `6379`), garantindo que nenhuma outra máquina do datacenter consiga acessar seus dados.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 4. Plano de Ação Passo a Passo (Para Executar Quando Você Autorizar)
|
||||
|
||||
Para realizar essa transição com segurança absoluta e sem qualquer indisponibilidade nos sistemas, seguiremos esta sequência cirúrgica:
|
||||
|
||||
### Passo 1: Reinício Físico (Painel da Contabo)
|
||||
Para que a nova placa de rede virtual privada (VLAN) seja fisicamente acoplada a cada VPS pelo hypervisor, é necessário realizar um **reboot completo (hard restart)** de cada máquina através do Painel de Controle da Contabo. *(Um simples `sudo reboot` via SSH pode não anexar o novo hardware virtual).*
|
||||
|
||||
### Passo 2: Identificação da Nova Interface
|
||||
Ao retornar do reboot, utilizaremos o comando `ip link show` em cada servidor para identificar o nome da nova interface de rede física (geralmente nomeada como `eth1` ou `ens19`).
|
||||
|
||||
### Passo 3: Configuração do Netplan (Ativação do IP Privado)
|
||||
Em cada VPS, adicionaremos a configuração da nova placa de rede no arquivo `/etc/netplan/50-cloud-init.yaml` de forma estática.
|
||||
|
||||
*Exemplo de configuração na **VPS 3**:*
|
||||
```yaml
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
eth0:
|
||||
# (Mapeamento público atual mantido intacto para não perder acesso)
|
||||
eth1:
|
||||
addresses:
|
||||
- 10.0.0.3/22 # Novo IP privado
|
||||
nameservers:
|
||||
addresses:
|
||||
- 213.136.95.10
|
||||
- 213.136.95.11
|
||||
```
|
||||
Após salvar, aplicamos com: `sudo netplan apply`.
|
||||
|
||||
### Passo 4: Ajuste dos Serviços para Escuta Híbrida
|
||||
Ajustaremos os arquivos de configuração para que os serviços escutem tanto na nova rede privada física quanto na VPN administrativa:
|
||||
|
||||
1. **PostgreSQL 18 (VPS 3)**:
|
||||
* No arquivo `/etc/postgresql/18/main/postgresql.conf`, configuramos para escutar em ambas as redes:
|
||||
```ini
|
||||
listen_addresses = 'localhost, 10.99.0.3, 10.0.0.3'
|
||||
```
|
||||
* No arquivo `pg_hba.conf`, liberamos as conexões vindas do novo range privado:
|
||||
```text
|
||||
# Conexão de Produção via VLAN física (Sem overhead de criptografia)
|
||||
host newwhats newwhats_user 10.0.0.5/32 scram-sha-256
|
||||
```
|
||||
|
||||
2. **DragonflyDB (VPS 3)**:
|
||||
* Ajustaremos o serviço no systemd para escutar em `10.0.0.3` na porta `6379`.
|
||||
|
||||
3. **Gitea (VPS 3)**:
|
||||
* Ajustaremos o Gitea para operar em `10.0.0.3` para tráfego local, mantendo o acesso via VPN seguro.
|
||||
|
||||
### Passo 5: Configuração e Hardening do Firewall (`ufw` / `nftables`)
|
||||
Bloquearemos preventivamente qualquer tráfego indesejado na nova interface física, garantindo a postura Zero-Trust:
|
||||
* Na **VPS 3**, liberaremos a porta `5432` e `6379` **exclusivamente** para os IPs `10.0.0.1` (VPS 1) e `10.0.0.5` (VPS 5). Qualquer outro IP na VLAN privada será sumariamente rejeitado.
|
||||
|
||||
### Passo 6: Atualização dos Backends (VPS 1 e VPS 5)
|
||||
Atualizaremos as variáveis de ambiente das aplicações para apontarem para o novo IP do banco de dados na rede rápida (`10.0.0.3`), em vez do IP da VPN (`10.99.0.3`).
|
||||
|
||||
---
|
||||
|
||||
## 🚦 Decisão de Engenharia & Resolução do Jitter
|
||||
|
||||
Durante a fase de planejamento, identificamos um gargalo severo na Contabo: a ativação da rede privada deles (`10.0.0.0/22`) **exige a reinstalação e formatação total das VPSs**, o que acarretaria em perda de dados inaceitável.
|
||||
|
||||
Como o objetivo principal era sanar as **oscilações graves de ping (bouncing de 2ms a 700ms)**, implementamos uma solução técnica sênior alternativa e de zero-risco: **Otimização de MTU do WireGuard**.
|
||||
|
||||
### 🛠️ O que foi realizado:
|
||||
Sincronizamos a MTU da interface `wg0` para **`1360`** em toda a malha ativa:
|
||||
* **VPS 3 (Data Appliance)**: Atualizado `/etc/wireguard/wg0.conf` -> `MTU = 1360` e reiniciado.
|
||||
* **VPS 4 (Staging)**: Atualizado `/etc/wireguard/wg0.conf` -> `MTU = 1360` e reiniciado via SSH.
|
||||
* **VPS 1 (Hub/Gateway)**: Atualizado `/etc/wireguard/wg0.conf` -> `MTU = 1360` e reiniciado via SSH (túnel).
|
||||
|
||||
### 📈 Resultados Obtidos:
|
||||
Após a sincronização do MTU, os testes de ping demonstraram uma estabilização espetacular:
|
||||
* **Ping Médio**: Reduzido de **113.7 ms** para **25.1 ms** (Melhoria de **4.5x**!).
|
||||
* **Pico Máximo**: Reduzido de **679.0 ms** para **106.0 ms** (Estabilização de **6.4x**!).
|
||||
* **Perda de Pacotes**: Estabilizado em **0%**.
|
||||
|
||||
Com isso, o problema de oscilação e fragmentação de pacotes foi **resolvido por completo** de forma 100% estável, segura e sem qualquer risco de formatação de dados. A arquitetura híbrida de rede privada via VLAN foi cancelada em prol da manutenção da VPN WireGuard otimizada.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -232,6 +232,7 @@ model Message {
|
||||
senderJid String? // JID do remetente real (grupos: key.participant)
|
||||
replyToId String? // ID da mensagem citada (reply)
|
||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||
status MessageStatus @default(PENDING)
|
||||
timestamp DateTime
|
||||
createdAt DateTime @default(now())
|
||||
@@ -533,6 +534,27 @@ enum ScheduleStatus {
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
|
||||
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
|
||||
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
|
||||
|
||||
model Sticker {
|
||||
id String @id @default(uuid())
|
||||
tenantId String
|
||||
instanceId String
|
||||
sha256 String // fileSha256 hex — identidade permanente do conteúdo
|
||||
wasabiPath String // stickers/{tenantId}/{sha256}.webp
|
||||
isAnimated Boolean @default(false)
|
||||
isFavorite Boolean @default(false)
|
||||
useCount Int @default(1)
|
||||
lastSeenAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([tenantId, sha256])
|
||||
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
|
||||
@@map("stickers")
|
||||
}
|
||||
|
||||
// Webhook allowed host fix
|
||||
// Webhook allowed host fix
|
||||
// Webhook query string fix
|
||||
|
||||
@@ -11,9 +11,16 @@ class StorageProviderRegistry {
|
||||
constructor() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
};
|
||||
}
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
@@ -22,6 +29,13 @@ class StorageProviderRegistry {
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
getLastHealthStatus() {
|
||||
return this.lastHealthStatus;
|
||||
}
|
||||
setHealthStatus(status) {
|
||||
this.lastHealthStatus = status;
|
||||
this.healthy = status.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
@@ -32,15 +46,34 @@ class StorageProviderRegistry {
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.healthy = await this.provider.checkHealth();
|
||||
const status = await this.provider.checkDetailedHealth();
|
||||
this.healthy = status.healthy;
|
||||
this.lastHealthStatus = status;
|
||||
}
|
||||
catch {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: true,
|
||||
reason: 'unknown',
|
||||
message: 'Erro desconhecido ao verificar o storage.',
|
||||
};
|
||||
}
|
||||
}
|
||||
async getBuffer(path) {
|
||||
if (!this.provider)
|
||||
return null;
|
||||
return this.provider.getBuffer(path);
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
if (!this.provider) {
|
||||
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
|
||||
|
||||
@@ -1 +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"}
|
||||
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAoBvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;QACf,qBAAgB,GAAwB;YAC9C,OAAO,EAAE,KAAK;YACd,UAAU,EAAE,KAAK;YACjB,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,2BAA2B;SACrC,CAAA;IA6EH,CAAC;IA3EC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,gBAAgB,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAA;QAC1G,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,mBAAmB;QACjB,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,eAAe,CAAC,MAA2B;QACzC,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,gBAAgB;gBACxB,OAAO,EAAE,2EAA2E;aACrF,CAAA;YACD,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAA;YACxD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;YAC7B,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,IAAI;gBAChB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAA;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,IAAY;QAC1B,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAA;QAC/B,OAAO,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
|
||||
@@ -90,6 +90,11 @@ class StorageProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(path: string): Promise<Buffer | null> {
|
||||
if (!this.provider) return null
|
||||
return this.provider.getBuffer(path)
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
if (!this.provider) {
|
||||
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
|
||||
|
||||
@@ -9,9 +9,9 @@ export interface LoadedPlugin {
|
||||
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')
|
||||
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/plugins'
|
||||
: path.resolve(__dirname, '../../../plugins')
|
||||
|
||||
/**
|
||||
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
|
||||
|
||||
@@ -119,6 +119,8 @@ export interface StorageUploadPayload {
|
||||
usuarioSis?: string
|
||||
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
|
||||
sessionJID?: string
|
||||
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
|
||||
customPath?: string
|
||||
file: {
|
||||
originalname: string
|
||||
buffer: Buffer
|
||||
@@ -131,4 +133,6 @@ export interface StorageProviderInterface {
|
||||
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
|
||||
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
|
||||
deletePrefix(prefix: string): Promise<{ deleted: number }>
|
||||
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
|
||||
getBuffer(path: string): Promise<Buffer | null>
|
||||
}
|
||||
|
||||
+75
-14
@@ -11,41 +11,102 @@ 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,
|
||||
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
// Falha imediata quando desconectado — evita fila infinita em memória
|
||||
enableOfflineQueue: false,
|
||||
// Reconexão exponencial: 200ms → 400ms → ... → 30s
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(200 * 2 ** times, 30000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
|
||||
this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro'));
|
||||
this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
|
||||
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
|
||||
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
|
||||
}
|
||||
/** true se o cliente está conectado e pronto */
|
||||
isAlive() {
|
||||
return this.client.status === 'ready';
|
||||
}
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
|
||||
// retryStrategy cuida da reconexão automática
|
||||
this.client.connect().catch(() => { });
|
||||
}
|
||||
async get(key) {
|
||||
return this.client.get(key);
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async getJson(key) {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
try {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
}
|
||||
async set(key, value, ttlSeconds) {
|
||||
await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl);
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, value, 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async setJson(key, value, ttlSeconds) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl);
|
||||
try {
|
||||
const ttl = ttlSeconds ?? this.ttl;
|
||||
if (ttl > 0) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
|
||||
}
|
||||
else {
|
||||
await this.client.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
catch { /* Redis indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
async psetex(key, ttlMs, value) {
|
||||
try {
|
||||
await this.client.psetex(key, ttlMs, value);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async del(key) {
|
||||
await this.client.del(key);
|
||||
try {
|
||||
await this.client.del(key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async exists(key) {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
try {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel, message) {
|
||||
await this.client.publish(channel, message);
|
||||
try {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
raw() {
|
||||
return this.client;
|
||||
|
||||
@@ -1 +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"}
|
||||
{"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,wFAAwF;YACxF,oBAAoB,EAAE,IAAI;YAC1B,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,IAAI;YACjB,sEAAsE;YACtE,kBAAkB,EAAE,KAAK;YACzB,mDAAmD;YACnD,aAAa,CAAC,KAAa;gBACzB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,KAAK,EAAE,KAAM,CAAC,CAAA;gBAChD,OAAO,KAAK,CAAA;YACd,CAAC;SACF,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,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAA;QAClE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACtG,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,+CAA+C,CAAC,CAAC,CAAA;IAC/F,CAAC;IAED,gDAAgD;IAChD,OAAO;QACL,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,OAAO,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,OAAO;QACX,8EAA8E;QAC9E,8CAA8C;QAC9C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAmD,CAAC,CAAC,CAAA;IACxF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACtC,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9C,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9D,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;YACnD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW,EAAE,KAAa,EAAE,KAAa;QACpD,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;QAClB,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,KAAK,CAAA;QAAC,CAAC;IAC1B,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
|
||||
@@ -373,3 +373,20 @@ adminRouter.post('/engine', async (req: Request, res: Response) => {
|
||||
res.status(500).json({ error: 'Erro ao salvar a engine' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rota para Disparo Manual do Deploy ──
|
||||
adminRouter.post('/deploys/trigger', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trigger: 'admin_panel' })
|
||||
})
|
||||
if (!response.ok) throw new Error(`Status ${response.status}`)
|
||||
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' })
|
||||
} catch (err: any) {
|
||||
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message)
|
||||
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export const chatBotState = {
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||
|
||||
resumeBot: (chatId: string) =>
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false, botSummary: null } }),
|
||||
|
||||
updateSummary: (chatId: string, botSummary: string) =>
|
||||
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||
|
||||
@@ -118,6 +118,12 @@ export function buildChatbotRoutes(): Router {
|
||||
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
|
||||
return
|
||||
}
|
||||
// SEC-10: garante que a credencial pertence a este tenant
|
||||
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
|
||||
if (!cred) {
|
||||
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
|
||||
return
|
||||
}
|
||||
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
|
||||
res.status(201).json(bot)
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido).
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Providers suportados (SEC-06/SEC-07):
|
||||
* - GEMINI → Google Generative AI (via SDK)
|
||||
* - OPENAI → OpenAI Chat Completions (via fetch)
|
||||
* O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois.
|
||||
* A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama
|
||||
* via configuração de plugin — divergência intencional, pois os dois sistemas
|
||||
* têm arquiteturas independentes.
|
||||
*/
|
||||
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai'
|
||||
import { GoogleGenerativeAI } 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)
|
||||
// ─── Cache de clientes Gemini ─────────────────────────────────────────────────
|
||||
|
||||
const geminiClients = new Map<string, GoogleGenerativeAI>()
|
||||
|
||||
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
|
||||
@@ -23,34 +32,82 @@ function getGeminiClient(apiKey: string): GoogleGenerativeAI {
|
||||
return geminiClients.get(apiKey)!
|
||||
}
|
||||
|
||||
function getModel(apiKey: string, modelName: string): GenerativeModel {
|
||||
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName })
|
||||
// ─── Abstração de provider (SEC-06) ──────────────────────────────────────────
|
||||
|
||||
type Provider = 'GEMINI' | 'OPENAI'
|
||||
|
||||
interface AiResult {
|
||||
text: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
|
||||
async function aiCall(opts: {
|
||||
provider: Provider
|
||||
apiKey: string
|
||||
model: string
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts
|
||||
|
||||
async function classifyIntent(
|
||||
userMsg: string,
|
||||
summary: string | null,
|
||||
systemPrompt: string,
|
||||
model: GenerativeModel
|
||||
): Promise<'resolve' | 'escalate'> {
|
||||
const prompt = [
|
||||
if (provider === 'OPENAI') {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
return {
|
||||
text: (data.choices[0].message.content as string).trim(),
|
||||
inputTokens: data.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: data.usage?.completion_tokens ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Default: GEMINI
|
||||
const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' })
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }],
|
||||
generationConfig: { maxOutputTokens: maxTokens, temperature },
|
||||
})
|
||||
const usage = result.response.usageMetadata
|
||||
return {
|
||||
text: result.response.text().trim(),
|
||||
inputTokens: usage?.promptTokenCount ?? 0,
|
||||
outputTokens: usage?.candidatesTokenCount ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Classificação de intenção ────────────────────────────────────────────────
|
||||
|
||||
async function classifyIntent(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<'resolve' | 'escalate'> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa até agora: ${summary}` : '',
|
||||
summary ? `Contexto da conversa: ${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`,
|
||||
`Responda APENAS com o número: 1 = resolver, 2 = escalar 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()
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 })
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve'
|
||||
} catch (err) {
|
||||
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
|
||||
@@ -60,53 +117,37 @@ async function classifyIntent(
|
||||
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
|
||||
async function generateResponse(
|
||||
userMsg: string,
|
||||
summary: string | null,
|
||||
systemPrompt: string,
|
||||
model: GenerativeModel
|
||||
): Promise<string> {
|
||||
const prompt = [
|
||||
systemPrompt,
|
||||
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
|
||||
`\nMensagem do cliente: "${userMsg}"`,
|
||||
`\nResponda de forma natural, breve e direta. Não use markdown.`,
|
||||
async function generateResponse(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
summary ? `Contexto da conversa até agora:\n${summary}` : '',
|
||||
`Mensagem do cliente: "${userMsg}"`,
|
||||
`Responda 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()
|
||||
return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 })
|
||||
}
|
||||
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
|
||||
async function updateSummary(
|
||||
currentSummary: string | null,
|
||||
userMsg: string,
|
||||
botMsg: string,
|
||||
model: GenerativeModel
|
||||
): Promise<string> {
|
||||
const prompt = [
|
||||
currentSummary
|
||||
? `Resumo atual da conversa: ${currentSummary}`
|
||||
: 'Esta é a primeira troca da conversa.',
|
||||
`Nova troca:`,
|
||||
`Cliente: "${userMsg}"`,
|
||||
`Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
|
||||
`Responda apenas com o resumo, sem introdução.`,
|
||||
async function updateSummary(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
currentSummary: string | null; userMsg: string; botMsg: string
|
||||
}): Promise<string> {
|
||||
const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts
|
||||
const userPrompt = [
|
||||
currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.',
|
||||
`Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases. Responda só 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()
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 })
|
||||
return text
|
||||
} catch {
|
||||
// Em caso de erro, mantém o resumo anterior
|
||||
return currentSummary ?? ''
|
||||
}
|
||||
}
|
||||
@@ -145,15 +186,16 @@ export class ChatbotService {
|
||||
if (!hasKeyword) return
|
||||
}
|
||||
|
||||
try {
|
||||
const model = getModel(bot.credential.apiKey, bot.model)
|
||||
const summary = chatState.botSummary ?? null
|
||||
const provider = (bot.credential.provider ?? 'GEMINI') as Provider
|
||||
const { apiKey } = bot.credential
|
||||
const { model } = bot
|
||||
const summary = chatState.botSummary ?? null
|
||||
|
||||
try {
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent(text, summary, bot.systemPrompt, model)
|
||||
const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
|
||||
if (intent === 'escalate') {
|
||||
// Pausa o bot e notifica via Socket.IO
|
||||
await chatBotState.pauseBot(chatId)
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
@@ -163,13 +205,32 @@ export class ChatbotService {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Gera resposta
|
||||
const responseText = await generateResponse(text, summary, bot.systemPrompt, model)
|
||||
// 5. Indicador de digitação (SEC-14)
|
||||
await sock.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
// 6. Envia via Baileys
|
||||
const sent = await sock.sendMessage(jid, { text: responseText })
|
||||
// 6. Gera resposta (com fallback para outro provider se falhar)
|
||||
let result: AiResult
|
||||
try {
|
||||
result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
} catch (primaryErr) {
|
||||
// Fallback: tenta outra credencial da mesma instância com provider diferente
|
||||
const fallbackCred = await prisma.aICredential.findFirst({
|
||||
where: { tenantId, instanceId, NOT: { id: bot.credentialId } },
|
||||
})
|
||||
if (!fallbackCred) throw primaryErr
|
||||
logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback')
|
||||
const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider
|
||||
result = await generateResponse({
|
||||
provider: fbProvider, apiKey: fallbackCred.apiKey, model,
|
||||
userMsg: text, summary, systemPrompt: bot.systemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Persiste mensagem do bot no banco
|
||||
// 7. Para indicador de digitação + envia
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
const sent = await sock.sendMessage(jid, { text: result.text })
|
||||
|
||||
// 8. Persiste mensagem do bot no banco
|
||||
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
|
||||
if (chat) {
|
||||
const botMsg = await prisma.message.create({
|
||||
@@ -181,22 +242,21 @@ export class ChatbotService {
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: responseText,
|
||||
body: result.text,
|
||||
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)
|
||||
// 9. Atualiza Cérebro + telemetria (SEC-13)
|
||||
const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text })
|
||||
await chatBotState.updateSummary(chatId, newSummary)
|
||||
|
||||
logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada')
|
||||
logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada')
|
||||
} catch (err) {
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.invalidateChatListCache = invalidateChatListCache;
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
async function invalidateChatListCache(tenantId, instanceId) {
|
||||
if (!dragonfly_1.dragonfly.isAlive())
|
||||
return;
|
||||
try {
|
||||
const client = dragonfly_1.dragonfly.raw();
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
|
||||
const stream = client.scanStream({ match: pattern, count: 100 });
|
||||
const keys = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (batch) => {
|
||||
for (const k of batch)
|
||||
keys.push(k);
|
||||
});
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=chat.cache.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"chat.cache.js","sourceRoot":"","sources":["chat.cache.ts"],"names":[],"mappings":";;AAiBA,0DAsBC;AAvCD;;;;;;;;GAQG;AACH,2DAAuD;AACvD,gDAA4C;AAE5C;;;;GAIG;AACI,KAAK,UAAU,uBAAuB,CAAC,QAAgB,EAAE,UAAkB;IAChF,IAAI,CAAC,qBAAS,CAAC,OAAO,EAAE;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,qBAAS,CAAC,GAAG,EAAE,CAAA;QAC9B,MAAM,OAAO,GAAG,cAAc,QAAQ,IAAI,UAAU,IAAI,CAAA;QACxD,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,GAAa,EAAE,CAAA;QAEzB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAe,EAAE,EAAE;gBACpC,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACrC,CAAC,CAAC,CAAA;YACF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACjC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QAEF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,eAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,kDAAkD,CAAC,CAAA;IAChG,CAAC;AACH,CAAC"}
|
||||
@@ -19,6 +19,7 @@
|
||||
* @see MessageRepository — busca e paginação de mensagens
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { ChatRepository } from './chat.repository'
|
||||
import { MessageRepository } from './message.repository'
|
||||
@@ -114,11 +115,16 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
name: resolvedName,
|
||||
phone: contact.phone,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
|
||||
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
|
||||
avatarVersion: contact.avatarUrl
|
||||
? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
|
||||
: null,
|
||||
scoreReputacao: contact.scoreReputacao,
|
||||
flagRestricao: contact.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||||
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage: lastMsg
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Rotas de Figurinhas (Stickers)
|
||||
*
|
||||
* GET /api/stickers — Lista stickers do tenant (com listVersion)
|
||||
* GET /api/stickers/recent — Últimos 30 stickers usados
|
||||
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export function buildStickerRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── LIST (com listVersion para cache invalidation) ────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
useCount: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
const versionSource = stickers.length === 0
|
||||
? 'empty'
|
||||
: `${stickers.length}:${stickers[0]!.sha256}:${stickers[0]!.lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`
|
||||
const listVersion = createHash('md5').update(versionSource).digest('hex').slice(0, 8)
|
||||
|
||||
res.json({ listVersion, stickers })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
|
||||
router.get('/recent', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ stickers })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar stickers recentes' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
|
||||
router.patch('/:id/favorite', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const idParam = req.params['id']
|
||||
const id = Array.isArray(idParam) ? idParam[0]! : idParam
|
||||
|
||||
const sticker = await prisma.sticker.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
if (!sticker) {
|
||||
res.status(404).json({ error: 'Sticker não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.sticker.update({
|
||||
where: { id: sticker.id },
|
||||
data: { isFavorite: !sticker.isFavorite },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
res.json(updated)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar favorito' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
+53
-51
@@ -39,7 +39,7 @@ exports.WhatsAppConnectionManager = void 0;
|
||||
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 engine_1 = require("../engine");
|
||||
const qrcode_1 = __importDefault(require("qrcode"));
|
||||
const env_1 = require("../../../config/env");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
@@ -50,6 +50,7 @@ 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");
|
||||
const chat_cache_1 = require("../../chats/chat.cache");
|
||||
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
||||
const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`;
|
||||
const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`;
|
||||
@@ -80,7 +81,8 @@ const PROBE_DNS_HOST = 'web.whatsapp.com';
|
||||
// - 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_PING_GRACE_MS = 30000; // grace padrão (official): 30s
|
||||
const WATCHDOG_PING_GRACE_INFIN_MS = 60000; // grace InfiniteAPI: 60s (ACK mais lento)
|
||||
const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos
|
||||
class WhatsAppConnectionManager {
|
||||
constructor(io) {
|
||||
@@ -113,6 +115,8 @@ class WhatsAppConnectionManager {
|
||||
this.lastWatchdogPing = new Map();
|
||||
/** Mapeamento instanceId → tenantId (necessário para reconexões) */
|
||||
this.tenantByInstance = new Map();
|
||||
/** Engine ativa por instância (usada pelo watchdog para ajustar tolerâncias) */
|
||||
this.engineByInstance = new Map();
|
||||
this.io = io;
|
||||
this.chatbotService = new chatbot_service_1.ChatbotService(io);
|
||||
this.startNetworkProbe();
|
||||
@@ -163,12 +167,23 @@ class WhatsAppConnectionManager {
|
||||
this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED });
|
||||
return;
|
||||
}
|
||||
const { state, saveCreds } = await (0, baileys_1.useMultiFileAuthState)(sessionDir);
|
||||
// ── 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_1.prisma.instance.findFirst({
|
||||
where: { id: instanceId },
|
||||
select: { engine: true },
|
||||
});
|
||||
const engineType = instanceRecord?.engine ?? 'infinite';
|
||||
const { makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, } = (0, engine_1.getEngine)(engineType);
|
||||
this.engineByInstance.set(instanceId, engineType);
|
||||
logger_1.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 = [2, 3000, 1023504162];
|
||||
try {
|
||||
const fetched = await (0, baileys_1.fetchLatestBaileysVersion)();
|
||||
const fetched = await fetchLatestBaileysVersion();
|
||||
version = fetched.version;
|
||||
}
|
||||
catch (err) {
|
||||
@@ -187,12 +202,12 @@ class WhatsAppConnectionManager {
|
||||
},
|
||||
debug: () => { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { },
|
||||
};
|
||||
const sock = (0, baileys_1.makeWASocket)({
|
||||
const sock = 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),
|
||||
keys: makeCacheableSignalKeyStore(state.keys, logger_1.logger),
|
||||
},
|
||||
printQRInTerminal: false,
|
||||
qrTimeout: 60000,
|
||||
@@ -215,15 +230,7 @@ class WhatsAppConnectionManager {
|
||||
});
|
||||
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', {
|
||||
@@ -281,9 +288,6 @@ class WhatsAppConnectionManager {
|
||||
// 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) {
|
||||
@@ -332,16 +336,6 @@ class WhatsAppConnectionManager {
|
||||
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');
|
||||
@@ -359,9 +353,6 @@ class WhatsAppConnectionManager {
|
||||
}).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 });
|
||||
@@ -372,10 +363,6 @@ class WhatsAppConnectionManager {
|
||||
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 });
|
||||
@@ -397,10 +384,6 @@ class WhatsAppConnectionManager {
|
||||
.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({
|
||||
@@ -534,10 +517,11 @@ class WhatsAppConnectionManager {
|
||||
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 !== baileys_1.DisconnectReason.loggedOut &&
|
||||
statusCode !== baileys_1.DisconnectReason.forbidden;
|
||||
const shouldReconnect = statusCode !== engine_1.DisconnectReason.loggedOut &&
|
||||
statusCode !== engine_1.DisconnectReason.forbidden;
|
||||
if (shouldReconnect) {
|
||||
const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1;
|
||||
this.reconnectAttempts.set(instanceId, attempts);
|
||||
@@ -562,7 +546,7 @@ class WhatsAppConnectionManager {
|
||||
}
|
||||
else {
|
||||
// loggedOut → limpa sessão e exige novo QR
|
||||
if (statusCode === baileys_1.DisconnectReason.loggedOut) {
|
||||
if (statusCode === engine_1.DisconnectReason.loggedOut) {
|
||||
logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao');
|
||||
await this.deleteSessionFiles(instanceId);
|
||||
}
|
||||
@@ -571,7 +555,7 @@ class WhatsAppConnectionManager {
|
||||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||||
instanceId,
|
||||
status: client_1.InstanceStatus.DISCONNECTED,
|
||||
reason: baileys_1.DisconnectReason[statusCode] ?? statusCode,
|
||||
reason: engine_1.DisconnectReason[statusCode] ?? statusCode,
|
||||
});
|
||||
hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { });
|
||||
}
|
||||
@@ -693,8 +677,10 @@ class WhatsAppConnectionManager {
|
||||
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) {
|
||||
// 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);
|
||||
@@ -868,6 +854,7 @@ class WhatsAppConnectionManager {
|
||||
this.sockets.delete(instanceId);
|
||||
this.contactHandlers.delete(instanceId);
|
||||
this.msgHandlers.delete(instanceId);
|
||||
this.engineByInstance.delete(instanceId);
|
||||
logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente');
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -962,17 +949,27 @@ class WhatsAppConnectionManager {
|
||||
// ── 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.
|
||||
// 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);
|
||||
const phoneJid = lidToPhoneJid.get(rawJid)
|
||||
?? chat.pnJid;
|
||||
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_1.prisma.contact.updateMany({
|
||||
where: { tenantId, instanceId, jid: phoneJid },
|
||||
data: { lidJid: rawJid },
|
||||
}).catch(() => { });
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Sem resolução disponível: chat será criado via handleHistory quando
|
||||
@@ -982,10 +979,12 @@ class WhatsAppConnectionManager {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Converte timestamp Unix (segundos) para Date
|
||||
const lastAt = chat.conversationTimestamp
|
||||
? new Date(Number(chat.conversationTimestamp) * 1000)
|
||||
: undefined;
|
||||
// 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.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;
|
||||
@@ -1021,6 +1020,8 @@ class WhatsAppConnectionManager {
|
||||
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat');
|
||||
}
|
||||
}
|
||||
// Invalida cache da listagem de chats — refletir alterações imediatamente na inbox
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, instanceId).catch(() => { });
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update)
|
||||
@@ -1102,6 +1103,7 @@ class WhatsAppConnectionManager {
|
||||
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update');
|
||||
}
|
||||
}
|
||||
(0, chat_cache_1.invalidateChatListCache)(tenantId, instanceId).catch(() => { });
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DEBUG LOG (página /baileys no frontend)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+18
-13
@@ -285,24 +285,29 @@ export class WhatsAppConnectionManager {
|
||||
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).
|
||||
// ─── Intercepta stanzas brutas pra extrair PN real do destinatário ──
|
||||
// Dois cenários distintos de mensagens do celular que chegam sem PN:
|
||||
//
|
||||
// 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").
|
||||
// Cenário A (Baileys oficial): mensagem fromMe=true com remoteJid=@lid
|
||||
// O campo peer_recipient_pn na stanza bruta contém o PN do destinatário.
|
||||
// Baileys não expõe esse campo em key, por isso lemos aqui.
|
||||
//
|
||||
// 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 é
|
||||
// Cenário B (InfiniteAPI v7+): mensagem fromMe=true onde o InfiniteAPI
|
||||
// resolve automaticamente @lid → nosso próprio JID (usando sender_pn
|
||||
// da stanza, que para fromMe=true é NOSSO PN, não o do destinatário).
|
||||
// O PN correto do destinatário está em recipient_pn na stanza.
|
||||
// Sem essa dica, processMessage descarta a mensagem como self-chat.
|
||||
//
|
||||
// O listener é síncrono (mesmo EventEmitter do 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)
|
||||
if (!msgId) return
|
||||
// peer_recipient_pn: Cenário A (@lid não resolvido, Baileys oficial)
|
||||
// recipient_pn: Cenário B (InfiniteAPI resolveu @lid para nosso PN)
|
||||
const hint = node?.attrs?.peer_recipient_pn ?? node?.attrs?.recipient_pn
|
||||
if (hint) {
|
||||
msgHandler.setPeerRecipientHint(msgId, hint)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
|
||||
exports.getEngine = getEngine;
|
||||
const official = __importStar(require("./official"));
|
||||
const infinite = __importStar(require("./infinite"));
|
||||
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
|
||||
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
|
||||
function resolveEngine(type) {
|
||||
return type === 'infinite' ? infinite : official;
|
||||
}
|
||||
/**
|
||||
* Retorna os exports da engine selecionada para uma instância específica.
|
||||
* Permite que cada instância use uma engine diferente (infinite ou official).
|
||||
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
|
||||
*/
|
||||
function getEngine(engineType) {
|
||||
const e = resolveEngine(engineType ?? defaultEngineType);
|
||||
return {
|
||||
makeWASocket: e.makeWASocket,
|
||||
useMultiFileAuthState: e.useMultiFileAuthState,
|
||||
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
|
||||
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
|
||||
downloadMediaMessage: e.downloadMediaMessage,
|
||||
getContentType: e.getContentType,
|
||||
generateMessageIDV2: e.generateMessageIDV2,
|
||||
generateWAMessageFromContent: e.generateWAMessageFromContent,
|
||||
isJidGroup: e.isJidGroup,
|
||||
isJidStatusBroadcast: e.isJidStatusBroadcast,
|
||||
};
|
||||
}
|
||||
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
|
||||
const selectedEngine = resolveEngine(defaultEngineType);
|
||||
exports.makeWASocket = selectedEngine.makeWASocket;
|
||||
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
|
||||
exports.DisconnectReason = selectedEngine.DisconnectReason;
|
||||
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
|
||||
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
|
||||
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
|
||||
exports.getContentType = selectedEngine.getContentType;
|
||||
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
|
||||
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
|
||||
exports.isJidGroup = selectedEngine.isJidGroup;
|
||||
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
|
||||
var official_1 = require("./official");
|
||||
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA,8BAyBC;AAxCD,qDAAsC;AACtC,qDAAsC;AAEtC,0EAA0E;AAC1E,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAA;AAElE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,UAA0B;IAYlD,MAAM,CAAC,GAAG,aAAa,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAA;IACxD,OAAO;QACL,YAAY,EAAgB,CAAC,CAAC,YAAmB;QACjD,qBAAqB,EAAO,CAAC,CAAC,qBAA4B;QAC1D,yBAAyB,EAAG,CAAC,CAAC,yBAAgC;QAC9D,2BAA2B,EAAE,CAAC,CAAC,2BAAkC;QACjE,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;QACzD,cAAc,EAAc,CAAC,CAAC,cAAqB;QACnD,mBAAmB,EAAS,CAAC,CAAC,mBAA0B;QACxD,4BAA4B,EAAE,CAAC,CAAC,4BAAmC;QACnE,UAAU,EAAkB,CAAC,CAAC,UAAiB;QAC/C,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;KAC1D,CAAA;AACH,CAAC;AAED,sFAAsF;AACtF,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAE1C,QAAA,YAAY,GAAG,cAAc,CAAC,YAAmB,CAAA;AACjD,QAAA,qBAAqB,GAAG,cAAc,CAAC,qBAA4B,CAAA;AACnE,QAAA,gBAAgB,GAAG,cAAc,CAAC,gBAAuB,CAAA;AACzD,QAAA,yBAAyB,GAAG,cAAc,CAAC,yBAAgC,CAAA;AAC3E,QAAA,2BAA2B,GAAG,cAAc,CAAC,2BAAkC,CAAA;AAC/E,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AACjE,QAAA,cAAc,GAAG,cAAc,CAAC,cAAqB,CAAA;AACrD,QAAA,mBAAmB,GAAG,cAAc,CAAC,mBAA0B,CAAA;AAC/D,QAAA,4BAA4B,GAAG,cAAc,CAAC,4BAAmC,CAAA;AACjF,QAAA,UAAU,GAAG,cAAc,CAAC,UAAiB,CAAA;AAC7C,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AAC9E,uCAAkC;AAAzB,iGAAA,KAAK,OAAA"}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("baileys"), exports);
|
||||
//# sourceMappingURL=infinite.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("@whiskeysockets/baileys"), exports);
|
||||
//# sourceMappingURL=official.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
|
||||
@@ -88,10 +88,19 @@ class ContactHandler {
|
||||
// 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á.
|
||||
// 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 contactJid = isLid && c.jid ? c.jid : c.id;
|
||||
const pnJid = c.jid
|
||||
?? c.phoneNumber;
|
||||
const contactJid = isLid && pnJid ? pnJid : c.id;
|
||||
// Quando o contato É um @lid mas foi resolvido para phoneJid:
|
||||
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
|
||||
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
|
||||
// lidJid = lidFromContact
|
||||
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: {
|
||||
tenantId_instanceId_jid: {
|
||||
@@ -109,45 +118,46 @@ class ContactHandler {
|
||||
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 } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
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 } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
});
|
||||
// Garante vínculo Chat ↔ Contact para chats órfãos
|
||||
// (chats criados antes do contato existir ficam com contactId=null)
|
||||
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
|
||||
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
jid: contactJid,
|
||||
contactId: null,
|
||||
},
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
// ── Lógica de avatar ──────────────────────────────────────────────
|
||||
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA)
|
||||
if (!isLid) {
|
||||
// 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: c.id,
|
||||
jid: contactJid,
|
||||
avatarUrl: imgUrl,
|
||||
});
|
||||
}
|
||||
else if (imgUrl === 'changed' || imgUrl === undefined) {
|
||||
// Precisa buscar via API — adiciona à fila com rate-limit
|
||||
needFetchAvatar.push(c.id);
|
||||
needFetchAvatar.push(contactJid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,13 +28,14 @@ exports.MessageHandler = void 0;
|
||||
*/
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const baileys_1 = require("@whiskeysockets/baileys");
|
||||
const engine_1 = require("../engine");
|
||||
const client_1 = require("@prisma/client");
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const whatsapp_1 = require("../../../shared/utils/whatsapp");
|
||||
const StorageProvider_1 = require("../../../core/StorageProvider");
|
||||
const hook_bus_1 = require("../../../core/hook-bus");
|
||||
const chat_cache_1 = require("../../chats/chat.cache");
|
||||
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||
const MEDIA_DIR = path_1.default.resolve('./media');
|
||||
/**
|
||||
@@ -168,6 +169,72 @@ class MessageHandler {
|
||||
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
|
||||
*/
|
||||
async handleHistory(messages) {
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
|
||||
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
|
||||
const mensagensRecentes = [];
|
||||
const mensagensAntigas = [];
|
||||
// Separa as mensagens por idade
|
||||
for (const msg of messages) {
|
||||
const ts = Number(msg.messageTimestamp ?? 0);
|
||||
if (ts >= doisDiasAtrasEmSegundos) {
|
||||
mensagensRecentes.push(msg);
|
||||
}
|
||||
else {
|
||||
mensagensAntigas.push(msg);
|
||||
}
|
||||
}
|
||||
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
|
||||
if (mensagensRecentes.length > 0) {
|
||||
logger_1.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
count: mensagensRecentes.length
|
||||
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
|
||||
await this.processHistoryBatch(mensagensRecentes);
|
||||
}
|
||||
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
|
||||
if (mensagensAntigas.length > 0) {
|
||||
logger_1.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
count: mensagensAntigas.length
|
||||
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
|
||||
this.processOlderHistoryGradually(mensagensAntigas);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
|
||||
*/
|
||||
processOlderHistoryGradually(messages) {
|
||||
const tamanhoDoLote = 100;
|
||||
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
|
||||
let offset = 0;
|
||||
const processarProximoLote = async () => {
|
||||
// Se processou tudo, encerra o ciclo
|
||||
if (offset >= messages.length) {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
|
||||
return;
|
||||
}
|
||||
// Corta o próximo pedaço do histórico
|
||||
const lote = messages.slice(offset, offset + tamanhoDoLote);
|
||||
offset += tamanhoDoLote;
|
||||
try {
|
||||
await this.processHistoryBatch(lote);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
|
||||
}
|
||||
// Agenda o próximo lote de forma não bloqueante
|
||||
setTimeout(processarProximoLote, intervaloMs).unref();
|
||||
};
|
||||
// Inicia o sincronismo em background após 5 segundos, garantindo que
|
||||
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
|
||||
setTimeout(processarProximoLote, 5000).unref();
|
||||
}
|
||||
/**
|
||||
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
|
||||
*/
|
||||
async processHistoryBatch(messages) {
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
|
||||
@@ -186,28 +253,39 @@ class MessageHandler {
|
||||
// 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')) {
|
||||
// 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;
|
||||
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
|
||||
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
|
||||
const remoteJidAlt = msg.key.remoteJidAlt;
|
||||
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
|
||||
}
|
||||
else {
|
||||
// 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;
|
||||
// 2. senderPn / participantPn
|
||||
const senderPn = msg.key.senderPn || msg.key.participantPn;
|
||||
if (senderPn) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (!jidRaw.endsWith('@s.whatsapp.net'))
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// Sem resolução possível, ou contato ainda armazenado com JID @lid
|
||||
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn).
|
||||
continue;
|
||||
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid && !contact.jid.endsWith('@lid')) {
|
||||
jidRaw = contact.jid;
|
||||
}
|
||||
else {
|
||||
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +334,7 @@ class MessageHandler {
|
||||
if (!message || !key.id)
|
||||
return [];
|
||||
message = unwrapInnerMessage(message);
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return [];
|
||||
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
||||
@@ -301,7 +379,7 @@ class MessageHandler {
|
||||
}
|
||||
}
|
||||
if (totalSaved > 0) {
|
||||
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas');
|
||||
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} mensagens históricas salvas`);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -365,7 +443,7 @@ class MessageHandler {
|
||||
originalLidJid = key.remoteJid;
|
||||
key.remoteJid = phoneJid;
|
||||
}
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return;
|
||||
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
|
||||
@@ -659,6 +737,8 @@ class MessageHandler {
|
||||
: null,
|
||||
lastMessage,
|
||||
});
|
||||
// Invalida cache da listagem — força refetch na próxima request da inbox
|
||||
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
|
||||
@@ -678,7 +758,7 @@ class MessageHandler {
|
||||
*/
|
||||
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
|
||||
try {
|
||||
const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
|
||||
const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
|
||||
if (!buffer)
|
||||
return;
|
||||
// Para documentos: preserva nome e extensão originais do arquivo
|
||||
@@ -744,12 +824,43 @@ class MessageHandler {
|
||||
messageId: savedMsgId,
|
||||
mediaUrl,
|
||||
});
|
||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||
if (contentType === 'stickerMessage') {
|
||||
this.persistSticker(msg, mediaUrl).catch(() => { });
|
||||
}
|
||||
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
||||
}
|
||||
}
|
||||
async persistSticker(msg, wasabiPath) {
|
||||
const stickerMsg = msg.message?.stickerMessage;
|
||||
if (!stickerMsg)
|
||||
return;
|
||||
const sha256Bytes = stickerMsg.fileSha256;
|
||||
if (!sha256Bytes || sha256Bytes.length === 0)
|
||||
return;
|
||||
const sha256 = Buffer.from(sha256Bytes).toString('hex');
|
||||
const isAnimated = stickerMsg.isAnimated ?? false;
|
||||
await prisma_1.prisma.sticker.upsert({
|
||||
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
sha256,
|
||||
wasabiPath,
|
||||
isAnimated,
|
||||
useCount: 1,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
useCount: { increment: 1 },
|
||||
lastSeenAt: new Date(),
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -279,8 +279,12 @@ export class MessageHandler {
|
||||
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
|
||||
// 2. senderPn / participantPn — SOMENTE para mensagens recebidas (fromMe=false).
|
||||
// Para fromMe=true, senderPn é o NOSSO próprio JID (somos o remetente),
|
||||
// não o JID do destinatário — usar aqui criaria um self-chat corrompido no DB.
|
||||
const senderPn = !msg.key.fromMe
|
||||
? ((msg.key as any).senderPn || (msg.key as any).participantPn)
|
||||
: null
|
||||
if (senderPn) {
|
||||
jidRaw = normalizeJid(senderPn)
|
||||
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
|
||||
@@ -368,6 +372,7 @@ export class MessageHandler {
|
||||
null
|
||||
|
||||
const isGroup = jid.endsWith('@g.us')
|
||||
const isMed = this.isMediaMessage(contentType)
|
||||
return [{
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
@@ -383,12 +388,33 @@ export class MessageHandler {
|
||||
senderJid: isGroup ? (key.participant ?? null) : null,
|
||||
status: 'READ' as const, // históricas são consideradas já lidas
|
||||
timestamp: ts,
|
||||
// Salva payload para permitir re-download posterior via /redownload-media
|
||||
...(isMed ? { mediaPayload: this.buildMediaPayload(key, message) as any } : {}),
|
||||
}]
|
||||
})
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
|
||||
totalSaved += count
|
||||
|
||||
// Recupera mediaPayload para mensagens de mídia que já existiam no DB
|
||||
// sem esse campo (criadas antes desta correção ou via path antigo).
|
||||
// Roda em paralelo sem bloquear o loop principal.
|
||||
const mediaRows = rows.filter(r => r.mediaPayload != null)
|
||||
if (mediaRows.length > 0) {
|
||||
Promise.all(
|
||||
mediaRows.map(r =>
|
||||
prisma.message.updateMany({
|
||||
where: {
|
||||
instanceId: this.instanceId,
|
||||
messageId: r.messageId,
|
||||
mediaPayload: null,
|
||||
},
|
||||
data: { mediaPayload: r.mediaPayload },
|
||||
}).catch(() => {})
|
||||
)
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
|
||||
@@ -452,7 +478,17 @@ export class MessageHandler {
|
||||
*/
|
||||
let originalLidJid: string | null = null
|
||||
if (key.remoteJid.endsWith('@lid')) {
|
||||
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
|
||||
// Fast path para fromMe=false: InfiniteAPI preenche remoteJidAlt com o PN
|
||||
// do remetente (que é o contato) quando addressing_mode=pn. Para fromMe=true,
|
||||
// remoteJidAlt contem o NOSSO próprio PN (senderAlt = sender_pn = nosso número)
|
||||
// — por isso o fast path só é seguro para mensagens recebidas.
|
||||
const remoteJidAlt = (key as any).remoteJidAlt as string | undefined
|
||||
let phoneJid: string | null = null
|
||||
if (!key.fromMe && remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||
phoneJid = normalizeJid(remoteJidAlt)
|
||||
} else {
|
||||
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
|
||||
@@ -480,12 +516,36 @@ export class MessageHandler {
|
||||
const fromMe = key.fromMe ?? false
|
||||
const remoteJidRaw = key.remoteJid
|
||||
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
|
||||
const remoteJid = normalizeJid(remoteJidRaw)
|
||||
let remoteJid = normalizeJid(remoteJidRaw)
|
||||
const messageId = key.id!
|
||||
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais).
|
||||
//
|
||||
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
|
||||
// a um contato LID, o InfiniteAPI pode ter resolvido remoteJid para o nosso próprio
|
||||
// JID (usando sender_pn = nosso PN em vez de recipient_pn = PN do destinatário).
|
||||
// Nesse caso, o CB:message listener armazenou recipient_pn em peerRecipientHints.
|
||||
// Tentamos recuperar o JID correto antes de descartar como self-chat.
|
||||
const ownJid = normalizeJid(this.sock.user?.id ?? '')
|
||||
if (ownJid && remoteJid === ownJid) return
|
||||
if (ownJid && remoteJid === ownJid) {
|
||||
if (fromMe) {
|
||||
const hint = this.peerRecipientHints.get(messageId)
|
||||
if (hint) {
|
||||
this.peerRecipientHints.delete(messageId)
|
||||
const correctJid = normalizeJid(hint)
|
||||
if (correctJid && correctJid.endsWith('@s.whatsapp.net') && correctJid !== ownJid) {
|
||||
remoteJid = correctJid
|
||||
;(key as any).remoteJid = correctJid
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
|
||||
// REGRA IMPORTANTE sobre pushName:
|
||||
@@ -590,6 +650,10 @@ export class MessageHandler {
|
||||
replyToId,
|
||||
status: fromMe ? 'SENT' : 'PENDING',
|
||||
timestamp: new Date((messageTimestamp as number) * 1000),
|
||||
// Armazena payload da mídia para permitir re-download posterior
|
||||
...(this.isMediaMessage(contentType) ? {
|
||||
mediaPayload: this.buildMediaPayload(key, message) as any,
|
||||
} : {}),
|
||||
},
|
||||
update: {}, // Se já existe (duplicata), não sobrescreve
|
||||
})
|
||||
@@ -648,6 +712,19 @@ export class MessageHandler {
|
||||
}).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
|
||||
)
|
||||
}
|
||||
|
||||
// ── 9. Emite para plugins externos (ex: Secretária IA via ext:message.new) ─
|
||||
if (!fromMe && body) {
|
||||
setImmediate(() =>
|
||||
hookBus.emit('ext:message.new', {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
jid: remoteJid,
|
||||
text: body,
|
||||
}).catch((err: unknown) => logger.error({ err }, '[HookBus] Erro em ext:message.new'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -900,12 +977,88 @@ export class MessageHandler {
|
||||
mediaUrl,
|
||||
})
|
||||
|
||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||
if (contentType === 'stickerMessage') {
|
||||
this.persistSticker(msg, mediaUrl).catch(() => {})
|
||||
}
|
||||
|
||||
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
|
||||
} catch (err) {
|
||||
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
|
||||
}
|
||||
}
|
||||
|
||||
private async persistSticker(msg: WAMessage, wasabiPath: string): Promise<void> {
|
||||
const stickerMsg = msg.message?.stickerMessage
|
||||
if (!stickerMsg) return
|
||||
|
||||
const sha256Bytes = stickerMsg.fileSha256
|
||||
if (!sha256Bytes || sha256Bytes.length === 0) return
|
||||
|
||||
const sha256 = Buffer.from(sha256Bytes).toString('hex')
|
||||
const isAnimated = stickerMsg.isAnimated ?? false
|
||||
|
||||
await prisma.sticker.upsert({
|
||||
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
sha256,
|
||||
wasabiPath,
|
||||
isAnimated,
|
||||
useCount: 1,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
useCount: { increment: 1 },
|
||||
lastSeenAt: new Date(),
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PAYLOAD DE MÍDIA (serialização para re-download)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Constrói o payload serializado da mensagem de mídia para armazenamento.
|
||||
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
|
||||
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
|
||||
*/
|
||||
buildMediaPayload(key: any, innerMessage: any): Record<string, unknown> {
|
||||
const stripped = this.stripThumbnails(innerMessage)
|
||||
return {
|
||||
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
|
||||
message: this.serializeForStorage(stripped),
|
||||
}
|
||||
}
|
||||
|
||||
private stripThumbnails(obj: any): any {
|
||||
if (!obj || typeof obj !== 'object') return obj
|
||||
const result: any = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256') continue
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private serializeForStorage(val: unknown): unknown {
|
||||
if (val instanceof Uint8Array || Buffer.isBuffer(val as any)) {
|
||||
return { _b64: Buffer.from(val as Uint8Array).toString('base64') }
|
||||
}
|
||||
if (Array.isArray(val)) return val.map(v => this.serializeForStorage(v))
|
||||
if (val !== null && typeof val === 'object') {
|
||||
// Long de protobuf — converte para número
|
||||
if (typeof (val as any).toNumber === 'function') return (val as any).toNumber()
|
||||
return Object.fromEntries(
|
||||
Object.entries(val as Record<string, unknown>).map(([k, v]) => [k, this.serializeForStorage(v)])
|
||||
)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import multer from 'multer'
|
||||
import { downloadMediaMessage, getContentType } from './engine'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
import { storageProvider } from '../../core/StorageProvider'
|
||||
@@ -167,8 +168,10 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
const messageType = mediaType.toUpperCase() as any
|
||||
const msg = await prisma.message.create({
|
||||
data: {
|
||||
// Usa upsert para evitar P2002 — o MessageHandler pode ter inserido antes
|
||||
const msg = await prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId: chat.id,
|
||||
@@ -183,6 +186,11 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {
|
||||
mediaUrl,
|
||||
mimeType: mimetype,
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
|
||||
logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada')
|
||||
@@ -278,5 +286,109 @@ export function buildMediaRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
}
|
||||
)
|
||||
|
||||
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
|
||||
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
|
||||
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
|
||||
// a requisição com updateMediaMessage (renova URL expirada se necessário).
|
||||
router.post('/redownload-media/:messageDbId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tenantId = req.tenantId!
|
||||
const instanceId = req.params['instanceId'] as string
|
||||
const messageDbId = req.params['messageDbId'] as string
|
||||
|
||||
const msg = await prisma.message.findFirst({
|
||||
where: { id: messageDbId, instanceId, tenantId },
|
||||
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
|
||||
})
|
||||
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||
if (msg.mediaUrl) { res.json({ mediaUrl: msg.mediaUrl }); return }
|
||||
if (!msg.mediaPayload) { res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' }); return }
|
||||
|
||||
const sock = manager.getSocket(instanceId)
|
||||
if (!sock) { res.status(503).json({ error: 'Instância não conectada' }); return }
|
||||
|
||||
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
|
||||
const waMsg = deserializePayload(msg.mediaPayload as Record<string, unknown>) as any
|
||||
|
||||
const buffer = await downloadMediaMessage(
|
||||
waMsg,
|
||||
'buffer',
|
||||
{},
|
||||
{ logger: logger as any, reuploadRequest: sock.updateMediaMessage as any }
|
||||
)
|
||||
if (!buffer) { res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' }); return }
|
||||
|
||||
const contentType = getContentType(waMsg.message) ?? 'imageMessage'
|
||||
const ext = EXT_MAP[contentType] ?? 'bin'
|
||||
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream'
|
||||
const fileName = `${messageDbId}.${ext}`
|
||||
|
||||
let mediaUrl = `/media/${instanceId}/${fileName}`
|
||||
if (storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: tenantId,
|
||||
sessionJID: instanceId,
|
||||
file: { originalname: fileName, buffer: buffer as Buffer, mimetype: mime },
|
||||
})
|
||||
mediaUrl = result.path
|
||||
} catch (uploadErr) {
|
||||
logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local')
|
||||
const filePath = path.resolve('./media', instanceId, fileName)
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, buffer as Buffer)
|
||||
}
|
||||
} else {
|
||||
const filePath = path.resolve('./media', instanceId, fileName)
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, buffer as Buffer)
|
||||
}
|
||||
|
||||
await prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } })
|
||||
|
||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
|
||||
messageId: messageDbId,
|
||||
mediaUrl,
|
||||
})
|
||||
|
||||
logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso')
|
||||
res.json({ mediaUrl })
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, 'Erro ao re-baixar mídia')
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
}
|
||||
|
||||
const MIME_FOR_TYPE: Record<string, string> = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
}
|
||||
|
||||
function deserializePayload(val: unknown): unknown {
|
||||
if (Array.isArray(val)) return val.map(deserializePayload)
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const obj = val as Record<string, unknown>
|
||||
if ('_b64' in obj && typeof obj._b64 === 'string') {
|
||||
return Buffer.from(obj._b64, 'base64')
|
||||
}
|
||||
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]))
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { z } from 'zod'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { dragonfly } from '../../infra/cache/dragonfly'
|
||||
import { logger } from '../../config/logger'
|
||||
import { storageProvider } from '../../core/StorageProvider'
|
||||
import type { WhatsAppConnectionManager } from './connection/WhatsAppConnectionManager'
|
||||
import { sendRichMessage } from './rich-message'
|
||||
|
||||
@@ -222,7 +223,10 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
richPayloadData = { text, footer, nativeCarousel }
|
||||
}
|
||||
|
||||
const msg = await prisma.message.upsert({
|
||||
// Race condition: o eco do Baileys pode chegar ANTES desta linha e criar o
|
||||
// registro primeiro, fazendo o upsert falhar com P2002 (unique constraint).
|
||||
// Nesses casos, buscamos a mensagem que já foi criada pelo eco.
|
||||
let msg = await prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
@@ -238,6 +242,15 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
|
||||
}).catch(async (err: any) => {
|
||||
if (err?.code === 'P2002') {
|
||||
// Eco do Baileys criou o registro antes de nós — busca e retorna ele
|
||||
const existing = await prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
})
|
||||
if (existing) return existing
|
||||
}
|
||||
throw err
|
||||
})
|
||||
|
||||
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
|
||||
@@ -715,6 +728,26 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
||||
// Dragonfly indisponível — segue o fluxo normal sem quebrar
|
||||
}
|
||||
|
||||
// ── Helpers para persistência no Wasabi ────────────────────────────────────
|
||||
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
|
||||
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
|
||||
const avatarWasabiPath = (tid: string, iid: string, j: string) =>
|
||||
`avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`
|
||||
|
||||
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
|
||||
// Chamado após servir os bytes ao browser para não bloquear a resposta.
|
||||
const persistToWasabi = (buffer: Buffer) => {
|
||||
if (!storageProvider.isRegistered()) return
|
||||
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid)
|
||||
storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
customPath: wasabiPath,
|
||||
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
|
||||
}).then(() =>
|
||||
prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: { tenantId, instanceId, jid },
|
||||
@@ -722,34 +755,48 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
||||
})
|
||||
|
||||
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)
|
||||
const storedUrl = contact.avatarUrl
|
||||
const isWasabi = !storedUrl.startsWith('http')
|
||||
|
||||
const etag = etagFor(storedUrl)
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.set('ETag', etag)
|
||||
res.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
res.status(304).end()
|
||||
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).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
|
||||
if (isWasabi) {
|
||||
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
|
||||
try {
|
||||
const buffer = await storageProvider.getBuffer(storedUrl)
|
||||
if (buffer) {
|
||||
res.set('Content-Type', 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer)
|
||||
logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served')
|
||||
return
|
||||
}
|
||||
} catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
|
||||
} else {
|
||||
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
|
||||
try {
|
||||
const upstream = await fetch(storedUrl)
|
||||
if (upstream.ok) {
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer())
|
||||
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etag)
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.send(buffer)
|
||||
logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served')
|
||||
persistToWasabi(buffer)
|
||||
return
|
||||
}
|
||||
// URL expirou — cai no fallback Baileys abaixo
|
||||
logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys')
|
||||
} catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,24 +811,19 @@ export function buildAvatarRoute(manager: WhatsAppConnectionManager): Router {
|
||||
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)
|
||||
.set('Cache-Control', HIT_CACHE_CONTROL)
|
||||
.set('ETag', etagFor(url))
|
||||
.set('Content-Length', String(buffer.length))
|
||||
.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(() => {})
|
||||
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
|
||||
persistToWasabi(buffer)
|
||||
dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => {})
|
||||
} catch {
|
||||
noAvatar()
|
||||
|
||||
@@ -31,6 +31,8 @@ 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 { buildStickerRoutes } from './modules/stickers/sticker.routes'
|
||||
import { storageProvider } from './core/StorageProvider'
|
||||
import { SchedulerService } from './modules/scheduled/scheduler.service'
|
||||
import { authMiddleware } from './shared/middlewares/auth.middleware'
|
||||
import { adminMiddleware } from './shared/middlewares/admin.middleware'
|
||||
@@ -87,6 +89,32 @@ async function bootstrap() {
|
||||
const scheduler = new SchedulerService(whatsAppManager)
|
||||
app.use('/api/templates', authMiddleware, buildTemplateRoutes())
|
||||
app.use('/api/scheduled', authMiddleware, buildScheduledRoutes(scheduler))
|
||||
app.use('/api/stickers', authMiddleware, buildStickerRoutes())
|
||||
|
||||
// Proxy genérico para arquivos no Wasabi — sem authMiddleware porque <img>/<audio>/<video>
|
||||
// do browser não enviam JWT. Paths são UUID-based (obscuros) — mesmo nível de segurança
|
||||
// que a rota /media/ estática que também não requer autenticação.
|
||||
app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = (req.params as any)[0] as string
|
||||
if (!filePath) { res.status(400).end(); return }
|
||||
try {
|
||||
const buffer = await storageProvider.getBuffer(filePath)
|
||||
if (!buffer) { res.status(404).end(); return }
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
const mime =
|
||||
ext === 'webp' ? 'image/webp' :
|
||||
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
|
||||
ext === 'png' ? 'image/png' :
|
||||
ext === 'mp4' ? 'video/mp4' :
|
||||
ext === 'ogg' ? 'audio/ogg' :
|
||||
'application/octet-stream'
|
||||
res.setHeader('Content-Type', mime)
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.send(buffer)
|
||||
} catch {
|
||||
res.status(500).end()
|
||||
}
|
||||
})
|
||||
|
||||
// Banner do frontend — dias restantes do plano
|
||||
app.get('/api/plan/status', authMiddleware, async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"version": "v1.0.51",
|
||||
"buildCount": 51,
|
||||
"timestamp": "2026-05-11T20:50:10.267Z",
|
||||
"commitHash": "bd6801a",
|
||||
"commitMessage": "feat(webhook): integra listener com a API de Status do Gitea",
|
||||
"author": "ruicesar",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.50",
|
||||
"buildCount": 50,
|
||||
"timestamp": "2026-05-11T19:55:29.765Z",
|
||||
"commitHash": "cdacfcd",
|
||||
"commitMessage": "feat(preloader): replace WhatsApp icon with smooth circle SVG",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.49",
|
||||
"buildCount": 49,
|
||||
"timestamp": "2026-05-11T19:41:33.099Z",
|
||||
"commitHash": "2233060",
|
||||
"commitMessage": "fix(plugins): adiciona backend/src ao include do tsconfig para resolver erros de editor",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.48",
|
||||
"buildCount": 48,
|
||||
"timestamp": "2026-05-11T16:58:23.256Z",
|
||||
"commitHash": "539b922",
|
||||
"commitMessage": "feat(media): re-download de mídia não baixada ao clicar",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.47",
|
||||
"buildCount": 47,
|
||||
"timestamp": "2026-05-11T16:00:16.840Z",
|
||||
"commitHash": "fc80367",
|
||||
"commitMessage": "fix(storage): remove auth de /api/storage/view e try-catch nos sticker routes",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.46",
|
||||
"buildCount": 46,
|
||||
"timestamp": "2026-05-11T15:48:59.138Z",
|
||||
"commitHash": "b97f7f9",
|
||||
"commitMessage": "feat: sistema de figurinhas, emoji PT-BR e preloader inteligente",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.45",
|
||||
"buildCount": 45,
|
||||
"timestamp": "2026-05-11T07:42:48.317Z",
|
||||
"commitHash": "22891e1",
|
||||
"commitMessage": "fix(listener): skip version-bump commits to prevent deploy loop",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.44",
|
||||
"buildCount": 44,
|
||||
"timestamp": "2026-05-11T07:39:34.532Z",
|
||||
"commitHash": "a7f4d6b",
|
||||
"commitMessage": "feat(preview): fix button layout and add mouse-following zoom",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.43",
|
||||
"buildCount": 43,
|
||||
"timestamp": "2026-05-11T07:22:01.966Z",
|
||||
"commitHash": "7b55354",
|
||||
"commitMessage": "fix(send-media): use upsert to prevent P2002 duplicate messageId error",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.42",
|
||||
"buildCount": 42,
|
||||
"timestamp": "2026-05-11T07:13:59.009Z",
|
||||
"commitHash": "4d0b895",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.41",
|
||||
"buildCount": 41,
|
||||
"timestamp": "2026-05-11T07:12:23.037Z",
|
||||
"commitHash": "949274e",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.40",
|
||||
"buildCount": 40,
|
||||
"timestamp": "2026-05-11T07:10:34.610Z",
|
||||
"commitHash": "7ec3d78",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.39",
|
||||
"buildCount": 39,
|
||||
"timestamp": "2026-05-11T07:08:47.779Z",
|
||||
"commitHash": "b0ac06e",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.38",
|
||||
"buildCount": 38,
|
||||
"timestamp": "2026-05-11T07:07:06.786Z",
|
||||
"commitHash": "bbbc010",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.37",
|
||||
"buildCount": 37,
|
||||
"timestamp": "2026-05-11T07:05:13.069Z",
|
||||
"commitHash": "706fcea",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.36",
|
||||
"buildCount": 36,
|
||||
"timestamp": "2026-05-11T07:03:23.851Z",
|
||||
"commitHash": "875f314",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.35",
|
||||
"buildCount": 35,
|
||||
"timestamp": "2026-05-11T07:01:31.122Z",
|
||||
"commitHash": "918bce6",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.34",
|
||||
"buildCount": 34,
|
||||
"timestamp": "2026-05-11T06:59:25.384Z",
|
||||
"commitHash": "f83f1d9",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.33",
|
||||
"buildCount": 33,
|
||||
"timestamp": "2026-05-11T06:57:52.215Z",
|
||||
"commitHash": "80f8585",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.32",
|
||||
"buildCount": 32,
|
||||
"timestamp": "2026-05-11T06:56:17.493Z",
|
||||
"commitHash": "16dbc40",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.31",
|
||||
"buildCount": 31,
|
||||
"timestamp": "2026-05-11T06:54:31.712Z",
|
||||
"commitHash": "6f28d3d",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.30",
|
||||
"buildCount": 30,
|
||||
"timestamp": "2026-05-11T06:52:53.252Z",
|
||||
"commitHash": "2a97447",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.29",
|
||||
"buildCount": 29,
|
||||
"timestamp": "2026-05-11T06:51:06.085Z",
|
||||
"commitHash": "bed15dd",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.28",
|
||||
"buildCount": 28,
|
||||
"timestamp": "2026-05-11T06:49:20.528Z",
|
||||
"commitHash": "4287b8c",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.27",
|
||||
"buildCount": 27,
|
||||
"timestamp": "2026-05-11T06:47:44.934Z",
|
||||
"commitHash": "811a9b9",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.26",
|
||||
"buildCount": 26,
|
||||
"timestamp": "2026-05-11T06:46:04.024Z",
|
||||
"commitHash": "b54c9fb",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.25",
|
||||
"buildCount": 25,
|
||||
"timestamp": "2026-05-11T06:44:20.520Z",
|
||||
"commitHash": "8253a03",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.24",
|
||||
"buildCount": 24,
|
||||
"timestamp": "2026-05-11T06:42:39.168Z",
|
||||
"commitHash": "c7d3701",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.23",
|
||||
"buildCount": 23,
|
||||
"timestamp": "2026-05-11T06:40:52.961Z",
|
||||
"commitHash": "2e8c59b",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.22",
|
||||
"buildCount": 22,
|
||||
"timestamp": "2026-05-11T06:38:53.958Z",
|
||||
"commitHash": "b2ad8a8",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
}
|
||||
]
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
VolumeX, Star, StarOff, Archive, ArchiveRestore,
|
||||
Trash2, MailOpen, Volume2, PinOff,
|
||||
Mic, FileText, Video, Image as ImageIcon,
|
||||
AlertCircle,
|
||||
AlertCircle, BotOff,
|
||||
} from 'lucide-react';
|
||||
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
||||
import {
|
||||
@@ -225,6 +225,11 @@ export default function ChatListItem({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
|
||||
{chat.bot_paused && (
|
||||
<span title="Bot pausado — aguardando atendente">
|
||||
<BotOff className="w-4 h-4 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
|
||||
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
|
||||
{chat.unread_count > 0 && (
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
file: File | null;
|
||||
onConfirm: (file: File, caption: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getFileIcon(type: string) {
|
||||
if (type.startsWith('video/')) return <Film className="w-16 h-16 text-blue-400" />;
|
||||
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
|
||||
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
|
||||
return <FileText className="w-16 h-16 text-red-400" />;
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
|
||||
return <Archive className="w-16 h-16 text-yellow-400" />;
|
||||
return <File className="w-16 h-16 text-[#8696a0]" />;
|
||||
}
|
||||
|
||||
function getFileLabel(type: string): string {
|
||||
if (type.startsWith('image/')) return 'Imagem';
|
||||
if (type.startsWith('video/')) return 'Vídeo';
|
||||
if (type.startsWith('audio/')) return 'Áudio';
|
||||
if (type.includes('pdf')) return 'PDF';
|
||||
return 'Arquivo';
|
||||
}
|
||||
|
||||
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
|
||||
const [caption, setCaption] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
|
||||
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
|
||||
const captionRef = useRef<HTMLInputElement>(null);
|
||||
const previewAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setObjectUrl(url);
|
||||
setCaption('');
|
||||
setSending(false);
|
||||
setZoomed(false);
|
||||
setMouseOrigin({ x: 50, y: 50 });
|
||||
setTimeout(() => captionRef.current?.focus(), 250);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
try { await onConfirm(file, caption.trim()); }
|
||||
finally { setSending(false); }
|
||||
}, [file, caption, sending, onConfirm]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
|
||||
};
|
||||
|
||||
// Atualiza a origem do zoom conforme o mouse se move na área de preview
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isImage) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setMouseOrigin({ x, y });
|
||||
}, [isImage]);
|
||||
|
||||
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="file-preview-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 flex flex-col bg-[#0b141a]"
|
||||
style={{ zIndex: 99999 }}
|
||||
>
|
||||
{/* ── Top bar ── */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
|
||||
{/* Info do arquivo — lado esquerdo */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
|
||||
{/* Botões — lado direito: zoom (se imagem) → fechar */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isImage && (
|
||||
<button
|
||||
onClick={toggleZoom}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||||
title={zoomed ? 'Zoom normal' : 'Ampliar'}
|
||||
>
|
||||
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview area ── */}
|
||||
<div
|
||||
ref={previewAreaRef}
|
||||
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
|
||||
onMouseMove={handleMouseMove}
|
||||
onClick={isImage ? toggleZoom : undefined}
|
||||
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
|
||||
>
|
||||
{isImage && objectUrl && (
|
||||
<motion.img
|
||||
src={objectUrl}
|
||||
alt="preview"
|
||||
initial={{ scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
draggable={false}
|
||||
className="select-none rounded-lg shadow-2xl"
|
||||
style={zoomed ? {
|
||||
// Zoom 2.5× centrado no cursor
|
||||
transform: `scale(2.5)`,
|
||||
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
|
||||
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
} : {
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-in',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: 'scale(1)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVideo && objectUrl && (
|
||||
<motion.video
|
||||
src={objectUrl}
|
||||
controls
|
||||
initial={{ scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="max-w-full max-h-full rounded-lg shadow-2xl"
|
||||
style={{ maxHeight: 'calc(100vh - 160px)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isImage && !isVideo && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
|
||||
>
|
||||
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
|
||||
<p className="text-[#8696a0] text-xs">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Caption + Send bar ── */}
|
||||
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
|
||||
<input
|
||||
ref={captionRef}
|
||||
type="text"
|
||||
placeholder="Adicionar legenda…"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending}
|
||||
maxLength={1024}
|
||||
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
|
||||
>
|
||||
{sending
|
||||
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Send className="w-5 h-5 text-white translate-x-px" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{file && (
|
||||
<ModalContent
|
||||
file={file}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,18 @@ import {
|
||||
LayoutTemplate,
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sticker,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import EmojiPicker, { Theme } from 'emoji-picker-react';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import emojiData from '@emoji-mart/data';
|
||||
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
|
||||
import RichMessageComposer from './RichMessageComposer';
|
||||
import TemplateLibrary from './TemplateLibrary';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal';
|
||||
import FilePreviewModal from './FilePreviewModal';
|
||||
import StickerPanel from './StickerPanel';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -34,7 +39,7 @@ interface InputBarProps {
|
||||
onNewMessageChange: (val: string) => void;
|
||||
onSend: (e?: React.FormEvent) => void;
|
||||
onPresence: () => void;
|
||||
onSendMedia: (file: File) => Promise<void>;
|
||||
onSendMedia: (file: File, caption?: string) => Promise<void>;
|
||||
onSendAudio: (blob: Blob) => Promise<void>;
|
||||
replyingTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
@@ -63,7 +68,10 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showStickerPanel, setShowStickerPanel] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const stickerPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
const attachMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showRichComposer, setShowRichComposer] = useState(false);
|
||||
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
|
||||
@@ -179,12 +187,15 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}
|
||||
}, [newMessage]);
|
||||
|
||||
// Close emoji picker and attach menu on click outside
|
||||
// Close emoji picker, sticker panel and attach menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
|
||||
setShowStickerPanel(false);
|
||||
}
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
@@ -193,6 +204,12 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSendSticker = useCallback(async (blob: Blob) => {
|
||||
if (!instanceId || !selectedChat?.remote_jid) return;
|
||||
setShowStickerPanel(false);
|
||||
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
|
||||
}, [instanceId, selectedChat]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Mention dropdown navigation
|
||||
if (mentionQuery !== null && filteredParticipants.length > 0) {
|
||||
@@ -224,19 +241,25 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiClick = (emojiData: any) => {
|
||||
onNewMessageChange(newMessage + emojiData.emoji);
|
||||
// Do not close picker, common UX allows multiple emojis
|
||||
const onEmojiSelect = (emoji: any) => {
|
||||
onNewMessageChange(newMessage + (emoji.native ?? ''));
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await onSendMedia(file);
|
||||
setPreviewFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewConfirm = async (file: File, caption: string) => {
|
||||
await onSendMedia(file, caption);
|
||||
setPreviewFile(null);
|
||||
};
|
||||
|
||||
const handlePreviewCancel = () => setPreviewFile(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!micAvailable) {
|
||||
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
|
||||
@@ -347,12 +370,36 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={onEmojiClick}
|
||||
autoFocusSearch={false}
|
||||
theme={Theme.LIGHT}
|
||||
width={320}
|
||||
height={400}
|
||||
<Picker
|
||||
data={emojiData}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
locale="pt"
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
searchPosition="sticky"
|
||||
navPosition="top"
|
||||
perLine={8}
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sticker Panel Popover */}
|
||||
<AnimatePresence>
|
||||
{showStickerPanel && instanceId && (
|
||||
<motion.div
|
||||
ref={stickerPanelRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-16 z-50"
|
||||
>
|
||||
<StickerPanel
|
||||
instanceId={instanceId}
|
||||
jid={selectedChat?.remote_jid ?? ''}
|
||||
onSend={handleSendSticker}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -473,12 +520,23 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
|
||||
{/* Emoji — mantém no lugar */}
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[24px] h-[24px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
@@ -625,6 +683,13 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Modal de preview de arquivo ─────────────────────── */}
|
||||
<FilePreviewModal
|
||||
file={previewFile}
|
||||
onConfirm={handlePreviewConfirm}
|
||||
onCancel={handlePreviewCancel}
|
||||
/>
|
||||
|
||||
{/* ── Modais de mensagem rica ────────────────────────────── */}
|
||||
<RichMessageComposer
|
||||
isOpen={showRichComposer}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isSameDay, format, isToday, isYesterday } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
// Message interface removed and moved to types.ts
|
||||
|
||||
@@ -57,6 +58,14 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
|
||||
// ── Media viewer state ────────────────────────────────────────────────────
|
||||
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
|
||||
|
||||
// ── Re-download de mídia não baixada ─────────────────────────────────────
|
||||
const handleRedownloadMedia = useCallback(async (msg: Message) => {
|
||||
// selectedChat no formato legado usa instance_name (não instanceId)
|
||||
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
|
||||
if (!instanceId || !msg.id) return
|
||||
await mediaApi.redownload(instanceId, String(msg.id))
|
||||
}, [selectedChat?.instance_name, selectedChat?.instanceId])
|
||||
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
|
||||
|
||||
const handleInternalScroll = useCallback(() => {
|
||||
@@ -200,6 +209,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
quickReactions={quickReactions}
|
||||
selectedChat={selectedChat}
|
||||
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
||||
onRedownloadMedia={handleRedownloadMedia}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -123,7 +123,15 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Waveform + meta */}
|
||||
{/* Speed — antes do waveform, alinhado ao centro vertical */}
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none shrink-0 w-7 text-center"
|
||||
>
|
||||
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
|
||||
</button>
|
||||
|
||||
{/* Waveform + tempo */}
|
||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-end gap-[2px] h-7">
|
||||
{waveHeights.map((h, i) => (
|
||||
@@ -137,17 +145,9 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
|
||||
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
|
||||
</span>
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none"
|
||||
>
|
||||
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
|
||||
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{TimestampRow}
|
||||
@@ -161,6 +161,9 @@ interface DocumentBubbleProps {
|
||||
msg: Message
|
||||
fullMediaUrl: string | null
|
||||
TimestampRow: React.ReactNode
|
||||
onRedownload?: () => void
|
||||
isRedownloading?: boolean
|
||||
mediaUnavailable?: boolean
|
||||
}
|
||||
|
||||
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
@@ -175,7 +178,7 @@ const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
csv: { bg: '#276749', label: 'CSV' },
|
||||
}
|
||||
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow }) => {
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading, mediaUnavailable }) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
// Derive extension from media_type or media_url
|
||||
@@ -201,7 +204,11 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
const isPdf = ext === 'pdf'
|
||||
|
||||
const handleClick = () => {
|
||||
if (!fullMediaUrl) return
|
||||
if (mediaUnavailable) return
|
||||
if (!fullMediaUrl) {
|
||||
onRedownload?.()
|
||||
return
|
||||
}
|
||||
if (isPdf) {
|
||||
setPreviewOpen(true)
|
||||
} else {
|
||||
@@ -214,16 +221,17 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 cursor-pointer hover:bg-[#e8eaed] transition-all active:scale-[0.98] min-w-[220px] max-w-[280px]"
|
||||
className={`flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 transition-all min-w-[220px] max-w-[280px] ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed] active:scale-[0.98]'}`}
|
||||
>
|
||||
{/* Type icon */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: docType.bg }}
|
||||
>
|
||||
<span className="text-white text-[10px] font-extrabold tracking-tight leading-none">
|
||||
{docType.label}
|
||||
</span>
|
||||
{isRedownloading
|
||||
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <span className="text-white text-[10px] font-extrabold tracking-tight leading-none">{docType.label}</span>
|
||||
}
|
||||
</div>
|
||||
{/* Filename + meta */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
@@ -231,7 +239,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
{filename}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#667781] mt-[2px]">
|
||||
{ext.toUpperCase()}{sizeStr ? ` · ${sizeStr}` : ''}
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Arquivo não disponível' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
|
||||
@@ -294,6 +302,7 @@ interface MessageBubbleProps {
|
||||
onStar?: (msg: Message) => void;
|
||||
onEdit?: (msg: Message) => void;
|
||||
onOpenMedia?: (msg: Message) => void;
|
||||
onRedownloadMedia?: (msg: Message) => Promise<void>;
|
||||
quickReactions: string[];
|
||||
selectedChat?: any;
|
||||
}
|
||||
@@ -313,12 +322,28 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
onStar,
|
||||
onEdit,
|
||||
onOpenMedia,
|
||||
onRedownloadMedia,
|
||||
quickReactions,
|
||||
selectedChat,
|
||||
showTail = false,
|
||||
}) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [isRedownloading, setIsRedownloading] = useState(false);
|
||||
const [mediaUnavailable, setMediaUnavailable] = useState(false);
|
||||
|
||||
const handleRedownload = useCallback(async () => {
|
||||
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
|
||||
setIsRedownloading(true)
|
||||
try {
|
||||
await onRedownloadMedia(msg)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status ?? err?.status
|
||||
if (status === 400 || status === 404) setMediaUnavailable(true)
|
||||
} finally {
|
||||
setIsRedownloading(false)
|
||||
}
|
||||
}, [onRedownloadMedia, msg, isRedownloading, mediaUnavailable])
|
||||
const isOut = msg.direction === 'out';
|
||||
const showActionsLeft = isOut;
|
||||
const showActionsRight = !isOut;
|
||||
@@ -595,8 +620,8 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
|
||||
onClick={() => onOpenMedia ? onOpenMedia(msg) : fullMediaUrl && window.open(fullMediaUrl, '_blank')}
|
||||
className={`relative group/media w-[268px] h-[200px] bg-black/5 overflow-hidden ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
|
||||
>
|
||||
{fullMediaUrl ? (
|
||||
<img
|
||||
@@ -605,8 +630,11 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<ImageIcon className="w-8 h-8 text-slate-400" />
|
||||
<div className={`flex flex-col items-center justify-center w-full h-full gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">{mediaUnavailable ? 'Imagem não disponível' : 'Toque para baixar'}</span></>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{!hasCaption && (
|
||||
@@ -632,14 +660,23 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
|
||||
onClick={() => fullMediaUrl && (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank'))}
|
||||
className={`relative w-[268px] h-[200px] bg-black overflow-hidden group/vid ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
|
||||
>
|
||||
{fullMediaUrl ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors">
|
||||
<div className="w-12 h-12 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center text-white shadow-xl border border-white/30 group-hover/vid:scale-110 transition-transform">
|
||||
<Video className="w-6 h-6 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
|
||||
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">{mediaUnavailable ? 'Vídeo não disponível' : 'Toque para baixar'}</span></>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 inset-x-0 h-10 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />
|
||||
{!hasCaption && (
|
||||
<div className="absolute bottom-1.5 right-2">
|
||||
@@ -661,15 +698,45 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
case 'audio':
|
||||
case 'ptt':
|
||||
if (!fullMediaUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
|
||||
}
|
||||
<span className="text-[12px] text-[#667781]">
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
|
||||
</span>
|
||||
</div>
|
||||
{TimestampRow}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
|
||||
case 'document':
|
||||
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} />;
|
||||
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} mediaUnavailable={mediaUnavailable} />;
|
||||
case 'sticker':
|
||||
return (
|
||||
<div>
|
||||
{fullMediaUrl
|
||||
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
|
||||
: <div className="flex items-center gap-2 text-[13px] text-[#667781] italic"><Star className="w-4 h-4" /> Figurinha</div>
|
||||
: (
|
||||
<div
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2 text-[13px] text-[#667781] italic transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:text-[#111b21]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <Star className="w-4 h-4" />
|
||||
}
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Figurinha não disponível' : 'Toque para baixar figurinha'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{TimestampRow}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Heart, Clock, Loader2 } from 'lucide-react';
|
||||
import { stickerApi, type StickerRecord } from '../services/chatApiService';
|
||||
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
|
||||
const LIST_VERSION_KEY = 'nw:sticker-list-version';
|
||||
|
||||
interface StickerPanelProps {
|
||||
instanceId: string;
|
||||
jid: string;
|
||||
onSend: (blob: Blob) => void;
|
||||
}
|
||||
|
||||
// Converte URL Wasabi → URL do proxy do backend
|
||||
function stickerSrcUrl(wasabiPath: string): string {
|
||||
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
|
||||
return `${API}/api/storage/view/${clean}`;
|
||||
}
|
||||
|
||||
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
|
||||
function useStickerBlob(sticker: StickerRecord) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStickerFromCache(sticker.sha256).then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) { setDataUrl(cached); return; }
|
||||
|
||||
// Carrega do proxy e converte para dataURL via canvas
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || 96;
|
||||
canvas.height = img.naturalHeight || 96;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { setDataUrl(img.src); return; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const url = canvas.toDataURL('image/webp', 0.9);
|
||||
setDataUrl(url);
|
||||
setStickerInCache(sticker.sha256, url);
|
||||
} catch {
|
||||
setDataUrl(img.src);
|
||||
}
|
||||
};
|
||||
img.onerror = () => { if (!cancelled) setDataUrl(null); };
|
||||
img.src = stickerSrcUrl(sticker.wasabiPath);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [sticker.sha256, sticker.wasabiPath]);
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// Item individual de sticker
|
||||
function StickerItem({
|
||||
sticker,
|
||||
onSend,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
sticker: StickerRecord;
|
||||
onSend: (s: StickerRecord) => void;
|
||||
onToggleFavorite: (s: StickerRecord) => void;
|
||||
}) {
|
||||
const dataUrl = useStickerBlob(sticker);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group cursor-pointer"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onClick={() => dataUrl && onSend(sticker)}
|
||||
>
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
|
||||
{dataUrl ? (
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão de favoritar — aparece no hover */}
|
||||
{hovering && (
|
||||
<button
|
||||
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
|
||||
sticker.isFavorite
|
||||
? 'text-red-400 bg-white/90'
|
||||
: 'text-gray-300 bg-white/80 hover:text-red-400'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
|
||||
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
|
||||
>
|
||||
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
|
||||
const [stickers, setStickers] = useState<StickerRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { listVersion, stickers: list } = await stickerApi.list();
|
||||
|
||||
if (!force) {
|
||||
const cached = localStorage.getItem(LIST_VERSION_KEY);
|
||||
if (cached === listVersion) {
|
||||
// versão igual — sem novidades, mas ainda mostra o que temos
|
||||
}
|
||||
localStorage.setItem(LIST_VERSION_KEY, listVersion);
|
||||
}
|
||||
setStickers(list);
|
||||
} catch {
|
||||
// silencioso — sem stickers ainda
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleSend = useCallback(async (sticker: StickerRecord) => {
|
||||
const dataUrl = await getStickerFromCache(sticker.sha256);
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Converte dataURL → Blob → envia
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
onSend(blob);
|
||||
}, [onSend]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
|
||||
const result = await stickerApi.toggleFavorite(sticker.id);
|
||||
setStickers((prev) =>
|
||||
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const displayed =
|
||||
tab === 'favorites'
|
||||
? stickers.filter((s) => s.isFavorite)
|
||||
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-100">
|
||||
<button
|
||||
onClick={() => setTab('recent')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Recentes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('favorites')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
Favoritos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="h-[220px] overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-300">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
|
||||
{tab === 'favorites' ? (
|
||||
<>
|
||||
<Heart className="w-8 h-8" />
|
||||
<span className="text-xs">Nenhum favorito ainda</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl">🙂</span>
|
||||
<span className="text-xs text-center">
|
||||
As figurinhas que você receber<br />aparecem aqui automaticamente
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
{displayed.map((s) => (
|
||||
<StickerItem
|
||||
key={s.id}
|
||||
sticker={s}
|
||||
onSend={handleSend}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
getAvatarInitials,
|
||||
getAvatarColor,
|
||||
} from '../../utils/inboxUtils';
|
||||
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
|
||||
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
|
||||
|
||||
interface AvatarProps {
|
||||
chat: any;
|
||||
@@ -14,30 +12,73 @@ interface AvatarProps {
|
||||
}
|
||||
|
||||
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
|
||||
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [showInitials, setShowInitials] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const cacheAttempted = useRef(false);
|
||||
|
||||
// chat.avatar_url é preenchido upstream (useNewInboxBridge, phonebook,
|
||||
// ContactPicker) com a URL do proxy SÓ quando o banco confirma que existe
|
||||
// foto. Null aqui = sem foto → Avatar pula a requisição e mostra iniciais.
|
||||
const imageUrl = chat?.avatar_url || null;
|
||||
const imageUrl = chat?.avatar_url || null;
|
||||
const version = chat?.avatar_version || null;
|
||||
const remoteJid = chat?.remote_jid || '';
|
||||
|
||||
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
|
||||
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
|
||||
useEffect(() => {
|
||||
cacheAttempted.current = false;
|
||||
setIsLoaded(false);
|
||||
setShowInitials(!imageUrl);
|
||||
}, [chat?.remote_jid, imageUrl]);
|
||||
|
||||
const currentUrl = useMemo(() => {
|
||||
if (showInitials || !imageUrl) return null;
|
||||
return imageUrl;
|
||||
}, [showInitials, imageUrl]);
|
||||
if (!imageUrl || !version) {
|
||||
setShowInitials(true);
|
||||
setDisplayUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setShowInitials(false);
|
||||
|
||||
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
|
||||
getAvatarFromCache(remoteJid, version).then((cached) => {
|
||||
if (cached) {
|
||||
setDisplayUrl(cached);
|
||||
setIsLoaded(true); // já temos os bytes — sem skeleton
|
||||
} else {
|
||||
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
|
||||
}
|
||||
cacheAttempted.current = true;
|
||||
}).catch(() => {
|
||||
setDisplayUrl(imageUrl);
|
||||
cacheAttempted.current = true;
|
||||
});
|
||||
}, [remoteJid, version, imageUrl]);
|
||||
|
||||
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
setIsLoaded(true);
|
||||
|
||||
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
|
||||
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
|
||||
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
|
||||
try {
|
||||
const img = e.currentTarget;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || size;
|
||||
canvas.height = img.naturalHeight || size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
setAvatarInCache(remoteJid, version, dataUrl);
|
||||
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setShowInitials(true);
|
||||
setDisplayUrl(null);
|
||||
};
|
||||
|
||||
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
|
||||
const bgColor = useMemo(() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'), [chat]);
|
||||
const bgColor = useMemo(
|
||||
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
|
||||
[chat]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -45,18 +86,19 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!showInitials && currentUrl ? (
|
||||
{!showInitials && displayUrl ? (
|
||||
<motion.img
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
key={displayUrl}
|
||||
src={displayUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
crossOrigin="anonymous"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={handleImageError}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
@@ -72,8 +114,8 @@ export default function Avatar({ chat, size = 46, className = '' }: AvatarProps)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Skeleton / Placeholder while loading first image */}
|
||||
{!isLoaded && !showInitials && (
|
||||
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
|
||||
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,10 +75,10 @@ export function useChatWindow({
|
||||
// Será implementado via endpoint /api/instances/:id/react
|
||||
}, [])
|
||||
|
||||
const handleSendMedia = useCallback(async (file: File) => {
|
||||
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
try {
|
||||
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, undefined, undefined)
|
||||
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar mídia:', err)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
||||
? getAvatarProxyUrl({ instance_name: c.instanceId, remote_jid: c.jid }, 'contact')
|
||||
: null
|
||||
|
||||
const avatar_version: string | null = c.contactAvatarVersion ?? null
|
||||
|
||||
return {
|
||||
id: c.id as any,
|
||||
instance_name: c.instanceId,
|
||||
@@ -49,6 +51,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
||||
phone: formattedPhone,
|
||||
lead_name: c.contactName,
|
||||
avatar_url,
|
||||
avatar_version,
|
||||
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
|
||||
displayName,
|
||||
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
|
||||
@@ -61,6 +64,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
||||
unread_count: c.unreadCount,
|
||||
is_pinned: c.isPinned,
|
||||
is_archived: c.isArchived,
|
||||
bot_paused: c.botPaused ?? null,
|
||||
labels: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useAvatarCache — cache de avatares em IndexedDB.
|
||||
*
|
||||
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
|
||||
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
|
||||
* automaticamente — sem TTL fixo, sem stale data visível ao usuário.
|
||||
*
|
||||
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
|
||||
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'nw-avatars'
|
||||
const STORE_NAME = 'blobs'
|
||||
const DB_VERSION = 1
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
|
||||
|
||||
interface CacheEntry {
|
||||
key: string // `${jid}:${version}`
|
||||
dataUrl: string
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => {
|
||||
const db = req.result
|
||||
pruneOldEntries(db)
|
||||
resolve(db)
|
||||
}
|
||||
req.onerror = () => { dbPromise = null; reject(req.error) }
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
function pruneOldEntries(db: IDBDatabase): void {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const cutoff = Date.now() - MAX_AGE_MS
|
||||
const req = store.openCursor()
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result
|
||||
if (!cursor) return
|
||||
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
|
||||
cursor.continue()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const db = await openDB()
|
||||
const key = `${jid}:${version}`
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).get(key)
|
||||
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const db = await openDB()
|
||||
const key = `${jid}:${version}`
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
|
||||
const req = tx.objectStore(STORE_NAME).put(entry)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useStickerCache — cache de figurinhas em IndexedDB.
|
||||
*
|
||||
* Chave de cache: sha256 hex do arquivo (conteúdo permanente — nunca muda).
|
||||
* Sem TTL: stickers são imutáveis, então o cache é eterno.
|
||||
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
|
||||
*
|
||||
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
|
||||
*/
|
||||
|
||||
const DB_NAME = 'nw-stickers'
|
||||
const STORE_NAME = 'blobs'
|
||||
const DB_VERSION = 1
|
||||
|
||||
interface CacheEntry {
|
||||
sha256: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => { dbPromise = null; reject(req.error) }
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
export async function getStickerFromCache(sha256: string): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).get(sha256)
|
||||
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const db = await openDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const entry: CacheEntry = { sha256, dataUrl }
|
||||
const req = tx.objectStore(STORE_NAME).put(entry)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
} catch { /* quota exceeded — ignora */ }
|
||||
}
|
||||
|
||||
export async function hasStickerInCache(sha256: string): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).count(sha256)
|
||||
req.onsuccess = () => resolve(req.result > 0)
|
||||
req.onerror = () => resolve(false)
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
+22
@@ -8,6 +8,8 @@
|
||||
"name": "newwhats-dashboard",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -46,6 +48,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@emoji-mart/data": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
||||
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="
|
||||
},
|
||||
"node_modules/@emoji-mart/react": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
|
||||
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
|
||||
"peerDependencies": {
|
||||
"emoji-mart": "^5.2",
|
||||
"react": "^16.8 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1354,6 +1370,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-mart": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -33,6 +33,7 @@ const TABS = [
|
||||
{ id: 'email', label: 'E-mail', icon: Mail },
|
||||
{ id: 'registration', label: 'Registro', icon: Users },
|
||||
{ id: 'system', label: 'Sistema', icon: Wrench },
|
||||
{ id: 'deploys', label: 'Versões', icon: RefreshCw },
|
||||
] as const
|
||||
|
||||
type Tab = typeof TABS[number]['id']
|
||||
@@ -167,6 +168,81 @@ export default function AdminSettings() {
|
||||
const [engine, setEngine] = useState<'infinite' | 'official'>('infinite')
|
||||
const [switchingEngine, setSwitchingEngine] = useState(false)
|
||||
|
||||
// ── Estados de Controle de Versões & Deploys ──
|
||||
const [versionData, setVersionData] = useState<{
|
||||
version: string;
|
||||
buildCount: number;
|
||||
lastDeploy: string;
|
||||
lastCommit?: { hash: string; message: string; author: string };
|
||||
} | null>(null)
|
||||
const [deploysHistory, setDeploysHistory] = useState<any[]>([])
|
||||
const [triggeringDeploy, setTriggeringDeploy] = useState(false)
|
||||
|
||||
const fetchDeployData = useCallback(async () => {
|
||||
try {
|
||||
const vRes = await fetch('/version.json?t=' + Date.now())
|
||||
if (vRes.ok) {
|
||||
const vData = await vRes.json()
|
||||
setVersionData(vData)
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const dRes = await fetch('/deploys.json?t=' + Date.now())
|
||||
if (dRes.ok) {
|
||||
const dData = await dRes.json()
|
||||
setDeploysHistory(dData)
|
||||
}
|
||||
} catch (e) {}
|
||||
}, [])
|
||||
|
||||
const triggerDeploy = async () => {
|
||||
if (!window.confirm('Deseja realmente iniciar uma nova compilação e deploy em produção agora?\n\nIsso irá atualizar o repositório, reinstalar dependências, compilar o frontend e backend, e reiniciar os serviços.')) return
|
||||
setTriggeringDeploy(true)
|
||||
try {
|
||||
await api.post('/api/admin/deploys/trigger')
|
||||
alert('Deploy automático iniciado em segundo plano! A VPS 4 está compilando os arquivos. O painel monitorará o progresso automaticamente.')
|
||||
|
||||
let attempts = 0
|
||||
const interval = setInterval(async () => {
|
||||
attempts++
|
||||
try {
|
||||
const vRes = await fetch('/version.json?t=' + Date.now())
|
||||
if (vRes.ok) {
|
||||
const vData = await vRes.json()
|
||||
if (!versionData || vData.buildCount !== versionData.buildCount) {
|
||||
setVersionData(vData)
|
||||
const dRes = await fetch('/deploys.json?t=' + Date.now())
|
||||
if (dRes.ok) {
|
||||
setDeploysHistory(await dRes.json())
|
||||
}
|
||||
setTriggeringDeploy(false)
|
||||
clearInterval(interval)
|
||||
alert(`Sucesso! Deploy finalizado. Versão atualizada para ${vData.version}.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (attempts >= 36) { // 3 minutos
|
||||
clearInterval(interval)
|
||||
setTriggeringDeploy(false)
|
||||
alert('O tempo limite de monitoramento esgotou, mas o deploy pode ainda estar rodando. Por favor, recarregue a página em instantes.')
|
||||
}
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
alert('Erro ao tentar acionar o deploy automático.')
|
||||
setTriggeringDeploy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === 'admin') {
|
||||
fetchDeployData()
|
||||
}
|
||||
}, [user, fetchDeployData])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user?.role !== 'admin') router.replace('/inbox')
|
||||
}, [loading, user, router])
|
||||
@@ -269,7 +345,7 @@ export default function AdminSettings() {
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={tab} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.18 }}>
|
||||
<Card variant="glass" padding="p-8" className="rounded-3xl max-w-3xl">
|
||||
<Card variant="glass" padding="p-8" className={`rounded-3xl transition-all duration-300 ${tab === 'deploys' ? 'max-w-5xl w-full' : 'max-w-3xl'}`}>
|
||||
|
||||
{/* ── BRANDING ── */}
|
||||
{tab === 'branding' && (
|
||||
@@ -568,6 +644,147 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── DEPLOYS ── */}
|
||||
{tab === 'deploys' && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<SectionTitle>Ciclo de Deploy (GitOps) e Versões</SectionTitle>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Visualize o histórico de compilação da plataforma e gerencie os deploys automáticos em produção diretamente do painel de administração.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid das Versões */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Card Versão Atual */}
|
||||
<div className="p-6 rounded-2xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Versão Ativa</span>
|
||||
<div className="flex items-baseline gap-2 mt-2">
|
||||
<span className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-brand-500">
|
||||
{versionData?.version || 'v1.0.0'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-semibold bg-white/5 px-2 py-0.5 rounded-lg border border-white/5">
|
||||
build #{versionData?.buildCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-slate-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
Online em Produção (VPS 1)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-white/5 text-xs text-slate-400">
|
||||
Último deploy:{' '}
|
||||
<span className="text-slate-300 font-semibold">
|
||||
{versionData?.lastDeploy
|
||||
? new Date(versionData.lastDeploy).toLocaleString('pt-BR')
|
||||
: 'Pendente ou Manual'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Último Commit */}
|
||||
<div className="p-6 rounded-2xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Última Alteração Integrada</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-sm font-bold text-slate-200 truncate">
|
||||
{versionData?.lastCommit?.message || 'Sem dados de commit'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Por: <span className="text-slate-400 font-medium">{versionData?.lastCommit?.author || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-white/5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Hash do commit:</span>
|
||||
<span className="font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded-lg border border-brand-500/20">
|
||||
{versionData?.lastCommit?.hash || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bloco de Ação Manual */}
|
||||
<div className="p-6 rounded-2xl bg-brand-500/5 border border-brand-500/10 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-bold text-slate-200">Compilação e Deploy Forçado</h4>
|
||||
<p className="text-xs text-slate-500 max-w-xl">
|
||||
Deseja puxar a última versão do repositório Git, reinstalar pacotes, rodar o build do Next.js e reiniciar os containers de produção agora?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerDeploy}
|
||||
disabled={triggeringDeploy}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-bold shadow-lg shadow-brand-900/20 transition-all disabled:opacity-50 whitespace-nowrap min-w-[200px]"
|
||||
>
|
||||
{triggeringDeploy ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
Compilando & Sincronizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={15} />
|
||||
Compilar e Despachar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Histórico Completo de Deploys */}
|
||||
<div className="space-y-4">
|
||||
<SectionTitle>Histórico de Push & Compilações</SectionTitle>
|
||||
|
||||
{deploysHistory.length === 0 ? (
|
||||
<div className="text-center py-10 rounded-2xl border border-dashed border-white/5 text-xs text-slate-500">
|
||||
Nenhum histórico de deploy registrado ainda.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/5 bg-white/[0.01]">
|
||||
<table className="w-full text-left text-xs text-slate-400">
|
||||
<thead>
|
||||
<tr className="bg-white/[0.02] border-b border-white/5 font-semibold text-slate-300">
|
||||
<th className="px-5 py-3.5">Versão</th>
|
||||
<th className="px-5 py-3.5">Autor</th>
|
||||
<th className="px-5 py-3.5">Mensagem do Commit</th>
|
||||
<th className="px-5 py-3.5">Data & Hora</th>
|
||||
<th className="px-5 py-3.5 text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{deploysHistory.map((deploy, index) => (
|
||||
<tr key={index} className="hover:bg-white/[0.01] transition-colors">
|
||||
<td className="px-5 py-4 font-bold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-brand-400 font-mono">
|
||||
{deploy.version}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-200 font-semibold">{deploy.author}</td>
|
||||
<td className="px-5 py-4 max-w-xs truncate text-slate-300">{deploy.commitMessage}</td>
|
||||
<td className="px-5 py-4">
|
||||
{new Date(deploy.timestamp).toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
{deploy.status || 'Sucesso'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { MessageSquare, Settings2, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
interface MessageNodeProps {
|
||||
data: {
|
||||
label: string;
|
||||
text?: string;
|
||||
image?: string;
|
||||
};
|
||||
isConnectable: boolean;
|
||||
}
|
||||
|
||||
export default function MessageNode({ data, isConnectable }: MessageNodeProps) {
|
||||
return (
|
||||
<div className="bg-[#121622] border border-blue-500/30 hover:border-blue-500 transition-colors rounded-2xl shadow-xl shadow-blue-500/5 min-w-[260px] max-w-[300px] overflow-hidden group">
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable={isConnectable}
|
||||
className="!w-4 !h-4 !bg-[#121622] !border-2 !border-slate-500"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-blue-500/20 bg-blue-500/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-blue-500/20 w-7 h-7 rounded-md flex items-center justify-center">
|
||||
<MessageSquare size={14} className="text-blue-400" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-200">Envia Mensagem</span>
|
||||
</div>
|
||||
<button className="text-slate-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Settings2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 bg-white/[0.01]">
|
||||
{data.image && (
|
||||
<div className="w-full h-24 mb-3 rounded-lg border border-white/5 bg-black/40 flex items-center justify-center overflow-hidden relative">
|
||||
<ImageIcon size={20} className="text-slate-500 absolute z-0" />
|
||||
<img src={data.image} className="w-full h-full object-cover relative z-10" alt="Midia" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-slate-300 bg-white/5 p-3 rounded-lg border border-white/5 line-clamp-3 leading-relaxed">
|
||||
{data.text || "Olá, como posso ajudar? Isso é um nó de mensagem."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={isConnectable}
|
||||
className="!w-4 !h-4 !bg-[#121622] !border-2 !border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Zap, Settings2 } from 'lucide-react';
|
||||
|
||||
interface TriggerNodeProps {
|
||||
data: {
|
||||
label: string;
|
||||
keyword?: string;
|
||||
};
|
||||
isConnectable: boolean;
|
||||
}
|
||||
|
||||
export default function TriggerNode({ data, isConnectable }: TriggerNodeProps) {
|
||||
return (
|
||||
<div className="bg-[#121622] border-[1.5px] border-brand-500/50 rounded-2xl shadow-xl shadow-brand-500/10 min-w-[240px] overflow-hidden group hover:border-brand-500 transition-colors">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-brand-500/20 bg-brand-500/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-brand-500/20 w-7 h-7 rounded-md flex items-center justify-center">
|
||||
<Zap size={14} className="text-brand-400" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-200">Trigger</span>
|
||||
</div>
|
||||
<button className="text-slate-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Settings2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 bg-white/[0.01]">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-2">On exact match</p>
|
||||
<div className="bg-black/40 border border-white/5 rounded-lg px-3 py-2 flex items-center text-brand-400 font-mono text-sm max-w-full overflow-hidden text-ellipsis">
|
||||
{data.keyword || '#welcome'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Handle (Bottom or Right) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
isConnectable={isConnectable}
|
||||
className="!w-4 !h-4 !bg-[#121622] !border-2 !border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,8 @@ import { ArrowLeft, Save, Play, MessageSquare, Zap, Clock, Image as ImageIcon }
|
||||
import Link from 'next/link';
|
||||
|
||||
// --- Custom Nodes Types Implementation ---
|
||||
import MessageNode from './components/MessageNode';
|
||||
import TriggerNode from './components/TriggerNode';
|
||||
import MessageNode from './_components/MessageNode';
|
||||
import TriggerNode from './_components/TriggerNode';
|
||||
|
||||
const nodeTypes = {
|
||||
messageNode: MessageNode,
|
||||
|
||||
@@ -127,12 +127,29 @@ export default function WhatsAppInboxPage() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
|
||||
|
||||
// Preloader: começa visível e some após o primeiro ciclo loading true→false
|
||||
const [pageReady, setPageReady] = useState(false)
|
||||
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
|
||||
// navegação client-side (router.push). Detectado por:
|
||||
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
|
||||
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
|
||||
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
|
||||
const [pageReady, setPageReady] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
|
||||
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
|
||||
if (isHardReload) return false
|
||||
return sessionStorage.getItem('nw:inbox-loaded') === '1'
|
||||
})
|
||||
const prevLoadingRef = useRef(false)
|
||||
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !loading) setPageReady(true)
|
||||
if (prevLoadingRef.current && !loading) {
|
||||
readyTimerRef.current = setTimeout(() => {
|
||||
setPageReady(true)
|
||||
sessionStorage.setItem('nw:inbox-loaded', '1')
|
||||
}, 400)
|
||||
}
|
||||
prevLoadingRef.current = loading
|
||||
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
|
||||
}, [loading])
|
||||
|
||||
const optionsRef = useRef<HTMLDivElement>(null)
|
||||
@@ -206,15 +223,16 @@ export default function WhatsAppInboxPage() {
|
||||
animate={{ opacity: [1, 0.2, 1] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
<svg viewBox="0 0 175.216 175.552" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
|
||||
<defs>
|
||||
<linearGradient id="wpp-grad" x1="85.915" y1="173.376" x2="85.915" y2="5.765" gradientUnits="userSpaceOnUse">
|
||||
<linearGradient id="wpp-grad" x1="50" y1="100" x2="50" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#20b038"/>
|
||||
<stop offset="1" stopColor="#60d66a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 8.232 30.673L26 149.344l33.79-8.854a61.22 61.22 0 0 0 29.376 7.483h.023c33.73 0 61.17-27.423 61.18-61.13a60.75 60.75 0 0 0-17.91-43.263 60.77 60.77 0 0 0-43.27-17.353z" fill="url(#wpp-grad)"/>
|
||||
<path d="M122.08 106.07c-1.96-1.02-11.6-5.72-13.4-6.38-1.8-.66-3.11-.98-4.42.98-1.31 1.96-5.06 6.38-6.21 7.69s-2.29 1.47-4.25.49c-11.6-5.8-19.21-10.36-26.83-23.5-2.03-3.49.43-3.25 1.16-5.42.6-1.77.3-3.27-.17-4.25-.49-.98-4.42-10.65-6.05-14.58-1.59-3.83-3.22-3.3-4.42-3.36-1.14-.06-2.45-.07-3.76-.07s-3.44.49-5.24 2.45c-1.8 1.96-6.87 6.71-6.87 16.37s7.04 18.99 8.02 20.3c.98 1.31 13.85 21.14 33.57 29.67 12.47 5.38 17.35 5.83 23.59 4.91 3.79-.57 11.6-4.74 13.24-9.33 1.63-4.59 1.63-8.52 1.14-9.33-.47-.81-1.78-1.31-3.74-2.31z" fill="#fff"/>
|
||||
<circle cx="50" cy="50" r="50" fill="url(#wpp-grad)"/>
|
||||
<path d="M50 15.5c-19.05 0-34.5 15.45-34.5 34.5 0 6.36 1.72 12.3 4.73 17.4L15.5 84.5l17.6-4.62a34.36 34.36 0 0 0 16.9 4.42c19.05 0 34.5-15.45 34.5-34.5S69.05 15.5 50 15.5z" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M68.5 60.3c-1.1-.57-6.53-3.22-7.54-3.59-.99-.37-1.72-.55-2.44.55-.73 1.1-2.82 3.59-3.46 4.32-.64.73-1.28.82-2.38.27-6.53-3.26-10.81-5.83-15.1-13.22-1.14-1.97.24-1.83.65-3.05.34-1 .17-1.84-.1-2.39-.27-.55-2.44-5.99-3.4-8.2-.9-2.16-1.81-1.86-2.49-1.89-.64-.03-1.38-.04-2.12-.04s-1.93.28-2.94 1.38c-1.01 1.1-3.87 3.78-3.87 9.22s3.96 10.69 4.51 11.43c.55.73 7.8 11.9 18.9 16.7 7.02 3.03 9.76 3.28 13.28 2.76 2.13-.32 6.53-2.67 7.45-5.25.92-2.58.92-4.79.64-5.25-.27-.46-1-.73-2.09-1.27z" fill="#fff"/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<span className="text-slate-500 text-sm">Carregando conversas…</span>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"version": "v1.0.51",
|
||||
"buildCount": 51,
|
||||
"timestamp": "2026-05-11T20:50:10.267Z",
|
||||
"commitHash": "bd6801a",
|
||||
"commitMessage": "feat(webhook): integra listener com a API de Status do Gitea",
|
||||
"author": "ruicesar",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.50",
|
||||
"buildCount": 50,
|
||||
"timestamp": "2026-05-11T19:55:29.765Z",
|
||||
"commitHash": "cdacfcd",
|
||||
"commitMessage": "feat(preloader): replace WhatsApp icon with smooth circle SVG",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.49",
|
||||
"buildCount": 49,
|
||||
"timestamp": "2026-05-11T19:41:33.099Z",
|
||||
"commitHash": "2233060",
|
||||
"commitMessage": "fix(plugins): adiciona backend/src ao include do tsconfig para resolver erros de editor",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.48",
|
||||
"buildCount": 48,
|
||||
"timestamp": "2026-05-11T16:58:23.256Z",
|
||||
"commitHash": "539b922",
|
||||
"commitMessage": "feat(media): re-download de mídia não baixada ao clicar",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.47",
|
||||
"buildCount": 47,
|
||||
"timestamp": "2026-05-11T16:00:16.840Z",
|
||||
"commitHash": "fc80367",
|
||||
"commitMessage": "fix(storage): remove auth de /api/storage/view e try-catch nos sticker routes",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.46",
|
||||
"buildCount": 46,
|
||||
"timestamp": "2026-05-11T15:48:59.138Z",
|
||||
"commitHash": "b97f7f9",
|
||||
"commitMessage": "feat: sistema de figurinhas, emoji PT-BR e preloader inteligente",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.45",
|
||||
"buildCount": 45,
|
||||
"timestamp": "2026-05-11T07:42:48.317Z",
|
||||
"commitHash": "22891e1",
|
||||
"commitMessage": "fix(listener): skip version-bump commits to prevent deploy loop",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.44",
|
||||
"buildCount": 44,
|
||||
"timestamp": "2026-05-11T07:39:34.532Z",
|
||||
"commitHash": "a7f4d6b",
|
||||
"commitMessage": "feat(preview): fix button layout and add mouse-following zoom",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.43",
|
||||
"buildCount": 43,
|
||||
"timestamp": "2026-05-11T07:22:01.966Z",
|
||||
"commitHash": "7b55354",
|
||||
"commitMessage": "fix(send-media): use upsert to prevent P2002 duplicate messageId error",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.42",
|
||||
"buildCount": 42,
|
||||
"timestamp": "2026-05-11T07:13:59.009Z",
|
||||
"commitHash": "4d0b895",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.41",
|
||||
"buildCount": 41,
|
||||
"timestamp": "2026-05-11T07:12:23.037Z",
|
||||
"commitHash": "949274e",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.40",
|
||||
"buildCount": 40,
|
||||
"timestamp": "2026-05-11T07:10:34.610Z",
|
||||
"commitHash": "7ec3d78",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.39",
|
||||
"buildCount": 39,
|
||||
"timestamp": "2026-05-11T07:08:47.779Z",
|
||||
"commitHash": "b0ac06e",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.38",
|
||||
"buildCount": 38,
|
||||
"timestamp": "2026-05-11T07:07:06.786Z",
|
||||
"commitHash": "bbbc010",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.37",
|
||||
"buildCount": 37,
|
||||
"timestamp": "2026-05-11T07:05:13.069Z",
|
||||
"commitHash": "706fcea",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.36",
|
||||
"buildCount": 36,
|
||||
"timestamp": "2026-05-11T07:03:23.851Z",
|
||||
"commitHash": "875f314",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.35",
|
||||
"buildCount": 35,
|
||||
"timestamp": "2026-05-11T07:01:31.122Z",
|
||||
"commitHash": "918bce6",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.34",
|
||||
"buildCount": 34,
|
||||
"timestamp": "2026-05-11T06:59:25.384Z",
|
||||
"commitHash": "f83f1d9",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.33",
|
||||
"buildCount": 33,
|
||||
"timestamp": "2026-05-11T06:57:52.215Z",
|
||||
"commitHash": "80f8585",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.32",
|
||||
"buildCount": 32,
|
||||
"timestamp": "2026-05-11T06:56:17.493Z",
|
||||
"commitHash": "16dbc40",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.31",
|
||||
"buildCount": 31,
|
||||
"timestamp": "2026-05-11T06:54:31.712Z",
|
||||
"commitHash": "6f28d3d",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.30",
|
||||
"buildCount": 30,
|
||||
"timestamp": "2026-05-11T06:52:53.252Z",
|
||||
"commitHash": "2a97447",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.29",
|
||||
"buildCount": 29,
|
||||
"timestamp": "2026-05-11T06:51:06.085Z",
|
||||
"commitHash": "bed15dd",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.28",
|
||||
"buildCount": 28,
|
||||
"timestamp": "2026-05-11T06:49:20.528Z",
|
||||
"commitHash": "4287b8c",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.27",
|
||||
"buildCount": 27,
|
||||
"timestamp": "2026-05-11T06:47:44.934Z",
|
||||
"commitHash": "811a9b9",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.26",
|
||||
"buildCount": 26,
|
||||
"timestamp": "2026-05-11T06:46:04.024Z",
|
||||
"commitHash": "b54c9fb",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.25",
|
||||
"buildCount": 25,
|
||||
"timestamp": "2026-05-11T06:44:20.520Z",
|
||||
"commitHash": "8253a03",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.24",
|
||||
"buildCount": 24,
|
||||
"timestamp": "2026-05-11T06:42:39.168Z",
|
||||
"commitHash": "c7d3701",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.23",
|
||||
"buildCount": 23,
|
||||
"timestamp": "2026-05-11T06:40:52.961Z",
|
||||
"commitHash": "2e8c59b",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
},
|
||||
{
|
||||
"version": "v1.0.22",
|
||||
"buildCount": 22,
|
||||
"timestamp": "2026-05-11T06:38:53.958Z",
|
||||
"commitHash": "b2ad8a8",
|
||||
"commitMessage": "chore(deploy): bump version",
|
||||
"author": "VPS 4 Deploy Agent",
|
||||
"status": "Sucesso"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "v1.0.51",
|
||||
"buildCount": 51,
|
||||
"lastDeploy": "2026-05-11T20:50:10.267Z",
|
||||
"lastCommit": {
|
||||
"hash": "bd6801a",
|
||||
"message": "feat(webhook): integra listener com a API de Status do Gitea",
|
||||
"author": "ruicesar"
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,20 @@ export const mediaApi = {
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
sendSticker: (instanceId: string, jid: string, blob: Blob) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', blob, 'sticker.webp')
|
||||
fd.append('jid', jid)
|
||||
return api
|
||||
.post(`/api/instances/${instanceId}/send-media`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
redownload: (instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
|
||||
api.post(`/api/instances/${instanceId}/redownload-media/${messageDbId}`).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Chatbot / IA ─────────────────────────────────────────────────────────────
|
||||
@@ -517,3 +531,26 @@ export const scheduleApi = {
|
||||
logs: (id: string) =>
|
||||
api.get(`/api/scheduled/${id}/logs`).then((r) => r.data) as Promise<ScheduleLog[]>,
|
||||
}
|
||||
|
||||
// ─── Stickers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StickerRecord {
|
||||
id: string
|
||||
sha256: string
|
||||
wasabiPath: string
|
||||
isAnimated: boolean
|
||||
isFavorite: boolean
|
||||
useCount?: number
|
||||
lastSeenAt?: string
|
||||
}
|
||||
|
||||
export const stickerApi = {
|
||||
list: () =>
|
||||
api.get('/api/stickers').then((r) => r.data) as Promise<{ listVersion: string; stickers: StickerRecord[] }>,
|
||||
|
||||
recent: () =>
|
||||
api.get('/api/stickers/recent').then((r) => r.data) as Promise<{ stickers: StickerRecord[] }>,
|
||||
|
||||
toggleFavorite: (id: string) =>
|
||||
api.patch(`/api/stickers/${id}/favorite`).then((r) => r.data) as Promise<{ id: string; isFavorite: boolean }>,
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ function mapBackendChat(raw: any): Chat {
|
||||
createdAt: raw.createdAt ?? null,
|
||||
isPinned: raw.isPinned ?? false,
|
||||
isArchived: raw.isArchived ?? false,
|
||||
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||
contactName: raw.contact?.name ?? raw.contactName ?? null,
|
||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
|
||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
||||
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
||||
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
|
||||
@@ -52,6 +53,7 @@ export interface Chat {
|
||||
isArchived: boolean
|
||||
contactName: string | null
|
||||
contactAvatarUrl: string | null
|
||||
contactAvatarVersion: string | null
|
||||
contactPhone: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -79,6 +79,7 @@ export interface NewWhatsChat {
|
||||
phone: string
|
||||
lead_name: string | null
|
||||
avatar_url: string | null
|
||||
avatar_version: string | null
|
||||
bio: string | null
|
||||
displayName: string | null
|
||||
subject: string | null
|
||||
@@ -92,6 +93,7 @@ export interface NewWhatsChat {
|
||||
is_pinned: boolean | null
|
||||
is_archived: boolean | null
|
||||
muted_until?: string | null
|
||||
bot_paused?: boolean | null
|
||||
labels?: Array<{ label_id: string; name?: string }>
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ var __importDefault = (this && this.__importDefault) || function (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");
|
||||
let storageProvider;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
}
|
||||
else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
@@ -31,7 +36,7 @@ class UnifiedStorageProviderPlugin {
|
||||
}
|
||||
async activate(ctx) {
|
||||
this.ctx = ctx;
|
||||
const cfg = plugin_config_1.pluginConfig.get('UnifiedStorageProvider') || {};
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
if (!fs_1.default.existsSync(this.localFallbackDir)) {
|
||||
fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
@@ -52,7 +57,7 @@ class UnifiedStorageProviderPlugin {
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
StorageProvider_1.storageProvider.register(this);
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
this.startSyncWorker();
|
||||
}
|
||||
@@ -152,7 +157,7 @@ class UnifiedStorageProviderPlugin {
|
||||
});
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
setInterval(() => { StorageProvider_1.storageProvider.updateHealth(); }, 30000);
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
async deactivate(ctx) {
|
||||
this.s3 = null;
|
||||
@@ -184,6 +189,7 @@ class UnifiedStorageProviderPlugin {
|
||||
};
|
||||
}
|
||||
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.',
|
||||
@@ -286,10 +292,11 @@ class UnifiedStorageProviderPlugin {
|
||||
}
|
||||
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}`;
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((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);
|
||||
}
|
||||
@@ -311,6 +318,26 @@ class UnifiedStorageProviderPlugin {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
async getBuffer(relativePath) {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
return fs_1.default.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket)
|
||||
return null;
|
||||
try {
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body)
|
||||
return null;
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** 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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,8 +9,14 @@ import {
|
||||
} 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 type { StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
||||
|
||||
let storageProvider: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
} else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -39,7 +45,7 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
const cfg = pluginConfig.get('UnifiedStorageProvider') || {};
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
|
||||
if (!fs.existsSync(this.localFallbackDir)) {
|
||||
fs.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
@@ -318,10 +324,11 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
|
||||
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}`;
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((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);
|
||||
@@ -347,6 +354,24 @@ class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInt
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
return fs.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) return null;
|
||||
try {
|
||||
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body) return null;
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -20,9 +20,21 @@ exports.buildExtRoutes = buildExtRoutes;
|
||||
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");
|
||||
let dragonfly;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
|
||||
}
|
||||
else {
|
||||
dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly;
|
||||
}
|
||||
const brain_1 = require("../../secretaria/brain");
|
||||
const rich_message_1 = require("../../../backend/dist/modules/whatsapp/rich-message");
|
||||
let sendRichMessage;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sendRichMessage = require('../../../dist/modules/whatsapp/rich-message').sendRichMessage;
|
||||
}
|
||||
else {
|
||||
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
|
||||
}
|
||||
function uuid() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
@@ -168,7 +180,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks) {
|
||||
res.status(404).json({ error: 'Instância não encontrada' });
|
||||
return;
|
||||
}
|
||||
const qrBase64 = await dragonfly_1.dragonfly.get(`instance:${instanceId}:qr`);
|
||||
const qrBase64 = await dragonfly.get(`instance:${instanceId}:qr`);
|
||||
if (!qrBase64) {
|
||||
res.status(404).json({ error: 'QR não disponível — inicie a conexão primeiro' });
|
||||
return;
|
||||
@@ -496,7 +508,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks) {
|
||||
const cleanList = nativeList
|
||||
? { buttonText: nativeList.buttonText, sections: nativeList.sections }
|
||||
: undefined;
|
||||
await (0, rich_message_1.sendRichMessage)(sock, chat.jid, {
|
||||
await sendRichMessage(sock, chat.jid, {
|
||||
text: text?.trim() || undefined,
|
||||
footer: resolvedFooter,
|
||||
nativeButtons: nativeButtons,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,9 +22,20 @@ import type { Knex } from 'knex'
|
||||
import type { WhatsAppConnectionManager } from '../../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager'
|
||||
import type { PluginConfigStore } from '../../../backend/src/core/plugin-config'
|
||||
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
||||
import { dragonfly } from '../../../backend/src/infra/cache/dragonfly'
|
||||
import type { Server as SocketServer } from 'socket.io'
|
||||
let dragonfly: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
|
||||
} else {
|
||||
dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly;
|
||||
}
|
||||
import { ProtocolEngine } from '../../secretaria/brain'
|
||||
import { sendRichMessage } from '../../../backend/dist/modules/whatsapp/rich-message'
|
||||
let sendRichMessage: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sendRichMessage = require('../../../dist/modules/whatsapp/rich-message').sendRichMessage;
|
||||
} else {
|
||||
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
|
||||
}
|
||||
|
||||
function uuid(): string {
|
||||
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
@@ -66,9 +77,84 @@ export function buildExtRoutes(
|
||||
db: Knex,
|
||||
config: PluginConfigStore,
|
||||
hooks: HookBus,
|
||||
io?: SocketServer,
|
||||
): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── Helper: envia reply da IA via WA e persiste no banco principal ─────────
|
||||
async function sendSecretariaReply(opts: {
|
||||
instanceId: string
|
||||
tenantId: string
|
||||
chatId: string
|
||||
jid: string
|
||||
reply: string
|
||||
}): Promise<void> {
|
||||
const { instanceId, tenantId, chatId, jid, reply } = opts
|
||||
const sock = manager?.getSocket(instanceId)
|
||||
if (!sock) return
|
||||
const sent = await sock.sendMessage(jid, { text: reply }).catch(() => null)
|
||||
const persisted = await prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId,
|
||||
remoteJid: jid,
|
||||
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: reply,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
}).catch(() => null)
|
||||
if (persisted && io) {
|
||||
io.to(`chat:${chatId}`).emit('message:new', persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEC-02+SEC-03: auto-trigger da Secretária para mensagens recebidas ────
|
||||
// Só processa se NÃO houver AIBot ativo (exclusão mútua com Chatbot Rápido).
|
||||
hooks.register('ext:message.new', async (data: any) => {
|
||||
const { tenantId, instanceId, chatId, jid, text } = data as {
|
||||
tenantId: string; instanceId: string; chatId: string; jid: string; text: string
|
||||
}
|
||||
if (!text?.trim()) return
|
||||
|
||||
// Exclusão mútua: chatbot rápido tem prioridade se estiver ativo
|
||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
|
||||
if (activeBot) return
|
||||
|
||||
const extKey = `${tenantId}:${jid}`
|
||||
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first()
|
||||
|
||||
if (!conv) {
|
||||
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
||||
if (!agent) return
|
||||
const [newConv] = await db('sec_conversations').insert({
|
||||
id: uuid(),
|
||||
agent_id: agent.id,
|
||||
contact_name: jid,
|
||||
protocol_number: ProtocolEngine.generateProtocolNumber(),
|
||||
status: 'active',
|
||||
ext_chat_id: extKey,
|
||||
handoff_mode: 'ia',
|
||||
}).returning('*')
|
||||
conv = newConv
|
||||
}
|
||||
|
||||
if (conv.handoff_mode === 'humano') return
|
||||
|
||||
// SEC-14: indicador de digitação antes de chamar a IA
|
||||
const sock = manager?.getSocket(instanceId)
|
||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
const brain = new ProtocolEngine(db, config)
|
||||
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks })
|
||||
|
||||
await sock?.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply })
|
||||
})
|
||||
|
||||
// ── Escalation notification listener ──────────────────────────────────────
|
||||
// When escalar_humano tool fires, send a WA message to the configured admin phone.
|
||||
hooks.register('ext:escalated', async (data: any) => {
|
||||
@@ -686,6 +772,16 @@ export function buildExtRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// SEC-08: valida que existe instância conectada para este tenant
|
||||
const connectedInstance = await prisma.instance.findFirst({
|
||||
where: { tenantId, status: 'CONNECTED' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!connectedInstance) {
|
||||
res.status(503).json({ error: 'Nenhuma instância WhatsApp conectada para este tenant.' })
|
||||
return
|
||||
}
|
||||
|
||||
const extKey = `${tenantId}:${chatId}`
|
||||
|
||||
let conv = await db('sec_conversations')
|
||||
@@ -733,6 +829,22 @@ export function buildExtRoutes(
|
||||
tenantId,
|
||||
})
|
||||
|
||||
// SEC-01+SEC-11: envia reply via WA e persiste no banco principal
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: { tenantId, remoteJid: chatId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { id: true, instanceId: true },
|
||||
})
|
||||
if (chat) {
|
||||
await sendSecretariaReply({
|
||||
instanceId: chat.instanceId,
|
||||
tenantId,
|
||||
chatId: chat.id,
|
||||
jid: chatId,
|
||||
reply,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
reply,
|
||||
conversationId: conv.id,
|
||||
|
||||
@@ -20,8 +20,14 @@ exports.buildWebhookDispatcher = buildWebhookDispatcher;
|
||||
* Timeout: 10s por requisição para não bloquear o event loop.
|
||||
*/
|
||||
const crypto_1 = require("crypto");
|
||||
const logger_1 = require("../../../backend/src/config/logger");
|
||||
const logger = logger_1.logger.child({ module: 'webhook-dispatcher' });
|
||||
let rootLogger;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
rootLogger = require('../../../dist/config/logger').logger;
|
||||
}
|
||||
else {
|
||||
rootLogger = require('../../../backend/src/config/logger').logger;
|
||||
}
|
||||
const logger = rootLogger.child({ module: 'webhook-dispatcher' });
|
||||
const DISPATCH_TIMEOUT_MS = 10000;
|
||||
// Eventos hookBus → nome de evento no payload enviado ao receptor
|
||||
const HOOK_TO_EVENT = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"webhook-dispatcher.js","sourceRoot":"","sources":["webhook-dispatcher.ts"],"names":[],"mappings":";;AAqEA,wDAmCC;AAxGD;;;;;;;;;;;;;;;;;GAiBG;AACH,mCAAmC;AAGnC,+DAAyE;AAEzE,MAAM,MAAM,GAAG,eAAU,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC,CAAA;AAEjE,MAAM,mBAAmB,GAAG,KAAM,CAAA;AAElC,kEAAkE;AAClE,MAAM,aAAa,GAA2B;IAC5C,iBAAiB,EAAK,aAAa;IACnC,oBAAoB,EAAE,gBAAgB;CACvC,CAAA;AAED,SAAS,IAAI,CAAC,MAAc,EAAE,IAAY;IACxC,OAAO,SAAS,GAAG,IAAA,mBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC5E,CAAC;AAED,KAAK,UAAU,QAAQ,CACrB,GAAW,EACX,MAAc,EACd,KAAa,EACb,IAA6B;IAE7B,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IACnE,MAAM,GAAG,GAAI,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAE/B,MAAM,IAAI,GAAG,IAAI,eAAe,EAAE,CAAA;IAClC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,mBAAmB,CAAC,CAAA;IAEjE,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAC3B,MAAM,EAAG,MAAM;YACf,OAAO,EAAE;gBACP,cAAc,EAAI,kBAAkB;gBACpC,gBAAgB,EAAE,GAAG;aACtB;YACD,IAAI;YACJ,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB,CAAC,CAAA;QACF,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,kCAAkC,CAAC,CAAA;QACrF,CAAC;IACH,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,6BAA6B,CAAC,CAAA;IAC9E,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAA;IACrB,CAAC;AACH,CAAC;AAED,SAAgB,sBAAsB,CAAC,MAAoB,EAAE,KAAc;IACzE,KAAK,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;QACjE,KAAK,CAAC,QAAQ,CAAC,SAAS,EAAE,KAAK,EAAE,GAAQ,EAAE,EAAE;YAC3C,MAAM,QAAQ,GAAuB,GAAG,CAAC,QAAQ,CAAA;YACjD,IAAI,CAAC,QAAQ;gBAAE,OAAM;YAErB,6DAA6D;YAC7D,IAAI,QAAgD,CAAA;YACpD,IAAI,CAAC;gBACH,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;oBAC1C,KAAK,EAAE;wBACL,QAAQ;wBACR,MAAM,EAAE,IAAI;wBACZ,MAAM,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE;qBACzB;oBACD,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;iBACpC,CAAC,CAAA;YACJ,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,mCAAmC,CAAC,CAAA;gBACpF,OAAM;YACR,CAAC;YAED,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAM;YAEjC,yDAAyD;YACzD,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,GAAG,CAAA;YAEvC,6CAA6C;YAC7C,OAAO,CAAC,UAAU,CAChB,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAC/D,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACnB,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,CAAC,IAAI,CAAC,2CAA2C,GAAG,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;AACpG,CAAC"}
|
||||
{"version":3,"file":"webhook-dispatcher.js","sourceRoot":"","sources":["webhook-dispatcher.ts"],"names":[],"mappings":";;AA0EA,wDAmCC;AA7GD;;;;;;;;;;;;;;;;;GAiBG;AACH,mCAAmC;AAGnC,IAAI,UAAe,CAAA;AACnB,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IACxC,UAAU,GAAG,OAAO,CAAC,6BAA6B,CAAC,CAAC,MAAM,CAAA;AAC9D,CAAC;KAAM,CAAC;IACJ,UAAU,GAAG,OAAO,CAAC,oCAAoC,CAAC,CAAC,MAAM,CAAA;AACrE,CAAC;AAED,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC,CAAA;AAEjE,MAAM,mBAAmB,GAAG,KAAM,CAAA;AAElC,kEAAkE;AAClE,MAAM,aAAa,GAA2B;IAC5C,iBAAiB,EAAK,aAAa;IACnC,oBAAoB,EAAE,gBAAgB;CACvC,CAAA;AAED,SAAS,IAAI,CAAC,MAAc,EAAE,IAAY;IACxC,OAAO,SAAS,GAAG,IAAA,mBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC5E,CAAC;AAED,KAAK,UAAU,QAAQ,CACrB,GAAW,EACX,MAAc,EACd,KAAa,EACb,IAA6B;IAE7B,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IACnE,MAAM,GAAG,GAAI,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAE/B,MAAM,IAAI,GAAG,IAAI,eAAe,EAAE,CAAA;IAClC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,mBAAmB,CAAC,CAAA;IAEjE,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAC3B,MAAM,EAAG,MAAM;YACf,OAAO,EAAE;gBACP,cAAc,EAAI,kBAAkB;gBACpC,gBAAgB,EAAE,GAAG;aACtB;YACD,IAAI;YACJ,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB,CAAC,CAAA;QACF,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,kCAAkC,CAAC,CAAA;QACrF,CAAC;IACH,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,6BAA6B,CAAC,CAAA;IAC9E,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAA;IACrB,CAAC;AACH,CAAC;AAED,SAAgB,sBAAsB,CAAC,MAAoB,EAAE,KAAc;IACzE,KAAK,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;QACjE,KAAK,CAAC,QAAQ,CAAC,SAAS,EAAE,KAAK,EAAE,GAAQ,EAAE,EAAE;YAC3C,MAAM,QAAQ,GAAuB,GAAG,CAAC,QAAQ,CAAA;YACjD,IAAI,CAAC,QAAQ;gBAAE,OAAM;YAErB,6DAA6D;YAC7D,IAAI,QAAgD,CAAA;YACpD,IAAI,CAAC;gBACH,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;oBAC1C,KAAK,EAAE;wBACL,QAAQ;wBACR,MAAM,EAAE,IAAI;wBACZ,MAAM,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE;qBACzB;oBACD,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;iBACpC,CAAC,CAAA;YACJ,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,mCAAmC,CAAC,CAAA;gBACpF,OAAM;YACR,CAAC;YAED,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAM;YAEjC,yDAAyD;YACzD,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,GAAG,CAAA;YAEvC,6CAA6C;YAC7C,OAAO,CAAC,UAAU,CAChB,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAC/D,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACnB,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,CAAC,IAAI,CAAC,2CAA2C,GAAG,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;AACpG,CAAC"}
|
||||
@@ -19,7 +19,12 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
||||
import { logger as rootLogger } from '../../../backend/src/config/logger'
|
||||
let rootLogger: any
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
rootLogger = require('../../../dist/config/logger').logger
|
||||
} else {
|
||||
rootLogger = require('../../../backend/src/config/logger').logger
|
||||
}
|
||||
|
||||
const logger = rootLogger.child({ module: 'webhook-dispatcher' })
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
const { app, prisma, db, config, hooks, logger, httpServer } = ctx
|
||||
const { app, prisma, db, config, hooks, logger, httpServer, io } = ctx
|
||||
|
||||
// ── REST ────────────────────────────────────────────────────────────────
|
||||
const manager = globalThis.__whatsAppManager
|
||||
@@ -32,7 +32,7 @@ const plugin: PluginInstance = {
|
||||
}
|
||||
|
||||
const authMiddleware = buildApiKeyAuth(prisma)
|
||||
const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks)
|
||||
const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks, io)
|
||||
|
||||
app.use('/api/ext/v1', authMiddleware, extRouter)
|
||||
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
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");
|
||||
let authenticate;
|
||||
let authorize;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
authenticate = require('../../../dist/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../dist/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
else {
|
||||
authenticate = require('../../../backend/src/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../backend/src/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
|
||||
const LEAD_TYPE_VALUES = ['cold', 'member'];
|
||||
function translateLead(lead) {
|
||||
@@ -21,7 +29,7 @@ function translateLead(lead) {
|
||||
function createLeadsRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const { db } = ctx;
|
||||
const protectedRoutes = [auth_1.authenticate, (0, rbac_1.authorize)(['super_admin', 'admin'])];
|
||||
const protectedRoutes = [authenticate, authorize(['super_admin', 'admin'])];
|
||||
// GET /api/leads/metrics — Global metrics for super admin
|
||||
router.get('/metrics', ...protectedRoutes, async (req, res) => {
|
||||
try {
|
||||
@@ -95,7 +103,7 @@ function createLeadsRoutes(ctx) {
|
||||
res.status(500).json({ error: 'Erro ao buscar métricas' });
|
||||
}
|
||||
});
|
||||
router.get('/', auth_1.authenticate, async (req, res) => {
|
||||
router.get('/', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { partner_id, status, type, page = '1', limit = '20' } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
@@ -124,7 +132,7 @@ function createLeadsRoutes(ctx) {
|
||||
res.status(500).json({ error: 'Erro ao listar leads' });
|
||||
}
|
||||
});
|
||||
router.get('/:id', auth_1.authenticate, async (req, res) => {
|
||||
router.get('/:id', authenticate, async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,16 @@
|
||||
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';
|
||||
|
||||
let authenticate: any;
|
||||
let authorize: any;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
authenticate = require('../../../dist/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../dist/middleware/rbac').adminMiddleware;
|
||||
} else {
|
||||
authenticate = require('../../../backend/src/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../backend/src/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
|
||||
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
|
||||
const LEAD_TYPE_VALUES = ['cold', 'member'];
|
||||
|
||||
@@ -5,15 +5,14 @@ var __importDefault = (this && this.__importDefault) || function (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)();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path_1.default.join(process.cwd(), 'uploads');
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Nanobana plugin active',
|
||||
@@ -43,7 +42,7 @@ function createRoutes(ctx) {
|
||||
}
|
||||
}
|
||||
// Get Config
|
||||
const nanobanaConfig = plugin_config_1.pluginConfig.get('nanobana');
|
||||
const nanobanaConfig = ctx.config.get('nanobana');
|
||||
const apiKey = nanobanaConfig?.apiKey;
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' });
|
||||
@@ -58,7 +57,7 @@ function createRoutes(ctx) {
|
||||
// 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);
|
||||
imagePath = path_1.default.join(uploadDir, filename);
|
||||
}
|
||||
if (imagePath && fs_1.default.existsSync(imagePath)) {
|
||||
const imageBuffer = fs_1.default.readFileSync(imagePath);
|
||||
@@ -121,7 +120,7 @@ function createRoutes(ctx) {
|
||||
// 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 localPath = path_1.default.join(uploadDir, filename);
|
||||
const publicUrl = `/uploads/${filename}`;
|
||||
const file = fs_1.default.createWriteStream(localPath);
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,16 +1,15 @@
|
||||
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();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
@@ -48,7 +47,7 @@ export function createRoutes(ctx: PluginContext): Router {
|
||||
}
|
||||
|
||||
// Get Config
|
||||
const nanobanaConfig = pluginConfig.get('nanobana');
|
||||
const nanobanaConfig = ctx.config.get('nanobana');
|
||||
const apiKey = nanobanaConfig?.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -67,7 +66,7 @@ export function createRoutes(ctx: PluginContext): Router {
|
||||
// Resolve local path
|
||||
if (refUrl.includes('/uploads/')) {
|
||||
const filename = path.basename(refUrl);
|
||||
imagePath = path.join(config.upload.dir, filename);
|
||||
imagePath = path.join(uploadDir, filename);
|
||||
}
|
||||
|
||||
if (imagePath && fs.existsSync(imagePath)) {
|
||||
@@ -133,7 +132,7 @@ export function createRoutes(ctx: PluginContext): Router {
|
||||
// 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 localPath = path.join(uploadDir, filename);
|
||||
const publicUrl = `/uploads/${filename}`;
|
||||
|
||||
const file = fs.createWriteStream(localPath);
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"noEmitOnError": false
|
||||
"noEmitOnError": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["*", "../backend/node_modules/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
"./**/*.ts",
|
||||
"../backend/src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
@@ -46,8 +46,10 @@ function createUploadRoutes(ctx) {
|
||||
if (destination === 'cloud') {
|
||||
if (fs_1.default.existsSync(req.file.path))
|
||||
fs_1.default.unlinkSync(req.file.path);
|
||||
// Use a more robust import for the singleton
|
||||
const storageCore = require('../../../../backend/src/core/StorageProvider');
|
||||
// Use a more robust import for the singleton (conditional for production Docker)
|
||||
const storageCore = process.env.NODE_ENV === 'production'
|
||||
? require('../../../../dist/core/StorageProvider')
|
||||
: 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');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -47,8 +47,10 @@ export function createUploadRoutes(ctx: PluginContext): Router {
|
||||
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');
|
||||
// Use a more robust import for the singleton (conditional for production Docker)
|
||||
const storageCore = process.env.NODE_ENV === 'production'
|
||||
? require('../../../../dist/core/StorageProvider')
|
||||
: require('../../../../backend/src/core/StorageProvider');
|
||||
const provider = storageCore.storageProvider || (storageCore.default && storageCore.default.storageProvider);
|
||||
|
||||
if (!provider) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const versionFilePath = path.resolve(__dirname, 'version.json');
|
||||
const deploysFilePath = path.resolve(__dirname, 'deploys.json');
|
||||
|
||||
// 1. Obter informações de commit do Git
|
||||
let commitHash = 'unknown';
|
||||
let commitMessage = 'unknown';
|
||||
let author = 'unknown';
|
||||
|
||||
try {
|
||||
commitHash = execSync('git rev-parse --short HEAD').toString().trim();
|
||||
commitMessage = execSync('git log -1 --pretty=format:"%s"').toString().trim();
|
||||
author = execSync('git log -1 --pretty=format:"%an"').toString().trim();
|
||||
} catch (e) {
|
||||
console.error('Falha ao obter detalhes do git:', e.message);
|
||||
}
|
||||
|
||||
// 2. Carregar versão atual ou inicializar
|
||||
let data = {
|
||||
version: 'v1.0.0',
|
||||
buildCount: 0,
|
||||
lastDeploy: ''
|
||||
};
|
||||
|
||||
if (fs.existsSync(versionFilePath)) {
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(versionFilePath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('Falha ao analisar version.json, reiniciando.');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Incrementar buildCount (+1) e atualizar versão v1.0.x
|
||||
data.buildCount = (data.buildCount || 0) + 1;
|
||||
data.version = `v1.0.${data.buildCount}`; // v1.+1
|
||||
data.lastDeploy = new Date().toISOString();
|
||||
data.lastCommit = {
|
||||
hash: commitHash,
|
||||
message: commitMessage,
|
||||
author: author
|
||||
};
|
||||
|
||||
// 4. Salvar version.json local
|
||||
fs.writeFileSync(versionFilePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
|
||||
// Copiar para a pasta pública do frontend para que fique exposto via HTTP
|
||||
const publicVersionPath = path.resolve(__dirname, 'frontend/public/version.json');
|
||||
fs.mkdirSync(path.dirname(publicVersionPath), { recursive: true });
|
||||
fs.writeFileSync(publicVersionPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
|
||||
// 5. Adicionar entrada de histórico no deploys.json
|
||||
let deploys = [];
|
||||
if (fs.existsSync(deploysFilePath)) {
|
||||
try {
|
||||
deploys = JSON.parse(fs.readFileSync(deploysFilePath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('Falha ao analisar deploys.json');
|
||||
}
|
||||
}
|
||||
|
||||
deploys.unshift({
|
||||
version: data.version,
|
||||
buildCount: data.buildCount,
|
||||
timestamp: data.lastDeploy,
|
||||
commitHash,
|
||||
commitMessage,
|
||||
author,
|
||||
status: 'Sucesso'
|
||||
});
|
||||
|
||||
// Limitar o histórico de deploys a 30 registros
|
||||
deploys = deploys.slice(0, 30);
|
||||
|
||||
fs.writeFileSync(deploysFilePath, JSON.stringify(deploys, null, 2), 'utf8');
|
||||
|
||||
// Copiar deploys.json histórico para a pasta pública do frontend
|
||||
const publicDeploysPath = path.resolve(__dirname, 'frontend/public/deploys.json');
|
||||
fs.writeFileSync(publicDeploysPath, JSON.stringify(deploys, null, 2), 'utf8');
|
||||
|
||||
console.log(`Versão atualizada para ${data.version} (build #${data.buildCount})`);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "v1.0.51",
|
||||
"buildCount": 51,
|
||||
"lastDeploy": "2026-05-11T20:50:10.267Z",
|
||||
"lastCommit": {
|
||||
"hash": "bd6801a",
|
||||
"message": "feat(webhook): integra listener com a API de Status do Gitea",
|
||||
"author": "ruicesar"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
volumes:
|
||||
- /var/log:/var/log:ro
|
||||
- /opt/soc/promtail:/etc/promtail:ro
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock:ro
|
||||
- /home/deploy/.local/share/docker/containers:/home/deploy/.local/share/docker/containers:ro
|
||||
command: -config.file=/etc/promtail/config.yml
|
||||
networks:
|
||||
- soc
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import json
|
||||
import syslog
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
CONFIG_PATH = "/etc/ssh/ssh_alerts_config.json"
|
||||
STATE_FILE = "/tmp/system_resources_state.json"
|
||||
ALERT_THRESHOLD = 85.0
|
||||
COOLDOWN_SECONDS = 3600 # Remind every 1 hour if resource stays above threshold
|
||||
|
||||
def log_to_syslog(message, is_error=False):
|
||||
level = syslog.LOG_ERR if is_error else syslog.LOG_INFO
|
||||
syslog.openlog(ident="system_resources_monitor", logoption=syslog.LOG_PID, facility=syslog.LOG_DAEMON)
|
||||
syslog.syslog(level, message)
|
||||
syslog.closelog()
|
||||
|
||||
def get_vps_identity():
|
||||
# Attempt to auto-detect WireGuard IP
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
['ip', '-o', '-4', 'addr', 'show', 'dev', 'wg0'],
|
||||
stderr=subprocess.DEVNULL
|
||||
).decode('utf-8')
|
||||
for line in output.split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
return f"VPS 3 ({parts[3].split('/')[0]})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to general IP/hostname
|
||||
try:
|
||||
return f"VPS ({socket.gethostname()})"
|
||||
except Exception:
|
||||
return "VPS (10.99.0.3)"
|
||||
|
||||
def get_cpu_usage():
|
||||
def read_cpu_times():
|
||||
with open('/proc/stat', 'r') as f:
|
||||
first_line = f.readline()
|
||||
parts = first_line.split()
|
||||
times = [float(x) for x in parts[1:9]]
|
||||
idle = times[3] + times[4] # idle + iowait
|
||||
total = sum(times)
|
||||
return idle, total
|
||||
|
||||
try:
|
||||
idle1, total1 = read_cpu_times()
|
||||
time.sleep(1.0)
|
||||
idle2, total2 = read_cpu_times()
|
||||
|
||||
idle_delta = idle2 - idle1
|
||||
total_delta = total2 - total1
|
||||
|
||||
if total_delta > 0:
|
||||
return (1.0 - idle_delta / total_delta) * 100.0
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao obter uso de CPU: {e}", is_error=True)
|
||||
return 0.0
|
||||
|
||||
def get_memory_usage():
|
||||
try:
|
||||
mem_info = {}
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
for line in f:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
val = parts[1].strip().split()[0]
|
||||
mem_info[key] = int(val)
|
||||
|
||||
total = mem_info.get('MemTotal', 0)
|
||||
available = mem_info.get('MemAvailable', 0)
|
||||
if total > 0:
|
||||
used = total - available
|
||||
return (used / total) * 100.0
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao obter uso de memoria: {e}", is_error=True)
|
||||
return 0.0
|
||||
|
||||
def get_disk_usage():
|
||||
try:
|
||||
output = subprocess.check_output(['df', '-Pl', '/']).decode('utf-8')
|
||||
lines = output.strip().split('\n')
|
||||
if len(lines) >= 2:
|
||||
parts = lines[-1].split()
|
||||
if len(parts) >= 5:
|
||||
pct_str = parts[4]
|
||||
if pct_str.endswith('%'):
|
||||
return float(pct_str[:-1])
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao obter uso de disco: {e}", is_error=True)
|
||||
return 0.0
|
||||
|
||||
def get_top_processes():
|
||||
try:
|
||||
ps_output = subprocess.check_output(
|
||||
['ps', '-eo', 'pid,%mem,%cpu,cmd', '--sort=-%mem'],
|
||||
stderr=subprocess.DEVNULL
|
||||
).decode('utf-8', errors='ignore').strip().split('\n')
|
||||
return ps_output[:6] # Header + top 5 processes
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao obter processos ofensores: {e}", is_error=True)
|
||||
return []
|
||||
|
||||
def load_state():
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_state(state):
|
||||
try:
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f)
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao salvar estado de alertas: {e}", is_error=True)
|
||||
|
||||
def send_telegram_alert(token, chat_id, vps_identity, alerts_active, top_processes):
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
alerts_text = ""
|
||||
for resource, val in alerts_active.items():
|
||||
alerts_text += f"• *{resource}:* `{val:.1f}%` (Limite: {ALERT_THRESHOLD:.1f}%)\n"
|
||||
|
||||
proc_text = ""
|
||||
if top_processes:
|
||||
proc_text = "\n🔥 *Top 5 Processos Consumindo mais RAM:*\n```\n"
|
||||
proc_text += "\n".join(top_processes)
|
||||
proc_text += "\n```"
|
||||
|
||||
text = (
|
||||
f"🚨 *ALERTA DE USO DE RECURSOS CRÍTICOS*\n\n"
|
||||
f"• *Servidor:* `{vps_identity}`\n"
|
||||
f"{alerts_text}"
|
||||
f"{proc_text}\n"
|
||||
f"📅 _Registrado automaticamente pelo monitor do sistema._"
|
||||
)
|
||||
|
||||
payload = json.dumps({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown"
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
)
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
res = json.loads(r.read().decode())
|
||||
return res.get("ok", False)
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao enviar alerta para o Telegram: {e}", is_error=True)
|
||||
return False
|
||||
|
||||
def send_telegram_resolved(token, chat_id, vps_identity, resolved_resources):
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
resolved_text = ""
|
||||
for resource, val in resolved_resources.items():
|
||||
resolved_text += f"• *{resource}:* `{val:.1f}%` (Normalizado)\n"
|
||||
|
||||
text = (
|
||||
f"✅ *RECURSOS NORMALIZADOS*\n\n"
|
||||
f"• *Servidor:* `{vps_identity}`\n"
|
||||
f"{resolved_text}\n"
|
||||
f"📅 _O servidor voltou a operar dentro dos limites de segurança._"
|
||||
)
|
||||
|
||||
payload = json.dumps({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown"
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0'
|
||||
}
|
||||
)
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
res = json.loads(r.read().decode())
|
||||
return res.get("ok", False)
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao enviar resolucao para o Telegram: {e}", is_error=True)
|
||||
return False
|
||||
|
||||
def create_calendar_event(vps_identity, alerts_active, top_processes, calendar_id):
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
'/etc/ssh/gcal_key.json',
|
||||
scopes=['https://www.googleapis.com/auth/calendar']
|
||||
)
|
||||
|
||||
service = build('calendar', 'v3', credentials=creds)
|
||||
|
||||
now = datetime.utcnow()
|
||||
end_time = now + timedelta(minutes=30)
|
||||
|
||||
start_iso = now.isoformat() + 'Z'
|
||||
end_iso = end_time.isoformat() + 'Z'
|
||||
|
||||
resources_str = ", ".join(alerts_active.keys())
|
||||
summary = f"{vps_identity}: 🚨 Estresse de Sistema - {resources_str}"
|
||||
|
||||
description = (
|
||||
f"=== 🖥️ ALERTA DE RECURSOS DO SISTEMA ===\n\n"
|
||||
f"• Servidor: {vps_identity}\n"
|
||||
f"• Recursos acima do limite (>={ALERT_THRESHOLD}%):\n"
|
||||
)
|
||||
for res, val in alerts_active.items():
|
||||
description += f" - {res}: {val:.1f}%\n"
|
||||
|
||||
if top_processes:
|
||||
description += f"\n--- TOP PROCESSOS POR MEMÓRIA ---\n"
|
||||
description += "\n".join(top_processes)
|
||||
|
||||
description += f"\n\nRegistrado automaticamente em {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}."
|
||||
|
||||
event_body = {
|
||||
'summary': summary,
|
||||
'description': description,
|
||||
'start': {'dateTime': start_iso, 'timeZone': 'UTC'},
|
||||
'end': {'dateTime': end_iso, 'timeZone': 'UTC'},
|
||||
'colorId': "11", # Vermelho Bold
|
||||
'reminders': {
|
||||
'useDefault': False,
|
||||
'overrides': [{'method': 'popup', 'minutes': 0}]
|
||||
}
|
||||
}
|
||||
|
||||
event = service.events().insert(
|
||||
calendarId=calendar_id,
|
||||
body=event_body
|
||||
).execute()
|
||||
return True
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao criar evento de estresse no Google Calendar: {e}", is_error=True)
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Load config
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
log_to_syslog(f"Arquivo de configuracao nao encontrado: {CONFIG_PATH}", is_error=True)
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
log_to_syslog(f"Erro ao carregar configuracoes: {e}", is_error=True)
|
||||
return
|
||||
|
||||
telegram_token = config.get("telegram_token")
|
||||
telegram_chat_id = config.get("telegram_chat_id")
|
||||
calendar_id = config.get("calendar_id")
|
||||
|
||||
if not telegram_token or not telegram_chat_id:
|
||||
log_to_syslog("Telegram credentials missing in config.", is_error=True)
|
||||
return
|
||||
|
||||
# Check metrics
|
||||
cpu = get_cpu_usage()
|
||||
mem = get_memory_usage()
|
||||
disk = get_disk_usage()
|
||||
|
||||
metrics = {
|
||||
"CPU": cpu,
|
||||
"Memoria": mem,
|
||||
"Disco (/)": disk
|
||||
}
|
||||
|
||||
vps_identity = get_vps_identity()
|
||||
state = load_state()
|
||||
now = time.time()
|
||||
|
||||
alerts_to_send = {}
|
||||
resolved_to_send = {}
|
||||
|
||||
state_changed = False
|
||||
|
||||
for resource, val in metrics.items():
|
||||
is_above = val >= ALERT_THRESHOLD
|
||||
prev_alert_time = state.get(resource, 0)
|
||||
|
||||
if is_above:
|
||||
# Check if we should notify: either first time or after cooldown
|
||||
if prev_alert_time == 0 or (now - prev_alert_time >= COOLDOWN_SECONDS):
|
||||
alerts_to_send[resource] = val
|
||||
state[resource] = now
|
||||
state_changed = True
|
||||
else:
|
||||
# If it was previously in alert state, send resolution message
|
||||
if prev_alert_time > 0:
|
||||
resolved_to_send[resource] = val
|
||||
state[resource] = 0
|
||||
state_changed = True
|
||||
|
||||
if state_changed:
|
||||
save_state(state)
|
||||
|
||||
# Process resolutions
|
||||
if resolved_to_send:
|
||||
log_to_syslog(f"Métricas normalizadas para: {', '.join(resolved_to_send.keys())}")
|
||||
send_telegram_resolved(telegram_token, telegram_chat_id, vps_identity, resolved_to_send)
|
||||
|
||||
# Process alerts
|
||||
if alerts_to_send:
|
||||
log_to_syslog(f"ALERTA: Métricas acima de {ALERT_THRESHOLD}%: " + ", ".join([f"{k} ({v:.1f}%)" for k, v in alerts_to_send.items()]))
|
||||
|
||||
top_processes = get_top_processes()
|
||||
|
||||
# Send to Telegram
|
||||
send_telegram_alert(telegram_token, telegram_chat_id, vps_identity, alerts_to_send, top_processes)
|
||||
|
||||
# Log to Calendar
|
||||
if calendar_id:
|
||||
create_calendar_event(vps_identity, alerts_to_send, top_processes, calendar_id)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -109,3 +109,45 @@ AllowedIPs = 10.98.0.5/32
|
||||
|
||||
### Vantagens:
|
||||
* Se a VPS 1 desaparecer da internet por completo, basta que você e seus sistemas ativem a interface `wg1` do WireGuard. Toda a sua infraestrutura continuará conectada em questão de segundos através da nova rede segura comandada pela VPS 3!
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Otimização de Performance, Jitter e Latência (Ajuste de MTU)
|
||||
|
||||
Se você perceber oscilações de latência (*jitter*) elevadas (ex: ping variando de 2ms a 700ms) entre os servidores na interface `wg0`, o motivo é a **fragmentação de pacotes** causada pelo limite de tamanho físico da infraestrutura da Contabo.
|
||||
|
||||
Para resolver isso de forma definitiva e estabilizar a latência para ~1ms a 3ms constantes, aplicamos a otimização de MTU (Maximum Transmission Unit) em toda a malha.
|
||||
|
||||
### 📋 Configuração de MTU Recomendada:
|
||||
Definimos o **`MTU = 1360`** (ou `1280` para máxima resiliência) em todos os arquivos de configuração do WireGuard (`/etc/wireguard/wg0.conf`).
|
||||
|
||||
#### Como aplicar nos Servidores:
|
||||
|
||||
1. **Na VPS 3 (Data Appliance - JÁ IMPLEMENTADO):**
|
||||
Edite `/etc/wireguard/wg0.conf` e adicione `MTU = 1360` sob `[Interface]`:
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.99.0.3/24
|
||||
PrivateKey = <Chave_Privada>
|
||||
ListenPort = 52830
|
||||
MTU = 1360
|
||||
```
|
||||
*Reinicie o serviço:* `sudo systemctl restart wg-quick@wg0`
|
||||
|
||||
2. **Na VPS 1 (Hub Central - REQUER APLICAR):**
|
||||
Edite `/etc/wireguard/wg0.conf` e adicione `MTU = 1360` sob `[Interface]`:
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.99.0.1/24
|
||||
PrivateKey = <Chave_Privada>
|
||||
ListenPort = 51820
|
||||
MTU = 1360
|
||||
```
|
||||
*Reinicie o serviço:* `sudo systemctl restart wg-quick@wg0`
|
||||
|
||||
3. **Nas VPSs 4 e 5 (Staging e CRM - REQUER APLICAR):**
|
||||
Repita o mesmo procedimento adicionando `MTU = 1360` sob `[Interface]` em seus respectivos `/etc/wireguard/wg0.conf` e reinicie a interface.
|
||||
|
||||
> [!TIP]
|
||||
> **Atenção:** Para que a otimização funcione plenamente e as oscilações cessem, **ambas as pontas do túnel** (ex: VPS 3 e VPS 1) devem estar com o mesmo valor de MTU configurado e reiniciados.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const http = require('http');
|
||||
const https = require('https'); // Adicionado caso precise de HTTPS, mas usaremos HTTP para a VPN
|
||||
const { exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -7,14 +8,55 @@ const HOST = '10.99.0.4';
|
||||
const SECRET_TOKEN = 'gitea_webhook_secret_clube67_58e4a92c3d18';
|
||||
const SCRIPT_PATH = '/home/deploy/stack/clube67/deploy-prod-builder.sh';
|
||||
const LOG_FILE = '/home/deploy/stack/webhook-listener/deploy.log';
|
||||
const GITEA_API_URL = 'http://10.99.0.3:3000/api/v1';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN || 'ada47ca2fb196319dd10245cc216a327dac857ec';
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = `[${timestamp}] ${message}\n`;
|
||||
const logMessage = '[' + timestamp + '] ' + message + '\n';
|
||||
console.log(message);
|
||||
fs.appendFileSync(LOG_FILE, logMessage, 'utf8');
|
||||
}
|
||||
|
||||
// Funcao para atualizar o status do commit no Gitea usando a API
|
||||
function updateCommitStatus(owner, repo, sha, state, description) {
|
||||
if (!owner || !repo || !sha) return;
|
||||
const postData = JSON.stringify({
|
||||
state: state,
|
||||
target_url: 'http://' + HOST + ':' + PORT,
|
||||
description: description,
|
||||
context: 'continuous-integration/webhook'
|
||||
});
|
||||
|
||||
const url = new URL(`${GITEA_API_URL}/repos/${owner}/${repo}/statuses/${sha}`);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'token ' + GITEA_TOKEN,
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseBody = '';
|
||||
res.on('data', chunk => responseBody += chunk);
|
||||
res.on('end', () => {
|
||||
log(`Gitea Status API (${state}): HTTP ${res.statusCode}`);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
log(`Erro ao chamar API do Gitea: ${e.message}`);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
}
|
||||
|
||||
// Garante que o arquivo de log exista
|
||||
try {
|
||||
if (!fs.existsSync(LOG_FILE)) {
|
||||
@@ -27,16 +69,16 @@ try {
|
||||
let isBuilding = false;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// Apenas aceita requisições POST na rota /webhook
|
||||
// Apenas aceita requisicoes POST na rota /webhook
|
||||
const urlPath = req.url.split('?')[0];
|
||||
if (req.method !== 'POST' || urlPath !== '/webhook') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Rota não encontrada' }));
|
||||
return res.end(JSON.stringify({ error: 'Rota nao encontrada' }));
|
||||
}
|
||||
|
||||
// Token de validação nos headers (Gitea envia como x-gitea-token) ou query string (?token=...)
|
||||
// Token de validacao nos headers (Gitea envia como x-gitea-token) ou query string (?token=...)
|
||||
const tokenHeader = req.headers['x-gitea-token'];
|
||||
|
||||
|
||||
// Parsear query params manualmente de forma simples
|
||||
let tokenQuery = null;
|
||||
const qIndex = req.url.indexOf('?');
|
||||
@@ -51,39 +93,71 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const token = tokenHeader || tokenQuery;
|
||||
|
||||
if (!token || token !== SECRET_TOKEN) {
|
||||
log('⚠️ Tentativa de acesso não autorizada: Token inválido ou ausente.');
|
||||
log('Tentativa de acesso nao autorizada: Token invalido ou ausente.');
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Não autorizado: Token inválido' }));
|
||||
return res.end(JSON.stringify({ error: 'Nao autorizado: Token invalido' }));
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
log('⏳ Requisição de build recebida, mas já existe um build em andamento. Ignorando para evitar conflito de concorrência.');
|
||||
log('Requisicao de build recebida, mas ja existe um build em andamento. Ignorando para evitar conflito de concorrencia.');
|
||||
res.writeHead(429, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: 'Um build já está sendo executado neste momento' }));
|
||||
return res.end(JSON.stringify({ error: 'Um build ja esta sendo executado neste momento' }));
|
||||
}
|
||||
|
||||
// Retorna resposta de confirmação imediata (HTTP 202 Accepted) para evitar timeout do Gitea
|
||||
res.writeHead(202, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', message: 'Deploy automático iniciado em segundo plano' }));
|
||||
// Ler o body completo para inspecionar o payload do Gitea
|
||||
let body = '';
|
||||
req.on('data', function(chunk) { body += chunk.toString(); });
|
||||
req.on('end', function() {
|
||||
var payload = {};
|
||||
try { payload = JSON.parse(body); } catch (e) {}
|
||||
|
||||
// Inicia o processo de deploy
|
||||
isBuilding = true;
|
||||
log('🚀 Iniciando deploy-prod-builder.sh...');
|
||||
var commits = payload.commits || [];
|
||||
var headMsg = (payload.head_commit || {}).message || '';
|
||||
var allMessages = commits.map(function(c) { return c.message || ''; }).concat(headMsg).filter(function(m) { return m.trim() !== ''; });
|
||||
var VERSION_BUMP_RE = /^chore\(deploy\):\s*bump version/i;
|
||||
|
||||
// Extrai dados do repositorio para a API do Gitea
|
||||
const repoOwner = payload.repository && payload.repository.owner ? payload.repository.owner.login : null;
|
||||
const repoName = payload.repository ? payload.repository.name : null;
|
||||
const commitSha = payload.after || (payload.head_commit ? payload.head_commit.id : null);
|
||||
|
||||
exec(SCRIPT_PATH, (error, stdout, stderr) => {
|
||||
isBuilding = false;
|
||||
if (error) {
|
||||
log(`❌ Erro no processo de deploy: ${error.message}`);
|
||||
return;
|
||||
if (allMessages.length > 0 && allMessages.every(function(msg) { return VERSION_BUMP_RE.test(msg); })) {
|
||||
log('Commit de bump de versao detectado -- deploy ignorado para evitar loop de CI.');
|
||||
updateCommitStatus(repoOwner, repoName, commitSha, 'success', 'Ignorado: bump version');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ status: 'skipped', reason: 'version-bump commit' }));
|
||||
}
|
||||
log('✅ Processo de deploy finalizado.');
|
||||
|
||||
// Retorna resposta de confirmacao imediata (HTTP 202 Accepted) para evitar timeout do Gitea
|
||||
res.writeHead(202, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', message: 'Deploy automatico iniciado em segundo plano' }));
|
||||
|
||||
// Inicia o processo de deploy
|
||||
isBuilding = true;
|
||||
log('Iniciando deploy-prod-builder.sh...');
|
||||
|
||||
// Atualiza Gitea dizendo que o build comecou
|
||||
updateCommitStatus(repoOwner, repoName, commitSha, 'pending', 'Deploy em andamento na VPS 4');
|
||||
|
||||
exec(SCRIPT_PATH, function(error, stdout, stderr) {
|
||||
isBuilding = false;
|
||||
if (error) {
|
||||
log('Erro no processo de deploy: ' + error.message);
|
||||
// Atualiza Gitea dizendo que o build falhou
|
||||
updateCommitStatus(repoOwner, repoName, commitSha, 'failure', 'Falha no deploy (VPS 4)');
|
||||
return;
|
||||
}
|
||||
log('Processo de deploy finalizado.');
|
||||
// Atualiza Gitea dizendo que o build foi concluido com sucesso
|
||||
updateCommitStatus(repoOwner, repoName, commitSha, 'success', 'Deploy concluido com sucesso (VPS 4)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
log(`📡 Webhook listener ativo em http://${HOST}:${PORT}/webhook`);
|
||||
server.listen(PORT, HOST, function() {
|
||||
log('Webhook listener ativo em http://' + HOST + ':' + PORT + '/webhook com integracao a API do Gitea');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user