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:
- Group ainda não linkado a nenhum Household → router tenta resolver via
senderIddo remetente. Se esse senderId já tá vinculado em outrochatId(DM), fazlinkChat({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.) - Group linkado, mensagem SEM mention do bot → silêncio total. Ninguém pediu nada pro bot; conversa entre o casal não dispara LLM.
- Group linkado, mensagem COM mention do bot (
mentions.includes(BOT_WAID)) → router chamaagentChat.ask({messages, resourceId:household.id, threadId:groupId}). Resposta vai pro group viagateway.sendMessage. - 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.
Scenario: Bot adicionado em group; auto-link via senderId já vinculado em DM
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 aochatId="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")retornaundefined(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.sendMessagefoi chamado uma vez com{chatId:"group-abc-xyz", text: <string contendo "vinculei" ou "casal" ou similar — confirmação humana do bind>} - And
agentChat.asknã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 aochatId="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.asknão é chamado (sem mention, conversa entre o casal — bot não ouve) - And
gateway.sendMessagenão é chamado (silêncio) - And nenhum side-effect:
householdLookupnã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 aochatId="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.askcom{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 === chatIddo group (≠ do DM da Ana — history do group é isolada da history do DM, working memory continua compartilhada perresourceId)
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 aochatId="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 (lookupsenderId → Member.idvia mapping registrado no Household — wa-001 grava esse vínculo) - And
agentChat.asknã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">}
Modelo
Section titled “Modelo”WhatsAppMessageRouter— composition piece emagent/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 emagent/domain/whatsapp/. POJO normalizado, opaco ao SDK Baileys:interface WhatsAppMessageEvent {chatId: string; // DM id ou group idsenderId: string; // waid do remetentetext?: string;media?: { mediaId: string; mimeType: string };isGroup: boolean;mentions: string[]; // waids mencionados no msg (vazio se nenhum)}WhatsAppGateway— port emagent/application/whatsapp/. Inversion of dependency: domain define forma; adapter (MockWhatsAppGatewaytest,BaileysAdapterprod) 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 emagent/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;}
Decisões de design
Section titled “Decisões de design”- Router não é Domain Service — vive em
application/whatsapp/, não emdomain/services/. Tem I/O (sendMessage, lookup), ortogonal aFeasibilityCheck/ExpenseReconciler(puros). Compõe domain (AgentChat) com infraestrutura (gateway, lookup). Padrão: orchestrators de fluxo cross-context ficam emapplication/. - 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
@botem texto —mentions: string[]vem do payload Baileys (mentioned waids). Unambíguo: compararmentions.includes(BOT_WAID). Detectar “@bot” via regex notextfalha 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 recebebotWaidno construtor. Spec injeta"5511777777777"; prod injeta o número real do bot. Mesma fronteira detodayemFeasibilityCheck— 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 terceiro —
agentChat.asknunca é 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 Household —
threadId = 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 é perresourceId = 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 viahouseholdLookup.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.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- wa-001 (onboarding DM — planejado) — pré-requisito direto. wa-003 assume que
senderId → Householdexiste via DM. wa-001 grava: cria Household no primeiro DM, registra senderId, populaMembercom 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.idcompartilhado entre DM e group threads; cada thread temgetMessagesisolado. 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})ganharwaidopcional, OU mappingsenderId → Member.idfica noHouseholdLookuppuramente. 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 semfindBySenderIdresolvendo → 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 @parceiropra promover senderId a Member do Household viahousehold.addMember(...). - Rotação de waid (member trocou de número). Cenário futuro.
Fora de escopo
Section titled “Fora de escopo”- Token-based link —
@bot link tok_abc123cobre 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_WAIDper-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_WAIDantes de emitir; router não vê próprio echo.
Próximo passo
Section titled “Próximo passo”- Implementar
WhatsAppMessageEvent(VO) emsrc/contexts/agent/domain/whatsapp/. - Implementar
WhatsAppGateway(port) emsrc/contexts/agent/application/whatsapp/. - Implementar
MockWhatsAppGateway(test fake) emsrc/contexts/agent/infrastructure/— emite viaemit(event), gravasentMessages: Array<{chatId, text}>. - Implementar
HouseholdLookup(port) emsrc/contexts/agent/application/+InMemoryHouseholdLookup(test fake) emsrc/contexts/agent/infrastructure/. - Implementar
WhatsAppMessageRouteremsrc/contexts/agent/application/whatsapp/recebendo{ gateway, householdLookup, agentChat, botWaid }via construtor.route(event)é o método público (também acessível viagateway.onMessage(router.route.bind(router))). - Passar as 4 scenarios. Depois, wa-001 e wa-002 reusam a mesma infraestrutura — wa-003 deixa o esqueleto pronto.