# WhatsApp — Real-time (Reverb / Echo)

**Scope:** how a new message / status tick reaches the open inbox **without a refresh**, and how to run + debug it locally.

## What it does

The inbox updates live: an inbound customer message, a delivery tick (sent → delivered → read), a reaction, or a phone-app-sent message appears in the open thread within ~a second, pushed over a WebSocket. A **6-second silent poll** is the safety net — the inbox still works (slower) when the socket or the queue worker is down.

> **Master toggle — `WHATSAPP_REALTIME` (off by default).** ALL WhatsApp broadcast events short-circuit in their `broadcastWhen()` when `config('whatsapp.realtime')` is false, so **no broadcast job is ever queued** — a server WITHOUT a running Reverb never piles up failed broadcast jobs, and the inbox runs on the 6-second poll alone. Set **`WHATSAPP_REALTIME=true`** in `.env` only where Reverb actually runs (local WSL, or prod once Reverb is deployed) to restore live push. Everything below describes the path when it IS on.
>
> **The event set (2026-07-22):** `NewWhatsAppMessage` + `WhatsAppMessageStatusUpdated` + `WhatsAppMessageUpdated` (revoke/edit/delete-for-me/call-state swaps the bubble) + `WhatsAppConversationRead` (also fired on a **phone-side read** — see below) + `WhatsAppChannelSyncStatus` (now carries a `progress` % for the sync banner) + `WhatsAppAiDraftSuggested` + two NEW ones: **`WhatsAppPresenceUpdated`** (the open 1:1 thread's "online / typing…" — ephemeral, zero DB writes, **no poll fallback on purpose**: presence is meaningless without live push) and **`WhatsAppMessageReceiptsUpdated`** (a group bubble's "· N read" counts — deliberately a tiny counts-only payload, NOT the full presenter shape).
>
> **Phone-mirroring events feeding these** (Bridge channels): the wa-bridge now also forwards `call.update` (incoming calls → TYPE_CALL bubbles), `chat.updated` (phone-side read — gated by **`WHATSAPP_SYNC_PHONE_READS`**, default on — plus pinned/muted/archived badges), `blocklist.set`/`blocklist.update` ("Blocked on phone" badge), `message.receipt` (group per-member receipts), `presence.update` (watched-window only; subscribe piggybacks on markRead, throttled 5 min, gated by **`WHATSAPP_PRESENCE`**), `history.status` (explicit sync-complete → faster settle), `group.join_request`, `label.edit`/`label.association` (see [tags.md](/docs/modules_handbook/manage/messages/whatsapp/tags.md)), `message.deleted_for_me` (an audit tag, never a revoke), and a `messages.reaction` fallback (re-shaped as a normal `message.received`). Lane routing: `history.set`/`history.status`/`contacts.updated`/`chat.updated`/`label.*` ride the paced FIFO **broadcast lane** (order + flood control); live message/status/call/presence/receipt/blocklist/join-request events stay on the fast default lane.

## How it works — the full path

```
ProcessInboundWhatsAppWebhook / SendWhatsAppMessage / MessagesController
        │  event(NewWhatsAppMessage | WhatsAppMessageStatusUpdated)   ← ShouldBroadcast
        ▼
  redis queue (broadcast jobs are QUEUED — a worker must run!)
        ▼
  Horizon worker  ──HTTP POST──▶  Reverb server (REVERB_HOST:REVERB_PORT, signed with app key/secret)
                                        │  WebSocket push (Pusher protocol)
                                        ▼
  Browser: echo.js (Laravel Echo + pusher-js) ── subscribed to private channels
        whatsapp.inbox                  → refresh the conversation list (silent partial reload)
        whatsapp.conversation.{uuid}    → append message / patch ticks+error in the open thread
```

Key facts:

- **No Pusher cloud account is involved.** Reverb is Laravel's **self-hosted** WebSocket server that *speaks the Pusher protocol* — `pusher-js` in the browser console is just the wire-protocol client. Nothing leaves your machine/server.
- **Broadcasts ride the queue.** `ShouldBroadcast` events are pushed as jobs on the default redis queue — **no running Horizon/queue worker ⇒ no realtime at all** (the poll still works).
- **Private channels are authorized.** On subscribe, Echo XHRs `/broadcasting/auth`; [routes/channels.php](/routes/channels.php) gates `whatsapp.inbox` + `whatsapp.conversation.{uuid}` to admins.
- **Payloads match the page props.** Both events serialize through the shared `MessagePresenter`, so a pushed message is identical in shape to one loaded by the controller (the thread appends it, deduped by uuid; the status event also carries the failure `error`).
- The frontend client lives in [resources/js/echo.js](/resources/js/echo.js) (skips Echo entirely when `VITE_REVERB_APP_KEY` is unset, so the app still boots without broadcasting).

## Configuration (one `.env` block, two consumers)

| Var | Used by | Meaning |
|-----|---------|---------|
| `WHATSAPP_REALTIME` | Laravel (each event's `broadcastWhen`) | **master on/off** for EVERY WhatsApp broadcast event (all 8 — see *The event set* above); `false` (default) = no broadcast job is queued (works with no Reverb, no failed jobs); `true` = live push |
| `BROADCAST_CONNECTION=reverb` | Laravel | which broadcaster the events use |
| `REVERB_APP_ID / _KEY / _SECRET` | both | app credentials (key is public, secret is not) |
| `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` | **queue worker** (server-side POST, [config/broadcasting.php](/config/broadcasting.php)) | where the worker delivers events |
| `REVERB_SERVER_HOST=0.0.0.0` / `REVERB_SERVER_PORT=8080` | `reverb:start` | the interface/port the server binds |
| `VITE_REVERB_HOST / _PORT / _SCHEME` (mirrors of the above) | **browser** ([echo.js](/resources/js/echo.js)) | where pusher-js connects; `_SCHEME=http` ⇒ `ws://`, `https` ⇒ `wss://` |

> **Vite only reads `.env` at startup** — after changing any `VITE_REVERB_*` value you MUST restart `npm run dev` (and rebuild for production assets), or the browser keeps the old values baked in.

## Local dev topology (Laragon + WSL) — run Reverb IN WSL

The web app is served by Laragon (Windows), but **Horizon runs in WSL** — and in WSL2's default NAT mode, `localhost` inside WSL is WSL itself, **not** Windows. So:

- Reverb started in **PowerShell (Windows)** → the browser can reach it, but **Horizon's broadcast POSTs to `localhost:8080` die inside WSL** → no realtime.
- Reverb started in **WSL** (same shell family as Horizon) → both directions work with the same `.env`:
  - Horizon (WSL) → `localhost:8080` → Reverb (WSL) ✓ same network namespace
  - Browser (Windows) → `ws://localhost:8080` → **Windows→WSL localhost forwarding is built into WSL2** ✓

```bash
# WSL — two terminals (or a Procfile/supervisor):
php artisan horizon
php artisan reverb:start
```

`REVERB_HOST=127.0.0.1`, `REVERB_PORT=8080`, `REVERB_SCHEME=http` then serve both consumers unchanged — **`127.0.0.1`, never `localhost`**: Chrome resolves `localhost` to `::1` first and the WSL2 relay is IPv4-only (see Troubleshooting).

> **`ws://` from an `https://` page is fine for `localhost`.** Browsers treat `localhost` as a *potentially trustworthy origin*, so mixed-content rules don't block `ws://localhost:8080` from `https://petav3.test`. In production, terminate TLS in front of Reverb (nginx `wss://…/app` proxy) and set `REVERB_SCHEME=https`.

## Troubleshooting

**Start with the `[echo]` dev logs** (Chrome console, dev builds only). [echo.js](/resources/js/echo.js) self-diagnoses: on boot it prints the EXACT resolved target — `[echo] connecting ws://localhost:8080 (key: …, VITE_REVERB_SCHEME: "http")` — and every connection transition (`connecting -> connected — stable ✅`, or `-> unavailable — events will NOT arrive`). If the printed target/scheme doesn't match your `.env`, the tab is running a stale bundle (restart `npm run dev`, hard-refresh). echo.js also defaults to plain `ws://` whenever `wsHost` is a local host and no scheme is set — a bare `reverb:start` has no TLS, so a `wss://` default could only ever fail locally.

| Symptom | Cause → fix |
|---|---|
| Console: `WebSocket connection to 'wss://localhost:8080/…' failed` | Either (a) the client forces TLS (`VITE_REVERB_SCHEME` isn't `http` **in the running bundle** — fix `.env`, restart `npm run dev`), or (b) the `ws://` attempt failed first and pusher-js fell back to wss — see the IPv6 row below (the `[echo]` boot line tells you which: it prints the scheme the bundle resolved). |
| `[echo] connecting ws://localhost:8080` then `-> unavailable` (curl to the port works!) | **IPv6 vs IPv4**: Chrome resolves `localhost` → `::1` first, but the WSL2 port relay listens **IPv4-only** → connection refused → pusher-js falls back to wss → also fails. Use **`REVERB_HOST=127.0.0.1`** (never "localhost") in `.env`, restart `npm run dev` + Horizon. Verify the split with `curl http://[::1]:8080/...` (refused) vs `http://127.0.0.1:8080/...` (101). |
| Socket connects, but nothing arrives | Broadcast jobs aren't being delivered: Horizon not running, or Reverb runs on Windows while Horizon is in WSL (see topology above — run Reverb in WSL). Check `storage/logs` + Horizon for failed `BroadcastEvent` jobs. |
| `/broadcasting/auth` returns 403 | Signed-in user isn't an admin (routes/channels.php) or the session cookie isn't sent (cross-origin dev URL). |
| Everything dead but inbox still updates ~6s | That's the fallback poll doing its job — fix the socket per the rows above (or `WHATSAPP_REALTIME` is off, which is the default). |
| Worked, then died after editing `.env` | Vite (and `php artisan config:cache`) snapshot env values — restart `npm run dev` / run `php artisan config:clear`, and restart Reverb + Horizon after REVERB_* changes. |

## Related files

- [resources/js/echo.js](/resources/js/echo.js) — Echo + pusher-js client (env-driven; no-ops without a key).
- [resources/js/Pages/Manage/Messages/Inbox.vue](/resources/js/Pages/Manage/Messages/Inbox.vue) — channel subscriptions, append/patch handlers, the 6s silent fallback poll.
- [app/Events/Whatsapp/NewWhatsAppMessage.php](/app/Events/Whatsapp/NewWhatsAppMessage.php) · [WhatsAppMessageStatusUpdated.php](/app/Events/Whatsapp/WhatsAppMessageStatusUpdated.php) — the two broadcast events (presenter-shaped payloads).
- [routes/channels.php](/routes/channels.php) — private-channel authorization.
- [config/broadcasting.php](/config/broadcasting.php) · [config/reverb.php](/config/reverb.php) — server-side delivery + the Reverb server itself.
