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 WhatsAppMessageEvent → ChatMessage[] 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” comAccount“Nubank” já registrado (cenário 001) e nenhum invoice ainda - And
HouseholdLookupvinculado:chatId="dm-5511999999999"→ esseHousehold - And
MockWhatsAppGateway.downloadMedia("media-nub-001")programado pra retornarUint8Arrayde fake PDF (bytes opacos — parser é mockado) - And parser mock injetado na
ImportInvoiceToolprogramado pra devolver fatura de maio/2026 com 3 transações somando R$ 4.500 - And
WhatsAppMessageRouterconfigurado comgateway,householdLookup,agentChat(comImportInvoiceTool+ 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.idviahouseholdLookup.findByChatId("dm-5511999999999") - And
gateway.downloadMediafoi chamado com"media-nub-001"(router detectamimeType === "application/pdf") - And
agentChat.askfoi chamado commessages: [{ 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 1Invoice(period maio/2026, total R$ 4.500, 3 transações) - And
gateway.sendMessagefoi chamado com{ chatId: "dm-5511999999999", text: <conteúdo da reply do agent — menciona "4.500" ou "fatura importada"> }
Scenario: Pessoa não vinculada manda PDF
Section titled “Scenario: Pessoa não vinculada manda PDF”- Given
HouseholdLookupsem binding prachatId="dm-5511988888888"(nenhumHouseholdcadastrado) - And
WhatsAppMessageRouterconfigurado 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.asknão é chamado - And
gateway.downloadMedianão é chamado (router só baixa mídia depois de confirmarHousehold) - And nenhum
Account/Invoiceé criado em nenhum lugar (router não temHouseholdpra 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 achatId="dm-5511999999999"(mesmo do scenario 1) - And
WhatsAppMessageRouterconfigurado 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.downloadMedianão é chamado (router só baixa mídia quandomimeType === "application/pdf") - And
agentChat.aské chamado mesmo assim — comcontentcontendo só{type:"text", text:"olha essa foto"}(router omite o file part pra evitar passar mídia não-suportada pro LLM) - And
ImportInvoiceToolnão é invocada (LLM decide responder direto, parser mock fica comcalls.length === 0) - And o
Account“Nubank” continua semInvoicenova - And
gateway.sendMessageé chamado com o texto da reply do agente (mencionando “PDF” ou explicação humana do mock)
Modelo
Section titled “Modelo”- Application Service novo —
WhatsAppMessageRouteremsrc/contexts/agent/application/. Não é Domain Service: lida com I/O orquestração (gateway → lookup → agent → gateway). Recebegateway,householdLookup,agentChatvia construtor (DI canônica). Side-effect único:gateway.onMessage(handler)nostart(). Stateless por mensagem: cada evento dispara um handler que cria seu própriopendingFilesqueue indireto viaAgentChat.ask. - Port
WhatsAppGateway— emsrc/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 eminfrastructure/.
- Port
HouseholdLookup— emsrc/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— emsrc/contexts/agent/domain/whatsapp/. Normaliza payload Baileys pra shape neutro:interface WhatsAppMessageEvent {chatId: string; // dm-<phone> ou group-<id>senderId: string; // waid do remetentetext?: string; // caption ou texto puromedia?: { mediaId: string; mimeType: string };isGroup: boolean;mentions: string[]; // waids mencionados (wa-003 consome; wa-002 ignora)} MockWhatsAppGateway— test fake colocado emsrc/contexts/agent/infrastructure/(mesma regra doInMemoryBudgetRepositoryem 007 eInMemoryAgentMemoryem 012). Métodos:emit(event)— programa um evento, dispara o handler registrado viaonMessage. Spec usa pra simular Baileys.programDownloadMedia(mediaId, bytes)— registra resposta de download.sendMessageedownloadMediagravam calls em arrays internos (sentMessages,downloadedMediaIds) pra spec inspecionar.
interface WhatsAppMessageEvent { chatId: string; senderId: string; text?: string; media?: { mediaId: string; mimeType: string }; isGroup: boolean; mentions: string[];}
// src/contexts/agent/application/whatsapp/WhatsAppGateway.tsinterface 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.tsinterface HouseholdLookup { findByChatId(chatId: string): Promise<Household | undefined>;}
// src/contexts/agent/application/WhatsAppMessageRouter.tsWhatsAppMessageRouter.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)Decisões de design
Section titled “Decisões de design”- Router em
application/, não emdomain/— orquestra I/O (gateway, lookup async, side-effect desendMessage). Gotcha do AGENTS.md: “se a coisa é pura, mora emdomain/services/; se é infra-facing, emapplication/”. 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 doChatMessagequando não baixa — LLM responde só com otext. ImportInvoiceToolcontinua decidida pelo LLM — router não chama tool direto. Empilha o file part noChatMessage, deixaAgentChat.askrodar o LLM, LLM decide chamarimportInvoice(ou não, em caso de imagem). Mantém a fronteira “decisão é do LLM” coerente com 008/009/013. ForçarimportInvoicena presença demimeType: "application/pdf"seria oportunista mas tira a chance de LLM responder “esse PDF não parece fatura, é outro documento?” no futuro.unboundReplyconfigurável, default consistente com wa-001 — quandohouseholdLookup.findByChatIdretornaundefined, router envia texto curto de waitlist + descarta. Default é string PT-BR (consistente com gotcha “messagehumano em PT no domínio enquanto monolíngue”). Override permite wa-001 onboarding flow customizar (ex: “responde com QR code de pair”).senderIdvai como input mas wa-002 não usa — campo presente noWhatsAppMessageEvent(parte do contract compartilhado wa-001/002/003), mas neste cenário individual não muda comportamento. Cenário wa-003 ativa lógicaisGroup + mentionspra decidir se responde.- Mapping fixo no router, não no agent —
resourceId = 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. pendingFilesqueue per-ask()herdado do 009 — router constrói umChatMessagemultimodal por evento.AgentChat.asklê o file part da últimausermessage e empilha nopendingFilesinterno do call. Múltiplas faturas em sequência (cenário 1 repetido) → cada evento dispara seu próprioask, cada ask tem seu próprio queue. Zero cross-contamination. Gotcha “Multimodal: extrair file bytes per-ask()” aplicada.- Spec não testa
gateway.onMessageregistration — confia querouter.start()registra; spec disparagateway.emit(...)que aciona o handler interno. Spec valida efeitos (downloadMedia called, ask called, sendMessage called) — não a mecânica de registration.
Fora de escopo
Section titled “Fora de escopo”- Onboarding / criação de
Householdno primeiro evento dechatIddesconhecido — cenário wa-001. Aqui o router só descarta + responde waitlist; criação doHouseholdé decisão deliberada do onboarding flow (chat-as-onboarding, ADR 006/007). - Group chat (
isGroup: true) — cenário wa-003. Router ignoramentions[]aqui. Accountautomático no primeiro upload via whats — assumeAccount“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
WhatsAppMessageEventraw — não persiste o evento original; só o que viraChatMessage(cenário 012 cobre viaAgentMemory). - Adapter real
BaileysAdapter— ADR 007 + spec colocada futura emsrc/contexts/agent/infrastructure/BaileysAdapter.spec.ts, gated por env.
Próximo passo
Section titled “Próximo passo”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.