# WhatsApp · Groups, Communities & History Sync (Manage)

**Portal:** Manage · **Provider:** **Bridge / QR only** (Baileys — the Cloud API has no groups/history feed) · **Nav:** part of the Messages **Inbox** (`/manage/messages`) · **Routes:** the existing inbox/message routes

## What it does
Captures **WhatsApp group and community messages** — and **past message history** — into the same inbox as 1:1 chats, on a **QR / Bridge** number. When a number **first links** (a fresh QR scan), the bridge backfills the **full** history WhatsApp pushes on link (default on — see *History strategy* below). Group chats (including **community** sub-groups) show up in the conversation list with the group name + member count, and each message in a group thread is **labelled with the member who sent it**.

> **History only backfills on a FRESH link.** WhatsApp pushes history **once**, at link time; a reconnect skips it, and there is **no reliable on-demand paging** (WhatsApp silently drops `fetchMessageHistory` for linked devices — Baileys [#2452](https://github.com/WhiskeySockets/Baileys/issues/2452)). So to backfill an already-connected number (or to pull groups it never synced), **log the channel out and re-scan the QR**.

> **Groups are CAPTURE-ONLY for automation.** An admin can read a group and **reply manually**, but the **AI reply engine, Flows and the STOP/opt-out handler NEVER run on a group** — auto-replying into a group is spammy and a fast ban vector. This is enforced in four independent layers (see *Capture-only safety* below).

> **Bridge-only.** Groups + history sync are a WhatsApp-Web (Baileys) capability. The **Cloud API** driver + the **Sandbox** driver return empty for every group / history / contact parser, so nothing changes for a Cloud channel. See [readMe.md](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) for the provider split.

## How it works

### Data model
- **`whatsapp_groups`** ([WhatsappGroup](/src/Whatsapp/WhatsappGroup.php), key model: uuid + blame + soft delete) — one row per group JID (`provider_group_id` = `…@g.us`, **unique per channel**): `subject` (name), `description`, `size`, `announce_only`, and the **community** fields. A **community is modelled as a group too**: the community parent group has `is_community = true`, and its sub / announcement groups reference it via **`community_group_id`** (a self-reference) — there is no separate communities table (the user's "sub-groups as groups, tagged by community" choice).
- **`whatsapp_group_participants`** ([WhatsappGroupParticipant](/src/Whatsapp/WhatsappGroupParticipant.php), child, no uuid) — the **complete** roster, keyed by `phone_e164` with a snapshot `name` + `role` (member/admin/superadmin) + `is_active`. `contact_id` links a `WhatsappContact` **only when one already exists** (a member who actually messaged) — the roster does **NOT** create a contact for every silent member, so the contact list stays limited to people who appear in a conversation (the "only people in synced chats" policy).
- **`whatsapp_conversations`** (ALTER) — `chat_type` (**1 individual / 2 group**, `WhatsappConversation::CHAT_*`), `group_id` (set for a group thread), and `contact_id` made **nullable** (a group thread has no single contact). A group conversation = `chat_type GROUP` + `group_id` + null contact.
- **`whatsapp_messages`** (ALTER) — `sender_contact_id` + `sender_name` (for a **group inbound**: which member sent it; null for 1:1 and outbound) and `is_historical` (true for a **history-sync backfill**, so the engine skips it and the inbox can style it).

### History strategy (why full-history-on-link)
On-demand paging is a **dead end** on WhatsApp Web: `fetchMessageHistory` is **silently dropped** by WhatsApp's servers for companion / linked devices (the request 200s but `messaging-history.set` never fires with data — Baileys [#2452](https://github.com/WhiskeySockets/Baileys/issues/2452)). The **only** reliable way to get deep history — and **group** history — is the **full history WhatsApp pushes at link time**. So the bridge runs `syncFullHistory: true` by default (`BRIDGE_SYNC_FULL_HISTORY`, `config.syncFullHistory`); there is no "load older" button. This backfill fires **only on a fresh link**, so re-scan to backfill an existing number.

### The bridge side (`wa-bridge/`, Baileys)
- **Browser fingerprint — `Browsers.ubuntu('Desktop')` (threads the needle).** The 428 refusal (Baileys [#2677](https://github.com/WhiskeySockets/Baileys/issues/2677), ~2026-06-30) fires only when `webSubPlatform` = DARWIN/WIN32, which — per rc13's `validate-connection.js` `getWebInfo` — needs `browser[0]` ∈ {`Mac OS`,`Windows`} **and** `browser[1]` = `Desktop`. But the *full-history request* comes from the registration node's `requireFullSync` + `platformType` = `getPlatformType(browser[1])`, independent of `webSubPlatform`. So `['Ubuntu','Desktop']` gives **`webSubPlatform` = WEB_BROWSER (no 428, `Ubuntu` isn't in the DARWIN/WIN32 map) + `platformType` = DESKTOP (value 7, a real enum — full-sync class)** — a desktop-class full-history request without the ban. Prior attempts: `Browsers.macOS('Desktop')` (fuller history, but 428) → `Browsers.ubuntu('Chrome')` (pairs, but `platformType`=CHROME → thin history) → **`Browsers.ubuntu('Desktop')`** (both). If WhatsApp ever 428s this too, fall back to `Browsers.ubuntu('Chrome')`. On-demand paging (`fetchMessageHistory`) stays a dead end — silently dropped for linked/companion devices ([#2452](https://github.com/WhiskeySockets/Baileys/issues/2452)).
- **History sync** — `syncFullHistory: config.syncFullHistory` (default `true`) with `shouldSyncHistoryMessage: ({syncType}) => config.syncFullHistory || syncType !== FULL` — with full history on it accepts **every** sync type (incl. the heavy `FULL` desktop backfill that carries deep + **group** history); with it off it accepts only the light `RECENT / INITIAL_BOOTSTRAP / ON_DEMAND` syncs (which still carry the **LID mappings + group participation** Baileys needs to route messages). The `messaging-history.set` handler normalises the batch and POSTs **`history.set`** (chunked; each message flagged `historical: true`).
- **Groups are no longer skipped** in `messages.upsert` — only `@broadcast` (status) and `@newsletter` (channels) are. A group message carries `chat_type: 'group'`, `group: {id, subject}` (subject resolved + cached via `groupMetadata`) and `sender: {phone, name, lid}` (the participant, resolved through `participantAlt` / the learned lid→phone map). `groups.upsert|update` + `group-participants.update` POST **`group.updated`** (subject / participants / `is_community` / `linkedParent`); `contacts.upsert` + the history contacts POST **`contacts.updated`**. See [wa-bridge/README.md](/wa-bridge/README.md).
- **A group message is NEVER dropped for an unresolved sender.** On a fresh link the LID→PN map is largely empty, so a group member addressed by **`@lid`** (privacy / never-DMed — the common case in real groups) has **no resolvable phone**. The bridge keeps the message anyway — attributed by `sender.lid` / `sender.name`, phone backfilled later — in **both** the history loop and the live `messages.upsert` path. *(This was a real bug: the earlier guard `if (isGroup && !sender.phone) continue;` silently discarded every such group message **before** any log, which is exactly why a number with many groups synced **zero** `@g.us` — the messages arrived and were thrown away, not "never received". The 1:1 path still requires a phone, since a 1:1 with no phone is unusable.)*

### The Laravel side (ingest)
- The **drivers** gained three parsers on the `WhatsappDriver` contract — `parseHistorySet` / `parseGroupUpdate` / `parseContactUpdates` — implemented by **[BridgeDriver](/src/Whatsapp/Drivers/BridgeDriver.php)** (a shared `normalizeBridgeMessage` handles both `message.received` and each history message) and returning **null / []** on [CloudApiDriver](/src/Whatsapp/Drivers/CloudApiDriver.php) + [SandboxDriver](/src/Whatsapp/Drivers/SandboxDriver.php). A new `GroupUpdate` DTO; `NormalizedMessage` gained `chatType` / `group` / `sender` / `historical`.
- **[ProcessInboundWhatsAppWebhook](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php)** routes a group `message.received` to `recordGroupMessage` (never the AI/flow path), and runs `processHistorySet` / `processGroupUpdates` / `processContactUpdates` alongside the existing status / template / account loops.
- **A big backfill never blocks live traffic.** The webhook controller ([WhatsAppWebhookController](/app/Http/Controllers/Webhooks/WhatsAppWebhookController.php)) dispatches a **`history.set`** event onto the **capped single-process broadcast lane** (`redis-broadcast` / `broadcast` queue) instead of the default lane — so a full-history backfill (potentially thousands of messages) paces itself in the background while live `message.received` / status stay on the fast default lane. **Requires the `redis-broadcast` Horizon worker to be running** (the same lane broadcasts use).
- **Real message time + chronological order.** Each message's `created_at` is **stamped with the provider timestamp** (`recordInbound` / `recordGroupInbound` → `stampMessageTime`), not left to default to the ingest time — otherwise every history-backfilled message would show the *pull* time. The inbox thread ([InboxController](/app/Http/Controllers/Manage/Whatsapp/InboxController.php)) orders by **`created_at`** (real time), not insert `id` — a backfill lands out of chronological order, so id-order would scramble the sequence; it takes the most recent 200 then flips to ascending for display. `parseTimestamp` normalises the provider time to the **app timezone** so it round-trips a `timestamp` column correctly (a UTC-wall-clock write read back as app-tz would shift every historical time by the tz offset — the 8h `Asia/Kuala_Lumpur` bug).
- **[WhatsappGroupRepository](/src/Whatsapp/Repositories/WhatsappGroupRepository.php)** — `resolveGroup` (lightweight find-or-create for message routing) + `upsertGroup` (full metadata: subject/flags, resolve+link the parent community group, sync the roster). **[WhatsappRepository](/src/Whatsapp/Repositories/WhatsappRepository.php)** gained `recordGroupInbound` (routes to the group conversation, records the sender — a sender **with** a phone becomes a contact; a sender addressed only by `@lid` is still recorded, attributed by `sender_name` with a **null** contact — it no longer drops phone-less senders), `enrichContactNames` (backfills names onto **existing** contacts only — never creates from the address book), and a **history-aware** `applyConversationActivity` (a backfilled message never regresses the live conversation's `last_activity_at` / `last_inbound_at`; only advances them if genuinely newer). A history backfill skips the `Cache::lock` (single ordered batch — the unique index dedupes) and fires **no live `NewWhatsAppMessage` broadcast** (no event storm over hundreds of messages) and **no media download** for the whole history.
- **Connect-time match-only contact linking + a "syncing" banner (2026-07-15).** The live inbound linker ([ContactLinker](/src/Whatsapp/Services/ContactLinker.php)) explicitly bails on `is_historical`, so history-synced contacts were left **unlinked** until they messaged again or an admin ran `whatsapp:link-contacts`. Now a fresh QR backfill auto-joins each synced contact to an **already-existing** lead / staff account by tolerant phone match — **without ever creating a new lead** (`ContactLinker::linkExisting()` = `resolve(create=false)`, attaching only on `OUTCOME_EXISTING_STAFF` / `OUTCOME_EXISTING_CUSTOMER`; the bulk-lead-creation path stays on the explicit command). The mechanics, deliberately kept off the paced broadcast lane:
  - `processHistorySet` does only **microsecond bookkeeping** per chunk — refreshes a `wa:hist-chunk:{id}` cache timestamp (the freshness signal) and, on the FIRST chunk of a sync, stamps `whatsapp_channels.history_sync_started_at` (via `WhatsappRepository::markHistorySyncStarted`, idempotent so it fires **once per sync** on the single-process lane), broadcasts **`WhatsAppChannelSyncStatus(syncing:true)`** on `whatsapp.inbox`, and arms one **[LinkChannelContacts](/app/Jobs/Whatsapp/LinkChannelContacts.php)** job on the **DEFAULT lane** (never `redis-broadcast`, so the phone matching never competes with the sync).
  - `LinkChannelContacts` is **self-settling**: it debounces on the `wa:hist-chunk` timestamp — while chunks are still arriving it re-dispatches itself; once **quiet** (no chunk for ~90 s — `LinkChannelContacts::QUIET_SECONDS`) it runs the match-only link over the channel's unlinked 1:1 contacts (same query guards as the command — skip `+1999`, skip group-only participants), then stamps `history_sync_completed_at` (`markHistorySyncCompleted`) + broadcasts **`WhatsAppChannelSyncStatus(syncing:false)`**. `WithoutOverlapping` collapses the chain to one job. Under the **sync queue driver** (tests / no worker) it does a single settle-free pass instead of a delayed self-re-dispatch (which would busy-loop). It snapshots the freshness timestamp so a **straggler chunk** that lands mid-pass defers completion instead of being stranded.
  - **Self-healing (no wedged channels).** `markHistorySyncStarted` refuses to re-announce only while a sync is **genuinely in progress** — a `history_sync_started_at` older than `WhatsappChannel::HISTORY_SYNC_STALE_MINUTES` (60, shared with `isSyncingHistory()`) is treated as stale, so a settle job that **died before stamping completed** can't jam the state machine: the next fresh link re-arms cleanly. `LinkChannelContacts::failed()` also stamps completion so a dead pass clears the banner immediately. And **`whatsapp:link-contacts --existing-only` is now SCHEDULED hourly** ([Kernel](/app/Console/Kernel.php)) as the ultimate backstop for any straggler.
  - The **Inbox banner** reads `WhatsappChannel::isSyncingHistory()` (`history_sync_started_at` set, `history_sync_completed_at` null, **Bridge only**, within the staleness cap). Surfaced as `channels[].is_syncing_history` from [InboxController](/app/Http/Controllers/Manage/Whatsapp/InboxController.php); [Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) shows *"Channel {name} is syncing past history…"* above the conversation list. The 6-second poll now includes `channels` (so it works with `WHATSAPP_REALTIME=false`); the Echo `.WhatsAppChannelSyncStatus` listener refreshes it instantly when Reverb is on.
  - **New migration** `2026_07_15_000001_add_history_sync_tracking_to_whatsapp_channels_table` adds the two nullable timestamps. Linking existing contacts is a CRM **join**, not automation — the four capture-only guards (no AI / flow / opt-out on history or groups) are untouched. Locked by `GroupCaptureTest` (`test_history_sync_links_existing_lead_by_phone_but_never_creates_leads`, `…stamps_channel_sync_state_and_settles`, `…stale_stuck_history_sync_rearms…`, `…arms_the_link_job_but_not_the_ai_or_flow_engine`).
- **Sync summary card + admin decides on the unmatched (2026-07-15, card redesigned).** When the backfill settles, `LinkChannelContacts` also stamps `whatsapp_channels.history_sync_report` (json) — a counts snapshot built by **[HistorySyncReporter](/src/Whatsapp/Services/HistorySyncReporter.php)** (`contacts` / `messages` / `linked_customer` / `linked_staff` / `unmatched` / `unlinkable`). The report becomes a **w-full card pinned above the inbox** ([Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue), `channels[].history_sync_report`) — moved out of the narrow list column. Since matches were already auto-linked on connect, the card only decides the **unmatched** contacts ("new numbers" — a valid phone, no existing lead), and it **only closes on a terminal decision** (Add all / Reject all); a partial "Add selected" keeps it open. Actions:
  - **Add selected (partial, keeps card open)** — the card fetches the current unmatched list from `GET channels/{id}/history-sync/unmatched` (`{report, contacts:[{uuid,name,phone}], has_more}`, capped at 300 rows; "Add all" still covers the rest server-side) and renders a checkbox pick-list. Ticking a few + **Add selected** POSTs `create-leads` with `contacts:[uuid…]` → the controller creates leads **synchronously** for just those (still-unmatched) contacts (`ContactLinker::link()`), rebuilds the report, and returns `{created, closed:false}`. The list re-fetches (shrinks) and the card stays open.
  - **Add all N (terminal, closes)** — POST `create-leads` with **no** `contacts` → queues **[CreateLeadsForSyncedContacts](/app/Jobs/Whatsapp/CreateLeadsForSyncedContacts.php)** (default lane, all unmatched, background) and clears the report → card closes. `{queued, closed:true}`.
  - **Reject all (terminal, closes)** — `POST channels/{id}/history-sync/dismiss` clears the report (creates nothing) → card closes.
  - **Export CSV** — `GET channels/{id}/history-sync/export` streams the unmatched contacts (phone + WhatsApp name) as CSV (plain `<a href>` download).
  - **New numbers = 0** → the card just shows "Everyone is already in your CRM" + a *Got it* close.
  - **Password of an auto-created lead-user:** `LeadRepository` sets `password => Str::random(40)` (hashed by `User::setPasswordAttribute`) — a random secret nobody holds, so these accounts can't password-login (email magic-link only). See [leads handbook](/docs/modules_handbook/manage/leads/readMe.md).
  - **Single source of truth:** the pick-list, the partial/all create, and the export ALL read `HistorySyncReporter::unlinkedContactsQuery($channel)` (`user_id IS NULL` + valid phone + not `+1999%` + a 1:1 conversation on the channel) so the shown count always equals what gets created/exported. A re-scan overwrites the report; `is_syncing_history` takes precedence so a stale report never shows mid-sync. Requests validated by `HistorySyncLeadsRequest`. Locked by `HistorySyncCardTest` (list / partial-only / all-queues-and-closes / reject-all) + `GroupCaptureTest::test_bulk_create_leads_makes_leads_for_unmatched_synced_contacts`. Migration `2026_07_15_000002_add_history_sync_report_to_whatsapp_channels_table`.

### 2026-07-22 additions (new Baileys event subscriptions)
- **Explicit sync-complete signal + progress %.** The bridge now forwards Baileys `messaging-history.status` as **`history.status`** — serialized behind the history chunks by a per-instance **`historyChain`** promise (bridge-side FIFO, so "complete" can never overtake a chunk it summarises) and routed onto the same broadcast lane. A `complete` of a deep phase (FULL/RECENT) sets the **`wa:hist-done:{channel}` cache marker** + nudges `LinkChannelContacts`, whose `historySettled()` then needs only a **10 s straggler grace** instead of the full 90 s quiet window (which stays as the no-signal fallback) — the sync card appears within seconds of the real end of the stream. `history.set` chunks also carry Baileys' **`progress` 0-100**, cached (`WhatsappChannel::historyProgressCacheKey`) and shown on the banner ("syncing… 45%") + the `WhatsAppChannelSyncStatus` payload. Locked by [HistoryStatusTest](/tests/Feature/Whatsapp/HistoryStatusTest.php).
- **Group JOIN REQUESTS.** Baileys `group.join-request` → `group.join_request` webhook → **`whatsapp_group_join_requests`** (keyed `(group_id, participant_key)` — phone else `lid:{digits}`, so lid-only requesters never collapse; `meta.jid` keeps the raw jid the provider API needs back). The **members drawer** ([GroupMembersModal](/resources/js/Pages/Manage/Messages/Partials/GroupMembersModal.vue)) shows pending requests with **Approve / Reject** (optimistic, `MANAGE_WHATSAPP`-gated server-side; `POST conversations/{id}/join-requests/update` → `BridgeGateway::groupJoinRequestUpdate`). **A request handled ON THE PHONE emits no event** — the list endpoint (`GET conversations/{id}/join-requests`) refreshes from the bridge's live pending list (`groupRequestParticipantsList`) and RECONCILES: a local PENDING absent from it → APPROVED when the person is now an active roster member, else REJECTED (a heuristic; `resolved_by` stays null). Locked by [GroupJoinRequestsTest](/tests/Feature/Whatsapp/GroupJoinRequestsTest.php).
- **Per-member group READ RECEIPTS.** Baileys `message-receipt.update` (groups only — 1:1 receipts ride the normal status ladder) → `message.receipt` webhook → **`whatsapp_message_receipts`** (keyed `(message_id, participant_key)`; forward-only timestamp merge; only OUR outbound messages in group threads). The thread shows a **"· N read"** chip on group outbound bubbles (counts via `withCount` on the thread query — no N+1) → [ReadReceiptsModal](/resources/js/Pages/Manage/Messages/Partials/ReadReceiptsModal.vue) lists who read/received when (`GET conversations/{id}/messages/{message}/receipts`); live bumps ride the tiny `WhatsAppMessageReceiptsUpdated` broadcast. Locked by [GroupReceiptsTest](/tests/Feature/Whatsapp/GroupReceiptsTest.php).
- **Incoming CALLS** land as `TYPE_CALL` bubbles in 1:1 threads (order-proof upsert per call id — see `WhatsappRepository::recordCallEvent`; never `last_inbound_at`, never automation; outbound calls are not observable on a linked device). Locked by [CallCaptureTest](/tests/Feature/Whatsapp/CallCaptureTest.php).
- **Phone-state mirroring:** chat read on the phone clears the inbox badge (`WHATSAPP_SYNC_PHONE_READS`, default on; never dispatches `MarkWhatsAppRead` — the phone already blue-ticked), pinned/muted/archived badges (`chats.update`, explicit-null = cleared — the bridge deliberately does NOT `clean()` these), the phone's blocklist ("Blocked on phone", stamp-only snapshots + live deltas, display-only — `isBlocked()` semantics untouched), and delete-for-me audit tags ("removed on phone" — never a revoke). Locked by [PhoneReadSyncTest](/tests/Feature/Whatsapp/PhoneReadSyncTest.php) + [BlocklistSyncTest](/tests/Feature/Whatsapp/BlocklistSyncTest.php) + [DeletedForMeTest](/tests/Feature/Whatsapp/DeletedForMeTest.php).
- **Live presence** ("online / typing…") for the OPEN 1:1 thread — watch-window model (no unsubscribe exists in Baileys; the bridge forwards only watched jids, deduped by state; Laravel subscribes on markRead, throttled). Requires `WHATSAPP_REALTIME` + `WHATSAPP_PRESENCE`. Locked by [PresenceTest](/tests/Feature/Whatsapp/PresenceTest.php).

### LID / username readiness (2026-07-22)
WhatsApp's **username rollout** hides phone numbers: Bridge chats arrive addressed by **@lid** privacy identities (Baileys v7 is LID-first; **LID→phone cannot be network-resolved**), and Cloud webhooks carry a **BSUID** (`CC.alphanumeric`) instead of a phone. The module now survives a permanently-phoneless contact:

- **Contact identity keys, in priority order: `phone_e164` > `wa_user_id` (BSUID) > `lid`** — all three nullable-unique on `whatsapp_contacts` (migrations `2026_07_22_1001{01,02}`); `wa_username` (`100103`) is **display-only, NEVER a key** (usernames are changeable/reusable — matching on one would hand a stranger another person's thread). One resolver owns the rules: [`WhatsappRepository::resolveContactByIdentity`](/src/Whatsapp/Repositories/WhatsappRepository.php) — a single matched row backfills its missing keys IN PLACE (the merge moment: a lid row learning its phone becomes linkable through the normal identity gate); **two matched rows NEVER auto-merge** (phone row wins for the message, keys untouched, warn-logged — `contact.user_id` is the inbox RBAC gate and a guessed merge could leak a thread).
- **"Same person?" merge suggestion (admin-confirmed, never automatic).** That two-row split IS an authoritative "same person" signal (WhatsApp paired a phone with a lid/BSUID in one message), so instead of only warn-logging, [`recordMergeSuggestion`](/src/Whatsapp/Repositories/WhatsappRepository.php) stashes `meta.merge_suggestion` on the **phoneless** row pointing at the phone row (best-effort, only when the phone row owns a lead and the loser is phoneless + unlinked). The inbox surfaces it as a thread-top banner ("This might be *Name* (+60…). Same person?") — [`InboxController::mergeSuggestion`](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) exposes it on `contact_detail` (phoneless contacts only); **Merge** (`contacts/{id}/merge`) or **Not the same** (`contacts/{id}/dismiss-merge`, marks it `dismissed` so it stops re-nagging). Confirming runs [`WhatsappRepository::mergeContacts`](/src/Whatsapp/Repositories/WhatsappRepository.php) in one transaction: **fold conversations** (same-channel → re-point the loser conversation's messages/flow-runs/cta into the winner conversation + carry the activity window forward + drop the emptied thread; other channels → re-parent `contact_id`), **dedupe-then-repoint** the composite-unique children (tags / consents / cta), plain re-point the rest (`sender_contact_id`, broadcast recipients, flow runs, group participants), **backfill the loser's freed lid/BSUID/username onto the winner** (so future messages land there), then soft-delete the loser (tombstoned `meta.merged_into_contact_id`). RBAC follows automatically — visibility is `conversation → contact → user → lead`, and the winner already owns the lead. The **guard**: only a phoneless, unlinked loser may be merged from here; a loser that owns a phone or lead is refused (that is the Leads account-merge flow, `LeadRepository::mergeVerifiedPair`). Idempotent (a replay on the retired loser is a no-op).
- **Never-toE164 rule:** lid/BSUID digit strings must NEVER enter phone normalisation — `PhoneNumber::e164`'s `isPossibleNumber` fallback can mint a bogus-but-valid E.164 from them (→ fake contact → fabricated `TRUST_NETWORK_VERIFIED` lead). The Cloud driver detects the BSUID shape at parse (`CloudApiDriver::isBsuid`), the Bridge keeps `lid` a separate field end-to-end.
- **No more silent drops:** lid-only 1:1 messages (live, history, offline flush, reaction fallback), calls, chat-state, label associations, presence and address-book contacts are all forwarded carrying `lid` and captured lid-keyed. The **only** skip left is a 1:1 with *neither* phone nor lid.
- **The bridge lid map is PERSISTED** (`wa_bridge_lid_map`, warm-loaded on start, write-through on learn with a memory-identical skip guard) and fed from every source incl. the history sync's `lidPnMappings` batch (previously discarded) and Baileys' own persisted `LIDMappingStore` (`tryRecoverLidPhone` async pre-step on unknown lids).
- **Provider-aware replies** ([`SendWhatsAppMessage::recipientFor`](/app/Jobs/Whatsapp/SendWhatsAppMessage.php)): phone → phone; Bridge+lid → `{lid}@lid` (the bridge's resolveRecipient passes full JIDs through); Cloud+BSUID → Meta's **`recipient`** field (never `to` — and never through the digit-stripping `recipient()`, which would mangle a BSUID into a stranger's "phone"); anything else fails with a readable "no reachable address". AUTHENTICATION templates refuse BSUID recipients (Meta rule). `MarkWhatsAppRead` + avatar sync are lid-aware.
- **Linking / leads:** a phoneless contact is **UNLINKABLE by design** (the identity gate needs email/phone) — no lead, no RBAC assignment; the sync-report buckets it under `unlinkable`, and the moment a phone is learned the normal link paths (live `considerLinkContact`, hourly `whatsapp:link-contacts --existing-only`) pick it up.
- **BSUID rotation** (`user_id_update` webhook, the user changed numbers) is handled idempotently ([`rotateContactUserId`](/src/Whatsapp/Repositories/WhatsappRepository.php)).
- **UI:** a phoneless contact shows `@username` (else "Privacy identity"/"Hidden number") — never a blank row.
- **Boundary (honest):** initiating a chat BY username (`@name` lookup) is not possible yet — Baileys `7.0.0-rc13` has no username addressing (type hooks only); we capture, display and reply, we can't dial out by handle.
- Locked by [LidContactTest](/tests/Feature/Whatsapp/LidContactTest.php) (capture, fake-phone regression, in-place merge, split-no-merge, `{lid}@lid` reply + Cloud clean-fail, UNLINKABLE) · [CloudBsuidTest](/tests/Feature/Whatsapp/CloudBsuidTest.php) (BSUID capture + no fake `+1…`, dual keys one row, `recipient`-field sends, idempotent rotation) · [UsernameCaptureTest](/tests/Feature/Whatsapp/UsernameCaptureTest.php) (rename-follows, never-a-key) · [ContactMergeTest](/tests/Feature/Whatsapp/ContactMergeTest.php) (suggestion recorded on a paired split, conversation fold + re-parent, tag dedupe, phoneless-only guard, idempotent, RBAC-follows, `contact_detail` exposure + merge/dismiss endpoints).

### Capture-only safety (four layers)
1. `ProcessInbound` routes a group message to `recordGroupMessage`, which **does not call** `considerOptOut` / `considerFlow` / `considerAiReply`.
2. `considerAiReply` / `considerFlow` / `considerOptOut` each early-return on `$conversation->isGroup()` or `$message->is_historical`.
3. **[GenerateAiReply](/app/Jobs/Whatsapp/GenerateAiReply.php)** returns on `isGroup()` / `is_historical` (a group has no single contact, so the existing contact guard already returns — this is belt-and-suspenders).
4. A **historical** message never engages anything (the same guards + the `recordGroupMessage` / `processHistorySet` paths never call the engine).

### Inbox UI ([Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) + [InboxController](/app/Http/Controllers/Manage/Whatsapp/InboxController.php))
- The conversation list mixes 1:1 + groups (a **chat-type filter** — All / Direct / Groups — narrows it; search matches a group's subject). A group row shows a **group avatar + the group name + `N members` / `Community`**.
- A group thread **labels each inbound bubble with its sender** (a stable colour per member); the header shows the group name + `Community · N members` (the parent community's name for a sub-group). The **1:1-only header controls** (marketing consent, tags, Pause-AI, Block, flow) are hidden for a group.
- **Member roster (drawer).** Clicking the group header opens a **members drawer** ([Partials/GroupMembersModal.vue](/resources/js/Pages/Manage/Messages/Partials/GroupMembersModal.vue) → `GET conversations/{id}/members` → [InboxController::groupMembers](/app/Http/Controllers/Manage/Whatsapp/InboxController.php)) listing every member with their role (member / admin / super-admin), phone (or "privacy identity" for an `@lid`-only member), and left-the-group state. **The roster also powers the funnel hub's Group tab** — a funnel's `whatsapp_group_link` is resolved to its group JID via the bridge's `GET instances/{i}/group-invite-info` (Baileys `groupGetInviteInfo`, no join — `BridgeGateway::groupInviteInfo`) and the synced roster is compared against the funnel's registrants; a **community** link unions the parent + every synced linked group, since WhatsApp limits the community parent's own roster to the admins (see the [Events · Funnels handbook](/docs/modules_handbook/manage/events/funnels/readMe.md)). The roster is populated by the bridge's **initial `groupFetchAllParticipating()` sync on connect** (`syncAllGroups`, throttled 30 min) — so existing groups have a full roster on a fresh link without waiting for a membership change — plus `group.updated` events; **`@lid`-only members are now kept** (the driver + `whatsapp_group_participants.lid` column) instead of dropped, so the roster is complete.
- **Group avatar.** The list + header show the group's WhatsApp **profile picture** when synced (`whatsapp_groups.avatar_url`, refreshed by `whatsapp:sync-avatars`), falling back to the group icon. See [readMe.md](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) → *Profile pictures*.
- **Manual reply works** — a group is addressed by its **group JID** (not a contact phone): `MessagesController` skips the contact-required / block checks for a group, and **[SendWhatsAppMessage](/app/Jobs/Whatsapp/SendWhatsAppMessage.php)** resolves the recipient as `group.provider_group_id` for a group (else the contact phone). Text / media / location / reaction / quote-reply all flow through the same group-aware path (the bridge's `resolveRecipient` passes a `@g.us` JID straight through).
- **No "load older" control.** On-demand paging can't work (see *History strategy*), so the thread shows exactly what the link-time backfill synced; deeper history comes from a re-scan (full-history-on-link), not a button.
- **History media is downloaded ON DEMAND.** A history-backfilled image / voice note / video / document is ingested with its metadata but **not its bytes** (a fresh-link sync of thousands of files would be huge). Its bubble shows a **Download** button; clicking it queues [DownloadWhatsappMedia](/app/Jobs/Whatsapp/DownloadWhatsappMedia.php) (`POST conversations/{id}/messages/{message}/fetch-media`) which pulls the bytes through the driver → the bridge decrypts from its **durable `wa_bridge_media` proto store** (so it works even after the in-memory cache evicted the message or the bridge restarted) → MediaService → the file swaps in. **WhatsApp expires media on its CDN (~14 days)**, so an old historical download can legitimately fail — the button re-enables for a retry and the job lands in `failed_jobs`, nothing is silently corrupted.
- `MessagePresenter` adds `sender` (name/phone/uuid) + `is_historical` to every message payload (identical for the controller props and the broadcast).

## Phased build (all shipped)
1. **wa-bridge** — history sync config + `messaging-history.set` + un-skip groups + group/sender normalisation + `group.updated` / `contacts.updated` events + a per-instance group-metadata cache.
2. **Laravel data model + ingest** — the tables/columns above, the models, the driver parsers + DTO, the group repository + repository additions, and the ProcessInbound routing + the capture-only guards.
3. **Inbox UI** — group rendering in the list + thread (per-message sender), the chat-type filter, and hiding the 1:1-only controls in a group.
4. **Polish** — manual group reply (group-JID send), community labelling, this handbook, tests.
5. **Full-history-on-link + backfill lane** (2026-07-02) — switched from the recent-only / broken on-demand strategy to `syncFullHistory: true` (the only reliable deep + group backfill), removed the non-functional "load older" path, and routed `history.set` ingest to the capped broadcast lane so a large backfill can't drown live traffic.
6. **Group `@lid` sender fix + desktop emulation** (2026-07-02) — fixed the bug where a group message with an unresolved `@lid` sender was silently dropped at **three** guards (bridge history loop, bridge live `messages.upsert`, and `WhatsappRepository::recordGroupInbound`) — the real reason a group-rich number synced zero `@g.us`. Group messages are now always captured (attributed by lid/name); switched `browser` to `Browsers.macOS('Desktop')` for a fuller on-link history bundle *(reverted to `Browsers.ubuntu('Chrome')` the same day — WhatsApp began 428-refusing desktop fingerprints at pairing, see the fingerprint bullet above)*. Regression test: `GroupCaptureTest::test_group_message_is_captured_even_without_a_resolvable_sender_phone`.
7. **Real message time + sequence** (2026-07-02) — history-backfilled messages were showing the *pull* time and sorting in ingest order. Fixed by stamping `created_at` from the provider timestamp (`stampMessageTime`), ordering the thread by `created_at` (not `id`), and normalising `parseTimestamp` to the app timezone (a UTC instant persisted then read back as app-tz shifted every time by the tz offset — the 8h KL bug). Regression tests: `test_history_set_backfills_messages_as_historical_without_automation` (time) + `test_historical_messages_are_ordered_by_real_time_not_ingest_order` (sequence).

## Prerequisites
Everything the Bridge inbox needs (see [readMe.md](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) → *Prerequisites*): the **`wa-bridge/`** Node service running, **Horizon** (inbound ingest + sends — **including the `redis-broadcast` worker**, which now also carries the history backfill), **Reverb** (live thread), GCS (media). After deploying the Laravel side, run `php artisan migrate` (the 4 group migrations) then `php artisan horizon:terminate`; restart the bridge so history sync + group capture turn on. **To backfill an already-connected number, log its channel out and re-scan the QR** — history only syncs on a fresh link.

## Not yet
- **Historical media** is not auto-downloaded on a sync (only the text/caption/type is kept — downloading a whole chat's media would be heavy); a live group message downloads media as usual.
- **Batch-insert of a full backfill** — each history message is still ingested one-by-one (contact/conversation reuse, per-message dedupe). The broadcast lane paces it so it's non-disruptive, but a bulk insert would make a large backfill finish faster; a future optimisation.
- **A dedicated community hierarchy screen** — communities are surfaced as a label on their sub-groups, not a separate tree.

## Related files
**Backend — Models**
- [src/Whatsapp/WhatsappGroup.php](/src/Whatsapp/WhatsappGroup.php) · [WhatsappGroupParticipant.php](/src/Whatsapp/WhatsappGroupParticipant.php) · `WhatsappConversation` (`CHAT_*` / `isGroup()` / `group()`) · `WhatsappMessage` (`sender()` / `is_historical`) · `WhatsappChannel` (`groups()`).

**Backend — Drivers + DTO**
- [src/Whatsapp/Drivers/WhatsappDriver.php](/src/Whatsapp/Drivers/WhatsappDriver.php) (`parseHistorySet` / `parseGroupUpdate` / `parseContactUpdates`) · [BridgeDriver.php](/src/Whatsapp/Drivers/BridgeDriver.php) (impl + `normalizeBridgeMessage`) · [CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) / [SandboxDriver.php](/src/Whatsapp/Drivers/SandboxDriver.php) (empty) · [Drivers/Data/GroupUpdate.php](/src/Whatsapp/Drivers/Data/GroupUpdate.php) · [NormalizedMessage.php](/src/Whatsapp/Drivers/Data/NormalizedMessage.php).

**Backend — Repositories, Job, Presenter**
- [src/Whatsapp/Repositories/WhatsappGroupRepository.php](/src/Whatsapp/Repositories/WhatsappGroupRepository.php) · [WhatsappRepository.php](/src/Whatsapp/Repositories/WhatsappRepository.php) (`recordGroupInbound` / `enrichContactNames` / `openGroupConversation` / `applyConversationActivity`) · [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) · [SendWhatsAppMessage.php](/app/Jobs/Whatsapp/SendWhatsAppMessage.php) (group-JID send) · [src/Whatsapp/Support/MessagePresenter.php](/src/Whatsapp/Support/MessagePresenter.php) (`sender` / `is_historical`).
- [src/Whatsapp/Services/GroupPostGate.php](/src/Whatsapp/Services/GroupPostGate.php) — the guard for the **one deliberate exception** to capture-only (a funnel's own session-timed rule posting into its WhatsApp group): may this channel post into this group right now? `check()` → `OK` · `NOT_SYNCED` · `NOT_MEMBER` · `COMMUNITY_PARENT` · `NOT_ADMIN` · `STALE` · `WRONG_PROVIDER` (+ `REASONS`, the single wording source for the API, the funnel hub's Group tab and the failure alert); `allows()` is the boolean. Read-only over **already-synced data — no bridge call**: our own participant row found by **LID first, phone second** (see the `lid` migration below — an ordinary member of a large group is stored lid-only, so a phone-only match would wrongly say "not a member"), `whatsapp_groups.announce_only` (a column the sync has always written and nothing read until now), and `STALE_AFTER_DAYS` (14). **STALE still allows the post** — leaving a group emits no Baileys event, so an old roster is merely unproven, and refusing on that basis would silently stop a working automation. Its only caller today is the funnel WhatsApp GROUP audience — see [Events · Funnel WhatsApp](/docs/modules_handbook/manage/events/funnel-whatsapp/readMe.md).

**Backend — Controllers + Routes**
- [app/Http/Controllers/Manage/Whatsapp/InboxController.php](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) (chat-type filter, group props) · [app/Http/Controllers/Webhooks/WhatsAppWebhookController.php](/app/Http/Controllers/Webhooks/WhatsAppWebhookController.php) (routes `history.set` to the broadcast lane) · [MessagesController.php](/app/Http/Controllers/Manage/Whatsapp/MessagesController.php) (group-aware send guards) · [routes/web.php](/routes/web.php).

**Frontend + Bridge**
- [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) (group list/thread rendering, chat-type filter, sender labels) · [wa-bridge/src/instances.js](/wa-bridge/src/instances.js) (full-history sync + group normalisation) · [wa-bridge/src/config.js](/wa-bridge/src/config.js) (`syncFullHistory`) · [wa-bridge/README.md](/wa-bridge/README.md).

**Migrations**
- [database/migrations/2026_07_01_000030_create_whatsapp_groups_table.php](/database/migrations/2026_07_01_000030_create_whatsapp_groups_table.php) · `_000031_create_whatsapp_group_participants_table.php` · `_000032_add_group_dimension_to_whatsapp_conversations_table.php` · `_000033_add_sender_and_historical_to_whatsapp_messages_table.php`.
- [database/migrations/2026_07_28_100002_add_lid_to_whatsapp_channels.php](/database/migrations/2026_07_28_100002_add_lid_to_whatsapp_channels.php) — `whatsapp_channels.lid` (`string(191)` nullable, indexed): the **connected number's own** LID, stored digits-only, the same normalisation `BridgeDriver` already applies to participant + contact lids so the two sides compare directly. The bridge always sent `me.lid` on every heartbeat (`wa-bridge/src/instances.js` exposes `{phone, name, lid}`) but [ChannelInfoSync::applyBridgeIdentity](/src/Whatsapp/Services/ChannelInfoSync.php) read only phone + name and discarded it. Without this column there is no key to find **OUR OWN** `whatsapp_group_participants` row: in a large group WhatsApp addresses members by LID, so an ordinary member's `phone_e164` is NULL (in this install's 1,375-member funnel group, 1,368 of them), and any "is this number a member / an admin here?" check would match on phone, find nothing, and wrongly answer "not a member" — in exactly the group `GroupPostGate` exists to guard.

**Tests**
- [tests/Feature/Whatsapp/GroupCaptureTest.php](/tests/Feature/Whatsapp/GroupCaptureTest.php) — group ingest (with the sender) + the **capture-only** guarantee (AI / flow NEVER run on a group, even on a keyword), duplicate dedupe, history backfill (`is_historical`, no automation), group-metadata + roster + community (self-reference) sync, the contact-enrichment "existing only" rule, and **manual group send** (addresses the group JID — the regression guard for `BridgeDriver::recipient()` mangling `…@g.us` into a 1:1). Runs on the `petav3_testing` MySQL DB (`php artisan test --filter=GroupCaptureTest`).
