# 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`, plus **`purpose`** — `PURPOSE_INBOX` is a company line belonging to this inbox, `PURPOSE_CEO` is somebody's personal number linked from the [CEO Dashboard](/docs/modules_handbook/manage/ceo-dashboard/readMe.md), which is capture-only and excluded from every list, picker and engine here). `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.
- **@mentions in a group are LIDs, and are decoded for DISPLAY only** ([`Src\Whatsapp\Support\MentionResolver`](/src/Whatsapp/Support/MentionResolver.php) + [`Components/Whatsapp/MessageText.vue`](/resources/js/Components/Whatsapp/MessageText.vue)). Since WhatsApp's privacy-identity migration a mention inside a group no longer carries a phone number: the text arrives as **`@49263392329973`**, a LID, and neither the Cloud payload nor the Baileys one ships a `mentionedJid` table to decode it with — so a thread of colleagues tagging each other reads as a wall of digits, and so does every AI transcript built from it. The resolver maps a LID through **`whatsapp_contacts.lid`** (a contact we have captured — and, when its own name is the masked number WhatsApp gives a hidden line, through its linked CRM user's profile) then **`whatsapp_group_participants.lid`** (every member of every group we are in, which covers people who have only ever spoken in a room), then the Node bridge's own **`wa_bridge_lid_map`** — for the member whose participant row arrived with neither a name nor a number — falling back to the phone number itself. That third source is **guarded**, for two reasons that are easy to get wrong: the table is created by `wa-bridge/src/db.js` with `CREATE TABLE IF NOT EXISTS`, NOT by a Laravel migration, so an install that has never started the bridge has no such table and an unguarded query would 500 every inbox page; and its rows are keyed by `(instance, lid)` while the resolver has no instance in hand, so a lid the map gives two different phones for is **skipped rather than guessed at** (8 of 16,516 here). **Nothing is rewritten in the database and `body` ships raw**: `MessagePresenter` attaches a `mentions` map (`lid => name`) beside it and `MessageText.vue` paints the name over the token, because the body is what an **edit** sends back to WhatsApp — replacing it would turn a live mention into the literal text `@Shawn`. Display-only strings (a chat list `preview`, a quoted reply snippet, an AI transcript line) are rendered through `MentionResolver::render()` instead. An **unknown** LID is left exactly as it arrived rather than given a plausible name, and a group JID (`@120363423488021890@g.us`) is a room, not a person, so it is never touched. `prime()` batches a whole page or job into a handful of queries; the memo is static, so a queue job calls `flush()` first.
- **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 **confirmed** outbound reply — sent, delivered or read — at or after it; a queued, failed or deleted send does not count), i.e. threads waiting on a reply. The predicate lives in [`InboxState`](/src/Whatsapp/Support/InboxState.php) and is shared with the mobile Agent API's `needs_reply`, as is the unread predicate. 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 vs QR Bridge — create only; **the one editable field afterwards is the name**, same modal in `edit` mode). A Cloud channel connects one of two ways: **Connect with Facebook** — Meta's **Embedded Signup** popup (Tech Provider model: the customer authorises, we exchange the code for THEIR non-expiring business token, stored encrypted on the channel; offers **Coexistence**, the same number staying on the WhatsApp Business App — shown only when `WHATSAPP_ES_CONFIG_ID` is set) — or **Enter IDs manually** (the Partner-Sharing fallback: WABA id + phone number id, sends on the central token). Both then run the same register + subscribe + info-sync finalisation. See [coexistence.md](/docs/modules_handbook/manage/messages/whatsapp/coexistence.md) for the whole flow, the three Coexistence webhook fields and the Meta-side prerequisites. Row actions = View / Scan-QR (bridge) / Rename / Delete. **The reduced, review-facing Messages hub (`WHATSAPP_QR_BRIDGE_HIDDEN_EMAILS`):** written for a **Meta App Review login** — the comma-separated emails listed there get a Messages tab strip of **Dashboard · Inbox · Templates · Settings** (no *AI Agent*, no *AI Automation*, and *Templates* in place of *Broadcasts* — approved templates are the only part of that section a reviewer needs; the flag rides on `auth.user.messages_review_ui`, shared from `HandleInertiaRequests`, because the strip is a component on every Messages page). They also see the platform as if only the official providers existed — no bridge rows on Channels, no bridge entry in the provider filter, no Type selector in *Add channel* (Cloud only), no bridge threads or bridge entry in the inbox's channel filter, no bridge numbers in the Broadcasts / Flows pickers, no amber "QR ⚠" badge on a broadcast row or its Show page, no Bridge inbound URL (and no QR wording) on Settings → General, and no **QR (Bridge) broadcasting — high risk** panel on Broadcasts → Settings — the loudest bridge mention in the product, and the one a reviewer must never read. The unofficial WhatsApp Web bridge is not part of what Meta reviews and must not be on a reviewer's screen; nor should the bulk-broadcast tooling or the AI layer, which only raise questions the review is not about. **It is DISPLAY-ONLY and deliberately so** — `Src\Whatsapp\Support\BridgeVisibility` filters what a page renders and nothing else: no permission changes, no 403s, no disabled features. The bridge keeps running, its channels keep sending and receiving, its broadcasts and flows keep working, every other admin is unaffected, and clearing the env var restores the account. It is **not** wired into `AccountVisibility` on purpose (the permission layer stays untouched, so a mistake here can hide a row but never deny access); each controller that builds page props calls it beside the ownership scoping — `ChannelsController::index`, `InboxController::renderWorkspace` (channel list + the shared `$contextFilters`), `BroadcastsController` (`channelOptions` + `transform` + `settings`), `FlowsController::channelOptions`, `SettingsController::index`, plus the one shared prop in `HandleInertiaRequests` that `Components/Messages/MessagesTabs.vue` reads. **When you add a surface that names the bridge, add it here too** — the panels above were each found by looking at a real screen, not by grep. 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 how it was connected (Embedded Signup / partner sharing, with a **Coexistence** chip) and 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), plus `is_on_biz_app` → the channel's **Coexistence** flag (`mergeChannelProviderConfig`). 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` (a channel connected through **Embedded Signup** carries its own customer token instead and never touches the central one). QR — the running bridge above.
- **Embedded Signup / Coexistence (optional):** the Meta app id + secret (`WHATSAPP_APP_ID` / `WHATSAPP_APP_SECRET`, else `META_APP_ID` / `META_APP_SECRET`) + `WHATSAPP_ES_CONFIG_ID`, the app registered as a Meta **Tech Provider** with Advanced Access, the host in Allowed Domains, and the app's WhatsApp webhook subscribed to `history`, `smb_app_state_sync`, `smb_message_echoes` and `account_update` — see [coexistence.md](/docs/modules_handbook/manage/messages/whatsapp/coexistence.md).

## 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.
- [src/Whatsapp/Support/MentionResolver.php](/src/Whatsapp/Support/MentionResolver.php) — `@<lid>` → name for group mentions (`map()` for the presenter's `mentions` prop, `render()` for display-only text, `prime()` / `flush()` for batching); rendered by [resources/js/Components/Whatsapp/MessageText.vue](/resources/js/Components/Whatsapp/MessageText.vue). Tests: [tests/Feature/Whatsapp/MentionResolverTest.php](/tests/Feature/Whatsapp/MentionResolverTest.php) · [MessageText.test.js](/resources/js/Components/Whatsapp/MessageText.test.js).
- [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 + `storeEmbeddedSignup` (the Meta popup's code → a Cloud channel on the customer's token; see [coexistence.md](/docs/modules_handbook/manage/messages/whatsapp/coexistence.md)).
- [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 · [EmbeddedSignupRequest.php](/app/Http/Requests/Manage/Whatsapp/EmbeddedSignupRequest.php) — the popup's code + ids.

**Backend — Jobs, Events & Command**
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — parse + persist inbound (both providers) + download media via `MediaService`; for a Coexistence number also the owner's phone echoes, the history backfill and the address book.
- [app/Jobs/Whatsapp/RequestCoexistenceSync.php](/app/Jobs/Whatsapp/RequestCoexistenceSync.php) — the one-shot Coexistence history / contacts sync request (once per onboarding, stamped on the channel).
- [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 (incl. `cloud.embedded_signup_config_id`), CA-bundle fallback.
- [src/Whatsapp/Services/EmbeddedSignupClient.php](/src/Whatsapp/Services/EmbeddedSignupClient.php) — the pre-channel Graph calls of Embedded Signup (code → business token, token debug, WABA phone discovery) · [resources/js/composables/useFacebookSdk.js](/resources/js/composables/useFacebookSdk.js) — the Facebook JS SDK loader + popup launcher.
- [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}`.
