Skip to content

wa-001 — Onboarding via WhatsApp — primeira mensagem cria Household direto, opcionalmente com referral

CEO killou a waitlist em 2026-06-03. A landing virou versão final (não pre-launch) — visitante clica CTA, abre wa.me/<bot>?text=oi, manda a mensagem. O bot cria o Household imediatamente. Sem token requerido, sem fila, sem signup (ADR 006 reforçado).

Opcional: se a primeira mensagem começa com ref-<shareToken> (ex: ref-tok_abc123 oi), o bot detecta o prefix, cria o Household normalmente e dispara Referrals.attribute({inviteeHouseholdId, shareToken}) (cenário 016 refactored). Se o token for inválido (ou inexistente), faz fallback silencioso pro onboarding normal — nenhum erro pro user. Attribution não é parte do happy path principal; é insumo de growth tracking opcional.

Breaking change: removida HouseholdLookup.bootstrapFromOnboardingToken({chatId, senderId, token}). Use HouseholdLookup.bootstrapFromChat({chatId, senderId, referralToken?}) em vez. Token-based onboarding flow foi eliminado junto com a waitlist.

ADR 007 escolheu WhatsApp via Baileys como único canal conversacional em produção; ADR 006 ancorou identidade em waid (anonymous-first, sem signup). Este cenário é o primeiro toque real do casal no canal: a primeira mensagem que cai no número do bot já decide se a conversa entra como Household existente ou vira onboarding novo (com ou sem referral attribution).

O BaileysAdapter real (descrito em ADR 007) traduz o evento Baileys pra WhatsAppMessageEvent e repassa pro orquestrador WhatsAppMessageRouter. Nesta spec o adapter é substituído pelo MockWhatsAppGateway (test fake, mora em agent/infrastructure/, segue o pattern InMemoryAgentMemory do 012), que expõe emit(event) pra spec disparar mensagens entrando e sentMessages[] pra observar o que o router enviou de volta.

O WhatsAppMessageRouter é capability-named (igual FeasibilityCheck em planning/ e o resto de agent/), vive em agent/application/ porque depende de ports (gateway, lookup, repository) e decide o fluxo:

  1. Se householdLookup.findByChatId(chatId) resolve → fluxo normal: chama AgentChat.ask({ messages, resourceId: household.id, threadId: chatId }) (cenários 008/009/012/013). Mapping resourceId = household.id / threadId = chatId é o que o ADR 007 fixou.
  2. Se o chat é novo:
    • Parse opcional do prefix ref-<shareToken> no text via regex case-insensitive (/^ref-(\S+)/i).
    • Chama householdLookup.bootstrapFromChat({ chatId, senderId, referralToken }) (token undefined se não tiver prefix). O lookup cria Household novo com Member primário derivado do waid, persiste via HouseholdRepository.save, linka chatId → household.id e, se referralToken foi passado, chama Referrals.attribute({inviteeHouseholdId, shareToken: ShareToken.of(referralToken)}) — attribution silenciosa em caso de token inválido (retorna undefined).
    • Envia saudação personalizada via gateway.sendMessage. Wording menciona o inviter (“te convidou Ana e Bruno”) quando attribution sucedeu; caso contrário, saudação genérica.

A spec roda no tier domain (ADR 002): MockWhatsAppGateway + MockLanguageModelV1 + simulateReadableStream. Sem rede, sem Baileys, sem LLM real. Mecânica do BaileysAdapter real fica em spec colocada src/contexts/agent/infrastructure/BaileysAdapter.spec.ts (sem doc Gherkin, ADR 003).

BOT_WAID e o resto dos detalhes whats inalterados — só o fluxo de entry mudou. waid continua string opaca (formato "5511999999999", sem + nem @s.whatsapp.net).

Scenario: Primeira mensagem “oi” cria Household direto, sem token

Section titled “Scenario: Primeira mensagem “oi” cria Household direto, sem token”
  • Given nenhum Household está vinculado ao chatId="dm-5511999999999"
  • And nenhum setup de Referrals é necessário (não há prefix ref- no text)
  • When o MockWhatsAppGateway.emit({chatId: "dm-5511999999999", senderId: "5511999999999", text: "oi", isGroup: false, mentions: []}) dispara o evento
  • Then householdLookup.bootstrapFromChat foi chamado com { chatId: "dm-5511999999999", senderId: "5511999999999", referralToken: undefined }
  • And um Household novo foi criado e persistido via HouseholdRepository.save (size do repo era 0, agora é 1)
  • And householdLookup.findByChatId("dm-5511999999999") retorna esse Household
  • And o gateway recebeu exatamente um sendMessage({chatId: "dm-5511999999999", text}) cuja text cumpre regex case-insensitive /oi|mel|casal|começar/
  • And a saudação não menciona “convidou” / “te trouxe” (sem referral)

Scenario: Primeira mensagem “ref-tok_abc123 oi” registra attribution

Section titled “Scenario: Primeira mensagem “ref-tok_abc123 oi” registra attribution”
  • Given existe um inviter Household “Ana e Bruno” com ReferralLink cujo shareToken="tok_abc123" em referrals/
  • And nenhum Household está vinculado ao chatId="dm-5599999999999" ainda
  • When o MockWhatsAppGateway.emit({chatId: "dm-5599999999999", senderId: "5599999999999", text: "ref-tok_abc123 oi", isGroup: false, mentions: []}) dispara o evento
  • Then householdLookup.bootstrapFromChat foi chamado com { ..., referralToken: "tok_abc123" }
  • And um Household novo foi criado (size do repo era 1, agora é 2 — inviter + invitee)
  • And Referrals.attribute({ inviteeHouseholdId: <novo Household.id>, shareToken: ShareToken.of("tok_abc123") }) foi chamado e retornou uma attribution válida (referencia o inviter “Ana e Bruno”)
  • And o gateway recebeu exatamente um sendMessage({chatId: "dm-5599999999999", text}) cuja text cumpre regex case-insensitive /convidou|junto|Ana e Bruno/

Scenario: ref- faz fallback silencioso pra onboarding normal

Section titled “Scenario: ref- faz fallback silencioso pra onboarding normal”
  • Given nenhum link com token tok_nope existe em referrals (Referrals.attribute retornará undefined)
  • And nenhum Household está vinculado ao chatId="dm-5500000000000"
  • When o MockWhatsAppGateway.emit({chatId: "dm-5500000000000", senderId: "5500000000000", text: "ref-tok_nope oi", isGroup: false, mentions: []}) dispara o evento
  • Then o Household foi criado normalmente (size do repo era 0, agora é 1)
  • And Referrals.attribute({ inviteeHouseholdId, shareToken: ShareToken.of("tok_nope") }) foi chamado mas retornou undefined (token desconhecido)
  • And a saudação enviada pelo gateway não menciona “convidou” / “te trouxe” (fallback silencioso — wording igual ao onboarding sem referral)
  • And nenhum erro foi mostrado pro user

Scenario: Casal já vinculado manda nova mensagem — não re-onboarda

Section titled “Scenario: Casal já vinculado manda nova mensagem — não re-onboarda”
  • Given um Household “Casa” já criado e vinculado a chatId="dm-5511999999999" (Member primário Ana já registrada)
  • And o AgentChat está configurado com um BudgetTool e com AgentMemory (InMemoryAgentMemory)
  • And o LLM mockado responde com texto qualquer (mock script controlado)
  • When o MockWhatsAppGateway.emit({chatId: "dm-5511999999999", senderId: "5511999999999", text: "qual o saldo?", isGroup: false, mentions: []}) dispara o evento
  • Then householdLookup.bootstrapFromChat NÃO foi chamado (nenhum re-onboarding)
  • And agentChat.ask({ messages, resourceId: household.id, threadId: "dm-5511999999999" }) foi chamado (mapping ADR 007)
  • And o gateway recebeu exatamente um sendMessage({chatId: "dm-5511999999999", text}) com a resposta que o LLM mockado devolveu
  • Context novowhatsapp/ no nível de scenarios (mesmo agrupamento de landing/). Não cria context whatsapp/ em src/ — código vive em agent/ (port + VO + router + adapter mock), porque WhatsApp é canal do agente, não domínio próprio. Convenção: cenários cross-stack (landing/whats) ganham subfolder em scenarios/; código vai pro context que orquestra.
  • Capability-named orchestratorWhatsAppMessageRouter em agent/application/ (não em domain/, porque depende de ports e persistência). Stateless: recebe evento, resolve dependências via DI, retorna void. Mirror de FeasibilityCheck.evaluate, mas com I/O via ports.
  • Port WhatsAppGatewayagent/application/whatsapp/. Métodos:
    • onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void — assinatura observer/pub-sub; adapter real (Baileys) chama o handler a cada evento decriptado; mock invoca via emit(event).
    • sendMessage({chatId, text}): Promise<void> — manda texto pro chat (DM ou grupo).
    • downloadMedia(mediaId): Promise<Uint8Array> — baixa mídia decriptada (cenário wa-002 usará pra fatura PDF).
  • VO WhatsAppMessageEventagent/domain/whatsapp/. POJO serializável: { chatId, senderId, text?, media?: {mediaId, mimeType}, isGroup, mentions: string[] }. Sem behavior — adapter Baileys normaliza payload bruto pra esse shape.
  • Port HouseholdLookupagent/application/. Métodos:
    • findByChatId(chatId): Promise<Household | undefined> — usa link chatId → household.id + HouseholdRepository.load.
    • linkChat({chatId, householdId}): Promise<void> — persiste o mapping (em tabela própria no adapter real; Map no fake).
    • bootstrapFromChat({chatId, senderId, referralToken?}): Promise<Household> — cria Household novo (Member primário do waid), persiste via HouseholdRepository, linka, e se referralToken foi passado, chama Referrals.attribute({inviteeHouseholdId, shareToken: ShareToken.of(referralToken)}) (silent fallback se token inválido). Returns o Household criado.
  • Test fake MockWhatsAppGatewayagent/infrastructure/. Expõe emit(event) (dispara o handler registrado via onMessage) e sentMessages: Array<{chatId, text}> (push em cada sendMessage). Pattern do InMemoryAgentMemory: test fake é adapter legítimo, não mock inline.
  • Test fake InMemoryHouseholdLookupagent/infrastructure/. Map<chatId, householdId> + HouseholdRepository + Referrals injetado via construtor. Bootstrap cria Household, persiste, linka, e opcionalmente chama Referrals.attribute quando referralToken chega.
src/contexts/agent/domain/whatsapp/WhatsAppMessageEvent.ts
export interface WhatsAppMessageEvent {
chatId: string; // DM id ou group id, opaco
senderId: string; // waid do remetente (ex: "5511999999999")
text?: string;
media?: { mediaId: string; mimeType: string };
isGroup: boolean;
mentions: string[]; // waids mencionados (vazio em DM)
}
// src/contexts/agent/application/whatsapp/WhatsAppGateway.ts
export interface WhatsAppGateway {
onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void;
sendMessage(input: { chatId: string; text: string }): Promise<void>;
downloadMedia(mediaId: string): Promise<Uint8Array>;
}
// src/contexts/agent/application/HouseholdLookup.ts
export interface HouseholdLookup {
findByChatId(chatId: string): Promise<Household | undefined>;
linkChat(input: { chatId: string; householdId: string }): Promise<void>;
bootstrapFromChat(input: {
chatId: string;
senderId: string;
referralToken?: string; // opaque shareToken; lookup resolve via Referrals injetado
}): Promise<Household>;
}
// src/contexts/agent/application/WhatsAppMessageRouter.ts
WhatsAppMessageRouter.create({
gateway: WhatsAppGateway;
lookup: HouseholdLookup;
agent: AgentChat; // já configurado com memory + tools
}): WhatsAppMessageRouter
router.start(): void // registra handler via gateway.onMessage
  • Router em agent/application/, NÃO em domain/ — depende de ports (WhatsAppGateway, HouseholdLookup) e orquestra I/O. Mesma regra do BudgetRepository: domain é puro (input → output sem efeito colateral), application orquestra adapters. Router não é Domain Service.
  • WhatsAppGateway port espelha API mínima do BaileysonMessage/sendMessage/downloadMedia cobrem o uso real do canal. Mesma filosofia de AgentMemory ↔ Mastra Memory: adapter wrapper fino → port copia forma (gotcha do 012). Reduz tradução no BaileysAdapter.
  • WhatsAppMessageEvent é VO POJO serializável — sem behavior, sem invariante. Adapter normaliza payload Baileys pra essa forma; spec constrói à mão. chatId/senderId ficam opacos (string, sem VO) pra não inflar o modelo. mentions[] entra agora porque wa-003 (group chat) vai usar pra detectar “@mel” em grupo.
  • Mapping resourceId = household.id / threadId = chatId fixado aqui — ADR 007 cravou; este cenário materializa. Router faz o mapping; AgentChat continua sem conhecer Household (gotcha “resourceId opaco no contract”).
  • Bootstrap é incondicional, referral é opcional — qualquer primeira mensagem cria Household. CEO killou waitlist (2026-06-03); não há mais “visitante desconhecido” que merece bounce. referralToken é insumo de growth tracking, não gate.
  • Prefix ref-<token> é convenção de deep link — landing-final gera wa.me/<bot>?text=ref-<shareToken> quando o user veio via ?ref= na URL (cenário landing-002). Visitante orgânico abre wa.me/<bot>?text=oi direto. Router usa regex /^ref-(\S+)/i pra parsear; resto do text vira messages normal (não é consumido pela tool de attribution).
  • Silent fallback em token inválidoReferrals.attribute(...) retorna undefined quando token não existe. Router/lookup não throw, não loga, não muda saudação. UX: visitante que coloca lixo no link nunca sabe que tentou um referral inválido — entra normal e fim. Mirror do gotcha “ref inválido é silenciosamente ignorado” (cenário 016).
  • waid é string opaca — formato "5511999999999" (DDI+DDD+número, sem + nem @s.whatsapp.net). Convenção do BaileysAdapter normalizar pra esse shape. Sem VO WhatsAppId agora — promove quando aparecer regra de negócio (ex: distinguir DDI BR vs outros, validar comprimento).
  • MockWhatsAppGateway.emit é async no contract mas spec aguarda — gateway propaga pro handler que é async. Spec faz await gateway.emit(event) pra garantir que o turno completou antes das asserções. Mirror do pattern de appendMessages no 012.
  • Tier domain via fakes; Baileys real fica isoladoMockWhatsAppGateway (fake) + InMemoryHouseholdLookup (fake) + InMemoryHouseholdRepository (já existe pelo 010). Spec roda em ms, determinístico. BaileysAdapter real ganha spec colocada com adapter real + setup manual de QR pair (ADR 003, ADR 007).
  • Referrals injetado no InMemoryHouseholdLookup, não no router — attribution é detalhe do bootstrap, não do roteamento. Router só passa referralToken opaco; lookup decide chamar (ou não) o port Referrals. Mantém router enxuto e centraliza a side-effect numa única camada (mesma régua de “composition root resolve dependências”).
  • Cenário 008 (AgentChat read-only) — fluxo normal pós-bootstrap reusa AgentChat.ask como hoje. Sem mudança contract.
  • Cenário 012 (chat persistido)resourceId/threadId continuam opacos no AgentChat; este cenário fixa que valem Household.id e chatId no canal whats. Spec 012 não precisa touchup.
  • Cenário 010 (HouseholdRepository) — bootstrap consome HouseholdRepository.save. Sem mudança contract.
  • Cenário 016 (referral attribution refactored) — segundo consumer do Referrals.attribute (primeiro é a landing-002 direto). Domain de agent/ recebe referralToken como string opaca; lookup resolve via port Referrals injetado.
  • Cenário 018 (onboarding zero-state) — wa-001 cria o Household zero-state; 018 preenche via write tools. Sem touchup — 018 já assume Household pre-criado.
  • Cenário landing-001/002 (landing final) — CTA agora é wa.me/<bot>?text=oi (sem token) ou wa.me/<bot>?text=ref-<shareToken> (com referral). Landing precisa update separado pra refletir o killing da waitlist.
  • ADR 001 (stack) — Baileys entra na lista adopt quando primeiro consumer instalar (BaileysAdapter real).
  • Adapter real BaileysAdapter — pair multi-device, QR code, reconexão, decrypt de mídia. Spec colocada gated por env (cenário separado).
  • Group chat (isGroup: true, mentions[]) — cenário wa-003. wa-001 cobre só DM (isGroup false).
  • Upload de fatura PDF via whats — cenário wa-002. wa-001 não toca em mídia (media continua undefined em todos os eventos).
  • Validação de unicidade do referralToken cross-attributionReferrals.attribute cuida (cenário 016 refactored). Router não conhece regras (single-use? multi-use? expiração?).
  • Múltiplos chats por mesmo waid — casal usa 2 celulares, mesma conta → 2 chatId distintos. Mapeamento many-to-one (waids → Household) fica pra cenário futuro.
  • Rate limiting / anti-abuse — fora do domain. Adapter ou layer de orquestração decide.
  • Persistência do mapping chatId → household.id — adapter real (InMemoryHouseholdLookup no fake; tabela whatsapp_links no SQLite real). Schema fica em spec colocada do adapter de produção.
  • Recovery se waid não bate com cookie da landing — corner case fora do happy path (ex: clica no link no celular do amigo). Trata em cenário separado se aparecer.
  • Wording exato da saudação personalizada — spec valida via regex permissivo (/convidou|junto|Ana e Bruno/). LLM em prod renderiza; mock devolve string controlada.
  1. Criar VO WhatsAppMessageEvent em src/contexts/agent/domain/whatsapp/.
  2. Criar ports WhatsAppGateway (em agent/application/whatsapp/) e HouseholdLookup (em agent/application/) com bootstrapFromChat({chatId, senderId, referralToken?}).
  3. Criar WhatsAppMessageRouter em agent/application/ com start() que registra handler via gateway.onMessage e parse /^ref-(\S+)/i pro prefix.
  4. Criar fakes MockWhatsAppGateway + InMemoryHouseholdLookup em agent/infrastructure/ até este spec passar (tier domain). InMemoryHouseholdLookup recebe Referrals (port do cenário 016 refactored) via construtor.
  5. Estender agent/domain/index.ts e agent/application/index.ts (barrels) com os novos exports.
  6. Adapter real BaileysAdapter + spec colocada vêm em cenário separado (src/contexts/agent/infrastructure/BaileysAdapter.spec.ts, gated por env, ADR 007).