# WHATSAPP_GROUPS — WhatsApp Groups (Meta oficial e Evolution API)

| Campo | Valor |
| :---- | :---- |
| Status | Implementado — Meta (gestão completa + envio/recebimento de mensagem) e Evolution API (create/update/delete/foto/sync/sync-all/participantes, testado ao vivo de ponta a ponta com participante real — texto, citação, mídia e reação nos dois sentidos). Encaminhamento de webhook de grupo pro cliente final implementado pra Evolution API. Remetente de grupo sempre pelo número real, nunca LID (2026-09-28) — ver [LID](#lid--remetente-sempre-pelo-número-real) |
| Escopo de parceiro | **Meta Cloud API** e **Evolution API**. Gupshup não tem nenhuma API de grupo publicada (conferido no índice completo da doc deles) — segue recusando via orquestrador. Ver [Padrão: orquestrador multi-parceiro](../architecture/ARCHITECTURE.md#padrão-orquestrador-multi-parceiro-coreomnimetausecase) |
| ADR | Nenhum dedicado — decisões registradas nesta doc |

## Escopo

| Incluído | Excluído / futuro |
| :------- | :----------------- |
| CRUD de Group — create/list/show/sync/update/delete (Meta) | Adicionar participante Meta por telefone diretamente — **não existe essa API na Meta**, só via link de convite ou template aprovado |
| Create/update/delete/sync de Group na **Evolution API** (create/edit/"delete" = leave), listar participantes, promover/rebaixar admin, avisos/restrição (`announce`/`restrict`), link de convite (ler e gerar novo) e importar todos os grupos do número (`sync-all`) | Join-requests na Evolution API (orquestrador responde `GROUPS_OPERATION_NOT_SUPPORTED`) |
| Foto do grupo (`updateGroupPicture`) — **Evolution API apenas** (URL ou base64; Meta ignora o campo) | Sincronização do cache local a partir dos webhooks de grupo da **Meta** (a Evolution já atualiza subject/description/announce/restrict/own_role/participant_count a partir dos eventos) |
| Remover participantes, link de convite (gerar/resetar) — Meta. Adicionar/remover participantes por telefone — **Evolution API** (Meta não tem essa API, só convite) | Encaminhar webhooks de grupo da **Meta** pro cliente final (hoje só loga, ver [Webhooks de grupo](#webhooks-de-grupo--encaminhamento-pro-cliente-final)) |
| Join requests (list/approve/reject) — Meta, grupos com `join_approval_mode=approval_required` | Front-end — decisão de produto: grupos não têm tela no OmniMeta, só API |
| Enviar mensagem pro grupo pelo endpoint existente (`groupId` em vez de `to`) — texto, citação, mídia e reação, testado nos dois sentidos na Evolution API | Grupo em Gupshup (sem API do parceiro) |
| Receber mensagem de grupo pelo webhook normal (`groupId` decodificado em `Message`, contato resolvido pelo `participant` real, nunca pelo JID do grupo) | — |
| **Encaminhar eventos de webhook de grupo pro cliente final (Evolution API)** — `GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE`, ver [Webhooks de grupo](#webhooks-de-grupo--encaminhamento-pro-cliente-final) | — |

**Público-alvo:** desenvolvedores OmniMeta, QA, suporte técnico.

## Objetivo

1. Permitir criar e gerenciar grupos do WhatsApp via API OmniMeta, sem o cliente chamar a API do parceiro diretamente.
2. Enviar e receber mensagens de grupo pelo **mesmo endpoint** já usado pra mensagens 1:1 — zero breaking change pra quem já integra.
3. Seguir o mesmo padrão de orquestrador multi-parceiro já estabelecido por Template/Flow — hoje Meta e Evolution API implementados, Gupshup plugável no futuro sem reescrever nada externo.

## Conceitos — Meta

- **Um Group pertence a um número de telefone** (`POST /{phone-number-id}/groups`), diferente de Flow/Template, que são recursos da WABA inteira. Por isso `Create Group` pede `phoneNumber` (o número real de WhatsApp, ex.: `5511999999999`) — não `whatsappAccountId` nem um id interno — mesma convenção já usada no envio de mensagem (`PhoneMiddleware`). Ver [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno).
- **Não existe "adicionar participante por telefone"**: a Meta só permite entrar num grupo clicando no link de convite (`invite_link`), ou aceitando um template pré-aprovado que contenha esse link (fluxo "group invite link template", configurado manualmente no Business Manager). O OmniMeta expõe a geração/reset do link; o convite em si é sempre uma ação humana do participante.
- **`join_approval_mode`**: `auto_approve` (padrão — quem clica o link entra direto) ou `approval_required` (quem clica vira um "join request" pendente, aprovado/rejeitado via API).
- **Eligibilidade**: mesmo contas Cloud API válidas podem não ter a Groups API liberada ainda pela Meta — erro real: `(#131215) This phone number is not eligible to access Groups APIs`.

## Conceitos — Evolution API

- **Diferente da Meta, criar grupo exige pelo menos 1 participante real já na criação** — o schema de validação da própria Evolution (`participants.minItems = 1`, confirmado no código-fonte oficial) não permite grupo vazio. O OmniMeta valida isso localmente antes de chamar a API (`GroupException` clara, sem gastar chamada) caso `participants` venha vazio/ausente.
- **`participants`**: array de números **só dígitos** (sem `+`, sem `@c.us`) — é o único partner onde `Create Group` usa esse campo; Meta ignora se vier.
- **Não existe "deletar grupo" de verdade** — só existe `leaveGroup` (a Evolution/Baileys não expõe destruir um grupo pra todo mundo). "Delete" no OmniMeta pra Evolution na prática **remove só a presença do número conectado** — o grupo continua existindo pros outros membros. Diferente da Meta, onde deletar é irreversível pra todo mundo. O registro local vira `status: DELETED` nos dois casos.
- **`groupId` local guarda o JID completo** (`120363xxxxxxxxxxxx@g.us`), não um id numérico puro como a Meta.
- **Instância = `WhatsappAccount.name`, autenticação = `WhatsappAccount.accessToken`** (o token real do instance, não o `EVOLUTIONAPI_TOKEN` global do `.env`, que é só um fallback administrativo usado por algumas chamadas de conexão/QR code pré-existentes).
- **Escopo implementado é create/update (subject, description, foto, `announce`/`restrict`)/delete/sync/sync-all/participantes (listar, add, remove, promover/rebaixar admin)/link de convite (ler e gerar novo)** — join-requests respondem `GROUPS_OPERATION_NOT_SUPPORTED`. Edição de `announce`/`restrict` (`POST /group/updateSetting`), link de convite (`GET /group/inviteCode`, `POST /group/revokeInviteCode`) e papel (`updateParticipant` com `promote`/`demote`) exigem que o número conectado seja admin — sem isso, 403 `GROUP_NOT_ADMIN`. Diferente da Meta, a Evolution **consegue adicionar participante por telefone diretamente** (`POST /groups/{id}/participants`) — a Meta só permite via link de convite. O `updateParticipant` da Evolution responde 201 mesmo quando o WhatsApp recusa alguns números (privacidade, já membro): cada item traz seu `status` (`"200"`, `"403"`, `"409"`, …) e o OmniMeta devolve os recusados em `failed_participants`.
- **Foto do grupo** (`UpdateGroupInputDTO.image`, só Evolution): URL ou base64. URLs de hosts com proteção anti-hotlink (ex.: `placehold.co`) retornam 403 pro fetch do **servidor da Evolution** (não é erro do OmniMeta) — testado e confirmado funcionando com base64 e com `raw.githubusercontent.com`.
- **`groupsIgnore` da instância** (`true` = a Evolution descarta **todo** evento de grupo — mensagem, reação, `groups.*` — antes de mandar webhook). Quem liga é o próprio OmniMeta: `CreateWhatsappAccountUseCase` cria toda instância com `groupsIgnore: true`, e a Evolution persiste isso no Postgres (`Setting`), sobrevivendo a restart. O "voltou a `true` sozinho" observado em 2026-08-27 é compatível com isso (instância recriada), não com um bug da Evolution — nada no código da v2.3.7 reseta o valor. Pra receber grupos numa instância é preciso `POST /settings/set/{instance}` com `groupsIgnore: false` — o schema exige **o objeto completo** (`rejectCall`, `groupsIgnore`, `alwaysOnline`, `readMessages`, `readStatus`, `syncFullHistory`) e sobrescreve tudo, então leia antes com `GET /settings/find/{instance}`. Decisão de produto (2026-10-01): a instância continua sendo criada com `groupsIgnore: true` (quem nunca usa grupos não recebe tráfego de grupos pessoais), e o OmniMeta **liga sozinho** (`groupsIgnore: false`) na instância que começa a usar grupos — `EvolutionGroupDirectory::ensureGroupsEnabled`, chamado no create, no sync e no sync-all. Lê as configurações atuais, troca só o `groupsIgnore` e regrava o objeto completo; cache de 1 dia por conta (instância recriada é religada no dia seguinte, ou na hora numa nova operação depois de expirar); falha é só registrada, nunca derruba a operação de grupo.
- **Citação (`idRef`) na Evolution exige o objeto `quoted` completo** (`{key: {remoteJid, fromMe, id, participant}, message: {...}}`) — mandar só `{key: {id}}` faz a própria Evolution tentar resolver o resto sozinha via lookup interno, que na prática **não populava `participant`** pra mensagens de grupo nos testes feitos, e o WhatsApp não desenhava o balão de resposta. O OmniMeta monta o objeto completo ele mesmo a partir da mensagem local (`Message.participantJid`), sem depender desse lookup.
- **`Message.participantJid`** (novo campo) é **específico da Evolution/Baileys** — guarda o JID cru (ex.: `156079296114689@lid`) de quem mandou a mensagem, capturado no inbound (`PhoneExtractor`) e sintetizado no outbound (número da própria conta + `@s.whatsapp.net`). É o dado que faltava pra citação funcionar corretamente na Evolution; sem ele a citação cai num fallback só com `id`. **Não deve ter equivalente quando a Meta implementar mensagem de grupo**: a citação da Meta usa só `context.message_id` (referenciando o wamid direto), sem precisar de um identificador de participante separado — o `from` da própria mensagem já é o número real do remetente (a Meta não tem o esquema de LID do Baileys). Fica como campo opcional na entidade, populado só pela Evolution, mesmo padrão de outros campos partner-específicos já existentes (ex.: `join_approval_mode` só Meta, `image` em Update Group só Evolution).
- **JID vs número real (LID)**: a Evolution/Baileys pode identificar um participante pelo **LID** (`156079296114689@lid`, um id de privacidade da própria WhatsApp) em vez do número real. Resolvido em 2026-09-28 — ver [LID — remetente sempre pelo número real](#lid--remetente-sempre-pelo-número-real).
- **Reação na Evolution usa um payload diferente do da Meta**: `{key: {remoteJid, fromMe, id, participant}, reaction: "<emoji>"}` (string simples), não `{reaction: {message_id, emoji}}` como a Meta. Por isso existe `Core\EvolutionAPI\UseCase\Message\ProcessReactionUseCase` próprio — a versão compartilhada (`Core\UseCase\Message\ProcessReactionUseCase`) é só pra Meta/Gupshup.
- **Mensagens outbound pro grupo não geram `Contact`** — antes o código criava um Contact com o JID do grupo como `wa_id` (corrompendo dados); agora `contactId` fica `''` quando `recipientType === 'group'`, tanto na Evolution quanto na Meta. O mesmo vale pra mensagem digitada **no celular** do número conectado (`fromMe` no `messages.upsert`, `key.participant` = o próprio LID) e pra remetente só identificado por LID não resolvível. `MessageWebhookTriggerUseCase` trata mensagem sem Contact (antes lançava `Contact Not Found` e o `UPDATE_MESSAGE_STATUS` de grupo nunca chegava ao cliente).
- **Papel do número conectado (`own_role`)**: `member` | `admin` | `superadmin`, `null` enquanto desconhecido. Calculado cruzando o roster (`participants[].admin` do Baileys) com o número dono do grupo — sempre pelo número resolvido, nunca LID. Fontes: `groups.upsert`, `findGroupInfos` (show/sync), `fetchAllGroups` (sync-all), `GET /group/participants`, e `group-participants.update` `promote`/`demote`/`add`/`remove` envolvendo o próprio número. Grupo criado pela API já nasce `superadmin` (Meta e Evolution). No WhatsApp, `restrict=true` = só admin edita dados do grupo; `announce=true` = só admin envia mensagem.
- **Edição sem permissão**: o WhatsApp responde `forbidden`/`not-authorized` e a Evolution devolve 400/500 com `response.message = ["Error updating group subject", "Error: forbidden"]`. O OmniMeta traduz pra **403 `GROUP_NOT_ADMIN`** (`EvolutionGroupErrors::fromResponse`). O texto exato vem do servidor do WhatsApp — detecção por regex (`forbidden|not-authorized|not admin`), a confirmar ao vivo.

### LID — remetente sempre pelo número real

Evolution 2.3.7 usa Baileys `7.0.0-rc.9`. Numa mensagem de grupo, o `key` do `messages.upsert` é repassado sem alteração (conferido no código-fonte, `decode-wa-message.js`):

| `addressingMode` | `key.participant` | `key.participantAlt` |
| :--------------- | :---------------- | :------------------- |
| `lid` | LID do remetente (`…@lid`) | número real (`…@s.whatsapp.net`), quando o WhatsApp divulga (`participant_pn`) |
| `pn` | número real | LID |

`participantPn`/`senderPn` **não existem** nessa versão (eram do Baileys 6.7). `remoteJidAlt` só vem em 1:1.

Regra do OmniMeta (`PhoneExtractor` + `EvolutionGroupDirectory`):

1. `from` = o primeiro JID de telefone entre `participant` e `participantAlt`.
2. Se só houver LID, resolve LID → número pelo roster do grupo (`participants[] = {id: …@lid, phoneNumber}`), em cache **por conta** (7 dias) — o LID identifica a mesma pessoa em todos os grupos, então um número divulgado num grupo resolve a pessoa em todos (necessário em grupo de avisos de comunidade, onde o WhatsApp lista quase todo mundo só por LID). Alimentado por `groups.upsert`, `findGroupInfos`, `fetchAllGroups`, `GET /group/participants` e `group-participants.update`; o `own_role` também é calculado sobre esse roster resolvido. Numa falta de cache, busca `GET /group/participants` ao vivo no máximo 1x a cada 60s por grupo.
3. Não resolveu: a mensagem é salva e entregue com **`from: null`**, **sem criar Contact** (`contactId: ''`, `visitor.userId: null`, `visitor.name` = pushName, guardado em `Message.participantName`). Um LID nunca vira `wa_id` nem `from`, e **não é enviado ao cliente** — fica só interno (`Message.participantJid`, necessário pra citação).
4. **Contato legado com LID**: antes dessa correção o LID virava `wa_id` (≈15 dígitos). Quando o número real do mesmo LID aparece, o Contact legado (`wa_id` = dígitos do LID) é **migrado no lugar** — mantém o id, `wa_id` passa a ser o número real — a não ser que já exista um Contact com o número real (aí o legado fica como está).
5. `Message.participantJid` continua guardando o JID cru (LID incluído) — a citação na Evolution precisa dele verbatim.

O mesmo vale pros eventos: `group-participants.update` na v2.3.7 manda `participants` como **objetos** `{id, phoneNumber, lid, admin}` (não strings) e o `participantsData` que a Evolution adiciona vem quebrado (`"[object Object]"`) — o handler lê `participants[]` e nunca `participantsData`. Participante não resolvível vai pra `unresolvedParticipants` (LIDs), nunca pra `participants`. O roster de `GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE` traz `{phoneNumber, lid, admin}` com `lid` preenchido só quando `phoneNumber` é `null`.

## Webhooks de grupo — encaminhamento pro cliente final

Segue o mesmo padrão já usado pra `NEW_MESSAGE`/`UPDATE_MESSAGE_STATUS`/`BILLING_UPDATE`: um evento
canônico em `WebhookEventsEnum` (`Core\Domain\Enum\Meta\Webhook\WebhookEventsEnum`), independente
de parceiro, que o cliente assina criando um `WebhookTrigger` com esse valor em `signed_events`
(`POST /v1/webhook-triggers`). Um handler por parceiro traduz o payload cru pro formato canônico
e delega a entrega pra uma UseCase de trigger compartilhada — o mesmo desenho de
`BillingWebhookTriggerUseCase`, agora com `Core\UseCase\Group\GroupWebhookTriggerUseCase`.

### Eventos canônicos

Os 4 valores espelham 1:1 os 4 webhook fields reais da Meta — confirmado direto na doc oficial
(`developers.facebook.com/.../whatsapp/groups/reference`, seção "Subscribe to groups metadata
webhooks") — em vez de um esquema inventado, já que a Meta é a referência que os outros parceiros
devem convergir. Evolution só cobre 3 dos 4 (não tem equivalente de status):

| Evento (`signed_events`) | Disparado por (Evolution API) | Disparado por (Meta, confirmado na doc oficial) |
| :------------------------ | :----------------------------- | :-------------------- |
| `GROUP_LIFECYCLE_UPDATE` | `groups.upsert` (Baileys — grupo criado/entrado, ou resync periódico da instância; **nunca** sinaliza exclusão) | `group_lifecycle_update` — grupo **criado OU deletado** |
| `GROUP_SETTINGS_UPDATE` | `groups.update` | `group_settings_update` — subject/descrição/foto atualizados |
| `GROUP_PARTICIPANTS_UPDATE` | `group-participants.update` | `group_participants_update` — participante entra/sai, join request aceito/rejeitado |
| `GROUP_STATUS_UPDATE` | — (Evolution não tem equivalente — é moderação de conteúdo específica da Meta) | `group_status_update` — grupo **suspenso por violação** (`type: group_suspend`) ou **suspensão removida** (`type: group_suspend_cleared`) |

**Atenção ao usar `GROUP_LIFECYCLE_UPDATE` do lado Evolution**: como `groups.upsert` do Baileys
nunca sinaliza exclusão de grupo, esse evento na prática só chega como "criado/sincronizado" pela
Evolution — mas o MESMO nome de evento, quando o lado Meta for implementado, também vai poder
significar "grupo deletado". Um cliente que assina `GROUP_LIFECYCLE_UPDATE` esperando só criação
(por causa do comportamento hoje só-Evolution) vai precisar checar `raw`/o partner de origem
quando o lado Meta entrar, pra não tratar delete como create.

**Só a Evolution API está com o encaminhamento implementado nesta etapa.** O lado Meta
(`Core\Application\Webhook\Groups\GroupWebhookProcessing`) continua só logando
(`Log::info('Group webhook received (not yet fully processed)', ...)`) — reconhece os 4 campos
pra não quebrar o processamento, mas não chama `GroupWebhookTriggerUseCase`. Fica pronto pra
plugar depois: é só o `GroupWebhookProcessing::process()` mapear cada `field` (`change['field']`)
pro `WebhookEventsEnum` de mesmo nome (tradução é 1:1, não precisa de lookup) e montar o array
`group` a partir de `change['value']['groups'][0]` da Graph API — schema completo confirmado em
[developers.facebook.com/.../whatsapp/groups/webhooks](https://developers.facebook.com/documentation/business-messaging/whatsapp/groups/webhooks/)
(ver abaixo) — e chamar a UseCase compartilhada. Gupshup ainda não tem nenhuma API de grupo publicada; quando
tiver, o encaixe é o mesmo: um handler em `Core\Gupshup\UseCase\Webhook\Group\*` traduzindo pro
mesmo formato canônico.

### Contrato canônico do `group` — precisa ser o mesmo não importa o parceiro

Esse é o ponto que mais importa aqui: **o cliente final não pode precisar de um `if partner ===
X` pra ler esse payload** — mesma exigência que já vale pra mensagem receptiva (`Message.groupId`,
`from`/`to` sempre número normalizado, nunca JID cru, não importa se veio da Meta, Gupshup ou
Evolution), documentada como princípio geral em
[ARCHITECTURE.md — Contrato de webhook uniforme entre parceiros](../architecture/ARCHITECTURE.md#contrato-de-webhook-uniforme-entre-parceiros)
(inclusive a ressalva sobre o "envelope" externo, que hoje tem 3 formatos diferentes por razão
histórica — o `group` interno é uniforme, o envelope que o carrega segue o formato genérico mais
recente). Cada evento tem um vocabulário fechado de `type` (e `changed`, só em
`GROUP_SETTINGS_UPDATE`) que TODO parceiro tem que traduzir pra esses mesmos valores — nunca
repassar o termo cru do parceiro (`group_create`, `group_participants_add`, etc.) como se fosse o
canônico:

| Evento | Campo extra | Vocabulário fechado | Quem emite qual valor hoje |
| :----- | :---------- | :------------------- | :--------------------------- |
| `GROUP_LIFECYCLE_UPDATE` | `type` | `created` \| `deleted` | Evolution: `created` no `groups.upsert` e quando o grupo é **descoberto ou reativado** por qualquer outro caminho (mensagem, `groups.update`, `group-participants.update`, `sync-all`) — o cliente deve tratar `created` como upsert. `deleted` quando o **número conectado não está mais no grupo**: saiu, foi removido ou o grupo/comunidade foi desfeito (o WhatsApp não tem evento próprio de "grupo apagado" — tudo chega como `group-participants.update` `remove` com o próprio número), ou quando o `sync-all` não encontra mais o grupo. O grupo local vira `status: DELETED`, `ownRole: null`; volta a `ACTIVE` (com novo `created`) se o número reaparecer no grupo. Meta (não implementado): `created` de `group_create`, `deleted` de `group_delete` |
| `GROUP_SETTINGS_UPDATE` | `changed` (array) | subset de `["subject", "description", "picture", "announce", "restrict"]` | Evolution: deriva de quais chaves vieram no patch do Baileys (`announce`/`restrict` só Evolution). Meta (não implementado): deriva de quais sub-objetos (`group_subject`/`group_description`/`profile_picture`) vieram no evento |
| `GROUP_PARTICIPANTS_UPDATE` | `type` | `add` \| `remove` \| `promote` \| `demote` \| `join_request_created` \| `join_request_revoked` | Evolution só emite `add`/`remove`/`promote`/`demote` (Baileys). Meta (não implementado) só emite `add`/`remove`/`join_request_created`/`join_request_revoked` — **não tem** promote/demote |
| `GROUP_STATUS_UPDATE` | `type` | `suspended` \| `suspension_cleared` | Só Meta (não implementado ainda) — Evolution não tem equivalente |

`participants` (em todos os 3 eventos) é **sempre número de WhatsApp normalizado**
(`PhoneNumberNormalizerTrait::normalize`), nunca o JID cru/LID da Evolution — mesma normalização
que `from`/`to` já recebem no payload de mensagem. Isso foi corrigido nesta sessão: a primeira
versão desse handler deixava passar o JID cru (`5511999999999@s.whatsapp.net`) direto no campo, o
que quebraria a uniformidade assim que o lado Meta (que manda `wa_id` num formato diferente) fosse
implementado.

Os 3 eventos incluem, além do id do grupo, o estado atual do grupo local: `ownRole`
(`member`|`admin`|`superadmin`|`null`), `announce`, `restrict` (`null` enquanto desconhecido) — o
cliente atualiza permissões sem precisar consultar. Também `by` (número real de quem fez a ação —
Evolution pareia o campo LID com um irmão `...Pn`, ex.: `authorPn`/`ownerPn`).
`GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE` trazem `participants` (roster —
`[{phoneNumber, lid, admin}]`, `lid` só quando o número não é divulgado; sempre no `groups.upsert`,
best-effort no `groups.update`). `GROUP_PARTICIPANTS_UPDATE` traz `participants` (números reais)
+ `unresolvedParticipants` (LIDs) + `participantCount` atualizado + `byParticipant` (o autor é um
dos próprios participantes do evento — ex.: saída pelo celular —, comparado aqui por LID/número
pra o cliente nunca precisar de LID, inclusive quando o número do participante não é divulgado).
`by` vem resolvido mesmo quando a Evolution manda o autor só como LID: primeiro pelos participantes
do próprio evento, depois pelo mapa de LID (`resolveLid`). Extração em
`api/src/Core/EvolutionAPI/UseCase/Group/Helpers/` (`GroupRoster`, `GroupMetadataMapper`,
`WhatsappJid`).

**Regra pra quando implementar Meta/Gupshup**: o handler daquele parceiro traduz o `field`/evento
cru pro `WebhookEventsEnum` de mesmo nome e monta o `group` só com os valores da tabela acima —
nunca repassando os termos crus da API do parceiro num campo que o cliente vai tratar como
estável. `raw` existe exatamente pra isso: quem quiser o dado cru do parceiro usa `raw`, o resto
do payload é sempre o mesmo formato.

### Payload real da Meta por campo (referência pra quando for implementar)

Confirmado em [developers.facebook.com/.../whatsapp/groups/webhooks](https://developers.facebook.com/documentation/business-messaging/whatsapp/groups/webhooks/)
— todos chegam como `entry[].changes[].value.groups[0]`, com `field` no nível de `changes[]`
indicando qual dos 4 é:

- **`group_lifecycle_update`**: `type: group_create | group_delete`, mais `group_id`,
  `request_id`, `subject`, `invite_link`, `join_approval_mode`, `errors[]` (em falha).
- **`group_settings_update`**: `type: group_settings_update`, mais `profile_picture` /
  `group_subject` / `group_description` — cada um com `update_successful: bool` e `errors[]`
  próprios (uma chamada pode mudar vários campos de uma vez, cada um falhando independente).
- **`group_participants_update`**: `type: group_participants_add | group_participants_remove |
  group_join_request_created | group_join_request_revoked`, mais `added_participants[]` /
  `removed_participants[]` / `failed_participants[]` (cada item com `wa_id`/`input`),
  `initiated_by: business | participant`, `reason` (ex.: `invite_link`), `join_request_id`.
  **Não expõe promote/demote de admin** — diferente do `group-participants.update` da Evolution
  (Baileys), que tem essas ações. Só cobre entrada/saída/pedido de entrada.
- **`group_status_update`**: só `type: group_suspend | group_suspend_cleared` + `group_id` —
  grupo suspenso por violação de política ou suspensão removida. Sem equivalente na Evolution
  API (é moderação de conteúdo específica da Meta) — nenhum handler vai produzir esse evento
  fora do lado Meta.

Todos vêm dentro de `value.metadata.phone_number_id`/`display_phone_number` também — dá pra
resolver o `Phone`/`phoneNumber` local da mesma forma que o resto do módulo faz (ver
[Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno)).

### Pipeline (Evolution API)

```
Evolution → POST /webhook/evolution-api-partner
  → EvolutionAPIWebhookController → ProcessWebhookUseCase (dispatch por `event`)
    → HandleGroupUpsertUseCase | HandleGroupUpdateUseCase | HandleGroupParticipantsUpdateUseCase
      → EvolutionGroupDirectory::findOrCreate — mapeia o JID pro Group local **da conta que
        recebeu** (findByGroupId filtra por whatsapp_account_id), com retry curto (groups.upsert do
        próprio create do OmniMeta pode chegar antes do registro local salvar, confirmado ao vivo;
        CreateGroupUseCase reaproveita o registro se isso acontecer) e auto-provisiona o grupo se
        nunca foi criado/sincronizado via OmniMeta — dono = o Phone do `sender` do webhook (não o
        primeiro phone da conta), metadata via `GET /group/findGroupInfos` (só leitura) quando não
        vem no evento ou o subject ainda é o JID — e anuncia com GROUP_LIFECYCLE_UPDATE `created`.
        Aplica a metadata do evento no registro local (subject, description, announce, restrict,
        own_role, participant_count, owner, picture_url) e alimenta o cache LID → número. Não é à
        prova de corrida entre dois webhooks concorrentes pro mesmo grupo nunca visto antes
        (poderiam criar 2 registros) — aceito como caso raro, sem lock distribuído pra isso
      → GroupWebhookTriggerUseCase::execute(...)
        → busca WebhookTrigger por whatsapp_account_id + signed_events
        → POST no endpoint do cliente, loga em LogRepository (mesmo fluxo de log das mensagens)
```

`ProcessWebhookUseCase` responde 200 (`request_id: ""`) pra esses 3 eventos — antes de existir
esse tratamento, a Evolution recebia 400 de volta (evento não reconhecido), sem nenhum log.

**Atualização (2026-09-30)**: na validação com o ElevOne, um `group-participants.update` **chegou** para um add feito pela própria API (`by: null`), o que contraria o achado abaixo. Por isso o `participantCount` no evento passou a vir do roster ao vivo (`GET /group/participants`, com soma do delta só como fallback) — não dobra, venha o evento ou não. Trate os 2 eventos como **possíveis** também para mudanças feitas pelo OmniMeta.

**Achado original (2026-09-02)**: `GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE` **não
disparam** quando a mudança é feita pela própria API da Evolution (`updateGroupSubject`,
`updateGroupDescription`, `updateParticipant` add/remove) — testado 2x cada, confirmando a mudança
de verdade via `GET /group/findGroupInfos` mas sem nenhum webhook chegando. `GROUP_LIFECYCLE_UPDATE`
é diferente — dispara normalmente em `Create Group`. Ou seja, hoje esses 2 eventos só chegam pra
mudanças feitas **fora** do OmniMeta (direto no WhatsApp) — mudanças feitas pela própria API do
OmniMeta não geram notificação (o cliente já sabe pela resposta síncrona da chamada, então não é
necessariamente um problema, só uma característica a documentar).

### Formato entregue ao cliente final

```json
{
  "id": "<uuid da entrega>",
  "event": "GROUP_PARTICIPANTS_UPDATE",
  "subscriptionId": "<id do WebhookTrigger>",
  "timestamp": 1773100000,
  "clientId": "<client_id do OmniMeta>",
  "whatsappAccountId": "<whatsapp_account_id do OmniMeta>",
  "group": {
    "id": "<id local do Group no OmniMeta, null se ainda não sincronizado>",
    "groupId": "120363xxxxxxxxxx@g.us",
    "phoneId": "<id local do Phone dono do grupo, null se o grupo ainda não é conhecido localmente>",
    "phoneNumber": "5511985544884",
    "ownRole": "admin",
    "announce": false,
    "restrict": true,
    "type": "add",
    "by": "5511985544884",
    "byParticipant": false,
    "participants": ["5511999999999"],
    "unresolvedParticipants": ["299999999999999@lid"],
    "participantCount": 5,
    "raw": { "...": "payload original da Evolution, sem alteração — pode conter LIDs, não usar pra identificar pessoas" }
  }
}
```

`phoneNumber` é o número real de WhatsApp dono do grupo — resolvido a partir de `Group.phoneId`
(ver [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno)),
já que uma `WhatsappAccount` pode ter vários números e o cliente final precisa saber a qual deles
o evento pertence. Fica `null` se a Evolution notificar um grupo que o OmniMeta ainda não conhece
localmente (nunca criado/sincronizado via API) — não há como saber o número dono nesse caso.

`raw` vem sempre presente nos três eventos — o mapeamento pro contrato canônico acima (`type`,
`changed`, `subject`, `description`, `participantCount`) é best-effort a partir do formato
conhecido do Baileys (a biblioteca por trás da Evolution API), porque a doc pública da Evolution
(`docs.evolutionfoundation.com.br`) descreve quando cada evento dispara mas não publica o shape
exato do payload — só confirmável recebendo de verdade. Por isso o `raw` nunca é omitido: o
cliente final não fica bloqueado esperando o mapeamento ficar 100% exato, mas o contrato canônico
(`type`/`changed`/`participants` normalizado) é o que deve ser usado pra lógica de negócio — `raw`
é só um extra de depuração/fallback, nunca deveria ser parseado como parte do contrato estável.

**Cache local (Evolution)**: os 3 eventos atualizam o registro local (subject, description,
announce, restrict, own_role, participant_count). Como a Evolution **não** emite
`groups.update`/`group-participants.update` pra mudanças feitas pela própria API, os use cases de
update/add/remove ajustam o registro local eles mesmos. `POST /groups/{id}/sync` (e `sync-all`)
continuam disponíveis pra forçar a leitura ao vivo.

### Menções (@) em mensagem de grupo (Evolution API)

**Recebimento (`NEW_MESSAGE`).** O WhatsApp escreve a menção no texto como `@<id>`, onde `<id>` é a
parte do usuário do JID mencionado — num grupo endereçado por LID, os dígitos do LID
(`contextInfo.mentionedJid: ["2183…@lid"]`, texto `"@2183…"`). O OmniMeta nunca entrega LID ao
cliente, então `GroupMentionResolver` (`HandleMessageUpsertUseCase`) faz, para texto e legenda de mídia:

- Cada `mentionedJid` com telefone (JID de telefone, ou LID resolvido por
  `EvolutionGroupDirectory::resolveLid`) vira `@<telefone>` no texto e entra em `mentions`, com o
  nome do `Contact` quando conhecido (`name` null caso contrário). Mesma pessoa citada duas vezes
  entra uma vez.
- LID sem telefone conhecido fica como foi digitado no texto e **fora** de `mentions`.
- "Mencionar todos" chega como o texto `@all` com `contextInfo.nonJidMentions` ≥ 1 (sem
  `mentionedJid`) — vira `mentionsEveryone: true`. Só o texto `@all` digitado, sem o marcador,
  não é menção a todos.

Gravado em `Message.content` (`mentions`, `mentions_everyone`) e entregue no item de `contents`:

```json
{ "type": "text", "text": "Oi @5511977776666 e @all",
  "mentions": [{ "phone": "5511977776666", "name": "Ana" }], "mentionsEveryone": true }
```

Sem menção, os dois campos não aparecem (payload igual a antes).

**Envio (`POST channels/whatsapp/messages` com `groupId`).** O item de texto aceita
`mentions` (telefones, só dígitos) e `mentionsEveryone` (boolean). O texto deve trazer
`@<telefone>` para cada menção (e `@all` para todos, se quiser que apareça). Para grupo,
`ProcessTextUseCase` repassa à Evolution como `mentioned`.

**"Todos" no envio vira uma menção por participante** (`GroupEveryoneMentionExpander`): o `@all` nativo
do WhatsApp (`contextInfo.nonJidMentions`) não é exposto pela Evolution, e o `mentionsEveryOne` dela
(todos na lista de mencionados, sem o `@` de cada um no texto) não notifica ninguém em grupo LID
(testado; ver também evolution-api#1807). Com `mentionsEveryone: true`, o OmniMeta lê os participantes
ao vivo (`EvolutionGroupDirectory::participantPhones`), troca o `@all` do texto por `@<telefone>` de cada
um (sem o próprio número; sem `@all` no texto, as menções vão ao fim) e manda todos em `mentioned`. No
celular aparece a lista de nomes, cada um notificado. Participante só conhecido por LID fica de fora. Sem
conseguir ler os participantes, segue a menção oculta da Evolution (`mentionsEveryOne`). A mensagem
gravada fica com o texto expandido, `mentions` de todos e `mentions_everyone: true`.
Em 1:1 os campos são ignorados (nem vão à Evolution nem são gravados). Validação:
`SendWhatsappMessageRequest` e `TextValidation` aceitam só dígitos (8 a 15) — recusam um JID
(`…@lid`, `…@s.whatsapp.net`), mas não distinguem os dígitos de um LID de um telefone. Cabe ao
cliente mencionar só telefones de participantes (o ElevOne valida contra a lista de participantes do
grupo antes de enviar).

### Exemplos para validar (Bruno)

`request-collections/OmniMeta/Bruno/Webhook/Evolution partner - Group *.bru` simulam a Evolution
chamando `/webhook/evolution-api-partner` com os 3 eventos; `request-collections/OmniMeta/Bruno/Webhook Trigger Events/Example - Group *.bru`
mostram o formato que chega no webhook do cliente final depois de processado.

## Estado atual (evidência no código)

| Tema | Situação | Arquivo / evidência |
| :--- | :------- | :------------------ |
| Entity / enums | `Group`, `GroupStatusEnum`, `GroupJoinApprovalModeEnum` | `api/src/Core/Domain/Entity/Group.php`, `api/src/Core/Domain/Enum/Meta/Group/` |
| Persistência | Collection `groups` (Mongo) | `api/app/Models/Group.php`, `api/database/migrations/2026_08_27_160000_create_groups_table.php` |
| Cliente Graph API (Meta) | Create/get/list/update/remove-participants/invite-link (get+reset)/join-requests (list/approve/reject)/delete | `api/app/Services/Meta/WhatsAppGroupsApiService.php` |
| Cliente Evolution API | `createGroup`/`updateGroupSubject`/`updateGroupDescription`/`updateGroupPicture`/`leaveGroup`/`updateParticipant`/`findGroupInfo`/`findGroupParticipants`/`fetchAllGroups` no client HTTP já usado pra mensagem/instância; `EvolutionAPIApiServiceFactory` pra injeção/teste | `api/app/Services/EvolutionAPI/EvolutionAPIApiService.php` |
| Implementação Meta (CRUD) | 10 use cases, mesmo estilo do Flow — resolvem `WhatsappAccount`/`Phone`, chamam o client HTTP, `throw GroupException` em erro | `api/src/Core/UseCase/Group/*` |
| Implementação Evolution API | Use cases create/update (inclui foto)/delete/add/remove/sync/sync-all/list-participants — resolvem `WhatsappAccount` (instância = `name`, token = `accessToken`). `EvolutionGroupDirectory` centraliza grupo local (find-or-create por conta, metadata, own_role), cache LID → número e anúncio de grupo descoberto. Erros via `EvolutionGroupErrors` (`GROUP_NOT_ADMIN` / `GROUP_PARTNER_ERROR`) | `api/src/Core/EvolutionAPI/UseCase/Group/*` |
| Citação/reação (Evolution) | `EvolutionAPISendWhatsappMessageUseCase::buildQuoted()` monta `{key, message}` completo a partir da mensagem local; `ProcessReactionUseCase` (Evolution) monta `{key, reaction}` — shapes específicos, diferentes da Meta | `api/src/Core/EvolutionAPI/UseCase/Message/EvolutionAPISendWhatsappMessageUseCase.php`, `api/src/Core/EvolutionAPI/UseCase/Message/ProcessReactionUseCase.php` |
| `Message.participantJid` | Novo campo — JID cru do remetente real numa mensagem de grupo (inbound) ou da própria conta (outbound); usado pra montar citação/reação corretamente | `api/src/Core/Domain/Entity/Message.php`, `api/src/Core/EvolutionAPI/UseCase/Webhook/Helpers/PhoneExtractor.php` |
| Orquestrador multi-parceiro | Delega pra Meta ou Evolution API conforme o partner; operação/partner não suportado sai via `GroupPartnerGuard` (`GROUPS_PARTNER_NOT_SUPPORTED` pra Gupshup, `GROUPS_OPERATION_NOT_SUPPORTED` pra operação que o partner não tem) sem chamar API nenhuma | `api/src/Core/OmniMeta/UseCase/Group/*` |
| Middlewares | `GroupMiddleware` (resolve via `phoneNumber` do body, só no create — Group é por telefone, não por WABA; mesma convenção do `PhoneMiddleware` de mensagem), `GroupResourceMiddleware` (resolve via `{id}` do grupo já existente) | `api/app/Http/Middleware/Group/` |
| `Group.phoneId` / `phone_id` | Novo campo persistido — qual `Phone` local é dono do grupo (uma `WhatsappAccount` pode ter vários números). Preenchido no create (Meta e Evolution), exposto em `GroupResource`. `Group.phoneNumber` é transiente (nunca persistido) — resolvido pelo UseCase (Show/Create/Update/Sync) a partir do `phoneId`, nunca pelo Resource ou pelo Controller | `api/src/Core/Domain/Entity/Group.php`, `api/src/Core/UseCase/Group/ShowGroupUseCase.php`, `CreateGroupUseCase.php`, `UpdateGroupUseCase.php`, `SyncGroupUseCase.php`, `api/app/Http/Resources/GroupResource.php` |
| `List Groups` por número | Filtro `phoneNumber` (além de `whatsappAccountId`) — resolvido pra `phoneId` dentro de `ListGroupsUseCase`, nunca no controller | `api/src/Core/UseCase/Group/ListGroupsUseCase.php`, `api/app/Http/Controllers/GroupController.php` |
| API REST | 16 rotas (`GroupController`); erros sempre `{message, code}` — ver [Erros](#erros) | `api/app/Http/Controllers/GroupController.php`, `api/routes/api.php` |
| Envio pro grupo (compatível) | `groupId` opcional em `SendWhatsappMessageRequest`; quando presente, resolve pro `group_id` real e seta `recipient_type: group` — payload de 1:1 fica byte-idêntico a antes quando `groupId` não é enviado | `api/app/Http/Requests/SendWhatsappMessageRequest.php`, `api/app/Http/Controllers/OmniBusinessController.php` |
| `recipient_type` no payload outbound | Adicionado só quando `group` — Meta e Gupshup (envio de mensagem, não gestão de grupo) | `api/src/Core/UseCase/Message/SendWhatsappMessageUseCase.php`, `api/src/Core/Gupshup/UseCase/GupshupSendWhatsappMessageUseCase.php` |
| Recebimento de mensagem de grupo | `Message.groupId` capturado do `group_id` do webhook; sender continua resolvido normalmente via `contacts[]` (nunca tratado como Contact) | `api/src/Core/Domain/Entity/Message.php`, `api/src/Core/Application/Webhook/Messages/MessageProcessing.php` |
| Webhook outbound (trigger) | Campo `groupId` aditivo no payload pro cliente (null pra 1:1) | `api/src/Core/UseCase/Webhook/MessageWebhookTriggerUseCase.php` |
| Webhook fields de grupo (Meta) | 4 campos (`group_lifecycle_update`, `group_participants_update`, `group_settings_update`, `group_status_update`) reconhecidos e logados — **ainda não encaminhados** pro cliente final, ver linha abaixo | `api/src/Core/Domain/Enum/Meta/Webhook/WebhookFieldsEnum.php`, `api/src/Core/Application/Webhook/Groups/GroupWebhookProcessing.php` |
| Webhook de grupo → cliente final (Evolution API) | `groups.upsert`/`groups.update`/`group-participants.update` traduzidos pro canônico `GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE` e encaminhados via `WebhookTrigger` (mesmo mecanismo de `NEW_MESSAGE`/`BILLING_UPDATE`) — ver [Webhooks de grupo](#webhooks-de-grupo--encaminhamento-pro-cliente-final) | `api/src/Core/Domain/Enum/Meta/Webhook/WebhookEventsEnum.php`, `api/src/Core/EvolutionAPI/UseCase/Webhook/HandleGroupUpsertUseCase.php`, `HandleGroupUpdateUseCase.php`, `HandleGroupParticipantsUpdateUseCase.php`, `api/src/Core/UseCase/Group/GroupWebhookTriggerUseCase.php` |
| Front-end | **Não implementado** — decisão de produto: Grupos é só API, sem tela no OmniMeta | — |

## Arquitetura

```mermaid
flowchart TD
  subgraph Gestao["Gestão (CRUD)"]
    Cliente1[Cliente OmniMeta] -->|POST/PUT/.../groups| GroupCtrl[GroupController]
    GroupCtrl --> Orq[Core.OmniMeta.UseCase.Group.X]
    Orq -->|partner Meta| MetaUC[Core.UseCase.Group.X]
    Orq -->|partner Evolution API - create/update/delete| EvoUC[Core.EvolutionAPI.UseCase.Group.X]
    Orq -->|Gupshup| Err[throw GroupException]
    MetaUC --> GraphGroups[Graph API groups]
    EvoUC --> EvoGroups[Evolution API group]
    MetaUC --> MongoGroups[(groups)]
    EvoUC --> MongoGroups
  end

  subgraph Envio
    Cliente2[Cliente OmniMeta] -->|POST channels/whatsapp/messages groupId=local| SendMsg[SendWhatsappMessageUseCase]
    SendMsg -->|resolve groupId to group_id real| CloudApi[Graph API messages recipient_type=group]
  end

  subgraph Inbound
    Meta[Meta] -->|webhook messages com group_id| WebhookCtrl[WebhookController]
    WebhookCtrl --> MsgProc[MessageProcessing captura group_id]
    MsgProc --> MongoMsg[(messages)]
    MsgProc --> Trigger[MessageWebhookTriggerUseCase]
    Trigger --> ClienteWH[Webhook outbound do cliente com groupId]
  end

  subgraph WebhooksGrupo["Webhooks de grupo (log, não sincroniza ainda)"]
    Meta -->|group_lifecycle_update etc| GroupWH[GroupWebhookProcessing]
  end
```

### Diferença de Flow/Template: Group é por telefone, não por WABA

`Create Group` pede `phoneNumber` (o número real, não `whatsappAccountId` nem um id interno) porque a Meta cria o grupo em `POST /{phone-number-id}/groups` — resolvido via `PhoneRepositoryInterface::findByPhoneNumberAndClientId`, mesma convenção do `PhoneMiddleware` de mensagem (ver [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno)). O `phoneId` interno resolvido é persistido em `Group.phone_id` pra uso posterior (List Groups filtra por ele, o webhook de grupo devolve o número real a partir dele). As demais operações (update/delete/sync/participants/invite-link/join-requests) já resolvem tudo a partir do `{id}` do Group salvo localmente — mesma mecânica do `FlowResourceMiddleware`.

## Contratos

### Collection `groups` (Mongo)

```json
{
  "id": "uuid",
  "whatsapp_account_id": "uuid",
  "group_id": "120363404694233820",
  "subject": "Suporte OmniMeta",
  "description": "Grupo de suporte tecnico",
  "status": "ACTIVE",
  "join_approval_mode": "auto_approve",
  "invite_link": "https://chat.whatsapp.com/XXXXXXXXXXXXXXXXXXXXXX",
  "participant_count": 3,
  "own_role": "superadmin",
  "announce": false,
  "restrict": false,
  "picture_url": "https://pps.whatsapp.net/...",
  "owner": "5511985544884",
  "partner_created_at": 1773093000,
  "created_at": "...",
  "updated_at": "..."
}
```

`own_role`/`announce`/`restrict`/`picture_url`/`owner`/`partner_created_at` (2026-09-28): `own_role` preenchido nos dois partners (grupo criado pela API = `superadmin`); os demais só Evolution, `null` na Meta. No `GroupResource` saem como `own_role`, `announce`, `restrict`, `picture_url`, `owner`, `created_at_partner`. `picture_url` é descartado após `PUT` com `image` (a Evolution não devolve a URL nova — vem no próximo sync).

`group_id` é o id **do parceiro** — numérico puro na Meta, JID completo (`120363xxxxxxxxxx@g.us`) na Evolution API; `id` é o id **local do OmniMeta**, usado em toda a API do OmniMeta (inclusive no envio de mensagem). `join_approval_mode`/`invite_link` ficam `null` em grupos criados via Evolution API (conceitos que só existem na Meta).

### API REST (`/api/v1/groups*`)

| Método / rota | Uso | Partners |
| :------------ | :-- | :------- |
| `GET /groups` | Lista paginada — `{data: Group[], meta: {total, current_page, per_page, last_page}}`, `page`/`size` (padrão 50, máx 200), filtros `whatsappAccountId`, `phoneNumber`, `status` | Todos (local) |
| `GET /groups/{id}` | Detalhe local | Todos (local) |
| `POST /groups` | Cria de verdade no parceiro (`phoneNumber` obrigatório; `participants` obrigatório só na Evolution API) | Meta, Evolution API |
| `PUT /groups/{id}` | Edita subject/description/**image**/**announce**/**restrict** (`image`, `announce`, `restrict` — Evolution apenas; Meta ignora). `announce`/`restrict` nulos ou ausentes = sem mudança; exigem admin | Meta, Evolution API |
| `POST /groups/sync-all` | Body `{phoneNumber}`. Importa todos os grupos do número (`fetchAllGroups?getParticipants=true`), upsert local, anuncia novos/reativados com `GROUP_LIFECYCLE_UPDATE` `created`. Grupos locais `ACTIVE` desse número que não vierem na lista viram `DELETED` e saem como `deleted` — exceto se a Evolution devolver lista vazia (nunca confiada pra isso, pra instância desconectada não apagar tudo). Resposta `{data: Group[], meta: {total, created, updated, deleted}}` | **Evolution API apenas** |
| `POST /groups/{id}/sync` | Puxa ao vivo — Meta: subject/description/join_approval_mode/total_participant_count; Evolution: `findGroupInfos` (subject, description, size, picture, owner, announce, restrict, own_role) | Meta, Evolution API |
| `GET /groups/{id}/picture` | Devolve os **bytes** da foto do grupo (`Content-Type: image/*`, `Cache-Control: private, max-age=300`), buscados na hora no CDN do WhatsApp — o OmniMeta **não guarda a imagem**, só a URL (`picture_url`). URL do WhatsApp é assinada e expira: se falhar, pede uma nova à Evolution (`POST /chat/fetchProfilePictureUrl` com o JID do grupo) e atualiza `picture_url`. Só busca `https` em `*.whatsapp.net`, só aceita `image/*`, máx. 5 MB. Sem foto: 404 `GROUP_PICTURE_NOT_FOUND` | **Evolution API apenas** |
| `GET /groups/{id}/participants` | Lista ao vivo, paginada aqui (`page`, `size` até 500): `{data: [{phone_number, lid, role, name}], meta}` — `phone_number` nunca é LID; `lid` só quando o número não é divulgado. Atualiza `own_role`/`participant_count` de quebra | **Evolution API apenas** |
| `POST /groups/{id}/participants` | Adiciona participantes por telefone. Resposta `{success, failed_participants: [{phoneNumber, lid, status}]}` | **Evolution API apenas** (Meta não tem essa API) |
| `DELETE /groups/{id}/participants` | Remove participantes (array de telefones/wa_id). Mesma resposta | Meta e Evolution API |
| `PUT /groups/{id}/participants/role` | Body `{participants: [números], role: admin\|member}` — promove ou rebaixa. Resposta `{success, failed_participants}`. Exige admin | **Evolution API apenas** |
| `GET /groups/{id}/invite-link` | Retorna o link de convite atual `{invite_link}` (salvo também no grupo). Na Evolution exige admin | Meta, Evolution API |
| `POST /groups/{id}/invite-link/reset` | Gera um link novo, invalida o anterior `{invite_link}`. Na Evolution exige admin | Meta, Evolution API |
| `GET /groups/{id}/join-requests` | Lista pedidos de entrada pendentes (`approval_required`) | Meta apenas |
| `POST /groups/{id}/join-requests/approve` | Aprova pedidos | Meta apenas |
| `POST /groups/{id}/join-requests/reject` | Rejeita pedidos | Meta apenas |
| `DELETE /groups/{id}` | Meta: deleta (irreversível pra todo mundo). Evolution API: **sai do grupo** (`leaveGroup` — só remove a presença do número conectado, o grupo continua existindo pros outros). Nos dois casos, local vira `status: DELETED`, nunca some do banco | Meta, Evolution API |

#### Erros

Toda resposta de erro das rotas de grupo (e do envio de mensagem com `groupId`) é `{message, code}` — o cliente deve decidir pelo `code`, nunca pelo `message`:

| HTTP | `code` | Quando |
| :--- | :----- | :----- |
| 422 | `GROUPS_PARTNER_NOT_SUPPORTED` | Partner sem nenhum suporte a grupo (Gupshup) — inclusive envio de mensagem com `groupId` |
| 422 | `GROUPS_OPERATION_NOT_SUPPORTED` | Partner tem grupos mas não essa operação (ex.: join-requests na Evolution, participants/sync-all/papel de participante na Meta, add participante na Meta) |
| 422 | `GROUP_NOT_ACTIVE` | Envio pra grupo com `status` ≠ `ACTIVE` |
| 422 | `GROUP_PARTICIPANTS_REQUIRED` | Create na Evolution sem `participants` |
| 422 | `VALIDATION_ERROR` | `phoneNumber` ausente (create/sync-all) |
| 422 | `INVALID_ARGUMENT` | Partner desconhecido |
| 403 | `GROUP_NOT_ADMIN` | O WhatsApp recusou por falta de permissão (número conectado não é admin) |
| 403 | `GROUP_ACCESS_DENIED` | Middleware não conseguiu resolver phone/conta/partner (a resposta também mantém a chave antiga `error`, mesmo texto, por compatibilidade) |
| 404 | `GROUP_PICTURE_NOT_FOUND` | `GET /groups/{id}/picture` de grupo sem foto (ou foto invisível pro número conectado) |
| 404 | `NOT_FOUND` | Grupo (ou phone do filtro) não encontrado — antes o middleware respondia 403 pra grupo inexistente |
| 502 | `GROUP_PARTNER_ERROR` | O parceiro (Graph API / Evolution) rejeitou a chamada ou está inacessível |
| 500 | `INTERNAL_ERROR` | Qualquer outra falha |

### Envio (`POST /api/v1/channels/whatsapp/messages`) — compatível com o endpoint existente

```json
{
  "from": "5511999999999",
  "groupId": "72ba8a0c-0f11-4c89-af82-24df0bd95e4b",
  "contents": [{ "type": "text", "text": "Olá grupo!" }]
}
```

Resposta síncrona: `{id}` (id interno da mensagem — sem mudança). O `wamId` da própria mensagem só chega depois, no `UPDATE_MESSAGE_STATUS` — em grupo pela Evolution não há status além de `sent`, então hoje não há como obter o `wamId` da própria mensagem enviada a um grupo (decisão de 2026-09-28: não alterar o retorno do envio agora). `idRef` = **WAMID da mensagem citada** (a descrição antiga de "idempotência/correlação" estava errada). No envio assíncrono (`OUTBOUND_MESSAGE_PARTNER_ENABLED=true`, resposta 202 `{id: requestId}`), `groupId` e `recipientType` agora vão no payload enfileirado — antes se perdiam e o consumidor mandava pro JID do grupo como se fosse uma pessoa (criando um Contact com o JID). Gupshup com `groupId` → 422 `GROUPS_PARTNER_NOT_SUPPORTED`.

`groupId` é **opcional e aditivo** — quem já manda `to`/`userId` (1:1) não muda nada. Quando `groupId` vem, ele tem prioridade, o backend resolve pro `group_id` real da Meta e injeta `recipient_type: "group"` no payload enviado — campo que só aparece quando o destino é um grupo, então o payload de mensagens 1:1 continua **byte-idêntico** ao de antes dessa feature existir. Todo tipo de `contents[]` que já funciona pra 1:1 (text, template, flow, media, etc.) funciona igual pra grupo — só muda o destinatário.

### Webhook inbound — mensagem de grupo

O payload de mensagem da Meta ganha um campo `group_id` no nível da mensagem quando ela vem de um grupo (`from` continua sendo o participante que enviou, resolvido normalmente). O OmniMeta captura isso em `Message.groupId` e repassa como `groupId` no webhook outbound do cliente (`null` pra mensagens 1:1 — campo aditivo, não quebra consumidores existentes que ignoram campos desconhecidos).

`NEW_MESSAGE` de grupo (contrato):

- `message.groupId` = id **local** do OmniMeta; `message.to` = número da conta.
- `message.from` = número real do participante (IN) ou o número da conta (OUT — enviada pela API ou digitada no celular). **Nunca LID.** `null` só quando o remetente é LID não resolvível (o LID não é enviado ao cliente; decisão de 2026-09-29 — o campo `fromLid` inicialmente previsto foi removido).
- `message.visitor.userId` = Contact do participante, `null` quando não há Contact (LID não resolvível, OUT). `visitor.name` = pushName.
- `context{wamId, id}` pra citação; reação = `type: reaction` + `contents[0].emoji`.

`UPDATE_MESSAGE_STATUS` ganhou `message.wamId` e `message.groupId` (`null` em 1:1). Pra grupo na Evolution o status fica em `sent` — ver Limites.

## Limites e dependências

- **Meta e Evolution API têm Group implementado.** Gupshup não publica nenhuma API de grupo (índice completo da doc deles verificado — zero menção) — segue recusando via orquestrador.
- **Evolution API — create/update (subject/description/foto/announce/restrict)/delete/sync/sync-all/participantes (listar/add/remove/papel)/link de convite implementados**. Join-requests respondem `GROUPS_OPERATION_NOT_SUPPORTED`.
- **Evolution API — `participants` é obrigatório no create** (mínimo 1 número real, confirmado no schema de validação oficial do projeto) — Evolution não permite criar grupo vazio como a Meta permite.
- **Evolution API — "delete" é na verdade "sair do grupo"** (`leaveGroup`) — a API não expõe destruir o grupo pra todo mundo, só a saída do número conectado. O grupo continua existindo pros outros membros.
- **Elegibilidade da Meta é um bloqueio real observado em teste**: testei `Create Group` em 4 números Meta diferentes desta base local (incluindo o `551131327075`) — todos retornaram `(#131215) This phone number is not eligible to access Groups APIs`. A implementação está correta (erro estruturado e específico da Graph API, não um crash), mas nenhuma conta de teste disponível localmente tem a Groups API liberada pela Meta ainda. Validação end-to-end completa (criar → convite → entrar → mandar mensagem) na Meta fica pendente até alguma conta ter elegibilidade confirmada — **já validado de ponta a ponta na Evolution API**, com create/update/delete reais.
- **Não existe "adicionar participante por telefone" na Meta** — sempre via link de convite (ação humana) ou template aprovado com esse link.
- Webhooks de grupo (Meta) são recebidos e reconhecidos (não quebram mais o processamento), mas só logam — **não encaminham pro cliente final nem sincronizam o cache local ainda**; use `Sync Group` manualmente após mudanças de participantes feitas fora do OmniMeta.
- **Webhooks de grupo (Evolution API) já são encaminhados pro cliente final** (`GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE`) — **validado ao vivo com grupo/participante real**. `GROUP_LIFECYCLE_UPDATE` confirmado disparando de verdade no `Create Group`. Mudanças feitas pela própria API da Evolution: em 2026-09-02 não geraram `GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE`; em 2026-09-30 um add pela API gerou `group-participants.update` (`by: null`). Tratar como possível nos dois sentidos — ver "Atualização (2026-09-30)" no pipeline.
- **Status de entrega/leitura (`delivered`/`read`) não progride pra mensagem de grupo via Evolution — confirmado no código-fonte (2026-09-28)**: pra grupo, o Baileys emite recibo por participante como `message-receipt.update` (não `messages.update`), e a Evolution 2.3.7 só usa esse evento pra marcar lido no banco dela, sem mandar webhook. O status de grupo fica em `sent`; só mudaria alterando a própria Evolution. O `UPDATE_MESSAGE_STATUS` de grupo (quando houver) agora chega ao cliente — antes quebrava em `Contact Not Found`.
- **Sem front-end** — decisão de produto explícita: Grupos é um recurso só de API, sem tela de gestão no OmniMeta.

## Critérios de aceite

- [x] CRUD completo (create/list/show/sync/update/delete) via API OmniMeta — Meta
- [x] Create/update/delete via API OmniMeta — Evolution API, testado ao vivo de ponta a ponta
- [x] Remover participantes, gerar/resetar link de convite — Meta
- [x] Join requests (list/approve/reject) — Meta
- [x] Enviar mensagem pro grupo pelo endpoint existente, sem quebrar o fluxo 1:1 (`groupId` aditivo, payload 1:1 inalterado quando ausente)
- [x] Receber mensagem de grupo pelo webhook normal, com `groupId` decodificado
- [x] CRUD de Group atrás do mesmo padrão de orquestrador multi-parceiro (Meta + Evolution API implementados; Gupshup recusa com erro claro, sem chamar API nenhuma)
- [x] Webhooks de grupo reconhecidos sem quebrar o processamento (`Unsupported field` resolvido)
- [x] Foto do grupo — Evolution API (`updateGroupPicture`, URL ou base64)
- [x] Citação de mensagem no grupo — Evolution API, validado nos dois sentidos com participante real
- [x] Reação no grupo — Evolution API, validado nos dois sentidos com participante real
- [x] Mídia (imagem/vídeo) no grupo — Evolution API, validado nos dois sentidos com participante real
- [x] Suíte de testes sem regressão em nenhuma etapa
- [x] Validado ao vivo sem tocar em nenhum grupo pré-existente (só grupo criado durante o próprio teste, encerrado via `DELETE /groups/{id}` ao final)
- [x] Regressão confirmada: envio 1:1 pela Meta oficial continua funcionando normalmente após toda a implementação de Grupos
- [ ] Front-end — decisão de produto: não será implementado
- [ ] Validação end-to-end completa na Meta (bloqueado por elegibilidade da conta de teste disponível)
- [x] Listar participantes, sync e sync-all na Evolution API (2026-09-28)
- [x] Remetente de grupo pelo número real, nunca LID; contato legado com LID migrado (2026-09-28)
- [x] `own_role`/`announce`/`restrict` persistidos, expostos no `GroupResource` e nos eventos (2026-09-28)
- [x] Erros de grupo com `{message, code}` estável, todos os partners (2026-09-28)
- [x] Sincronização do cache local a partir dos webhooks de grupo — Evolution (2026-09-28)
- [ ] Invite-link/join-requests na Evolution API
- [ ] Sincronização do cache local a partir dos webhooks de grupo — Meta
- [ ] Validar ao vivo com grupo em modo `lid` real: `participantAlt` preenchido, detecção de `GROUP_NOT_ADMIN`, shape de `group-participants.update` com objetos
- [ ] Group em Gupshup
- [ ] Webhooks de grupo da **Evolution API** encaminhados pro cliente final (`GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE`) — implementado, aguardando validação com evento real (ver [Webhooks de grupo](#webhooks-de-grupo--encaminhamento-pro-cliente-final))
- [ ] Webhooks de grupo da **Meta** encaminhados pro cliente final — não implementado ainda, `GroupWebhookProcessing` continua só logando

## Referências

- Meta — Groups API: `https://developers.facebook.com/documentation/business-messaging/whatsapp/groups`, guia de início: `.../groups/get-started`, referência de endpoints: `.../groups/reference`, **webhooks (payload completo dos 4 campos)**: `.../groups/webhooks/`, mensagens de grupo: `.../groups/groups-messaging`, códigos de erro: `.../groups/error-codes`
- Evolution API — código-fonte oficial (Group Controller/DTOs/schema de validação): `github.com/EvolutionAPI/evolution-api` (`src/api/routes/group.router.ts`, `src/api/dto/group.dto.ts`, `src/validate/validate.schema.ts`); doc: `docs.evolutionfoundation.com.br/evolution-api/create-group`
- [ARCHITECTURE.md — Padrão orquestrador multi-parceiro](../architecture/ARCHITECTURE.md#padrão-orquestrador-multi-parceiro-coreomnimetausecase)
- [WHATSAPP_FLOWS.md](./WHATSAPP_FLOWS.md) — mesmo padrão de orquestrador, aplicado antes pro Flow

## Histórico

| Data | Nota |
| :--- | :--- |
| 2026-08-27 | Implementado (Meta): entity/repositório/migration, `WhatsAppGroupsApiService`, 10 use cases Meta, orquestrador multi-parceiro (`Core\OmniMeta\UseCase\Group\*`), 12 endpoints REST, envio/recebimento de mensagem de grupo compatível com o endpoint existente, 4 webhook fields de grupo reconhecidos, front-end completo. Testado ao vivo: CRUD local, rejeição limpa da Gupshup, envio/recebimento de mensagem 1:1 sem regressão. Create Group bloqueado por elegibilidade da Meta em todos os 4 números de teste disponíveis — `(#131215)` |
| 2026-08-27 | Implementado (Evolution API): create/update/delete via `Core\EvolutionAPI\UseCase\Group\*` + 4 métodos novos em `EvolutionAPIApiService`, plugado no mesmo orquestrador. Testado ao vivo de ponta a ponta (create com participante real → update de subject/description → leave) na instância pessoal conectada do usuário, sem tocar em nenhum grupo pré-existente — só o grupo criado durante o próprio teste |
| 2026-08-27 | Segunda rodada de teste ao vivo, com participante real (Junior) respondendo no grupo. Encontrados e corrigidos 4 bugs: (1) `groupsIgnore` da instância Evolution voltou a `true` sozinho após restart do container, descartando silenciosamente todo evento inbound de grupo; (2) mensagens outbound pro grupo criavam um `Contact` com o JID do grupo como `wa_id` (corrompendo dados) — corrigido tanto na Evolution quanto na Meta, `contactId` agora fica vazio pra sends de grupo; (3) citação (`idRef`) só mandava `{key: {id}}`, a Evolution não populava `participant` no lookup interno pra mensagens de grupo e o WhatsApp não desenhava o balão de resposta — `EvolutionAPISendWhatsappMessageUseCase::buildQuoted()` agora monta `{key, message}` completo a partir da mensagem local (novo campo `Message.participantJid`); (4) reação usava o payload da Meta (`{reaction: {message_id, emoji}}`), que a Evolution rejeita — criado `Core\EvolutionAPI\UseCase\Message\ProcessReactionUseCase` próprio com o shape correto (`{key, reaction: "emoji"}`). Implementada também foto do grupo (`updateGroupPicture`, `UpdateGroupInputDTO.image`). Validado ao vivo, nos dois sentidos (ativo/receptivo): texto, citação, mídia (imagem/vídeo) e reação — tudo no grupo de teste, sem tocar em nenhum grupo/dado pré-existente. Grupo de teste encerrado via `DELETE /groups/{id}` (Evolution: sai do grupo) ao final. Confirmada regressão zero no envio 1:1 pela Meta oficial. Front-end de Grupos removido do projeto — decisão de produto: Grupos é só API |
| 2026-09-01 | Implementado: encaminhamento dos webhooks de grupo da **Evolution API** pro cliente final. Novos eventos canônicos `GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE` em `WebhookEventsEnum`, `Core\UseCase\Group\GroupWebhookTriggerUseCase` (mesmo padrão de `BillingWebhookTriggerUseCase`) e 3 handlers em `Core\EvolutionAPI\UseCase\Webhook\HandleGroup*UseCase` traduzindo `groups.upsert`/`groups.update`/`group-participants.update` — antes esses 3 eventos causavam 400 sem log nenhum (`ProcessWebhookUseCase` não os reconhecia). Mapeamento de campos é best-effort a partir do shape conhecido do Baileys (a doc pública da Evolution não publica o payload exato); `raw` sempre incluído no envio pro cliente. Lado Meta (`GroupWebhookProcessing`) e Gupshup (sem API de grupo) ficam de fora desta etapa, propositalmente — estrutura pronta pra plugar depois. **Não validado com evento real ainda** — pendente de teste ao vivo pelo usuário. Exemplos de payload adicionados em `request-collections` (pasta `Webhook` pros 3 eventos inbound, `Webhook Trigger Events` pro formato entregue ao cliente) |
| 2026-09-01 | Refinamento: `Group` ganhou `phoneId` (persistido) e `phoneNumber` (transiente, resolvido pelo UseCase). `Create Group`/`List Groups` passaram a usar `phoneNumber` (o número real) em vez de `phoneId`/`whatsappAccountId`, mesma convenção do envio de mensagem — ver [Ergonomia de broker](../architecture/ARCHITECTURE.md#ergonomia-de-broker-prefira-o-número-de-telefone-não-o-id-interno), motivado por uso futuro no OmniPlus-Next. O webhook de grupo (Evolution) passou a incluir `phoneId`/`phoneNumber` no payload. Durante a implementação, uma primeira versão chamou `PhoneRepositoryInterface` direto no `GroupController` e no `GroupResource` — corrigido pra passar pelos UseCases (`ListGroupsUseCase`, `ShowGroupUseCase`, `CreateGroupUseCase`, `UpdateGroupUseCase`, `SyncGroupUseCase`); regra "Controller/Resource nunca chama Repository" documentada em `.cursor/agents/reviewer.mdc` |
| 2026-09-01 | Correção de nomenclatura dos eventos canônicos de webhook de grupo, depois de conferir contra a doc oficial da Meta (`developers.facebook.com/.../whatsapp/groups/reference`): `GROUP_UPSERT`→`GROUP_LIFECYCLE_UPDATE` e `GROUP_UPDATE`→`GROUP_SETTINGS_UPDATE`, pra espelhar 1:1 os 4 webhook fields reais da Meta (confirmados na doc: `group_lifecycle_update` cobre criação **e exclusão** de grupo, não só criação como o nome antigo `GROUP_UPSERT` sugeria); adicionado `GROUP_STATUS_UPDATE` como 4º evento canônico (Meta-only, gatilho exato não confirmado pela doc). Atualizado em `WebhookEventsEnum`, os 2 handlers Evolution afetados, o picker de eventos no front (`WebhookSignedEventsOptions.ts` — também estava faltando os eventos de grupo por completo até essa sessão) e exemplos em `request-collections` |
| 2026-09-01 | Confirmado o gatilho de `group_status_update` na página `developers.facebook.com/.../whatsapp/groups/webhooks/` (não achada antes — fica dentro de "Groups > Webhooks", separada da página "reference" usada pra confirmar os nomes dos 4 campos): grupo **suspenso por violação** (`type: group_suspend`) ou **suspensão removida** (`group_suspend_cleared`) — sem equivalente na Evolution API, é moderação de conteúdo específica da Meta. Essa mesma página trouxe o schema completo (`entry[].changes[].value.groups[0]`) dos 4 campos, documentado na nova seção "Payload real da Meta por campo" — inclusive uma diferença relevante: `group_participants_update` da Meta **não expõe promote/demote de admin**, só entrada/saída/join-request, diferente do `group-participants.update` da Evolution (Baileys), que tem essas ações |
| 2026-09-01 | Uniformidade do contrato canônico corrigida (o payload precisa ser idêntico não importa o parceiro, mesma exigência já aplicada à mensagem receptiva): (1) `GROUP_PARTICIPANTS_UPDATE.participants` agora vem sempre normalizado (número puro via `PhoneNumberNormalizerTrait`) em vez do JID cru da Evolution (`5511999999999@s.whatsapp.net`) — sem essa correção, o campo quebraria assim que a Meta (que usa `wa_id` num formato diferente) fosse implementada; (2) campo `action` renomeado pra `type` nesse mesmo evento, unificando o nome do discriminador em todos os 4 eventos; (3) `GROUP_LIFECYCLE_UPDATE` ganhou `type: "created"` (Evolution nunca emite `"deleted"`, já que `groups.upsert` do Baileys não sinaliza exclusão); (4) `GROUP_SETTINGS_UPDATE` ganhou `changed: string[]` listando quais campos aquele evento específico tocou (subject/description/picture), já que a Meta pode mudar vários de uma vez num único evento. Nova seção "Contrato canônico do `group`" documenta o vocabulário fechado de cada campo, pra quem implementar Meta/Gupshup seguir sem inventar valores novos |
| 2026-09-02 | Sessão de teste ao vivo com grupo e participante reais (Evolution, número pessoal do usuário — grupo criado só pra teste, nenhum outro grupo tocado). Achados e correções: (1) race condition confirmada — `groups.upsert` de um grupo recém-criado chegava ~0,7s antes do registro local salvar, `id`/`phoneId` vinham `null` — corrigido com `GroupLookupHelper` (retry curto); (2) vazamento de privacidade — `WebhookTrigger` de teste assinado em `NEW_MESSAGE` capturou mensagens de outros grupos reais do usuário (evento não tem filtro por grupo) — removido do trigger, log expurgado; (3) `Message.groupId` guardava o JID cru do parceiro em vez do id local do OmniMeta, nos dois sentidos (receptivo e ativo) — corrigido pra sempre usar o id local, com fallback pro JID só se o grupo não for conhecido; (4) auto-provisionamento implementado — mensagem/evento de um grupo desconhecido agora cria um registro mínimo em `groups`, inclusive pra grupos que nunca passaram pelo OmniMeta (com refresh automático do nome/descrição via `GET /group/findGroupInfos`, só leitura, quando o evento não traz isso); (5) bug de fundação achado — `empty($group->phoneId)` sempre retornava `true` por causa de `__get`/`__isset` do `MethodsMagicsTrait` — corrigido nos 4 pontos afetados (não no trait compartilhado, por pedido do usuário); (6) endpoints `POST`/`DELETE /groups/{id}/participants` implementados pra Evolution (não existiam) — testados ao vivo, add e remove funcionam de verdade; (7) confirmado que `GROUP_SETTINGS_UPDATE`/`GROUP_PARTICIPANTS_UPDATE` **não disparam** pra mudanças feitas pela própria API da Evolution (só `GROUP_LIFECYCLE_UPDATE` no create) — mudança real confirmada via leitura direta, zero webhook; (8) `GROUP_LIFECYCLE_UPDATE`/`GROUP_SETTINGS_UPDATE` ganharam `by` (número de quem fez a ação) e `participants` (roster completo, números reais) — antes só tinha `participantCount`; (9) status de entrega/leitura de mensagem de grupo não progride além de `"sent"`, mesmo com destinatário respondendo ativamente — suspeita de limitação da Evolution pra esse tipo de recibo em grupo, não 100% confirmado. `GROUP_STATUS_UPDATE` removida do picker do front (confirmado exclusivo Meta, nunca usado por nenhum cliente) |
| 2026-09-28 | Ajustes pro job #784 do ElevOne (grupos via Evolution). Base: leitura do código-fonte da Evolution 2.3.7 + Baileys 7.0.0-rc.9. (1) **LID**: `from` pelo número real (`participantAlt`, depois roster em cache, depois `GET /group/participants`); sem resolução → `from: null`, sem Contact (LID não vai pro cliente); Contact legado com LID como `wa_id` migrado no lugar; mesmo tratamento em `group-participants.update` (que na 2.3.7 manda objetos — o handler antigo fazia `(string)` num array e perdia o evento) e nos rosters. (2) `GET /groups/{id}/participants` (Evolution). (3) `POST /groups/sync-all` e `POST /groups/{id}/sync` (Evolution); `GET /groups` com `GroupResource` + `meta` de paginação (breaking pro formato array cru — front do OmniMeta ajustado). (4) Grupo descoberto por qualquer caminho anunciado com `GROUP_LIFECYCLE_UPDATE` `created`; dono = phone que recebeu o webhook (também na Meta); subject = JID força nova leitura. (5) `own_role`/`announce`/`restrict`/`picture_url`/`owner`/`created_at_partner` persistidos e expostos; `ownRole`/`announce`/`restrict` em todo evento `GROUP_*`; `GROUP_NOT_ADMIN`. (6) Trigger de webhook aceita mensagem sem Contact (DLR de grupo não quebra mais), `groupId`/`wamId` no `UPDATE_MESSAGE_STATUS` (resposta síncrona do envio mantida como `{id}`, por decisão). (7) `{message, code}` estável em todas as rotas de grupo, todos os partners (`GroupPartnerGuard`). (8) Envio assíncrono propaga `groupId`/`recipientType`. (9) `findByGroupId` filtrado por conta; mensagem de grupo digitada no celular entregue como OUT sem Contact; OpenAPI de `idRef`/`groupId` corrigido; `failed_participants` no add/remove. `groupsIgnore`: causa real documentada (o OmniMeta cria a instância com `true`), mantido — decisão de produto. Bug achado no smoke local: `Cache::add()` não funciona no store `database` sobre MongoDB — trocado por `has`+`put` |
| 2026-09-28 | Saída do grupo: `group-participants.update` `remove` com o próprio número (saiu, removido, grupo/comunidade desfeito — Baileys/Evolution não têm evento de "grupo apagado") marca o grupo `DELETED` e emite `GROUP_LIFECYCLE_UPDATE` `deleted`; `sync-all` faz o mesmo pros grupos que sumiram da lista (4 grupos locais obsoletos marcados na conta de teste). Número de volta ao grupo reativa (`created`). Cache LID → número passou a ser por conta (o LID é o mesmo em todos os grupos) — resolve `own_role` em grupo de avisos de comunidade, onde o WhatsApp lista quase todos só por LID. Endpoint `GET /groups/{id}/picture` (bytes relayados do CDN do WhatsApp, imagem nunca guardada). **Validado ao vivo** no grupo de teste "Teste OmniMeta Grupos - v2" com participante real (Junior): grupo em modo LID (`participant` = `…@lid`) → `NEW_MESSAGE.from` = número real, Contact correto (sem Contact novo por LID); citação nos dois sentidos (`context{wamId, id}` corretos); reação com `participant` LID; `UPDATE_MESSAGE_STATUS` de grupo entregue com `wamId`/`groupId`; PUT de descrição aplicado na Evolution (sem `GROUP_SETTINGS_UPDATE`, como esperado pra mudança feita pela própria API) e restaurado; `sync`/`participants`/`picture` (404 sem foto). `sync-all`/`participants`/`picture` validados em leitura nos demais grupos do número, com eventos interceptados (nada entregue a endpoint externo). Ambiente local sem `queue:work`: `NEW_MESSAGE`/status (fila `database`) só saem com worker — no teste o trigger foi executado direto, restrito a um receptor local. Observação não corrigida: `context.from` do inbound Evolution traz o remetente da mensagem nova, não o autor da mensagem citada (comportamento anterior) |
| 2026-10-01 | Evolution: link de convite (`GET`/`POST .../invite-link[/reset]`, `InviteLinkUseCase` — `GET /group/inviteCode`, `POST /group/revokeInviteCode`), papel de participante (`PUT /groups/{id}/participants/role`, `promote`/`demote`), `announce`/`restrict` no `PUT /groups/{id}` (`POST /group/updateSetting`), `participantCount` do evento de participantes pelo roster ao vivo (corrige contagem dobrada observada com o ElevOne), e `groupsIgnore` ligado automaticamente na instância que passa a usar grupos (create/sync/sync-all). Padronização: casos de uso de grupo da Evolution passam a usar `EvolutionAPIApiServiceFactory` (testáveis), validação da rota de papel via FormRequest (mesmo formato 422 das outras rotas), `announce`/`restrict` nulos não alteram mais a configuração (antes um `null` explícito virava `false`), comentários e OpenAPI alinhados. Contratos de resposta inalterados |
