Skip to content

wa-002 — Importar fatura Nubank via WhatsApp (router → agent → ImportInvoiceTool)

O casal já vinculou o Household ao número do whats (cenário wa-001). Toda primeira semana do mês o Nubank manda o email com o PDF da fatura. Em vez de abrir o app, eles encaminham o PDF direto pro chat do bot. O agente recebe a mídia, parseia, registra o Invoice no Account “Nubank”, reconcilia contra o Budget e responde no whats com o sumário — total, transações categorizadas, transações sem categoria. Mesma UX do cenário 009 (chat web), mas pelo canal nativo do BR.

Esse cenário cruza ADR 007 (WhatsApp via Baileys como canal único) com o cenário 009 (multimodal stack neutro). O domínio do agent/ não muda: AgentChat.ask já aceita content: [{type:"file",data,mediaType:"application/pdf"}], ImportInvoiceTool já registra Invoice + dispara ExpenseReconciler, pendingFiles queue per-ask() já protege contra cross-contamination (gotcha “Multimodal: extrair file bytes per-ask()”). O que entra de novo é o router — um application service em agent/application/ que traduz WhatsAppMessageEventChatMessage[] multimodal, resolve chatId → Household.id, baixa a mídia decriptada via gateway, chama AgentChat.ask, e devolve a resposta via gateway.sendMessage.

O BaileysAdapter real (que implementa WhatsAppGateway via @whiskeysockets/baileys per ADR 007) fica deferido — ganha spec colocada em src/contexts/agent/infrastructure/BaileysAdapter.spec.ts (ADR 003) gated por env porque pair multi-device exige QR scan. Aqui o tier é domain: MockWhatsAppGateway programa eventos, MockLanguageModelV1 + simulateReadableStream (ai/test) controla o turno do LLM, parser PDF é mock injetado na ImportInvoiceTool (mesmo padrão do 009 domain spec). Sem rede, sem token, sem QR.

Mapping fica fixo no router (gotcha “resourceId opaco no contract, mapping fica no caller”): resourceId = household.id, threadId = chatId. AgentChat continua sem saber o que é Household ou chatId — só consome strings opacas. senderId (waid) entra como mentions[] ou metadado, mas neste cenário não muda comportamento (cenário wa-003 ativa a lógica de mentions em group chat).

Scenario: Casal manda PDF de fatura no chat individual

Section titled “Scenario: Casal manda PDF de fatura no chat individual”
  • Given um Household “Casa” com Account “Nubank” já registrado (cenário 001) e nenhum invoice ainda
  • And HouseholdLookup vinculado: chatId="dm-5511999999999" → esse Household
  • And MockWhatsAppGateway.downloadMedia("media-nub-001") programado pra retornar Uint8Array de fake PDF (bytes opacos — parser é mockado)
  • And parser mock injetado na ImportInvoiceTool programado pra devolver fatura de maio/2026 com 3 transações somando R$ 4.500
  • And WhatsAppMessageRouter configurado com gateway, householdLookup, agentChat (com ImportInvoiceTool + parser mock)
  • And o LLM mockado decide chamar importInvoice({ account: "Nubank" }) no primeiro turno e depois responde em PT-BR (“Vi 3 transações somando R$ 4.500 na fatura de maio.”)
  • When MockWhatsAppGateway.emit({ chatId: "dm-5511999999999", senderId: "5511999999999", text: "aqui está a fatura desse mês", media: { mediaId: "media-nub-001", mimeType: "application/pdf" }, isGroup: false, mentions: [] })
  • Then o router resolve chatId → Household.id via householdLookup.findByChatId("dm-5511999999999")
  • And gateway.downloadMedia foi chamado com "media-nub-001" (router detecta mimeType === "application/pdf")
  • And agentChat.ask foi chamado com messages: [{ role: "user", content: [{type:"text", text:"aqui está a fatura desse mês"}, {type:"file", data:<bytes>, mediaType:"application/pdf"}] }], resourceId: <household.id>, threadId: "dm-5511999999999"
  • And o Account “Nubank” passou a ter 1 Invoice (period maio/2026, total R$ 4.500, 3 transações)
  • And gateway.sendMessage foi chamado com { chatId: "dm-5511999999999", text: <conteúdo da reply do agent — menciona "4.500" ou "fatura importada"> }
  • Given HouseholdLookup sem binding pra chatId="dm-5511988888888" (nenhum Household cadastrado)
  • And WhatsAppMessageRouter configurado normalmente
  • When MockWhatsAppGateway.emit({ chatId: "dm-5511988888888", senderId: "5511988888888", media: { mediaId: "x", mimeType: "application/pdf" }, isGroup: false, mentions: [] })
  • Then o router descarta a mensagem cedo — agentChat.ask não é chamado
  • And gateway.downloadMedia não é chamado (router só baixa mídia depois de confirmar Household)
  • And nenhum Account/Invoice é criado em nenhum lugar (router não tem Household pra escrever)
  • And gateway.sendMessage é chamado com { chatId: "dm-5511988888888", text: <mensagem de waitlist — consistente com wa-001 scenario "número não vinculado"> }

Scenario: Casal manda mídia não-PDF (imagem)

Section titled “Scenario: Casal manda mídia não-PDF (imagem)”
  • Given Household “Casa” vinculado a chatId="dm-5511999999999" (mesmo do scenario 1)
  • And WhatsAppMessageRouter configurado normalmente
  • And o LLM mockado decide responder direto em PT-BR sem tool-call (“entendi, mas por enquanto só processo PDF de fatura — manda o PDF do Nubank que eu cuido”)
  • When MockWhatsAppGateway.emit({ chatId: "dm-5511999999999", senderId: "5511999999999", text: "olha essa foto", media: { mediaId: "media-img", mimeType: "image/jpeg" }, isGroup: false, mentions: [] })
  • Then gateway.downloadMedia não é chamado (router só baixa mídia quando mimeType === "application/pdf")
  • And agentChat.ask é chamado mesmo assim — com content contendo só {type:"text", text:"olha essa foto"} (router omite o file part pra evitar passar mídia não-suportada pro LLM)
  • And ImportInvoiceTool não é invocada (LLM decide responder direto, parser mock fica com calls.length === 0)
  • And o Account “Nubank” continua sem Invoice nova
  • And gateway.sendMessage é chamado com o texto da reply do agente (mencionando “PDF” ou explicação humana do mock)
  • Application Service novoWhatsAppMessageRouter em src/contexts/agent/application/. Não é Domain Service: lida com I/O orquestração (gateway → lookup → agent → gateway). Recebe gateway, householdLookup, agentChat via construtor (DI canônica). Side-effect único: gateway.onMessage(handler) no start(). Stateless por mensagem: cada evento dispara um handler que cria seu próprio pendingFiles queue indireto via AgentChat.ask.
  • Port WhatsAppGateway — em src/contexts/agent/application/whatsapp/. Interface mínima:
    • onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void — registra callback.
    • sendMessage({ chatId, text }): Promise<void> — envia texto.
    • downloadMedia(mediaId): Promise<Uint8Array> — baixa mídia decriptada.
    • Sem start()/stop() explícito por ora — adapter real (BaileysAdapter) decide ciclo de vida em infrastructure/.
  • Port HouseholdLookup — em src/contexts/agent/application/. Shared entre wa-001/002/003. Wa-001 escreve (bind(chatId, household)); wa-002/wa-003 leem (findByChatId(chatId)). Aqui só o read aparece — write entra com wa-001.
  • VO WhatsAppMessageEvent — em src/contexts/agent/domain/whatsapp/. Normaliza payload Baileys pra shape neutro:
    interface WhatsAppMessageEvent {
    chatId: string; // dm-<phone> ou group-<id>
    senderId: string; // waid do remetente
    text?: string; // caption ou texto puro
    media?: { mediaId: string; mimeType: string };
    isGroup: boolean;
    mentions: string[]; // waids mencionados (wa-003 consome; wa-002 ignora)
    }
  • MockWhatsAppGateway — test fake colocado em src/contexts/agent/infrastructure/ (mesma regra do InMemoryBudgetRepository em 007 e InMemoryAgentMemory em 012). Métodos:
    • emit(event) — programa um evento, dispara o handler registrado via onMessage. Spec usa pra simular Baileys.
    • programDownloadMedia(mediaId, bytes) — registra resposta de download.
    • sendMessage e downloadMedia gravam calls em arrays internos (sentMessages, downloadedMediaIds) pra spec inspecionar.
src/contexts/agent/domain/whatsapp/WhatsAppMessageEvent.ts
interface WhatsAppMessageEvent {
chatId: string;
senderId: string;
text?: string;
media?: { mediaId: string; mimeType: string };
isGroup: boolean;
mentions: string[];
}
// src/contexts/agent/application/whatsapp/WhatsAppGateway.ts
interface WhatsAppGateway {
onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void;
sendMessage(args: { chatId: string; text: string }): Promise<void>;
downloadMedia(mediaId: string): Promise<Uint8Array>;
}
// src/contexts/agent/application/HouseholdLookup.ts
interface HouseholdLookup {
findByChatId(chatId: string): Promise<Household | undefined>;
}
// src/contexts/agent/application/WhatsAppMessageRouter.ts
WhatsAppMessageRouter.create({
gateway: WhatsAppGateway;
householdLookup: HouseholdLookup;
agentChat: AgentChat;
unboundReply?: string; // texto enviado quando chatId não tem Household (default "waitlist")
}): WhatsAppMessageRouter
router.start(): void // chama gateway.onMessage(internalHandler)
  • Router em application/, não em domain/ — orquestra I/O (gateway, lookup async, side-effect de sendMessage). Gotcha do AGENTS.md: “se a coisa é pura, mora em domain/services/; se é infra-facing, em application/”. Router consome ports (WhatsAppGateway, HouseholdLookup) e Domain Services (AgentChat); ele mesmo não é puro — é coordenador.
  • Download só pra mimeType === "application/pdf" — economiza decrypt CPU + memória pra mídias que o domínio não usa hoje (imagem, áudio, vídeo). Decisão minimalista até aparecer cenário que pede. Router omite o file part do ChatMessage quando não baixa — LLM responde só com o text.
  • ImportInvoiceTool continua decidida pelo LLM — router não chama tool direto. Empilha o file part no ChatMessage, deixa AgentChat.ask rodar o LLM, LLM decide chamar importInvoice (ou não, em caso de imagem). Mantém a fronteira “decisão é do LLM” coerente com 008/009/013. Forçar importInvoice na presença de mimeType: "application/pdf" seria oportunista mas tira a chance de LLM responder “esse PDF não parece fatura, é outro documento?” no futuro.
  • unboundReply configurável, default consistente com wa-001 — quando householdLookup.findByChatId retorna undefined, router envia texto curto de waitlist + descarta. Default é string PT-BR (consistente com gotcha “message humano em PT no domínio enquanto monolíngue”). Override permite wa-001 onboarding flow customizar (ex: “responde com QR code de pair”).
  • senderId vai como input mas wa-002 não usa — campo presente no WhatsAppMessageEvent (parte do contract compartilhado wa-001/002/003), mas neste cenário individual não muda comportamento. Cenário wa-003 ativa lógica isGroup + mentions pra decidir se responde.
  • Mapping fixo no router, não no agentresourceId = household.id, threadId = chatId. Reforça gotcha “resourceId opaco no contract”. Quando aparecer caso “uma thread por mês” ou “uma thread por meta”, composição muda no router, agent continua igual.
  • pendingFiles queue per-ask() herdado do 009 — router constrói um ChatMessage multimodal por evento. AgentChat.ask lê o file part da última user message e empilha no pendingFiles interno do call. Múltiplas faturas em sequência (cenário 1 repetido) → cada evento dispara seu próprio ask, cada ask tem seu próprio queue. Zero cross-contamination. Gotcha “Multimodal: extrair file bytes per-ask()” aplicada.
  • Spec não testa gateway.onMessage registration — confia que router.start() registra; spec dispara gateway.emit(...) que aciona o handler interno. Spec valida efeitos (downloadMedia called, ask called, sendMessage called) — não a mecânica de registration.
  • Onboarding / criação de Household no primeiro evento de chatId desconhecido — cenário wa-001. Aqui o router só descarta + responde waitlist; criação do Household é decisão deliberada do onboarding flow (chat-as-onboarding, ADR 006/007).
  • Group chat (isGroup: true) — cenário wa-003. Router ignora mentions[] aqui.
  • Account automático no primeiro upload via whats — assume Account “Nubank” pré-cadastrado (cenário 001 manual). Criar Account inferindo do PDF é cenário futuro (mesmo limite do 009).
  • PDF protegido por senha (CPF) — Nubank envia com senha. Adapter real (BaileysAdapter + NubankPdfParser) decide UX de senha. Spec domain assume PDF aberto.
  • Multiple media no mesmo evento — Baileys permite, modelagem futura. Router assume 1 media por WhatsAppMessageEvent.
  • Erro de download / timeout / mídia expirada (Baileys media expira em ~14 dias) — router happy path. Retry/error handling entra com cenário de falha real.
  • Status de “lido” / “digitando…” — UX futura. Router só responde com sendMessage.
  • Múltiplas mensagens em sequência rápida (race condition entre eventos) — Baileys serializa, router processa um por vez (await no handler). Concorrência real fica pro adapter resolver.
  • Persistência de WhatsAppMessageEvent raw — não persiste o evento original; só o que vira ChatMessage (cenário 012 cobre via AgentMemory).
  • Adapter real BaileysAdapter — ADR 007 + spec colocada futura em src/contexts/agent/infrastructure/BaileysAdapter.spec.ts, gated por env.

Criar WhatsAppMessageRouter em agent/application/, port WhatsAppGateway em agent/application/whatsapp/, port HouseholdLookup em agent/application/, VO WhatsAppMessageEvent em agent/domain/whatsapp/, e test fake MockWhatsAppGateway em agent/infrastructure/. Reusar AgentChat + ImportInvoiceTool + multimodal ChatMessage do 009 sem mudança. BaileysAdapter real entra com spec colocada em agent/infrastructure/BaileysAdapter.spec.ts (ADR 003 + 007), gated por env.