Pular para o conteúdo
He4rt / Docs
Toggle sidebar
Interno noindex

Referência técnica — acessível, mas fora dos buscadores.

Discord
Engenharia Leitura · 10 min Integration Whatsapp
Proposto · 0%
documento interno · noindex

Replan — Data lake do WhatsApp resistente a falhas

Artefato de planejamento — pode não refletir o estado atual do código.

Data: 2026-06-12 · Origem: sessão grill-me (Clinton × Claude) Escopo: dois repos — wpp-tui (Collector / branch feat/whatsapp-collector) e he4rt-bot-api módulo integration-whatsapp (branch feat/integration-whatsapp-ingest). Substitui: plano 0001-ingest-implementation.md e a spec.md histórica. Decisão de arquitetura: este plano motiva um ADR-0003 que supersede partes do ADR-0002.


1. Contexto

Por que replanejar

O objetivo foi reescrito: um data lake dos dados importantes do WhatsApp, resistente a falhas (nenhum evento importante se perde). A análise de 2026-06-07 e a re-análise de 2026-06-11 expuseram que o desenho vigente (ADR-0002) tem dois pilares que contradizem esse objetivo:

  1. event_id aleatório (randomUUID()) — decisão do ADR-0002 que o Clinton reconheceu como erro. Ela desarma a dedup na origem (INSERT OR IGNORE virou inerte) e faz re-emits do Baileys (reconexão, append, re-snapshot de groups.metadata) virarem linhas duplicadas no lake. O ADR chamou isso de "raro"; o código prova que é recorrente (toda reconexão).
  2. Ingest assíncrono (job + 202 antes de persistir) — sob QUEUE_CONNECTION=redis (prod), o 202 é dado antes da gravação. Se o job falhar, o evento some em failed_jobs e o bot já apagou do outbox → perda silenciosa. Hoje mascarado por QUEUE_CONNECTION=sync, mas prod será Redis.

Some-se a isso o poison payload (/UTF-8 inválido que o Postgres recusa no jsonb), eventos de grupo descartados por bug de extração de JID, e o outbox sem dead-letter (retry eterno em falha permanente).

Estado atual (arquivos afetados)

Bot (wpp-tui):

Backend (integration-whatsapp):

Constraints travados na sessão


2. As 10 decisões

# Decisão Resolução
1 Escopo de captura Denylist (default-allow) + filtro @g.us. Payload CRU. "Importante" = tudo que não é o ruído conhecido.
2 Onde computar event_id No bot, UUIDv5 determinístico por tipo. Envelope segue enxuto. Revive INSERT OR IGNORE (dedup na origem).
3 Granularidade da dedup Por conteúdo: re-emit idêntico deduplica; mudança real (trocou emoji, roster mudou) = linha nova.
4 Histórico/backfill append sim (backfill idempotente de reconexão), messaging-history.set não (denylistado).
5 Durabilidade do ingest Sem fila: controller faz firstOrCreate síncrono, 2xx só após commit. O lake não usa Redis — a resistência vem do outbox durável do bot + 2xx-pós-commit, independente do driver de fila. O QUEUE_CONNECTION=redis é app-wide (necessário ao moderation/bot-discord), não ao WhatsApp. Só um futuro passo derivado (não o write do lake) iria à fila.
6 Poison payload Sanitizar ( + UTF-8 inválido) na borda do backend; manter jsonb consultável.
7 Resiliência do outbox Classificar resposta: 5xx/rede/408/429 = retry; 401/403 = retry + alerta; 422/400 = dead-letter local. Estouro = alerta. Nunca auto-descarta.
8 Segurança do canal Mínimo: event_id no material assinado do HMAC + segredo alinhado/fail-loud. Resto adiado.
9 Canonicalização de snapshot ✅ Antes do hash de groups.metadata/deltas: ordenar participantes e dropar campos voláteis, senão cada reconexão vira "snapshot novo".
10 Observabilidade mínima ✅ Heartbeat (status de conexão, outbox.count(), idade do item mais antigo) + alerta de desconexão. Ban-detection e healthcheck HTTP ficam para fase futura.

3. O novo contrato (ponta a ponta)

WhatsApp ──► bot captura ──► outbox (event_id DETERMINÍSTICO = PK)
                               │   • INSERT OR IGNORE → dedup na ORIGEM
                               │   • durável: segura o evento até confirmar gravação
                               ▼ (sender, background)
   POST /api/webhooks/whatsapp
   Headers: X-Event-Id (UUIDv5),  X-Signature = HMAC(`${eventId}.${rawBody}`)
   Body: { type, chat_jid, payload }   ← CRU
                               ▼
   middleware: valida HMAC(eventId+body) + UUID
                               ▼
   controller → Action: sanitiza payload → firstOrCreate(event_id) SÍNCRONO → commit
                               ▼
   2xx só após commit ──► bot markSent (apaga do outbox)   ← dedup no DESTINO
                          5xx/rede → retry · 401 → retry+alerta · 422 → dead-letter

Dupla proteção de idempotência: dedup na origem (INSERT OR IGNORE no outbox) e no destino (firstOrCreate + UNIQUE).

Chave determinística por tipo (UUIDv5)

Tipo Chave canônica Efeito
messages.upsert (type, remoteJid, key.id) notify e append do mesmo msg → mesma chave → backfill idempotente
messages.reaction (type, target.key.id, reactor_jid, reaction.text) re-emit deduplica; troca de emoji/remoção = linha nova
group-participants.update (type, group_jid, hash(payload canônico)) re-emit idêntico deduplica; delta diferente = linha nova
groups.metadata (type, group_jid, hash(meta canônica)) snapshot idêntico deduplica; roster mudou = snapshot novo
genérico / desconhecido (type, chat_jid, hash(payload canônico)) default seguro: qualquer re-emit idêntico deduplica

senderTimestampMs da reação não entra na chave — é instável entre re-emissões (visto nos logs: mesma 😂 com 3 timestamps distintos). "canônico" (D9) = JSON com chaves ordenadas, participantes ordenados por id, campos voláteis (timestamps de subject, etc.) removidos antes do hash.


4. Comportamento esperado (BDD)

Idempotência — re-emit (happy path do objetivo):

Idempotência — retry HTTP:

Mudança de estado preservada:

Ingest síncrono sob Redis:

Poison payload:

Falha permanente:

Bug corrigido (não perder dado importante):

Compatibilidade:


5. Antes/depois (trechos-chave)

Bot — outbox.enqueue volta a receber o event_id determinístico:

- enqueue(body) {
-   const eventId = randomUUID()
-   insert.run(eventId, body.type, JSON.stringify(body), now, now)
- }
+ enqueue(event) {                       // { event_id, body }
+   insert.run(event.event_id, event.body.type, JSON.stringify(event.body), now, now)
+ }                                       // INSERT OR IGNORE agora deduplica de fato

Bot — extractor calcula a chave (exemplo messages.upsert):

- return (d.messages ?? []).map((msg) => ({
-   type: 'messages.upsert', chat_jid: msg.key?.remoteJid ?? null, payload: msg,
- }))
+ return (d.messages ?? []).map((msg) => ({
+   event_id: uuidv5(`messages.upsert|${msg.key?.remoteJid}|${msg.key?.id}`, NS),
+   body: { type: 'messages.upsert', chat_jid: msg.key?.remoteJid ?? null, payload: msg },
+ }))

Bot — fix do group.member-tag.update (genericExtractor):

- chat_jid: (it?.id) ?? (it?.key?.remoteJid) ?? null,
+ chat_jid: (it?.id) ?? (it?.groupId) ?? (it?.key?.remoteJid) ?? null,

Bot — sender classifica a resposta:

- const ok = await sendOnce(...)
- if (ok) outbox.markSent(row.event_id)
- else outbox.reschedule(row.event_id, attempts, next)
+ const res = await sendWithTimeout(...)          // AbortController
+ if (res.status >= 200 && res.status < 300)  outbox.markSent(row.event_id)
+ else if (res.status === 422 || res.status === 400)  outbox.deadLetter(row.event_id, res.status)
+ else if (res.status === 401 || res.status === 403)  { outbox.reschedule(...); alert('auth') }
+ else  outbox.reschedule(...)                    // 5xx / rede / 408 / 429

Backend — ingest síncrono via Action (convenção: Action, não Service):

- dispatch(new ProcessWhatsAppEvent(eventId, type, chatJid, payload));
- return response()->json(['status' => 'accepted'], 202);
+ resolve(StoreWhatsAppEventAction::class)($eventId, $type, $chatJid, $payload);  // síncrono
+ return response()->json(['status' => 'stored'], 201);                            // após commit

Backend — Action sanitiza antes do firstOrCreate:

// StoreWhatsAppEventAction (invokable)
$clean = $this->sanitize($payload); // remove �, JSON_INVALID_UTF8_SUBSTITUTE
WhatsAppEventLog::query()->firstOrCreate(
    ['event_id' => $eventId],
    ['type' => $type, 'chat_jid' => $chatJid, 'received_at' => now(), 'payload' => $clean],
);

Backend — HMAC cobre o event_id:

- $expected = hash_hmac('sha256', $request->getContent(), $secret);
+ $expected = hash_hmac('sha256', $eventId.'.'.$request->getContent(), $secret);

6. Sequência de implementação (fatias verticais)

Cada fatia é entregável e verificável de ponta a ponta.

  1. Idempotência determinística (bot) — helper UUIDv5 + chave por tipo + enqueue(event_id) + INSERT OR IGNORE vivo. ✅ Verificar: reconexão não duplica.
  2. Ingest síncrono + sanitização (backend) — controller→Action síncrona, drop do job, sanitizer, HMAC com event_id. ✅ Verificar: poison persiste, sem loss/loop; DB-down → 5xx → retry.
  3. Resiliência do outbox (bot) — classificação de resposta, outbox_dead, timeout no fetch, alerta de estouro, poda por idade. ✅ Verificar: 422 → dead-letter; 401 → alerta sem descarte.
  4. Segurança mínima (ambos) — event_id no HMAC (já na fatia 2 no backend; fechar no bot) + alinhar nome do env do segredo + fail-loud no 401 persistente.
  5. Observabilidade (bot) — heartbeat (status, outbox.count(), idade do mais antigo) + alerta de desconexão.
  6. Fix avulso (bot, a qualquer momento)group.member-tag.update via groupId.
  7. Docs — ADR-0003 + atualizar CONTEXT.md; apagar obsoletos (§7).

7. Reestruturação de documentação

Criar:

Atualizar:

Apagar (obsoletos — git preserva o histórico):

Manter como histórico: ADR-0001 e ADR-0002 (registro de por que o desenho evoluiu).


8. Pontos confirmados / em aberto