|
| 1 | +# Análise: Perda de Mensagens Chatwoot → WhatsApp |
| 2 | + |
| 3 | +## Resumo do Problema |
| 4 | +Mensagens enviadas do Chatwoot às vezes não chegam no WhatsApp dos destinatários. O problema afeta tanto mensagens de texto quanto imagens/anexos. |
| 5 | + |
| 6 | +## Arquitetura Atual |
| 7 | + |
| 8 | +### Fluxo de Mensagens (Chatwoot → WhatsApp) |
| 9 | +1. Webhook recebe mensagem do Chatwoot (`receiveWebhook` - linha 1604) |
| 10 | +2. Valida instância e tipo de mensagem |
| 11 | +3. Formata texto com assinatura do atendente |
| 12 | +4. Para cada mensagem: |
| 13 | + - **Anexos**: Chama `sendAttachment` (linha 1491) com retry |
| 14 | + - **Texto**: Chama `waInstance.textMessage` com retry |
| 15 | + |
| 16 | +### Mecanismos de Proteção Existentes |
| 17 | + |
| 18 | +#### 1. Retry com Exponential Backoff (linhas 47-85) |
| 19 | +```typescript |
| 20 | +// 5 tentativas: 3s, 6s, 12s, 24s, 48s |
| 21 | +retryWithBackoff(fn, maxAttempts=5, operationName, baseDelayMs=3000) |
| 22 | +``` |
| 23 | +- ✅ Usado para: criar conversação, criar mensagem no Chatwoot |
| 24 | +- ❌ Retorna `null` em caso de falha (não lança exceção) |
| 25 | +- ❌ Mensagem é perdida se todas as tentativas falharem |
| 26 | + |
| 27 | +#### 2. Cache Anti-Duplicação (linhas 90-130) |
| 28 | +```typescript |
| 29 | +// TTL de 5 minutos para evitar duplicatas |
| 30 | +messageDeduplicationCache.isDuplicate(messageId) |
| 31 | +``` |
| 32 | +- ✅ Previne mensagens duplicadas |
| 33 | +- ⚠️ Pode bloquear retentativas legítimas |
| 34 | + |
| 35 | +#### 3. Cache "Enviando" (linhas 1897, 1967) |
| 36 | +```typescript |
| 37 | +// Marca mensagem como "enviando" por 30 segundos |
| 38 | +await this.cache.set(`cw_sending_${body.id}`, true, 30); |
| 39 | +``` |
| 40 | +- ✅ Previne envios simultâneos da mesma mensagem |
| 41 | +- ❌ Se falhar, cache expira mas mensagem não é reenviada |
| 42 | + |
| 43 | +#### 4. Sistema de Recuperação (Cron) |
| 44 | +- `syncLostMessages`: Executa a cada 30 minutos |
| 45 | +- Recupera mensagens das últimas 6 horas que não foram sincronizadas |
| 46 | + |
| 47 | +## Causas Prováveis de Perda de Mensagens |
| 48 | + |
| 49 | +### 1. Falhas Silenciosas ⚠️ CRÍTICO |
| 50 | +**Localização**: `retryWithBackoff` (linha 47) |
| 51 | + |
| 52 | +```typescript |
| 53 | +// Retorna null em vez de lançar exceção |
| 54 | +if (attempt === maxAttempts) { |
| 55 | + logger.error(`❌ ${operationName} falhou após ${maxAttempts} tentativas`); |
| 56 | + return null; // ❌ Mensagem perdida! |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +**Impacto**: |
| 61 | +- Mensagem falha após 5 tentativas (~45 segundos) |
| 62 | +- Retorna `null` sem lançar exceção |
| 63 | +- Código continua executando como se tudo estivesse OK |
| 64 | +- Mensagem só será recuperada pelo cron (30 minutos depois) |
| 65 | + |
| 66 | +### 2. Erros no `sendAttachment` Não Capturados |
| 67 | +**Localização**: `sendAttachment` (linha 1491) |
| 68 | + |
| 69 | +```typescript |
| 70 | +public async sendAttachment(...) { |
| 71 | + try { |
| 72 | + // ... código de envio |
| 73 | + } catch (error) { |
| 74 | + this.logger.error(error); |
| 75 | + throw error; // ✅ Re-lança erro |
| 76 | + } |
| 77 | +} |
| 78 | +``` |
| 79 | + |
| 80 | +**Problema**: |
| 81 | +- Método lança exceção em caso de erro |
| 82 | +- Mas o retry wrapper pode não capturar corretamente |
| 83 | +- Erros de rede/timeout podem não ser tratados |
| 84 | + |
| 85 | +### 3. Cache Bloqueando Retentativas |
| 86 | +**Localização**: linhas 1897, 1967 |
| 87 | + |
| 88 | +```typescript |
| 89 | +const cacheKey = `cw_sending_${body.id}`; |
| 90 | +if (await this.cache.get(cacheKey)) { |
| 91 | + this.logger.warn('Mensagem já está sendo enviada, ignorando duplicata'); |
| 92 | + return { message: 'already_sending' }; |
| 93 | +} |
| 94 | +await this.cache.set(cacheKey, true, 30); // 30 segundos |
| 95 | +``` |
| 96 | + |
| 97 | +**Problema**: |
| 98 | +- Se primeira tentativa falhar, cache permanece por 30s |
| 99 | +- Webhook duplicado dentro de 30s será ignorado |
| 100 | +- Após 30s, cache expira mas ninguém retenta |
| 101 | + |
| 102 | +### 4. Delays Intencionais (Anti-Ban) |
| 103 | +**Localização**: linhas 1531, 1556, 1963 |
| 104 | + |
| 105 | +```typescript |
| 106 | +// Áudio: delay aleatório 500-2000ms |
| 107 | +delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500 |
| 108 | + |
| 109 | +// Mídia: delay fixo 1200ms |
| 110 | +delay: 1200 |
| 111 | + |
| 112 | +// Texto: delay aleatório 500-2000ms |
| 113 | +delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500 |
| 114 | +``` |
| 115 | + |
| 116 | +**Nota**: Estes delays são INTENCIONAIS para evitar ban do WhatsApp. Não são a causa da perda. |
| 117 | + |
| 118 | +### 5. Timeout do Axios (45 segundos) |
| 119 | +**Localização**: linha 1455 |
| 120 | + |
| 121 | +```typescript |
| 122 | +timeout: 45000, // 45s para enviar mídia ao Chatwoot |
| 123 | +``` |
| 124 | + |
| 125 | +**Problema**: |
| 126 | +- Timeout pode ser insuficiente para arquivos grandes |
| 127 | +- Conexão lenta pode causar timeout |
| 128 | +- Erro de timeout não é sempre recuperável |
| 129 | + |
| 130 | +## Cenários de Perda de Mensagem |
| 131 | + |
| 132 | +### Cenário 1: Falha Total de Conexão |
| 133 | +``` |
| 134 | +1. Chatwoot envia webhook |
| 135 | +2. receiveWebhook inicia processamento |
| 136 | +3. waInstance.textMessage() falha (WhatsApp offline) |
| 137 | +4. Retry 1: falha (3s depois) |
| 138 | +5. Retry 2: falha (6s depois) |
| 139 | +6. Retry 3: falha (12s depois) |
| 140 | +7. retryWithBackoff retorna null |
| 141 | +8. onSendMessageError envia mensagem privada no Chatwoot |
| 142 | +9. ❌ Mensagem perdida até cron (30 min) |
| 143 | +``` |
| 144 | + |
| 145 | +### Cenário 2: Timeout em Anexo Grande |
| 146 | +``` |
| 147 | +1. Chatwoot envia webhook com imagem 10MB |
| 148 | +2. sendAttachment inicia download |
| 149 | +3. Axios timeout após 45s |
| 150 | +4. Retry 1: timeout novamente |
| 151 | +5. Retry 2: timeout novamente |
| 152 | +6. Retry 3: timeout novamente |
| 153 | +7. Exceção lançada |
| 154 | +8. Cache removido |
| 155 | +9. onSendMessageError notifica Chatwoot |
| 156 | +10. ❌ Mensagem perdida até cron (30 min) |
| 157 | +``` |
| 158 | + |
| 159 | +### Cenário 3: Cache Bloqueando Retry |
| 160 | +``` |
| 161 | +1. Chatwoot envia webhook (tentativa 1) |
| 162 | +2. Cache marca como "enviando" (30s) |
| 163 | +3. Envio falha após 10s |
| 164 | +4. Cache ainda ativo (20s restantes) |
| 165 | +5. Chatwoot reenvia webhook (tentativa 2) |
| 166 | +6. Cache detecta duplicata |
| 167 | +7. ❌ Mensagem ignorada |
| 168 | +8. Após 30s, cache expira |
| 169 | +9. ❌ Ninguém retenta, mensagem perdida até cron |
| 170 | +``` |
| 171 | + |
| 172 | +## Recomendações de Correção |
| 173 | + |
| 174 | +### 1. Implementar Fila de Mensagens Persistente 🔥 PRIORIDADE ALTA |
| 175 | +```typescript |
| 176 | +// Usar Redis ou banco de dados para fila |
| 177 | +interface QueuedMessage { |
| 178 | + id: string; |
| 179 | + instanceId: string; |
| 180 | + chatwootMessageId: number; |
| 181 | + type: 'text' | 'attachment'; |
| 182 | + data: any; |
| 183 | + attempts: number; |
| 184 | + lastAttempt: Date; |
| 185 | + nextRetry: Date; |
| 186 | +} |
| 187 | + |
| 188 | +// Ao falhar após retry: |
| 189 | +await messageQueue.add({ |
| 190 | + id: generateId(), |
| 191 | + instanceId: instance.instanceId, |
| 192 | + chatwootMessageId: body.id, |
| 193 | + type: 'text', |
| 194 | + data: { number: chatId, text: formatText }, |
| 195 | + attempts: 0, |
| 196 | + nextRetry: new Date(Date.now() + 60000) // 1 minuto |
| 197 | +}); |
| 198 | +``` |
| 199 | + |
| 200 | +### 2. Melhorar Tratamento de Erros |
| 201 | +```typescript |
| 202 | +// Em vez de retornar null, lançar exceção específica |
| 203 | +class MessageSendError extends Error { |
| 204 | + constructor( |
| 205 | + message: string, |
| 206 | + public readonly retryable: boolean, |
| 207 | + public readonly originalError?: any |
| 208 | + ) { |
| 209 | + super(message); |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +// No retryWithBackoff: |
| 214 | +if (attempt === maxAttempts) { |
| 215 | + throw new MessageSendError( |
| 216 | + `Falhou após ${maxAttempts} tentativas`, |
| 217 | + true, // pode retentar |
| 218 | + error |
| 219 | + ); |
| 220 | +} |
| 221 | +``` |
| 222 | + |
| 223 | +### 3. Ajustar Estratégia de Cache |
| 224 | +```typescript |
| 225 | +// Cache mais inteligente |
| 226 | +const cacheKey = `cw_sending_${body.id}`; |
| 227 | +const sendingInfo = await this.cache.get(cacheKey); |
| 228 | + |
| 229 | +if (sendingInfo) { |
| 230 | + const elapsed = Date.now() - sendingInfo.startTime; |
| 231 | + |
| 232 | + // Se passou mais de 60s, permite retry |
| 233 | + if (elapsed > 60000) { |
| 234 | + this.logger.warn('Retry após timeout de cache'); |
| 235 | + await this.cache.delete(cacheKey); |
| 236 | + } else { |
| 237 | + return { message: 'already_sending' }; |
| 238 | + } |
| 239 | +} |
| 240 | + |
| 241 | +await this.cache.set(cacheKey, { |
| 242 | + startTime: Date.now(), |
| 243 | + attempts: (sendingInfo?.attempts || 0) + 1 |
| 244 | +}, 60); |
| 245 | +``` |
| 246 | + |
| 247 | +### 4. Aumentar Timeout para Anexos Grandes |
| 248 | +```typescript |
| 249 | +// Calcular timeout baseado no tamanho do arquivo |
| 250 | +const fileSize = await getFileSize(attachment.data_url); |
| 251 | +const timeoutMs = Math.max(45000, fileSize / 1024 * 100); // 100ms por KB |
| 252 | + |
| 253 | +const config = { |
| 254 | + // ... |
| 255 | + timeout: timeoutMs, |
| 256 | + maxContentLength: 50 * 1024 * 1024, // 50MB |
| 257 | +}; |
| 258 | +``` |
| 259 | + |
| 260 | +### 5. Adicionar Logging Detalhado |
| 261 | +```typescript |
| 262 | +// Log estruturado para debug |
| 263 | +this.logger.log({ |
| 264 | + event: 'message_send_attempt', |
| 265 | + messageId: body.id, |
| 266 | + chatId: chatId, |
| 267 | + type: 'text', |
| 268 | + attempt: attemptNumber, |
| 269 | + timestamp: new Date().toISOString() |
| 270 | +}); |
| 271 | + |
| 272 | +// Log de falha com contexto completo |
| 273 | +this.logger.error({ |
| 274 | + event: 'message_send_failed', |
| 275 | + messageId: body.id, |
| 276 | + chatId: chatId, |
| 277 | + error: error.message, |
| 278 | + stack: error.stack, |
| 279 | + retryable: error.retryable, |
| 280 | + willRetry: attemptNumber < maxAttempts |
| 281 | +}); |
| 282 | +``` |
| 283 | + |
| 284 | +### 6. Monitoramento e Alertas |
| 285 | +```typescript |
| 286 | +// Métricas para monitoramento |
| 287 | +interface MessageMetrics { |
| 288 | + sent: number; |
| 289 | + failed: number; |
| 290 | + retried: number; |
| 291 | + queued: number; |
| 292 | + avgLatency: number; |
| 293 | +} |
| 294 | + |
| 295 | +// Alerta se taxa de falha > 5% |
| 296 | +if (metrics.failed / metrics.sent > 0.05) { |
| 297 | + await sendAlert('Alta taxa de falha em mensagens Chatwoot→WhatsApp'); |
| 298 | +} |
| 299 | +``` |
| 300 | + |
| 301 | +## Próximos Passos |
| 302 | + |
| 303 | +### Investigação Imediata |
| 304 | +1. ✅ Analisar logs de produção para identificar padrões de erro |
| 305 | +2. ✅ Verificar se `onSendMessageError` está sendo chamado |
| 306 | +3. ✅ Confirmar se cron `syncLostMessages` está recuperando mensagens |
| 307 | +4. ✅ Testar cenários de falha em ambiente de desenvolvimento |
| 308 | + |
| 309 | +### Implementação (Ordem de Prioridade) |
| 310 | +1. 🔥 **CRÍTICO**: Implementar fila persistente de mensagens |
| 311 | +2. 🔥 **CRÍTICO**: Melhorar tratamento de erros (não retornar null) |
| 312 | +3. ⚠️ **ALTO**: Ajustar estratégia de cache anti-duplicação |
| 313 | +4. ⚠️ **ALTO**: Adicionar logging detalhado |
| 314 | +5. 📊 **MÉDIO**: Implementar métricas e monitoramento |
| 315 | +6. 📊 **MÉDIO**: Aumentar timeout para anexos grandes |
| 316 | + |
| 317 | +### Testes Necessários |
| 318 | +- [ ] Teste de falha de conexão WhatsApp |
| 319 | +- [ ] Teste de timeout em anexo grande (>10MB) |
| 320 | +- [ ] Teste de webhook duplicado |
| 321 | +- [ ] Teste de recuperação via cron |
| 322 | +- [ ] Teste de carga (múltiplas mensagens simultâneas) |
| 323 | + |
| 324 | +## Conclusão |
| 325 | + |
| 326 | +A perda de mensagens ocorre principalmente devido a: |
| 327 | +1. **Falhas silenciosas**: `retryWithBackoff` retorna `null` em vez de lançar exceção |
| 328 | +2. **Falta de fila persistente**: Mensagens falhadas não são enfileiradas para retry posterior |
| 329 | +3. **Cache bloqueando retries**: Webhooks duplicados são ignorados mesmo após falha |
| 330 | + |
| 331 | +A solução mais efetiva é implementar uma **fila persistente de mensagens** que garanta que nenhuma mensagem seja perdida, mesmo em caso de falhas temporárias. |
0 commit comments