Skip to content

wa-003 — Agente no group chat com parceiro (auto-link via senderId já vinculado em DM)

O casal já passou pelo wa-001: um dos dois (Ana) abriu o bot em DM, conversou, e o Household “Ana e Bruno” foi criado com o senderId (waid) dela vinculado. Agora eles querem o bot junto na conversa dos dois — adicionam o número do bot num group chat de 2 (Ana + Bruno + bot). A partir daí, qualquer pergunta no group é cross-context per-couple, com o agente respondendo lá, na frente dos dois.

ADR 007 ancora o canal whatsapp em WhatsAppGateway (port) + WhatsAppMessageEvent (VO normalizado). ADR 006 ancora identidade em waid — sem signup, sem email. ADR 004 / cenário 012 ancora memory: resourceId = Household.id (compartilhado entre members do mesmo casal), threadId = chatId (group id é diferente do DM id, então threadId muda entre group e DM mesmo pro mesmo casal — history fica isolada por thread, working memory continua compartilhada per-couple).

O router (composition piece em agent/application/whatsapp/) é o lugar onde quatro decisões acontecem por evento de group:

  1. Group ainda não linkado a nenhum Household → router tenta resolver via senderId do remetente. Se esse senderId já tá vinculado em outro chatId (DM), faz linkChat({chatId:groupId, householdId}) e responde no group “vinculei vocês aqui”. (Token explícito @bot link <token> fica como TODO — cenário futuro pra caso de group sem nenhum member previamente vinculado.)
  2. Group linkado, mensagem SEM mention do bot → silêncio total. Ninguém pediu nada pro bot; conversa entre o casal não dispara LLM.
  3. Group linkado, mensagem COM mention do bot (mentions.includes(BOT_WAID)) → router chama agentChat.ask({messages, resourceId:household.id, threadId:groupId}). Resposta vai pro group via gateway.sendMessage.
  4. Group linkado, remetente NÃO é member do Household → router responde uma mensagem educada “só members do casal podem perguntar aqui” e ignora. Não dispara LLM (e não vaza state per-couple pra terceiro).

BOT_WAID é constante configurada no router (ex.: "5511777777777"). Mention via waid é unambíguo — texto “@bot” puro fica ambíguo entre apelido humano e ping real.

Tier domain. Spec roda com MockWhatsAppGateway (eventos emitidos manualmente) + MockLanguageModelV1 + simulateReadableStream do Vercel AI SDK (ai/test). Zero rede, ms por spec.

Section titled “Scenario: Bot adicionado em group; auto-link via senderId já vinculado em DM”
  • Given o Household “Ana e Bruno” existe (resourceId = household.id) e está vinculado ao chatId="dm-5511999999999" (DM da Ana, via wa-001)
  • And Ana tem senderId="5511999999999" registrado como member do Household
  • And householdLookup.findByChatId("dm-5511999999999") retorna o Household; householdLookup.findBySenderId("5511999999999") retorna o Household
  • And o bot foi adicionado num group novo chatId="group-abc-xyz" com Ana e Bruno
  • And householdLookup.findByChatId("group-abc-xyz") retorna undefined (group ainda não linkado)
  • When MockWhatsAppGateway.emit({chatId:"group-abc-xyz", senderId:"5511999999999", text:"oi @bot", isGroup:true, mentions:["5511777777777"]}) (primeira mensagem do group, com mention)
  • Then o router consulta householdLookup.findByChatId("group-abc-xyz")undefined
  • And o router resolve via householdLookup.findBySenderId("5511999999999") → encontra Household “Ana e Bruno”
  • And o router chama householdLookup.linkChat({chatId:"group-abc-xyz", householdId: household.id})
  • And depois do turno, householdLookup.findByChatId("group-abc-xyz") retorna o mesmo Household
  • And gateway.sendMessage foi chamado uma vez com {chatId:"group-abc-xyz", text: <string contendo "vinculei" ou "casal" ou similar — confirmação humana do bind>}
  • And agentChat.ask não foi chamado nesse turno (o auto-link é responsabilidade do router, não do agente — primeira msg do group é só pra firmar o bind)

Scenario: Mensagem no group já linkado SEM mention é silenciada

Section titled “Scenario: Mensagem no group já linkado SEM mention é silenciada”
  • Given Household “Ana e Bruno” já vinculado ao chatId="group-abc-xyz" (estado pós-cenário anterior)
  • When MockWhatsAppGateway.emit({chatId:"group-abc-xyz", senderId:"5511999999999", text:"oi amor, lembra de pagar o aluguel?", isGroup:true, mentions:[]})
  • Then agentChat.ask não é chamado (sem mention, conversa entre o casal — bot não ouve)
  • And gateway.sendMessage não é chamado (silêncio)
  • And nenhum side-effect: householdLookup não muda, working memory não muda

Scenario: Mensagem no group já linkado COM mention dispara agente

Section titled “Scenario: Mensagem no group já linkado COM mention dispara agente”
  • Given Household “Ana e Bruno” já vinculado ao chatId="group-abc-xyz" (members: Ana waid "5511999999999", Bruno waid "5511888888888")
  • And LLM mockado decide responder direto em PT-BR sem tool-call: "Saldo total do mês: R$ 4.500"
  • When MockWhatsAppGateway.emit({chatId:"group-abc-xyz", senderId:"5511999999999", text:"@bot qual saldo do mês?", isGroup:true, mentions:["5511777777777"]})
  • Then o router consulta householdLookup.findByChatId("group-abc-xyz") → Household “Ana e Bruno”
  • And o router chama agentChat.ask com {messages: [{role:"user", content:"@bot qual saldo do mês?"}], resourceId: household.id, threadId:"group-abc-xyz"}
  • And gateway.sendMessage é chamado uma vez com {chatId:"group-abc-xyz", text:"Saldo total do mês: R$ 4.500"} (texto que o mock devolveu)
  • And threadId === chatId do group (≠ do DM da Ana — history do group é isolada da history do DM, working memory continua compartilhada per resourceId)

Scenario: Pessoa fora do casal manda mensagem com mention no group

Section titled “Scenario: Pessoa fora do casal manda mensagem com mention no group”
  • Given Household “Ana e Bruno” já vinculado ao chatId="group-abc-xyz" (members registrados: Ana waid "5511999999999", Bruno waid "5511888888888")
  • When MockWhatsAppGateway.emit({chatId:"group-abc-xyz", senderId:"5511955555555", text:"@bot oi, vocês podem me passar a planilha?", isGroup:true, mentions:["5511777777777"]}) (waid "5511955555555" não é member do Household — terceiro no group)
  • Then o router consulta householdLookup.findByChatId("group-abc-xyz") → Household “Ana e Bruno”
  • And o router checa household.members() e nota que o senderId não pertence a nenhum member (lookup senderId → Member.id via mapping registrado no Household — wa-001 grava esse vínculo)
  • And agentChat.ask não é chamado (terceiro não pode disparar fluxo no domínio do casal — protege state per-couple de leakage)
  • And gateway.sendMessage é chamado uma vez com {chatId:"group-abc-xyz", text: <string que casa regex case-insensitive em "members"|"casal"|"só" — mensagem educada do tipo "só os members do casal podem perguntar aqui">}
  • WhatsAppMessageRouter — composition piece em agent/application/whatsapp/. Não é Domain Service (cruza I/O — gateway, lookup, agent), não é aggregate. Pattern “router/orchestrator” puro: recebe evento, decide o que fazer. Stateless: dependências via DI (gateway, householdLookup, agentChat, botWaid).
  • WhatsAppMessageEvent — VO em agent/domain/whatsapp/. POJO normalizado, opaco ao SDK Baileys:
    interface WhatsAppMessageEvent {
    chatId: string; // DM id ou group id
    senderId: string; // waid do remetente
    text?: string;
    media?: { mediaId: string; mimeType: string };
    isGroup: boolean;
    mentions: string[]; // waids mencionados no msg (vazio se nenhum)
    }
  • WhatsAppGateway — port em agent/application/whatsapp/. Inversion of dependency: domain define forma; adapter (MockWhatsAppGateway test, BaileysAdapter prod) implementa.
    interface WhatsAppGateway {
    onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void> | void): void;
    sendMessage(input: { chatId: string; text: string }): Promise<void> | void;
    downloadMedia(mediaId: string): Promise<Uint8Array>;
    }
  • HouseholdLookup — port em agent/application/. Mapeia identifiers externos (chatId, senderId) pro aggregate Household. Bind cresce ao longo dos wa-* (wa-001 grava DM bind, wa-003 grava group bind).
    interface HouseholdLookup {
    findByChatId(chatId: string): Promise<Household | undefined> | Household | undefined;
    findBySenderId(senderId: string): Promise<Household | undefined> | Household | undefined;
    linkChat(input: { chatId: string; householdId: string }): Promise<void> | void;
    }
  • Router não é Domain Service — vive em application/whatsapp/, não em domain/services/. Tem I/O (sendMessage, lookup), ortogonal a FeasibilityCheck/ExpenseReconciler (puros). Compõe domain (AgentChat) com infraestrutura (gateway, lookup). Padrão: orchestrators de fluxo cross-context ficam em application/.
  • Auto-link via senderId já vinculado — único path implementado no cenário. Token explícito @bot link <token> fica como TODO pra cenário futuro (group entre dois desconhecidos, sem DM prévia, ou casal que quer rotacionar bot pra novo número). Razão: 99% dos casos reais é “Ana já falou com o bot no DM, agora trouxe Bruno pro group”. Token resolve edge case.
  • Mention via waid, não @bot em textomentions: string[] vem do payload Baileys (mentioned waids). Unambíguo: comparar mentions.includes(BOT_WAID). Detectar “@bot” via regex no text falha quando alguém chama outro contato de “bot” como apelido, e desliga quando o user mente certo mas digita errado (”@ bot”). Baileys já normaliza mentions — usa o que tem.
  • BOT_WAID é constante de configuração, não hard-code no domínio — router recebe botWaid no construtor. Spec injeta "5511777777777"; prod injeta o número real do bot. Mesma fronteira de today em FeasibilityCheck — config externa nunca implícita.
  • Sem mention = silêncio absoluto — conversa entre o casal sobre coisas do dia-a-dia não vira tool-call nem render LLM. Custo de token + latência + privacy: bot só fala quando chamado. Habit nativo de group chat com bots (Telegram/Slack defaults).
  • Terceiro com mention dispara resposta educada, não silêncio — terceiro pode ter sido adicionado errado ou ser um amigo curioso. Responder uma vez “só members do casal podem perguntar aqui” educa sem vazar state. Comparado com silêncio, custa 1 sendMessage; ganha clareza de UX.
  • State per-couple não vaza pra terceiroagentChat.ask nunca é chamado pra senderId não-member. LLM nem vê o prompt. Working memory (com renda, metas, members do casal) fica escondida do terceiro mesmo se ele pingar. Anonymous-first (ADR 006) garante que terceiro também é anônimo — ninguém recebe state de ninguém sem bind explícito.
  • Group e DM são threads diferentes do mesmo HouseholdthreadId = chatId. Group "group-abc-xyz" ≠ DM "dm-5511999999999". History per-thread fica isolada (Ana falar com o bot sozinha no DM não polui o group). Working memory é per resourceId = Household.id, compartilhada — facts persistem entre threads. Alinha com cenário 012.
  • Member ↔ waid mapping mora no Household — adicionar member não basta pra rejeitar terceiros; precisa saber qual waid é qual member. wa-001 grava esse vínculo (Member.create({name, waid}) ou via householdLookup.findBySenderId). wa-003 só consulta o vínculo. Se aparecer caso “member trocou de número”, vira cenário próprio (rotação de waid).
  • Auto-link silencioso ≠ confirmação — quando bot é adicionado num group, primeira msg (com mention) faz o bind E responde no group “vinculei vocês aqui”. Confirmação humana visível pro casal — sem isso, fica esquisito (“respondeu do nada, sabe nosso nome?”). Confirmação também é o teste do casal pra notar bind errado e contestar.
  • wa-001 (onboarding DM — planejado) — pré-requisito direto. wa-003 assume que senderId → Household existe via DM. wa-001 grava: cria Household no primeiro DM, registra senderId, popula Member com waid.
  • wa-002 (importar fatura via whats — planejado) — ortogonal. Mesma router/gateway/lookup, mas fluxo é “media event → downloadMedia → AgentChat com file parts”. Pode acontecer tanto no DM quanto no group (após bind). wa-003 não cobre media no group, mas a infraestrutura compartilhada já permite.
  • Cenário 008 (AgentChat) — sem touchup. Router chama agentChat.ask({messages, resourceId, threadId}) que já tem o contract do 012.
  • Cenário 012 (chat persistido) — sem touchup. resourceId = Household.id compartilhado entre DM e group threads; cada thread tem getMessages isolado. Working memory hidrata o bot no group com facts que Ana contou no DM — UX nativa pra “o bot já sabe quem somos”.
  • Cenário 005 (Household) — futuro touchup minor pra Member.create({name, waid}) ganhar waid opcional, OU mapping senderId → Member.id fica no HouseholdLookup puramente. Decisão diferida pra wa-001.
  • @bot link <token> como path explícito de bind quando nenhum member do group tem bind prévio em DM. Cenário futuro (wa-00X). Por agora: group sem findBySenderId resolvendo → router responde “pra começar, um de vocês me chama no DM primeiro” e silencia.
  • Member novo no group já linkado (senderId não é member do Household, mas o casal quer que vire member). Hoje router responde “só members do casal podem perguntar aqui”. Cenário futuro: comando explícito @bot add @parceiro pra promover senderId a Member do Household via household.addMember(...).
  • Rotação de waid (member trocou de número). Cenário futuro.
  • Token-based link@bot link tok_abc123 cobre group sem DM prévia. Fica pra cenário futuro.
  • Adicionar member novo via group — terceiro vira member do Household por comando explícito. Fica pra cenário futuro.
  • Media no group — upload de fatura no group dispara fluxo wa-002 + reconciliação cross-context. Cenário futuro (provavelmente wa-002 cobre group também na sua iteração).
  • Múltiplos groups por Household — casal cria segundo group (ex: com contadora) e quer bot lá. Cada group vira threadId próprio; resourceId continua o mesmo. Hoje: assumimos 1 group por Household.
  • Configuração do BOT_WAID per-deploy — composition root decide. Spec hardcoda.
  • Rate limiting de mentions — terceiro spammando “@bot oi” no group repete sendMessage de rejeição. Mitigação fica pra adapter Baileys (debounce per senderId) ou cenário futuro.
  • Mention parcial (“@bo” ou “@bot ” sem espaço) — depende de como Baileys normaliza. Confiar em mentions: string[] resolve. Free-text “bot” no texto sem mention real = sem ping.
  • Bot adicionado em group de >2 pessoas (família) — fora de escopo do MVP couples-first. Se member count > 2 e router ainda quer linkar, política vira “primeiro DM ganha”. Cenário futuro.
  • Echo de mention do próprio bot — bot menciona alguém na resposta e Baileys re-emite pro router como evento. Adapter filtra senderId === BOT_WAID antes de emitir; router não vê próprio echo.
  1. Implementar WhatsAppMessageEvent (VO) em src/contexts/agent/domain/whatsapp/.
  2. Implementar WhatsAppGateway (port) em src/contexts/agent/application/whatsapp/.
  3. Implementar MockWhatsAppGateway (test fake) em src/contexts/agent/infrastructure/ — emite via emit(event), grava sentMessages: Array<{chatId, text}>.
  4. Implementar HouseholdLookup (port) em src/contexts/agent/application/ + InMemoryHouseholdLookup (test fake) em src/contexts/agent/infrastructure/.
  5. Implementar WhatsAppMessageRouter em src/contexts/agent/application/whatsapp/ recebendo { gateway, householdLookup, agentChat, botWaid } via construtor. route(event) é o método público (também acessível via gateway.onMessage(router.route.bind(router))).
  6. Passar as 4 scenarios. Depois, wa-001 e wa-002 reusam a mesma infraestrutura — wa-003 deixa o esqueleto pronto.