# WhatsApp (Manage)

**Portal:** Manage · **Routes:** `manage.messages.*` (+ public `webhooks.whatsapp.*`) · **Nav:** Messages → Inbox · **Hub:** [manage/messages](/docs/modules_handbook/manage/messages/readMe.md)

## What it does
A unified **WhatsApp inbox** that runs **two providers behind one inbox**: the official **Meta Cloud API** and an unofficial **QR (WhatsApp Web)** channel served by a self-hosted **Baileys bridge** (the `wa-bridge/` Node sidecar in this repo). Admins see every conversation across every connected number in one place, reply with free-form text (or an approved template when the 24-hour window is closed), watch delivery ticks, and connect new numbers — official ones by credentials, QR ones by scanning. New messages and status changes arrive **in real time** (Reverb broadcasting, with a polling fallback).

> **Every inbound message type is handled end-to-end on both providers** — text, image, video, audio/**voice note**, document, sticker, location, shared **contacts (vCard)**, interactive (button / list) replies, and **reactions** — and each renders type-appropriately in the thread: inline **image / video / audio players**, sticker images, a **location card** linking to Google Maps, a **contact card** with tappable phone numbers, document download links with file size, and **reaction emoji overlaid** on the message they target. Unknown / unsupported types degrade gracefully (a `System` row, never a crash or a silent drop).

> The two providers are abstracted behind a single **`WhatsappDriver`** interface (`CloudApiDriver` + `BridgeDriver`), resolved per channel by **`WhatsappManager`**. Controllers, jobs, the repository and the Vue UI are **provider-agnostic** — the only place a channel's provider shows up in the UI is a badge. Each channel's credentials live **encrypted** on `whatsapp_channels.provider_config` (hidden from serialization); a separate plaintext **`provider_ref`** (the Cloud `phone_number_id` / bridge instance name) is the queryable routing key used to match inbound webhooks to a channel.

## How it works
- **Data model (6 tables + media).** `WhatsappChannel` = a connected number (provider constant + encrypted `provider_config` + `provider_ref` + connection `status`). `WhatsappContact` = the customer, deduped by **E.164 phone** across channels. `WhatsappConversation` = a thread (`assigned_admin_id`, `last_inbound_at` for the window). `WhatsappMessage` = one message (direction in/out, type, status ladder, `provider_message_id` **unique per channel** — Baileys ids aren't globally unique). `WhatsappAttachment` = media/location metadata (child of message; links to a stored file via `media_id`). `WhatsappTemplate` = approved Cloud templates synced from the WABA. Stored binaries live in the **shared `media` table** (`Src\Common\Media`, on GCS) — see *Media* below.
- **Inbound.** A provider webhook hits a **thin public controller** (`WhatsAppWebhookController`) which verifies it (Meta `X-Hub-Signature-256` + the GET verify-token handshake; the bridge an optional token), returns `200` fast, and dispatches **`ProcessInboundWhatsAppWebhook`** (Redis queue). The job resolves the channel by `provider_ref`, asks the driver to `parseInboundWebhook` / `parseStatusUpdates` / `parseMessageMutations` into provider-agnostic DTOs, and persists them via **`WhatsappRepository`**: idempotent dedupe (`Cache::lock` + the unique index), find-or-create contact + conversation, record attachments, and a **monotonic** status ladder (never downgrades `read → delivered`).
- **Message-type mapping (provider-agnostic).** Each driver's `mapContent` returns `[WhatsappMessage::TYPE_*, body, attachment, context]`. Media types (image/video/audio/document/sticker) build a `WhatsappAttachment` descriptor (`provider_media_id`, `mime`, `filename`, `size`, `width`/`height`, plus a `meta` flag for **voice** notes / **animated** stickers / **duration**); **location** carries `latitude`/`longitude` + a `meta{name,address}`; **contacts** build a `TYPE_CONTACT` attachment whose `meta.contacts[]` holds each name + phone numbers + emails (the structured vCard, not just a names string); **reactions** map to `TYPE_REACTION` and put `{target, emoji}` on the message's `meta.context.reaction`; **interactive** (button/list) replies keep both the title (`body`) and the machine-readable reply id (`meta.context.interactive`). The `NormalizedMessage` DTO carries this normalized `context`, and the job stores the message `meta` as `{context, raw}`.
- **Outbound.** The composer posts to `MessagesController@store`, which **enforces the 24-hour window** server-side **and validates that a template send is an APPROVED template for this channel + language** (an unapproved/paused/wrong-language template is rejected, never sent — Meta dings the account for it), then `WhatsappRepository::createOutbound` writes the message as **`QUEUED`** (so the thread echoes it instantly) and **`SendWhatsAppMessage`** (queued) calls the driver and records the result — `SENT` + provider id, or `FAILED` + error. Later status webhooks advance the ticks.
- **Outbound types (composer).** Beyond text/templates the composer sends **media** (the **+** attach menu: Photo/Video, Audio, Document — file upload with optional caption), **location** (modal: lat/lng + optional name/address), and **reactions** (the hover menu's quick-react emoji row 👍❤️😂😮😢🙏 — tap again to remove; only on messages that already have a provider id). The upload whitelist is the **intersection both providers accept** (Cloud is the strict one, per its per-type docs): image **JPEG/PNG ≤ 5 MB** (webp is sticker-only), video **MP4/3GP ≤ 16 MB** (H.264+AAC), audio **AAC/AMR/MP3/M4A/OGG-opus ≤ 16 MB** (no caption — both providers), document **PDF/TXT/Office ≤ 25 MB** (our cap — Cloud allows 100 MB but the bridge posts base64 JSON, body limit 60 MB). Reactions cannot be quote-replies (Cloud rule) and only get a `sent` status webhook; a reaction to a >30-day-old/deleted message fails provider-side (error 131009).
- **Optimistic sending (no page-load feel).** Sends are NOT Inertia form visits: every composer action (text / media / voice / location / reaction) pushes an **optimistic temp bubble into the thread instantly** (media/voice preview & play from a local blob URL) and submits via a **silent axios JSON XHR** — so the top progress bar never moves and the full Inbox props are never re-queried per send. The send endpoints answer JSON (`respondWithMessage` → the presenter shape; 422 + message via `sendRejection`) and the temp bubble is **swapped for the persisted message** on response (uuid-deduped against Echo); failures turn the temp bubble into a Failed bubble with the reason. Background syncs (the 6s fallback poll + the `whatsapp.inbox` conversations refresh) run with `async: true, showProgress: false`, the messages-prop watcher **preserves temp bubbles** and only auto-scrolls when something new actually arrived (no scroll hijack while reading history). Media uploads show a thin inline progress bar (axios `onUploadProgress`).
- **Send failures are always visible.** Three layers: (1) picking a too-big file shows an **instant inline error** (per-type caps mirrored client-side) before any upload; (2) server validation errors (`mediaForm/voiceForm/textForm.errors`) render in a composer banner; (3) a provider/transport failure marks the bubble **Failed with the reason underneath** (presenter + status broadcast carry `meta.error`). Timeout chain sized for 16 MB media: driver HTTP budget **120s** for media paths (the old 30s killed larger uploads) < job `$timeout` **160s** (+ a `failed()` net that marks the message FAILED instead of leaving it on the clock icon) < horizon supervisor-1 timeout **180s** < redis `retry_after` **190s** (no mid-run re-delivery / double-send). Cloud video must be **H.264/AAC** — iPhone HEVC mp4 passes mime checks but fails provider-side (the reason now shows on the bubble).
- **Voice notes (mic).** When the composer is empty the Send button becomes a **mic**: click → in-browser recording bar (pulsing dot + timer, trash to discard, send to deliver; 5-min cap; Esc-free — switching conversation cancels). Recording uses **opus-recorder** (WASM, lazy-loaded ~385 KB on first use) to produce **real OGG/Opus** — the one codec BOTH providers render as a push-to-talk voice bubble (Chrome's native MediaRecorder only yields webm, which neither accepts; no server-side ffmpeg needed). `POST …/messages/voice` (`SendVoiceRequest`: ogg ≤ 16 MB) stores via MediaService and persists a `TYPE_AUDIO` message whose attachment meta carries `voice: true`; the job passes a `$voice` flag through `sendMedia` — Cloud delivers the ogg as-is (auto-renders PTT), the Bridge sends Baileys `ptt: true` with the canonical `audio/ogg; codecs=opus` mimetype (iOS compatibility). `audio-decode` is installed in `wa-bridge/` so Baileys computes the **waveform** bar. Voice notes support quote-reply. Flow: dedicated endpoints (`messages/media|location|{message}/reaction`) + Form Requests → `createOutbound` persists the message (+`whatsapp_attachment` row); media uploads are stored via **`MediaService`** (GCS) and linked with `attachMedia` before dispatch, so the echo bubble renders the real file immediately. `SendWhatsAppMessage::deliver()` branches by type — **Cloud**: typed media payload (uploaded via the Graph `/media` endpoint; caption on image/video/document, filename on document), `type=reaction {message_id, emoji}` (empty emoji removes), `type=location`; **Bridge**: base64 media / `react {text, key}` / `location {degreesLatitude…}` via the new `/messages/reaction` + `/messages/location` endpoints. Media & location sends support quote-reply (`context.message_id` / `quoted`); all non-template sends require the open 24h window on Cloud (`rejectUnsendable`).
- **Quote-reply (WhatsApp-style).** Hovering any bubble shows a chevron → actions menu (**Reply** / Copy text); Reply seeds a **preview bar** above the composer (sender + snippet, X or Esc to cancel) and the send carries `reply_to` (message uuid). The controller resolves it (same conversation + has a provider id) into `meta.context.reply_to.target` (the provider message id); `SendWhatsAppMessage` passes a reply descriptor to the driver — **Cloud** sends `context.message_id` (Graph-native reply), **Bridge** sends `quoted` and the Node side quotes the cached original (or a body stub) via Baileys `{ quoted }` — so the contact's phone shows a **real** quoted reply. Inbound replies are parsed too (Cloud `message.context.id` / Baileys `contextInfo.stanzaId` → the same `meta.context.reply_to`), and `MessagePresenter` resolves the target into `reply_to {uuid, body, is_outbound}` so every reply bubble renders a **quoted block** — click it to scroll to (and briefly highlight) the original.
- **24-hour window + templates.** `WhatsappConversation::canSendFreeform()` is `true` within 24h of the contact's last inbound message **for Cloud** (the QR/Bridge **and Sandbox** channels have no window — the check returns true for any non-Cloud channel). The composer shows a **text box** when open, or an **approved-template picker** when a Cloud window is closed. Templates are pulled from the WABA by the **`whatsapp:sync-templates`** command (driver `syncTemplates` → repository `storeTemplates`).
- **Media.** Inbound media is downloaded **once** off the request path: after persisting a message, the inbound job calls the driver's **`fetchMedia`** (Cloud Graph media URL / the bridge's `/media` endpoint) for **any** attachment that has a `provider_media_id` (the only download gate — no per-type allow-list, so image/video/audio/voice/document/sticker all download; location & contacts carry no media and are correctly skipped). The bytes go to the general **`MediaService`** (stored on **private Google Cloud Storage** as a **`Media`** record), then `WhatsappRepository::attachMedia` links it to the attachment (`whatsapp_attachments.media_id`) and **backfills** any metadata the webhook omitted (notably the byte `size`, which the Cloud API only reveals on download). The inbox shows each file via a short-lived **signed (temporary) URL** minted by `MediaService::temporaryUrl()` — provider URLs (which expire) are never exposed.
- **Rendering (`MessageAttachment.vue`).** A dedicated component renders each attachment by type: **image** (inline thumbnail → open), **video** (`<video controls>`), **audio / voice** (`<audio controls>` + a "Voice message" label & duration), **sticker** (small inline image), **location** (a card with the place name, coordinates, and an *Open in Maps* link), **contact** (a card listing each name with `tel:` phone links + emails), and **document / other** (a download link with the file size). Anything still downloading shows a placeholder chip. **Reactions** never get their own bubble — `Inbox.vue` filters `TYPE_REACTION` messages out of the thread and overlays the (latest, replace/remove-aware) emoji on the bubble whose `uuid` the presenter resolved from the reaction's target. The shared **`MessagePresenter`** exposes the attachment's `type` / `mime` / `size` / `width` / `height` / `meta` and, for reaction messages, a resolved `{emoji, target_uuid}` — so controller props and the broadcast payload stay identical.
- **Deletes & edits (Bridge).** When a contact **deletes "for everyone"** or **edits** a message, Baileys delivers a `protocolMessage` (REVOKE / MESSAGE_EDIT) that the bridge turns into a dedicated **`message.revoked`** / **`message.edited`** webhook (carrying the target `message_id`, edits also the new `body` + `edited_at`). `BridgeDriver::parseMessageMutations` → `WhatsappRepository::revokeMessage` / `editMessage` update the **existing** row in place: a revoke flags `meta.revoked` (the row + original body are **kept for audit**; the bubble shows a *"This message was deleted"* placeholder), an edit swaps `body`, keeps the first version in `meta.original_body`, appends the full revision trail to **`meta.edits[]`** (`{previous, body, at, source, by}`, capped at 20) and stamps the indexed **`whatsapp_messages.edited_at`** column. `MessagePresenter` exposes `revoked` / `edited` / `edited_at` / `original_body` / `edit_error`, and the **`WhatsAppMessageUpdated`** broadcast pushes the fresh shape so the open thread updates the bubble live. Capture-only: mutations never wake the AI / flow engine.
- **Encrypted edits — why edits used to vanish (2026-07-29).** Since ~2026-05 WhatsApp clients no longer send an edit in the clear: it arrives sealed in a **`secretEncryptedMessage`** envelope whose AES-256-GCM key is derived from the **ORIGINAL** message's `messageContextInfo.messageSecret`. **No published Baileys build decrypts it** — 7.0.0-rc13 *and* rc14 contain zero references to the field (upstream [#2690](https://github.com/WhiskeySockets/Baileys/pull/2690) / #2547 / #2554 are all still open), so the edit reached the bridge and was silently dropped. The bridge now handles it itself: every inbound message's `messageSecret` is **banked at receive time** (in-memory + the `wa_bridge_msg_secrets` table, 7-day prune — it must be captured BEFORE any edit exists, and a bridge restart inside WhatsApp's 15-minute edit window is exactly what the table survives), and [`secret-edit.js`](/wa-bridge/src/secret-edit.js) derives the key `HKDF-SHA256(ikm = messageSecret, salt = 32 zero bytes, info = origMsgId ‖ origSenderJid ‖ editorJid ‖ "Message Edit")` and decrypts with an **empty AAD** — the schedule ported from whatsmeow's `msgsecret.go` and cross-checked against the Baileys PR (`secret-edit.test.js` asserts our key is byte-identical to the reference construction). Both LID and phone JID spellings are tried, since only the sending client knows which it used; a wrong guess is rejected by the GCM tag, so failure is clean and logged, never corrupting. Edits are additionally picked up from **`messages.update`** (Baileys' own MESSAGE_EDIT path, whose `key.id` is already the original's) and from the **history / offline-flush** batch — the event buffer merges one into the other on reconnect, so both must be handled; the Laravel apply is idempotent, making double delivery harmless. **Honest limit:** a message received *before* secret banking existed can never have its edits decrypted — the key material only ever travelled with the original.
- **Editing OUR OWN sent messages (Bridge only).** A bubble's hover menu offers **Edit** for an outbound text we sent, inside WhatsApp's ~15-minute window (`WhatsappMessage::EDIT_WINDOW_MINUTES`); the composer doubles as the editor (an amber "Editing message" chip, like WhatsApp itself). `PUT conversations/{id}/messages/{message}` → `MessagesController@update` gates it (ours, sent, not revoked, in-window, channel supports it), `WhatsappRepository::applyOutboundEdit` updates the row optimistically (`source: admin` in the revision trail) and **`EditWhatsAppMessage`** pushes it to WhatsApp via `WhatsappDriver::editText` → the bridge's `POST instances/{i}/messages/edit` → `sock.sendMessage(jid, { text, edit: key })`. Two things that would silently corrupt the thread are handled explicitly: the edit stanza's **own new id is never written over the original's `provider_message_id`**, and the bridge rewrites its cached copy of the original so a WhatsApp **retry receipt can't re-send the pre-edit text** and undo the edit on the customer's phone. A provider refusal rolls the body back (`failOutboundEdit`) and shows an *"edit failed"* tag rather than claiming an edit the customer never got.
- **⚠️ Cloud API cannot edit — at all, in either direction.** Meta exposes **no edit endpoint** (a sent message cannot be altered) and delivers **no webhook when a customer edits one of theirs** — so on a Cloud channel their phone shows the corrected text and our thread keeps the original, with no way for us to even know. This is a platform limitation, not a gap in this code: `CloudApiDriver::supportsMessageEdit()` returns false and `editText()` throws rather than pretending, `WhatsappChannel::supportsMessageEdit()` feeds `channel.supports_edit` to the inbox so the **Edit action is hidden entirely** on those numbers, and the controller answers a forced request with a clear 422 ("send a follow-up reply instead"). Do not assume driver parity here.
- **Phone-mirroring events (2026-07-22, Bridge-only).** The bridge subscribes to 10 more Baileys events so the inbox mirrors the phone: **incoming calls** (TYPE_CALL bubbles, order-proof upsert, inbound-only — a linked device can't observe outbound calls), **phone-side read/pin/mute/archive** (`chats.update`; a phone read clears the inbox badge behind `WHATSAPP_SYNC_PHONE_READS`), **the phone's blocklist** ("Blocked on phone" badge, display-only), **live presence** ("online / typing…" on the open 1:1 thread, `WHATSAPP_PRESENCE` + realtime), **per-member group read receipts** ("· N read" + who-read modal), **group join requests** (Approve/Reject in the members drawer), **WA-Business labels ⇄ tags bidirectional sync** (see [tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md); kill switch `WHATSAPP_LABEL_PUSH`), **an explicit history-sync-complete signal + progress %** (faster sync-card settle), **delete-for-me audit tags**, and a **reaction fallback channel**. Full detail per feature in [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md) → *2026-07-22 additions* and [realtime.md](/docs/modules_handbook/manage/messages/whatsapp/realtime.md). Lane rule: `history.set`/`history.status`/`contacts.updated`/`chat.updated`/`label.*` ride the paced FIFO broadcast lane; everything live stays on the default lane.
- **LID / username readiness (2026-07-22).** WhatsApp's username rollout can HIDE a contact's phone (Bridge: `@lid` privacy identities; Cloud: Meta **BSUID** `CC.alphanumeric` replacing `wa_id`/`from`). Contacts now key on **phone > `wa_user_id` (BSUID) > `lid`** (all nullable-unique; `wa_username` display-only, never a key) via one resolver — `WhatsappRepository::resolveContactByIdentity` (in-place key backfill; split identities NEVER auto-merge). lid/BSUID digits never enter phone normalisation (fake-contact trap); phoneless 1:1 messages/calls/state are captured, not dropped; replies are provider-aware (`{lid}@lid` on Bridge / Meta `recipient` field on Cloud); a phoneless contact stays **UNLINKABLE** (no lead) until its phone appears. When WhatsApp later pairs a phone with a lid/BSUID in one message (the two-row split), the inbox raises an admin-confirmed **"Same person? Merge"** banner (`WhatsappRepository::recordMergeSuggestion` → `mergeContacts` — folds the hidden-number chat into the phone contact, never automatic). Full detail: [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md) → *LID / username readiness*.
- **QR (Bridge).** `ChannelsController` + **`BridgeGateway`** create a bridge instance, fetch its **QR / pairing code** (`Channels.vue` shows it in a modal and polls `state` until `open`, then marks the channel connected), and tear it down. **The QR modal re-polls the QR every ~4s** (`QrConnectModal.vue`) and the bridge holds each QR request up to **20s** — a slow fresh pairing never strands the modal on a spinner, and re-polling keeps the shown QR fresh as WhatsApp rotates it. Bridge reconnects are **single-flight and bounded** (`BRIDGE_MAX_RECONNECT`, default 8; past it the instance parks as `dead` — no reconnect storm that gets the server IP flagged), and **fetching the QR revives** a dead / unresumed instance with a fresh budget, so Scan-QR is self-healing. **Heartbeat:** the scheduled **`whatsapp:ping-bridge`** (every minute) health-checks each Bridge channel with a **real WhatsApp `w:p` ping round-trip** (the bridge `state` endpoint only answers `open` on a genuine pong; a zombie socket is ended + reconnected on the spot) and stamps **`whatsapp_channels.last_ping_at`** — shown on the channel Show page's Connection tab — **and stamps the account's own identity**: an `open` state answer carries `me {phone, name, lid}` (read off `sock.user`), which `ChannelInfoSync::applyBridgeIdentity` writes onto `phone_e164` / `verified_name` (normalised, fill-if-provided, no-op when unchanged) — so a Bridge channel gets its real phone + account name on the first QR-connect poll, and an already-connected number backfills on the next heartbeat without a re-scan — flipping the status CONNECTED ↔ DISCONNECTED to match reality. Layered liveness: Baileys' own keep-alive every 15s (socket) → the heartbeat every 60s (end-to-end + DB/UI). The heartbeat also **auto-revives a `dead` instance** (reconnect budget spent) with a fresh budget, so a long outage never leaves a channel offline until a manual re-scan — and now fires **ops alerts** (`OpsAlertService`, [config/ops.php](/config/ops.php): log always + optional mail/Slack-webhook, keyed + throttled) on a channel's CONNECTED→DISCONNECTED transition, on recovery, and when the bridge process itself is unreachable. **The bridge additionally runs its own in-process watchdog** (every 60s, `BRIDGE_WATCHDOG_INTERVAL_MS`): it revives `dead`/chain-dead instances, restarts sockets stuck in `connecting`, self-pings stale `open` sockets, and starts DB instances missing from memory — so connection self-healing no longer depends on the Laravel scheduler chain being alive (a 440 stream conflict — a duplicate process on the same creds — parks the instance for a 5-min cool-down instead of reconnect-fighting). Everything is visible on **Manage → System Health** ([system-health handbook](/docs/modules_handbook/manage/system-health/readMe.md)). **Inbound reliability:** the bridge's webhook `post()` has a 15s timeout + bounded backoff retries, and an event whose retries ALL fail is **parked in a dead-letter table (`wa_bridge_webhook_dlq`) and replayed every 60s** once Laravel is back — so even a deploy window longer than the retry budget no longer loses inbound events (Laravel's inbound pipeline is idempotent, so replays are safe); **historical backfill messages are not cached** on the bridge, so a large fresh-link sync can't evict the live media/retry cache. **Security (fail-closed):** Laravel rejects the bridge webhook unless `BRIDGE_WEBHOOK_TOKEN` is set (shared via root `.env`; `server-setup.sh` auto-generates it), so the public endpoint can't be used to forge inbound. The instance's per-instance API key is stored (encrypted) on the channel; its webhook points back at `…/webhooks/whatsapp/bridge`, which feeds the **same** inbound job with `PROVIDER_BRIDGE`. The bridge itself is the **`wa-bridge/`** Node service (Baileys) — see its README; it speaks a clean normalized HTTP contract so the PHP side never parses Baileys shapes. Its `normalizeInbound` first **unwraps** ephemeral / view-once / document-with-caption / edited wrappers (`normalizeMessageContent`), then maps every Baileys content type — conversation/extendedText, image/video/audio (forwarding the **ptt** voice flag + **seconds**), document, sticker (**isAnimated**), location + **liveLocation**, **contactMessage / contactsArrayMessage** (parsing phones out of the vCard), **reactionMessage**, **buttons/list/template/interactive** responses, polls, and — as of Baileys 7.0.0-rc13 coverage — inbound business **templateMessage** + **interactiveMessage** (flattened to readable text: title / body / footer + each button as `[Label]`; empty ones degrade to a `📋/💬` system note, never a broken nested-media download) — and media descriptors now also carry **fileLength / width / height**. An **`albumMessage`** header is silently ignored (it carries no media of its own; WhatsApp delivers each album photo/video as a separate `imageMessage`/`videoMessage` the normal media path already captures). Anything still unmapped is **logged** (protocol noise is on a silent-ignore list) instead of being dropped without a trace.
- **LID identities (critical gotcha).** WhatsApp addresses many 1:1 chats by **`@lid`** — a privacy identity whose digits are **not** a phone number. The bridge resolves the real phone from **`msg.key.senderPn`** (groups: `participantPn`) so contacts are always keyed by a true E.164 phone (a lid message without `senderPn` is skipped with a warning, never stored as a fake contact). On send, **`resolveRecipient`** verifies the number via `sock.onWhatsApp()` (cached) and uses the canonical JID — a nonexistent recipient becomes an **explicit FAILED** in the inbox instead of a silently undelivered one-tick message. Bridge + Baileys activity is persisted **one file per day** to **`storage/logs/wa-bridge-YYYY-MM-DD.log`** (level `BRIDGE_LOG_LEVEL`; date follows `APP_TIMEZONE` so it matches the laravel dailies; retention `BRIDGE_LOG_RETENTION_DAYS`, default 30, `0` = keep forever; pretty-viewable in Manage → System Health → Logs); the send→ack trace lines are `outbound … accepted by socket` and `message ack` (`sent → delivered → read`).
- **Phone-app sync (`from_me`).** Messages the channel's own number sends **from its phone app** also reach the inbox: the bridge forwards `fromMe` upserts that are *not* our own socket echoes (echo = `key.id` in `msgCache`; Laravel's per-channel `provider_message_id` dedupe is the backstop) with **`from_me: true`** and `from` = the chat **peer's** phone. `BridgeDriver` maps that to `DIRECTION_OUT`; the repository persists it as **SENT** (+`sent_at`), bumps activity/preview but **never `last_inbound_at`** (the 24h anchor), and later status webhooks advance its ticks. In `@lid` chats the peer's phone for self-sent messages comes from a learned **lid→phone map** (fed by inbound `senderPn`, `onWhatsApp` results, and `chats.phoneNumberShare`); the map is in-memory, so right after a bridge restart a phone-app message in a lid chat may be skipped (warned) until the contact next messages in. **Requires `baileys@7.0.0-rc13`** (the bridge is ESM): 6.x cannot decrypt own-phone carbons in `@lid` chats at all (`SessionError: No session record`) — v7's LID session migration fixes it, and its `key.remoteJidAlt` carries the peer's phone for self-sent lid messages directly.
- **Real time.** After persisting, the jobs/controller fire **`NewWhatsAppMessage`** / **`WhatsAppMessageStatusUpdated`** (`ShouldBroadcast`) on the private channels `whatsapp.conversation.{uuid}` + `whatsapp.inbox` (authorized for admins in `routes/channels.php`); payloads come from the shared **`MessagePresenter`** so they match the controller props exactly (including the attachment signed URL). `Inbox.vue` listens with `window.Echo` (Reverb) — appending new messages (deduped) and patching ticks — and keeps a **6-second fallback poll** so it works even if Reverb / the queue worker is down. **Broadcasting is gated by `WHATSAPP_REALTIME` (off by default)** — each event's `broadcastWhen()` returns `config('whatsapp.realtime')`, so with it off no broadcast job is queued (a server without Reverb never accumulates failed jobs) and the 6s poll is the sole sync; set `WHATSAPP_REALTIME=true` where Reverb runs.
- **Read state & receipts (admin + customer).** Read/unread is tracked **team-wide**: opening a conversation stamps `whatsapp_conversations.last_read_at` (any admin opening it = read for everyone), and the list shows a green **unread count** of inbound messages newer than that. A conversation the **AI auto-replied** to is stamped `ai_replied_at` and surfaced as **"AI · review"** (a violet list marker + a *Needs review* filter) until an admin opens it — so an admin always knows when the AI answered on their behalf (`WhatsappConversation::needsAiReview()` = `ai_replied_at` newer than `last_read_at`, so opening clears both states). Opening also sends the customer a **read receipt** (blue ticks) off the request path — `InboxController@markRead` → `WhatsappRepository::markConversationRead` + the queued **`MarkWhatsAppRead`** job → the driver's **`markRead`** (Cloud marks each unread inbound `status:read`; the Bridge posts `messages/read` → Baileys `sock.readMessages`) — and broadcasts **`WhatsAppConversationRead`** on `whatsapp.inbox` so every other admin's list drops the badge live. **AI auto-replies are human-paced**: before composing a reply, `GenerateAiReply` marks the customer's unread messages **read → shows the typing indicator → sends** (mirroring how a real agent works), and `WhatsappConversation::unreadInboundProviderIds()` (everything since our last outbound) is the acknowledged set.
- **Conversation header.** The open thread's header is minimal — **avatar + name + phone** on the left, a **message-search** icon and a **⋯ overflow menu** on the right. Search filters the thread to matching messages (with a live match count). The **⋯ menu holds the ACTIONS** (flow monitor + Stop, marketing-consent toggle, tags, Pause/Resume AI, Block/Unblock); a small violet dot on ⋯ signals the conversation is in a flow. **Clicking the avatar/name slides in a "Contact info" panel WITHIN the thread column** (an `absolute inset-y-0 right-0` slide-in inside the `relative overflow-hidden` thread panel — NOT a full-page drawer; full-width on mobile, ~`max-w-sm` on desktop) with the read-only DETAILS: big avatar, phone, channel, tag chips, consent/blocked badges, first-seen date, and the linked **CRM lead** (name / email / status + "Open in Leads" link — resolved by `InboxController::leadSummary` via the contact's `user_id`, else by phone → `UserProfile.phone` → `Lead`, exposed as `active.contact_detail`).
- **Navigation (restructured 2026-07-27).** The module was renamed **Messages** and its URLs moved from `/manage/whatsapp/*` to **`/manage/messages/*`** (route names `manage.messages.*`); a catch-all `GET /manage/whatsapp/{path?}` 301s to the new path **preserving the query string**, so bookmarks and shared filter links keep working. The sidebar now carries a **single Messages entry** instead of ten, landing on the inbox. **Dashboard / Inbox / AI Agent / Broadcasts / AI Automation / Settings** are in-page **hub tabs** (underline mains with pill sub-tabs, the Funnel Marketing shape) rendered by **`Components/Messages/MessagesTabs.vue`** over the shared `HubTabs` — this replaced `BroadcastNav.vue`. **Dashboard**, **Inbox** and **AI Agent** are single pages (Dashboard + AI Agent are documented in the [Messages hub readMe](/docs/modules_handbook/manage/messages/readMe.md) — they read across BOTH engines, WhatsApp + Messenger); the last three main tabs each open their own pills:
  - **Broadcasts** → Campaigns (`/broadcasts`) · Templates (`/templates`) · Segments (`/segments`) · Settings (`/broadcasts/settings`)
  - **AI Automation** → CTA Links (`/cta-links`) · AI Setting (`/ai`) · Flows (`/flows`) · Sandbox (`/sandbox`)
  - **Settings** → Channels (`/channels`) · Tags (`/tags`) · General (`/settings`) · **Delivery APIs** (`/manage/integrations/messaging`, behind `view-integrations` — the DB-backed SMS/SMTP/GetResponse credentials, see [Messaging credentials](/docs/modules_handbook/shared/messaging-credentials/readMe.md))
  Each main tab declares the `prefixes` its sub-tabs live under, so "Broadcasts" stays lit on `/templates` and `/segments`. Inbox is resolved LAST — its path is a prefix of every other tab's, so it would otherwise claim them all. Tabs the user lacks permission for are dropped, so a settings-only admin never sees one that would 403. The **Meta Messenger** module folded into this group too — its threads render in the same Inbox and its m.me links on the same CTA Links page, so it has no nav entry of its own (see [messenger/readMe.md](/docs/modules_handbook/manage/messages/messenger/readMe.md)).
- **Profile pictures (QR / Bridge only).** Contact + group WhatsApp avatars are fetched from the Bridge (`GET instances/{id}/avatar?jid=` → Baileys `profilePictureUrl`) and stored on `whatsapp_contacts.avatar_url` / `whatsapp_groups.avatar_url`, refreshed by the scheduled **`whatsapp:sync-avatars`** command (capped per run; WhatsApp CDN URLs are time-limited, so `avatar_synced_at` drives a periodic re-sync). The inbox list, thread header and contact drawer render the avatar with an **`@error` fallback to the initials/icon** (so an expired URL degrades gracefully). The Cloud API has no avatar feed, so this only ever populates for Bridge-reachable contacts/groups. (Same bridge endpoint also revives the Lead-enrichment WhatsApp provider.)
- **Contact names.** A contact's name comes from the sender's **pushName** (live + history) and, for QR channels, their **address-book name** (the history sync's `contacts.updated`). The name-enrich runs on the **same single-process broadcast lane, after** the `history.set` chunks that create the contacts (the bridge forwards contacts last), so a fresh-link backfill's address-book names are no longer lost to a lane-ordering race.
- **The inbox shows the CRM name, not the pushname.** Once a contact is **linked** to an account (`whatsapp_contacts.user_id`, set by [ContactLinker](/docs/modules_handbook/manage/messages/whatsapp/group.md)), the conversation list, thread header and reply labels all show that person's **`user_profile.full_name`** — so an admin reads the name the CRM knows them by, not whatever they set as their WhatsApp display name ("Vi 🌸 KL"). `InboxController::contactDisplayName()` resolves it (CRM name → pushname → phone, never blank; `contact.user.profile` is eager-loaded on both the list and the open thread). The raw pushname still rides along as **`contact.wa_name`** and is rendered in **one place only — the contact drawer** ("WhatsApp name · …", and only when it differs from the displayed name). The list **search** matches the CRM name too (`orWhereHas('contact.user.profile', full_name like)`) alongside the pushname + phone, so a row can never be visible-but-unsearchable. An **unlinked** contact is unchanged: pushname, then phone. Locked by [InboxContactNameTest](/tests/Feature/Whatsapp/InboxContactNameTest.php). **On a fresh QR link, past-history contacts are auto-linked to an already-existing lead / staff account by tolerant phone match** (never creating new leads) so this CRM name shows immediately — see [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md) → *Connect-time match-only contact linking*, with a *"…is syncing past history"* banner while the backfill runs.
- **Tags** — full documentation in **[tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md)**. In short: admin-managed labels (title + palette colour) applied to **contacts** (the deduped person, so a tag follows them across every channel). Managed on a dedicated page (`WhatsappTag` + the `whatsapp_contact_tag` pivot; modal CRUD), applied from the inbox conversation header's **⋯ overflow menu** (optimistic, silent XHR to `ContactsController@syncTags`), shown as chips on conversation rows, and usable as an inbox `?tag=` filter (the tag dropdown shows each tag's **contact count**, from `WhatsappTag::withCount('contacts')`). A **bulk multi-select** in the list ("Select & tag" → tick 1:1 rows → pick a tag) applies one tag to many people at once via `ContactsController@bulkTag` → `WhatsappTagRepository::addTagToContacts` (additive + idempotent; group rows aren't selectable; the same person on two channels dedupes to one contact) — and because tags live on the contact, the label lands on every channel automatically.
- **Inbox filter chips + counts.** The list toggles are **Unread**, **Unreplied** and **Needs review**, each showing a live count for the current context (channel + chat type). *Unreplied* = the customer's message is the latest (an inbound anchor with no outbound sent since — `InboxController::applyUnrepliedFilter`), i.e. threads waiting on a reply. Counts come from the `filterCounts` prop (one COUNT per toggle, sharing the exact predicate the filter uses so the chip and its number never disagree) and refresh on the realtime/poll reload.
- **Bulk multi-select actions.** A **"Select"** toggle turns each list row into a checkbox; the bar then offers **Mark read** and **Tag ▾** (plus **"Select all unread"** for a one-tap mark-read sweep). *Mark read* stamps `last_read_at` on every selected thread (`InboxController::bulkRead` → `WhatsappRepository::bulkMarkConversationsRead`, one UPDATE, visibility-scoped) — clearing the unread badge + AI review flag for all admins; it deliberately does **NOT** blast customer read receipts the way opening one chat does. *Tag* applies one tag to the selected contacts (see tags.md). Any thread is selectable for Mark read (incl. groups); Tag additionally needs a 1:1 contact.
- **Conversation-list preview line.** `whatsapp_conversations.last_message_preview` is a **denormalised snapshot** written at ingest (`WhatsappRepository::previewFor` ← `createOutbound` / `applyConversationActivity`), not a live read of the last message — so anything that mutates a message *in place* has to re-sync it or the row lies forever. `refreshConversationPreview()` does that after an **edit**, a **revoke** (→ *"This message was deleted"*, never the deleted text), an **edit rolled back**, and a **send FAILURE** (→ the preview gains a **`❌ `** prefix — both the send-time failure in `recordSendResult` and an async status-webhook failure in `updateStatus`, e.g. Meta's 131026 — so a red bubble is visible from the LIST, not only inside the thread), and only when the mutated message is still the conversation's **last** one. An **outbound** preview is prefixed with who spoke — **`Me: ` / `AI: ` / `Flow: `** (`previewPrefix`, keyed off the same `meta.ai.generated` / `meta.flow` the bubble tint uses, so list and thread never disagree); inbound rows stay bare. `MessengerRepository::preview` mirrors the prefix (`Me: ` / `AI: ` — Messenger has no flow engine) so the merged list reads consistently. The prefix is stored, matching the existing `📞 Missed call` precedent; existing rows adopt it on their next message.
- **Preview a thread without opening it (👁).** Hovering a list row reveals an **eye** button → `PreviewConversationModal` shows the last 40 messages read-only (rendered by the shared `Components/Conversation/ConversationThread.vue`), with an **"Open & mark read"** button as the deliberate way out. It exists because *opening* a conversation has two irreversible side effects — it stamps `last_read_at` (clearing the unread badge for the **whole team**) and dispatches `MarkWhatsAppRead`, sending the **customer blue ticks**. The modal hits a dedicated **`GET conversations/{id}/preview`** (`InboxController::preview`) that only ever SELECTs: no read stamp, no receipt job, no presence subscribe — an admin can size up an unread message and leave no trace on the customer's phone. It is visibility-gated like every other thread read (`LeadVisibility::allowsConversation` → 403) and serves **both platforms** (a uuid miss on the WhatsApp table is looked up as a Messenger conversation).
- **"Why did this fail?" on a failed bubble (?).** A red bubble reading *"Message undeliverable (131026)"* tells a salesperson nothing — they cannot tell whether to retry, to phone the customer, or to escalate. Every FAILED message now carries an `error_detail` (`Src\Whatsapp\Support\SendErrorExplainer` → `MessagePresenter`), surfaced by a **?** next to the error text that opens `SendFailureModal`: a plain-language title, **who has to act** (recipient / this WhatsApp number / the message / temporary), the likely **causes**, the one **action** to take, whether **retrying can possibly help**, and the provider's raw words behind a disclosure. Two honesty rules are structural: several Meta codes (**131026** above all) are deliberately *bucket* errors — Meta groups many causes under one code and will not say which, to protect the recipient's privacy — so those render as *possibilities* and the modal says so; and an unrecognised code is reported as unknown rather than dressed in a confident guess. The Bridge has no codes at all (it fails with a sentence), so its recurring shapes are matched on text — *"…is not connected"* → the number was offline, retryable. Codes and their handling stay consistent with [`BroadcastErrorRouter`](/src/Whatsapp/Support/BroadcastErrorRouter.php), which decides what the broadcast ENGINE does about the same failures.
- **Toasts surface from the TOP.** All inbox notifications (bulk-action results, sync-card feedback) render top-centre, matching the app-wide `FlashToast` (which teleports to `body` at `top-4`) — no bottom-anchored toasts.
- **CTA Links (lead-capturing wa.me links)** — full documentation in **[cta_link.md](/docs/modules_handbook/manage/messages/whatsapp/cta_link.md)**. In short: an admin authors a shareable **`wa.me` link with a pre-filled default text** (for a Zoom webinar chat, an ad, a bio); when a customer sends the channel a matching message, `ProcessInboundWhatsAppWebhook::considerCtaCapture()` (before `considerFlow`) **captures a CRM lead** (`LeadRepository::firstOrCreateForPhone`), records the **first-touch CTA capture** on the contact (`whatsapp_cta_links` + `whatsapp_cta_captures`, shown on the lead's Attribution tab), links the contact to the lead's account, and can **start an attached flow** (`meta.started_by = cta_link` — the flow's drip is then the reply, keyword trigger + default AI stand down). Managed at `/manage/messages/cta-links` (modal CRUD, copyable link + live preview).
- **Channel management (§14 Index + Show).** `Channels/Index.vue` is a standard admin index (DataTable + search on name/phone + provider/status FilterDrawer with counts + whitelisted sorting via `ChannelQueryRequest` + `ResolvesListQuery`): "Add channel" opens `ChannelFormModal` (Cloud credentials vs QR Bridge — create only; **the one editable field afterwards is the name**, same modal in `edit` mode), row actions = View / Scan-QR (bridge) / Rename / Delete. Each row's **Show page** (`Channels/Show.vue`, smart back-URL via `ResolvesBackUrl`) = identity header + two tabs: **Connection** (`ConnectionTab` — provider/status/`provider_ref`/last-connected/conversation count + the QR re-connect flow via the shared `QrConnectModal`; for a **Cloud** channel it also shows the **Meta-synced details** — verified business name + review status, number verification, quality rating, messaging tier, last-synced — plus a **"Sync from Meta"** button) and **AI Setting** (a read-only card showing the assigned AI profile — name, mode — with a *Manage* link to the profile's edit page; assignment is driven from the profile side, see [ai_profile.md](/docs/modules_handbook/manage/messages/whatsapp/ai_profile.md)). **Meta channel-info sync (`Src\Whatsapp\Services\ChannelInfoSync`):** one Graph `GET /{phone_number_id}` (`CloudApiDriver::fetchPhoneInfo`) pulls `display_phone_number` (normalised → **`phone_e164`** — a connected Cloud number always gets its real phone even when the admin typed none), `verified_name` + `name_status`, `code_verification_status`, and `quality_rating` + `messaging_limit_tier`, persisted via `WhatsappRepository::updateChannelInfo` (identity fields are never blanked by a partial answer; health is always refreshed). It runs **on connect** (`ChannelsController::createCloud`, best-effort), **on demand** (`POST channels/{id}/sync-info`) and **hourly** (`whatsapp:sync-quality`).
- **Sandbox testing (no real WhatsApp).** Before connecting any number you can test the bot (AI profile + flows) end-to-end. The Channels page's **"Sandbox"** button (`ChannelsController::storeSandbox`) creates a no-op **`PROVIDER_SANDBOX`** channel (`SandboxDriver` — every send is accepted locally, NOTHING leaves the server) plus a default *Test Customer* conversation. Testing lives on a **dedicated Sandbox page** (`/manage/messages/sandbox`, sidebar → *WhatsApp Automation → Sandbox*) so test threads never clutter the real inbox — it is the **SAME `Inbox.vue` component + `InboxController`** driven by a `mode` flag (`InboxController::index` renders `mode:'inbox'` and scopes to **non-sandbox** channels; `InboxController::sandbox` renders `mode:'sandbox'` scoped to **sandbox** channels — one codebase, zero duplication). The playground has **all the same inbox features**; the additions are a violet **"send as customer"** bar (rendered only when `active.channel.is_sandbox`) that injects an inbound through the **EXACT** real pipeline — `InboxController::simulateInbound` → `ProcessInboundWhatsAppWebhook` (dispatchSync) → flow trigger + AI engine — so the bot replies live in the thread, plus the pairing tester (below). A **Reset** clears the test conversation (`resetSandboxConversation`). A sandbox channel is non-Cloud / non-Bridge, so the engine treats it permissively (no 24h window, no template approval, no quality gates — `WhatsappConversation::canSendFreeform()` returns true for any non-Cloud channel). Routes: `manage.messages.channels.sandbox` / `conversations.simulate` / `conversations.reset`. **CLI alternative:** `db:seed --class='\WhatsappDemoSeeder'` seeds a ready Sandbox channel + AI profile + flow, then **`php artisan whatsapp:flow-test "hello"`** injects a customer message and traces the whole flow + AI takeover from the terminal (run under WSL — see [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md) → *Testing*).
- **Sandbox pairing tester (flow × profile per conversation).** One sandbox channel holds **many test conversations, each pinned to a `(flow, ai_profile)` pairing** (`whatsapp_conversations.test_config` json = `{flow_id, ai_profile_id}`; `isSandboxTest()`) — so you can compare the same profile after Flow A's drip, after Flow B's drip, or with **no flow** at all, side by side. The list header's **"New sandbox test"** button opens `SandboxTestModal` → `InboxController::storeTestConversation` (`WhatsappRepository::createTestConversation` — a throwaway contact per pairing). The pinned **AI profile OVERRIDES** the flow's own during the takeover (`WhatsappAiConfig::resolveFor` honours `test_config` when the channel is sandbox); the pinned **flow is the only one that triggers** in that thread (`ProcessInboundWhatsAppWebhook::matchingFlow` is scoped) — by keyword for a reactive flow, or a **"Start flow"** button (`conversations.start-flow`) for a proactive one. `null` flow = the profile replies directly; `null` profile = the flow's own AI (as-configured). Full docs: [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md) → *Testing*.
- **AI assistant (settings + reply engine)** — full documentation in **[ai_profile.md](/docs/modules_handbook/manage/messages/whatsapp/ai_profile.md)**. In short: a shared **global base** (knowledge documents + instruction) + reusable named **AI Profiles** (`WhatsappAiProfile`) that carry the behaviour (mode draft-or-auto) and optionally **`extends_global`**; each profile is assigned to many channels (`whatsapp_channels.ai_profile_id`), a channel has one profile (or none = AI off). `WhatsappAiConfig::resolveFor($channel)` returns the effective config (profile + layered global when extending) for the engine — `GenerateAiReply` (an `AiJob`) either auto-sends or stores a draft suggestion broadcast to the composer; a per-profile **settle window** (`debounce_seconds`, default 10 s) plus newer-inbound supersede + pre-send re-check make a rapid multi-message burst produce ONE reply covering every question. The AI engages on every inbound message after the settle window — **bounded by anti-ban safety guards**: it skips blocked contacts and **handed-off threads** (a customer asking for a human — or an admin clicking *Pause AI* — stamps `whatsapp_conversations.ai_handoff_at` and pauses the AI until *Resume AI*), and a MODE_AUTO reply **falls back to a human-reviewed draft** outside the profile's auto-reply hours, on a long bot-only thread, past a daily cap, or on a RED Cloud quality rating (there is **no first-contact gate** — the AI auto-replies to brand-new customers too; see ai_profile.md → *Auto-reply safety guards*; the base prompt now also discloses the assistant is automated). It **always splits its answer by topic into human-paced bubbles** — `DripAiReply` trickles them out with a typing indicator between them (a single-topic reply stays one message; draft mode joins them into one editable suggestion). Documents are shared **`Media`** records (collection `whatsapp_ai`, ≤10/profile, **text only — TXT/MD/CSV ≤20 MB + Word `.docx` converted to text on upload**, no PDF) attached to every AI request via **`AiAttachment::fromMedia()`**; requests run under prompt key **`whatsapp_assistant`** on the **company key**, logged to AI Requests.
- **Settings (guards in DB; credentials in `.env`)** — full documentation in **[settings.md](/docs/modules_handbook/manage/messages/whatsapp/settings.md)**. In short: a single encrypted `whatsapp_settings` row holds the automation guards, but **two pages own different subtrees of it**: `/manage/messages/settings` manages the **AI auto-reply guards only** (`WhatsappSetting::aiPaths()`), while the **broadcast guards moved to the Broadcasts → Settings tab** (2026-07-12, `broadcastPaths()` — see [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md)); each form saves ONLY its own paths so neither clobbers the other. The **Cloud API credentials, general defaults** (`graph_version` / `default_country`) **and the QR Bridge / Baileys connection are all env-only** (`WHATSAPP_*` / `BRIDGE_*`). `WhatsappSettings::apply()` merges the DB guards over `config('whatsapp.*')` at boot (DB-first, `.env` fallback) so no driver changes; a Cloud connection soft-test records `verify_status`. Per-channel credentials stay on each channel.
- **Templates (author + submit to Meta)** — full documentation in **[template.md](/docs/modules_handbook/manage/messages/whatsapp/template.md)**. In short: a §14 **Templates** page lets an admin **author a Cloud message template** (TEXT header + body `{{n}}` + footer + quick-reply/URL buttons), **preview it as a WhatsApp bubble** (like Meta's Template library), and **submit it to Meta for approval** (`CloudApiDriver::createTemplate` → `POST /{WABA_ID}/message_templates`; positional params). `whatsapp_templates` now also holds locally-authored rows (`source` LOCAL/META + `meta_template_id` + `rejected_reason` + blame); the row trusts **Meta's returned category** (Meta may reclassify UTILITY→MARKETING). Approval flips the status via the **existing** `message_template_status_update` webhook; a **Sync from Meta** button reconciles. Approved templates feed broadcasts / funnel automation / the out-of-window composer.
- **Broadcast (bulk template / free-form blasting)** — full documentation in **[broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md)**. **Phases 0–4 shipped.** *Phase 0* = the consent foundation a previously-restricted business needs: a per-category **`whatsapp_consents`** ledger (opt-in/out + source + `proof`), inbound **STOP → durable OPTED_OUT-all-categories**, an admin consent toggle, frequency-cap columns. *Phase 1* = the MVP send pipeline: `whatsapp_broadcasts` + `whatsapp_broadcast_recipients` + `WhatsappBroadcastRepository`, a **Cloud-only hard gate** (the Phase-1/2/3 rule), audience materialise (dedupe + skip blocked/un-consented/invalid/frequency-capped), a **capped single-process `redis-broadcast` pacer lane**, `CloudApiDriver::sendTemplate` with **header media + body + buttons**, delivery webhooks roll up free, quiet-hours / daily-cap / quality-RED auto-pause, and the §14 Broadcasts Index/Show/compose UI. *Phase 2* = **real-time webhook auto-pause** (`account_update` quality/restriction + `message_template_status_update` PAUSED/DISABLED → pause running broadcasts instantly, resolving the channel by WABA id), recipient **`error_code`/`error_title`** from failed-status webhooks, a **counter reconciler** command, **CSV contact import** + **delivery-report export**. *Phase 3* = a shared **`AudienceResolver`** with four sources (**tags / saved segment / lead funnel / active members** — CRM sources reach only EXISTING contacts, never cold-create), the **Segments** module, **auto first-name personalization**, and a **default-checked WhatsApp opt-in** on the landing lead-capture form (+ a **CSV** one-off audience, 2026-07-09). *Phase 4 (2026-07-12)* = **engine reliability** (pacer generation token, heartbeat + revival, lock expiry, atomic PENDING→QUEUED claims, a failable BUILDING state, per-channel pace splitting), **Meta error-code routing** (`BroadcastErrorRouter` — 131049/130472 → a per-contact `marketing_cooldown_until`; 131048/131042 → pause + ops alert), **default opt-in** (`broadcast.require_consent_marketing` now defaults **false** — only explicit opt-outs are excluded), and **QR / Bridge broadcasting**.
  > **The Baileys channel CAN now broadcast (Phase 4) — this deliberately reversed the earlier `isCloudApi()` hard block.** It is HIGH-RISK and opt-in per broadcast, sending **free-form text / image only** (never templates) and always **forced to `CATEGORY_MARKETING`** so the STOP ladder applies in full. Its gates: the config **kill switch `whatsapp.broadcast.allow_bridge`** (`WHATSAPP_BROADCAST_ALLOW_BRIDGE`, re-checked every pacer tick — flipping it off pauses running Bridge broadcasts with `PAUSE_BRIDGE_DISABLED`) **AND** a per-broadcast admin **risk acknowledgement** (`risk_acknowledged_at` / `_by` — the compose modal's mandatory red "use at your own risk" checkbox). Its pacer is far stricter than Cloud's: a `bridge_per_minute` ceiling (default **4**), a `bridge_daily_cap` (default **150**), a **09:00–22:00** waking-hours window, **jittered** gaps (0.6–2.2× base + occasional long pauses — a fixed interval is itself a bot signal) and a typing-presence simulation before each send; a disconnected QR session auto-pauses the broadcast (`PAUSE_BRIDGE_OFFLINE`). All of these guards are UI-adjustable on the **Broadcasts → Settings** tab.
- **Flow (rule-based automation)** — full documentation in **[flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md)**. **Phases 1–3 shipped (reactive keyword flow + objective auto-detection + proactive Google Sheet trigger) + Campaign trigger & per-run variables (2026-07-02) + steps versioning & Runs view (2026-07-12) + interactive WAIT steps (2026-07-13) + flow TYPES & parallel run slots (2026-07-14) + proactive TEMPLATE MENUS / no-answer reminders / "Send to customers" bulk dispatch (2026-08-04: a rule-based bot can open with a Cloud template whose QUICK_REPLY buttons ARE the first menu — pasted/CSV recipient list, paced, per-recipient `{{variables}}`; a silent customer gets the menu re-sent up to N times, then the team is alerted via Notify `whatsapp.flow_no_response` and the run ends `no_response`).** In short: an admin-built automation on a channel, of one of **two product TYPES** fixed at creation (`flow_type` — a **time-based drip** (`TYPE_TIME_BASED`: a timed nurture sequence that never waits for a reply) or a **rule-based bot** (`TYPE_RULE_BASED`: an interactive menu tree, every send immediate); the builder, validation and slot rules are tailored to each). An inbound **keyword** (exact / contains) starts the tree of messages (text / media / Cloud template, per-step delays, `{{first_name}}` + per-run `variables`), after which an optional **AI profile** takes over toward an **objective** — reusing the existing `GenerateAiReply` engine via a flow-aware `WhatsappAiConfig::resolveFor($channel, $conversation)`. 3 tables (`whatsapp_flows` + `whatsapp_flow_steps` + the per-conversation `whatsapp_flow_runs` state machine), the `AdvanceFlowRun` drip pacer + `whatsapp:reap-flow-runs` reaper, the §14 Flows Index + step-builder edit page, and an inbox **Stop flow** control.
  - **Interactive WAIT steps** — a TEXT step can be a **menu**: `advance_mode = ADVANCE_REPLY` + `options` (each `{label, next}` targeting another step's stable **`step_key`**, or `end` / `ai` / `handoff`) + a `fallback` (re-ask message × `max_retries`, then end / AI / handoff). The run **parks** after sending and the reply is matched by native interactive id → bare number → exact label. **Cloud** renders it as NATIVE tappable UI (≤3 options → reply **buttons**, 4–10 → a **list** menu); **Bridge** renders a numbered text menu. Targets may hop **backward** ("back to main menu") — the run's `hop` counter defeats per-position idempotency so the menu re-sends.
  - **Steps versioning + Runs page** — `canonicalSteps()` → a content **hash** bumps `steps_version` only when the steps actually changed; `startRun` freezes a `steps_snapshot` (edits apply to NEW runs only) and stamps the version. Each flow has a per-flow **Runs page** (`flows/{id}/runs`: version filter cards, a §14 DataTable of runs, an expandable frozen-snapshot diagram, Stop, an Inbox deep-link), and every flow visual has a read-only **Graph ⇄ List toggle** (`FlowView` → `FlowGraph`, Vue Flow + dagre auto-layout, lazy-loaded).
  - **Parallel run slots** — a conversation holds **one run slot per TYPE**, so a rule-based bot and a time-based drip can run at the same time on the same customer; the bot **owns** the interactive conversation and its AI profile outranks the drip's, while the drip fires its timed sends in the background. A handoff (keyword, menu option or fallback) ends **every** run.
  - **⚠️ Cross-flow jumps were REMOVED (2026-07-13).** Run chaining (`next_flow_id`), AI intent routing (`routing_hint` + the `[[GOTO]]` token) and deterministic `branches` are all gone — columns dropped, engine paths deleted. A flow is now a **self-contained tree**; in-flow menu options replace what branches did (deterministic + provider-native). `[[OBJECTIVE_MET]]` auto-completion remains, but simply **ends** the run and returns the conversation to the channel's default AI.
  - A run ends on admin Stop / idle timeout / handoff / the tree finishing (`drip_done`) / the AI judging the **objective** reached / a template becoming unavailable at send time / a proactive opener failing. A flow can instead trigger from a **Google Sheet** (`TRIGGER_SHEET`) or a **Campaign** API call (`TRIGGER_CAMPAIGN`): rows/recipients → `StartProactiveFlow` creates the contact + CRM lead and starts the flow's proactive opener (Cloud **template** step / Bridge free-form), **paced** on the broadcast lane and **skipping opted-out contacts**. **Reactive flows are safe on BOTH providers** (the customer messaged first); proactive sends are paced + opt-out-respecting; consent is otherwise not gated in flows.
- **Groups, communities & history sync (Bridge-only)** — full documentation in **[group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md)**. In short: on a QR / Bridge number the inbox also captures **WhatsApp group + community messages** and backfills **past history** (the full history WhatsApp pushes on a **fresh QR link** — there is no *Load older* button; re-scan the QR to backfill an existing number). Groups appear in the same list (a **chat-type filter** + per-message **sender labels**); a group is a `WhatsappGroup` (self-referencing `community_group_id` for communities) + a `chat_type = group` conversation with a nullable contact + `sender_contact_id` / `is_historical` on messages. Groups are **CAPTURE-ONLY for automation** — the AI / flow / opt-out engine NEVER runs on a group (four guard layers) — but an admin can **reply manually** (the send resolves the **group JID**, not a contact phone). The **Cloud API + Sandbox** drivers no-op every group / history / contact parser (history + groups are a WhatsApp-Web capability).
- **Phone alerts on a new customer message (2026-07-26).** After persisting an inbound message the pipeline calls `ProcessInboundWhatsAppWebhook::considerNotify()`, which fires the **`whatsapp.message_received`** event on the shared **[Notify](/docs/modules_handbook/shared/notify/readMe.md)** service — pushing "New WhatsApp message" (sender, number, excerpt, a deep link to the thread) to the Telegram chats each admin subscribed on **Manage → Notifications**. It is **best-effort and queue-only** (the Notifier just enqueues), so it can never delay or break ingestion. Four guards keep it quiet, all config-driven ([config/notify.php](/config/notify.php) → the event's `options`): inbound only; **never a history backfill** (a fresh QR link replays thousands of old messages); **no group chats**; and **Cloud API channels only by default** (`options.providers`) — the QR/Bridge number carries far more incidental traffic. The Notifier additionally throttles **per conversation** (`NOTIFY_WA_THROTTLE_SECONDS`, default 180s), so a customer firing off five messages buzzes the phone once while a *different* customer still gets through.
- All writes go through `WhatsappRepository` / `MediaRepository` inside `DB::transaction`; controllers stay thin and return `Inertia::render` / `back()`.

## Prerequisites
- **Redis cache in production (`CACHE_DRIVER=redis`).** The flow drip pacer (`AdvanceFlowRun`'s `WithoutOverlapping`) and the flow-start / CTA `Cache::lock` take **atomic locks on the default cache store** — the `file` driver's locks break under multiple Horizon workers and are wiped by `optimize:clear` on every deploy (symptom: a storm of failed `AdvanceFlowRun` jobs with `fopen(storage/framework/cache/data/…): No such file or directory`, and drips that never send). Cache **data** lives on its own redis DB (`REDIS_CACHE_DB`, default 1) so the deploy's `cache:clear` never flushes the queue; **locks** stay on the never-flushed default DB (`config/cache.php` `lock_connection`).
- **Queue worker — Horizon** (`php artisan horizon`) — inbound processing (incl. media download), sending, the AI reply engine (`redis-ai`), flow drips (default lane) and broadcasting (`redis-broadcast`) all run on the queue. Horizon's daemon can't run on **native** Windows — run it under **WSL2 / Linux** (the prod VM). `php artisan horizon` does **not** hot-reload changed job code → run **`php artisan horizon:terminate`** after a deploy/code change so workers restart. All lanes (incl. **`supervisor-broadcast`** for the `redis-broadcast` queue — broadcasts + proactive flows) are activated in `config/horizon.php` `environments.{production,local}`; after a restart confirm a `horizon:work redis-broadcast` worker is up (see [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) → *Prerequisites*).
- **Reverb** (`php artisan reverb:start`) for live updates — installed/wired during the Laravel 13 upgrade; without it the inbox falls back to polling.
- **The QR bridge** — a running **`wa-bridge/`** Node service (`npm install && npm start`), with `BRIDGE_API_URL` / `BRIDGE_API_KEY` pointing at it. See `wa-bridge/README.md`.
- **Google Cloud Storage** (for media) — `MEDIA_DISK=gcs` + `GOOGLE_CLOUD_PROJECT_ID` / `GOOGLE_CLOUD_STORAGE_BUCKET` / `GOOGLE_CLOUD_KEY_FILE` (private bucket; signed URLs).
- **Credentials to actually deliver:** Cloud — `WHATSAPP_ACCESS_TOKEN` + a channel `phone_number_id`, webhook verify token `WHATSAPP_VERIFY_TOKEN`. QR — the running bridge above.

## Related files

**Backend — Models**
- [src/Whatsapp/WhatsappChannel.php](/src/Whatsapp/WhatsappChannel.php) — connected number; `PROVIDERS` (`PROVIDER_CLOUD_API` / `PROVIDER_BRIDGE` / `PROVIDER_SANDBOX`) / `STATUSES`; encrypted `provider_config` (`$hidden`); `isCloudApi()` / `isBridge()` / `isSandbox()`; Cloud account health `quality_rating` / `messaging_tier` + `QUALITIES` / `isQualityRed()`.
- [src/Whatsapp/WhatsappContact.php](/src/Whatsapp/WhatsappContact.php) — E.164 contact (unique); optional link to a platform `user`; `tags()` (many-to-many labels — see [tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md)); `isBlocked()` (`blocked_at` — set / cleared from the inbox header Block / Unblock button; blocks ALL messaging, human and AI); `consents()` / `isOptedIn()` / `isOptedOut()` (marketing consent — see [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md)).
- [src/Whatsapp/WhatsappConsent.php](/src/Whatsapp/WhatsappConsent.php) — per-category marketing-consent ledger (Phase 0 of broadcast); `CATEGORY_*` (mirror `WhatsappTemplate`) / `STATE_*` / `SOURCE_*` + `proof` evidence; `contact()` — full docs in [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md).
- [src/Whatsapp/WhatsappTag.php](/src/Whatsapp/WhatsappTag.php) — admin label (key model: uuid + blame + soft delete); `COLORS` palette; `contacts()` — full docs in [tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md).
- [src/Whatsapp/WhatsappConversation.php](/src/Whatsapp/WhatsappConversation.php) — thread; `canSendFreeform()` (the **Cloud** 24h window only — **Bridge + Sandbox are free-form anytime**); read tracking (`last_read_at` / `ai_replied_at` + `needsAiReview()` / `unreadInboundProviderIds()`); human handoff `isAiHandedOff()` (`ai_handoff_at`).
- [src/Whatsapp/WhatsappMessage.php](/src/Whatsapp/WhatsappMessage.php) — `DIRECTIONS` / `TYPES` (text…system + **`TYPE_REACTION`**) / `STATUSES`; soft-cascades to attachments.
- [src/Whatsapp/WhatsappAttachment.php](/src/Whatsapp/WhatsappAttachment.php) — media / location / **contact** metadata (child; no uuid/blame); `TYPE_*`; `mime`/`size`/`width`/`height`/`latitude`/`longitude`/`meta` (voice & animated flags, location name/address, structured contacts); `media()` → the stored file.
- [src/Whatsapp/WhatsappTemplate.php](/src/Whatsapp/WhatsappTemplate.php) — Cloud templates, **synced from Meta OR authored + submitted here** (`SOURCE_META` / `SOURCE_LOCAL` + `meta_template_id` / `rejected_reason` + blame); `CATEGORIES` / `STATUSES` / `SOURCES`; Meta→constant mappers + `countVariables()` — see [template.md](/docs/modules_handbook/manage/messages/whatsapp/template.md).

**Backend — Drivers (provider abstraction)**
- [src/Whatsapp/Drivers/WhatsappDriver.php](/src/Whatsapp/Drivers/WhatsappDriver.php) — the contract (send / parse / status / `fetchMedia` / templates / `markRead` read receipts / `sendTyping`).
- [src/Whatsapp/Drivers/CloudApiDriver.php](/src/Whatsapp/Drivers/CloudApiDriver.php) — Meta Graph API (send text/media/template, parse webhooks, sync templates, fetch media bytes).
- [src/Whatsapp/Drivers/BridgeDriver.php](/src/Whatsapp/Drivers/BridgeDriver.php) — the Baileys bridge REST (send, parse normalized `message.received` / `message.status` / `message.revoked` / `message.edited`, fetch media).
- [src/Whatsapp/Drivers/SandboxDriver.php](/src/Whatsapp/Drivers/SandboxDriver.php) — a no-op test transport (every send accepted locally; parses a simulated customer message) for the inbox sandbox tester.
- [src/Whatsapp/Drivers/Data/](/src/Whatsapp/Drivers/Data/) — DTOs: `SentMessage`, `NormalizedMessage`, `StatusUpdate`.

**Backend — Services & Support**
- [src/Whatsapp/Services/WhatsappManager.php](/src/Whatsapp/Services/WhatsappManager.php) — resolves the driver for a channel's provider.
- [src/Whatsapp/Services/PhoneNormalizer.php](/src/Whatsapp/Services/PhoneNormalizer.php) — E.164 normalization (default country = MY).
- [src/Whatsapp/Services/BridgeGateway.php](/src/Whatsapp/Services/BridgeGateway.php) — bridge instance lifecycle (create / connect-QR / state / logout / delete).
- [src/Whatsapp/Support/MessagePresenter.php](/src/Whatsapp/Support/MessagePresenter.php) — shared message → inbox-array transform (controller props **and** broadcast payload); per-attachment `type`/`mime`/`size`/`width`/`height`/`meta` + signed URL, a resolved `reaction` (`{emoji, target_uuid}`) for reaction messages, and `revoked` / `edited` / `edited_at` / `original_body` / `edit_error` for deleted / edited messages.
- [wa-bridge/src/secret-edit.js](/wa-bridge/src/secret-edit.js) — decrypts WhatsApp's `secretEncryptedMessage` edit envelope (HKDF-SHA256 + AES-256-GCM, ported from whatsmeow); `secret-edit.test.js` pins the key schedule against the upstream construction (`npm test` in `wa-bridge/`).
- [app/Jobs/Whatsapp/EditWhatsAppMessage.php](/app/Jobs/Whatsapp/EditWhatsAppMessage.php) — pushes an admin's edit of an already-sent message to WhatsApp, rolling the body back if the provider refuses.
- [src/Whatsapp/Support/RecipientResolver.php](/src/Whatsapp/Support/RecipientResolver.php) — the one provider-aware "what address do we send this conversation to" implementation (phone / `{lid}@lid` / BSUID / group), shared by the send + edit jobs.
- [src/Whatsapp/Support/LeadConversationPresenter.php](/src/Whatsapp/Support/LeadConversationPresenter.php) — **the ONLY sanctioned way to show a lead's WhatsApp threads outside the inbox** (the Lead Show → Channel → WhatsApp tab). See *Reference usage* below.

### Reference usage — `LeadConversationPresenter::forLead($lead, $viewer)`

Returns a lead's **1:1** WhatsApp threads (newest-active first, ≤10 threads × ≤30 messages), each `{uuid, channel, contact, last_activity_at, inbox_url, messages[]}` where `messages` is the standard `MessagePresenter::inbox()` shape — so the shared `resources/js/Components/Conversation/` components render it exactly like the inbox. Wire it as an **`Inertia::optional`** prop, gated on `Permission::VIEW_WHATSAPP`, and let the lazy-mounted tab fetch it (see [Leads](/docs/modules_handbook/manage/leads/readMe.md)). It is **read-only** — the caller links out to `manage.messages.inbox?conversation={uuid}` to reply.

**Do not hand-roll this query.** Three rules are baked in and each is load-bearing:
1. **`chat_type = CHAT_INDIVIDUAL`.** A group thread belongs to no single lead, and **`LeadVisibility::applyToConversations` does NOT filter it out** — `contact.user.lead` resolves fine for a group, so its check *passes*. This `where` is the only thing standing between one member's lead page and the entire group's chat history.
2. **`contact.user_id` strict equality.** Never widen to a tolerant phone match; that belongs to `ContactLinker`, which verifies canonical E.164 before binding. A missing thread is a **linking** bug, not a query bug.
3. **Non-sandbox channels only** (a Sandbox thread is an internal test, not a real conversation), and the caps exist because `MessagePresenter`'s `replyTo()` / `reaction()` resolve per message **without** memoisation.
- [src/Whatsapp/Services/HistorySyncReporter.php](/src/Whatsapp/Services/HistorySyncReporter.php) — builds the QR history-sync outcome snapshot (the inbox summary card's counts) and owns the canonical `unlinkedContactsQuery()` (the "unmatched" set) shared by the report, the bulk lead-creation job and the CSV export. See [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md).

**Backend — Repository**
- [src/Whatsapp/Repositories/WhatsappRepository.php](/src/Whatsapp/Repositories/WhatsappRepository.php) — all writes: `recordInbound` / `attachMedia` / `updateStatus` / `createOutbound` / `recordSendResult` / `markConversationRead` / `markAiReplied` / `storeTemplates` / `createChannel` / `setChannelConnection` / `deleteChannel`.

**Backend — General Media (shared, `Src\Common`)**
- [src/Common/Media.php](/src/Common/Media.php) — polymorphic stored file (key model: uuid + blame + soft delete), attachable to any model.
- [src/Common/Repositories/MediaRepository.php](/src/Common/Repositories/MediaRepository.php) — media DB writes (transactional).
- [src/Common/Services/MediaService.php](/src/Common/Services/MediaService.php) — store bytes / uploads on the configured disk (GCS), mint temporary signed URLs, delete.

**Backend — Controllers**
- [app/Http/Controllers/Manage/Whatsapp/InboxController.php](/app/Http/Controllers/Manage/Whatsapp/InboxController.php) — conversation list (+ unread / **unreplied** / needs-review filters, each with a `filterCounts` badge; shared `$contextFilters` closure keeps the list + counts in step) + thread; `markRead` (open → team-wide read + customer receipt) + `bulkRead` (multi-select mark-read, no receipts); `handoff` / `resumeAi` (pause / resume the AI for a thread).
- [app/Http/Controllers/Manage/Whatsapp/MessagesController.php](/app/Http/Controllers/Manage/Whatsapp/MessagesController.php) — send (text / template), enforces the 24h window + approved-template check + the blocked-contact block.
- [app/Http/Controllers/Manage/Whatsapp/ChannelsController.php](/app/Http/Controllers/Manage/Whatsapp/ChannelsController.php) — channel CRUD + bridge QR / state.
- [app/Http/Controllers/Webhooks/WhatsAppWebhookController.php](/app/Http/Controllers/Webhooks/WhatsAppWebhookController.php) — public Cloud (verify + handle) + bridge webhook endpoints.

**Backend — Form Requests**
- [app/Http/Requests/Manage/Whatsapp/SendMessageRequest.php](/app/Http/Requests/Manage/Whatsapp/SendMessageRequest.php) — body / template.
- [app/Http/Requests/Manage/Whatsapp/StoreChannelRequest.php](/app/Http/Requests/Manage/Whatsapp/StoreChannelRequest.php) — Cloud creds vs QR.

**Backend — Jobs, Events & Command**
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — parse + persist inbound (both providers) + download media via `MediaService`.
- [app/Jobs/Whatsapp/SendWhatsAppMessage.php](/app/Jobs/Whatsapp/SendWhatsAppMessage.php) — deliver an outbound message, record the result.
- [app/Jobs/Whatsapp/MarkWhatsAppRead.php](/app/Jobs/Whatsapp/MarkWhatsAppRead.php) — send the customer a read receipt (blue ticks) off the request path when an admin opens a conversation.
- [app/Jobs/Whatsapp/LinkChannelContacts.php](/app/Jobs/Whatsapp/LinkChannelContacts.php) — connect-time **match-only** contact link for one Bridge channel (links a synced contact to an EXISTING lead/staff by phone, never creates leads) + closes out the history backfill (self-settling; stamps `history_sync_completed_at` + the summary report). Runs on the **default** lane, never `redis-broadcast`. See [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md).
- [app/Jobs/Whatsapp/CreateLeadsForSyncedContacts.php](/app/Jobs/Whatsapp/CreateLeadsForSyncedContacts.php) — admin-triggered BULK lead creation for a channel's UNMATCHED history contacts (the sync-summary card's "Create N leads"); runs the CREATE path (`ContactLinker::link()`) — the only history path that makes *new* leads, and only on explicit admin action. Default lane, single-flighted.
- [app/Events/Whatsapp/NewWhatsAppMessage.php](/app/Events/Whatsapp/NewWhatsAppMessage.php) · [app/Events/Whatsapp/WhatsAppMessageStatusUpdated.php](/app/Events/Whatsapp/WhatsAppMessageStatusUpdated.php) · [app/Events/Whatsapp/WhatsAppConversationRead.php](/app/Events/Whatsapp/WhatsAppConversationRead.php) · [app/Events/Whatsapp/WhatsAppChannelSyncStatus.php](/app/Events/Whatsapp/WhatsAppChannelSyncStatus.php) — broadcast events (Reverb; `WhatsAppConversationRead` clears the unread/review badge team-wide on read; `WhatsAppChannelSyncStatus` shows/hides the inbox "syncing past history" banner on a fresh QR link).
- [app/Console/Commands/SyncWhatsappTemplates.php](/app/Console/Commands/SyncWhatsappTemplates.php) — `whatsapp:sync-templates`.
- [app/Console/Commands/SyncWhatsappQuality.php](/app/Console/Commands/SyncWhatsappQuality.php) — `whatsapp:sync-quality` (Cloud quality rating + messaging tier **+ the number's phone / verified business name / verification statuses**, via `ChannelInfoSync`; run on a schedule so the quality-RED AI guard stays fresh).
- [app/Console/Commands/PingWhatsappBridge.php](/app/Console/Commands/PingWhatsappBridge.php) — `whatsapp:ping-bridge` (every minute: real WhatsApp ping per Bridge channel → `last_ping_at` + status heal; a zombie bridge socket is ended + reconnected).

**Config**
- [config/whatsapp.php](/config/whatsapp.php) — Graph version (pinned `v25.0`), default country, media disk, Cloud + Bridge credentials/defaults, CA-bundle fallback.
- [config/media.php](/config/media.php) — media disk (`gcs`), signed-URL TTL, base directory.
- [config/filesystems.php](/config/filesystems.php) — the private `gcs` disk (spatie/laravel-google-cloud-storage).

**Frontend (Vue)**
- [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) — two-pane chat: list + filters, thread + ticks, composer (text / template), Echo + fallback poll; filters reaction messages into **emoji overlays** and delegates attachment rendering to `MessageAttachment`. **The conversation list is a windowed infinite scroll** — `InboxController` returns the top `per_page` most-recently-active threads (default 30, grows by 30 as the admin scrolls near the bottom, capped at 300; a `+1`-row probe sets `conversationsHasMore`), and the `per_page` window rides on every request (filter nav / poll / load-more) so the fallback poll refetches the whole loaded window (a bumped thread never duplicates); a filter change resets it to page 1. Beyond the cap, search / filters narrow the set.
- [resources/js/Components/Conversation/MessageAttachment.vue](/resources/js/Components/Conversation/MessageAttachment.vue) — renders one attachment by type: image / video / audio (voice) players, sticker, location (Maps link), contact card (`tel:` links), document download (with size), or a still-downloading placeholder. It sits in the **channel-agnostic** `Components/Conversation/` folder (it moved out of `Components/Whatsapp/`) because the same renderer serves every thread surface: `Inbox.vue` imports it directly, and the shared chain [`ConversationPane.vue`](/resources/js/Components/Conversation/ConversationPane.vue) → [`ConversationThread.vue`](/resources/js/Components/Conversation/ConversationThread.vue) → [`MessageBubble.vue`](/resources/js/Components/Conversation/MessageBubble.vue) → this file renders it inside the Leads → Show **WhatsApp _and_ Messenger** tabs. Anything WhatsApp-only (`TagChip`, `TemplateDesigner`, the flow views) stays under `Components/Whatsapp/`.
- [resources/js/Pages/Manage/Messages/Channels/Index.vue](/resources/js/Pages/Manage/Messages/Channels/Index.vue) — §14 channels index (DataTable + filters) · [Channels/Show.vue](/resources/js/Pages/Manage/Messages/Channels/Show.vue) — Connection + AI Setting tabs · [Channels/Partials/](/resources/js/Pages/Manage/Messages/Channels/Partials/) — `ChannelFormModal` (create / rename), `QrConnectModal`, `Tabs/ConnectionTab`.
- [resources/js/Pages/Manage/Messages/Ai/Index.vue](/resources/js/Pages/Manage/Messages/Ai/Index.vue) — global-base card + named-profiles table (`ProfileFormModal` to create) · [Ai/Edit.vue](/resources/js/Pages/Manage/Messages/Ai/Edit.vue) — scope-aware edit page (global base / profile) wrapping the shared [Partials/AiProfileForm.vue](/resources/js/Pages/Manage/Messages/Partials/AiProfileForm.vue) (behaviour + instruction + documents + channel multi-select).
- [resources/js/Components/ImageLightbox.vue](/resources/js/Components/ImageLightbox.vue) + [composables/useMessageGallery.js](/resources/js/composables/useMessageGallery.js) — clicking any picture in a thread browses every image in it. **Shared with the slot-poster modal — documented in [shared/image-lightbox](/docs/modules_handbook/shared/image-lightbox/readMe.md), which uses this inbox as its reference mount.**
- [resources/js/echo.js](/resources/js/echo.js) — Laravel Echo (Reverb) client, imported in `app.js`.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — the single `messagesGroup` sidebar entry · [resources/js/Components/Messages/MessagesTabs.vue](/resources/js/Components/Messages/MessagesTabs.vue) — the hub strip (Inbox / Broadcasts / AI Automation / Settings + their pills).

**The QR bridge (Node sidecar)**
- [wa-bridge/](/wa-bridge/) — minimal Express + Baileys service that mimics the bridge HTTP contract; MySQL-backed sessions; normalized webhooks. See [wa-bridge/README.md](/wa-bridge/README.md).

**Migrations**
- [database/migrations/2026_06_03_100001_create_whatsapp_channels_table.php](/database/migrations/2026_06_03_100001_create_whatsapp_channels_table.php) … `_100006_create_whatsapp_templates_table.php` (6 core tables).
- [database/migrations/2026_06_04_000001_create_media_table.php](/database/migrations/2026_06_04_000001_create_media_table.php) — the shared polymorphic `media` table.
- [database/migrations/2026_06_04_000002_add_media_id_to_whatsapp_attachments_table.php](/database/migrations/2026_06_04_000002_add_media_id_to_whatsapp_attachments_table.php) — links attachments to their stored file.
- [database/migrations/2026_06_15_000001_add_read_tracking_to_whatsapp_conversations_table.php](/database/migrations/2026_06_15_000001_add_read_tracking_to_whatsapp_conversations_table.php) — `last_read_at` + `ai_replied_at` (admin unread + AI-review tracking).
- [database/migrations/2026_06_16_000001_drop_reply_style_and_engagement_from_whatsapp_ai_profiles_table.php](/database/migrations/2026_06_16_000001_drop_reply_style_and_engagement_from_whatsapp_ai_profiles_table.php) — drop `reply_style` + the engagement columns (`trigger_type` / `delay_minutes` / `office_hours`); the AI now always bubbles + always engages.
- [database/migrations/2026_06_16_000002_drop_status_from_whatsapp_conversations_table.php](/database/migrations/2026_06_16_000002_drop_status_from_whatsapp_conversations_table.php) — drop the conversation workflow `status`. (The committed create migration `..._100003_` had its `status` default switched from the now-removed `WhatsappConversation::STATUS_OPEN` constant to the literal `1` — the one necessary exception to "don't edit committed migrations", since the deleted constant otherwise makes a fresh `migrate` fatal.)
- [database/migrations/2026_06_16_000005_create_whatsapp_tags_tables.php](/database/migrations/2026_06_16_000005_create_whatsapp_tags_tables.php) — `whatsapp_tags` + the `whatsapp_contact_tag` pivot (contact labels — see [tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md)).
- [database/migrations/2026_06_29_000002_add_auto_reply_hours_to_whatsapp_ai_profiles_table.php](/database/migrations/2026_06_29_000002_add_auto_reply_hours_to_whatsapp_ai_profiles_table.php) · `_000003_add_quality_tracking_to_whatsapp_channels_table.php` · `_000005_add_ai_handoff_to_whatsapp_conversations_table.php` — **anti-ban** (P0): per-profile auto-reply active hours, Cloud channel quality/tier tracking, and per-conversation human handoff (see [ai_profile.md](/docs/modules_handbook/manage/messages/whatsapp/ai_profile.md) → *Auto-reply safety guards*).
- [database/migrations/2026_06_29_000004_create_whatsapp_settings_table.php](/database/migrations/2026_06_29_000004_create_whatsapp_settings_table.php) — the single encrypted `whatsapp_settings` row (global credentials + guards — see [settings.md](/docs/modules_handbook/manage/messages/whatsapp/settings.md)).
- [database/migrations/2026_06_30_000001_create_whatsapp_consents_table.php](/database/migrations/2026_06_30_000001_create_whatsapp_consents_table.php) · `_000002_add_broadcast_tracking_to_whatsapp_contacts_table.php` — broadcast **Phase 0**: per-category consent ledger + contact frequency-cap columns (see [broadcast.md](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md)).

**Seeder + testing tooling**
- [database/seeds/WhatsappSeeder.php](/database/seeds/WhatsappSeeder.php) — 2 channels (Cloud + QR) + 8 sample conversations / 20 messages (skips if a channel exists).
- [database/seeds/WhatsappDemoSeeder.php](/database/seeds/WhatsappDemoSeeder.php) — a one-command **bot demo**: a Sandbox channel + an AI profile + a keyword flow (drip → AI, objective) + a Demo Customer, for testing the AI + flow with no real number. Run `php artisan db:seed --class='\WhatsappDemoSeeder'` (**leading backslash required** — global-namespace seeder).
- [app/Console/Commands/TestWhatsappFlow.php](/app/Console/Commands/TestWhatsappFlow.php) — `whatsapp:flow-test {text} {--watch=} {--sync}`: inject a sandbox inbound through the real pipeline, watch the thread, and diagnose the flow + AI takeover end-to-end (see [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md) → *Testing*).

**Routes**
- [routes/web.php](/routes/web.php) — `manage.messages.*` (inbox, messages, conversation **read** / **handoff** / **resume-ai**, channels.*, tags.*, settings.* + contacts.tags.sync / **block** / **unblock** / **consent**).
- [routes/main.php](/routes/main.php) — public `webhooks.whatsapp.*` (cloud verify/handle, bridge).
- [routes/channels.php](/routes/channels.php) — broadcast auth for `whatsapp.inbox` + `whatsapp.conversation.{uuid}`.
