# wa-bridge

A **minimal Node + [Baileys](https://github.com/WhiskeySockets/Baileys) gateway** for the QR
(WhatsApp Web) channel of petav3. PHP cannot run Baileys, so a QR number always needs a small
long-lived Node process — this is that process, kept deliberately tiny.

It exposes a **clean, normalized HTTP contract** that Laravel's `BridgeGateway` + `BridgeDriver`
talk to, and POSTs **already-normalized webhooks** back to Laravel. Baileys' raw shapes never
leak past this service.

```
Laravel (petav3)                          wa-bridge (this)                WhatsApp
  BridgeGateway ──HTTP /instances/*────▶   Express + Baileys  ──WebSocket──▶  WA Web
  BridgeDriver  ──HTTP .../messages/*──▶          │
  WhatsAppWebhookController  ◀── POST { event, instance, ... } ─┘
```

> ⚠️ Unofficial WhatsApp Web automation **violates WhatsApp's ToS** and risks the number being
> banned. Use a **dedicated, warmed-up real SIM** you can afford to lose (never the primary number,
> never VoIP), keep it **inbound/reply-led**, and route any proactive / template / broadcast /
> AI-initiated messaging through the official **Cloud API** driver instead.

## HTTP contract

All requests are authenticated with the `apikey` header (== Laravel `BRIDGE_API_KEY`).

| Method & path | Request body | Response |
|---|---|---|
| `POST /instances/:name` | `{ webhook_url }` | `{ apikey }` |
| `GET /instances/:name/qr` | — | `{ qr, pairing_code }` (`qr` is a data-URL PNG) |
| `GET /instances/:name/state` | — | `{ state, ping_ms?, last_ping_at, me? }` — `state` ∈ `open` / `connecting` / `close` / `dead` (budget spent — re-fetch the QR to revive). An `open` answer is backed by a **real `w:p` ping round-trip to WhatsApp** (the same iq Baileys' keep-alive uses); a zombie socket is ended on the spot so the reconnect logic revives it. On `open` it also carries **`me: { phone, name, lid }`** — the connected account's own phone digits / display name / lid read off `sock.user`, which Laravel stamps onto the channel (`phone_e164` / `verified_name`) |
| `POST /instances/:name/logout` | — | `{ status }` |
| `DELETE /instances/:name` | — | `{ status }` |
| `POST /instances/:name/messages/text` | `{ to, text }` | `{ id }` |
| `POST /instances/:name/messages/media` | `{ to, type, mime, data (base64), filename, caption }` | `{ id }` |
| `POST /instances/:name/media` | `{ message_id }` | `{ data (base64), mime }` |
| `GET /instances/:name/avatar?jid=` | — | `{ url }` (profile-picture URL, `null` when none / hidden) |
| `GET /instances/:name/lookup?number=` | — | `{ exists, name, about, picture }` — lead-enrichment existence probe (`onWhatsApp` + public profile). **Read-only** (sends nothing); the Laravel caller rate-caps it because bulk existence probing of unknown numbers is a WhatsApp ban signal |
| `GET /instances/:name/group-invite-info?code=` | — | `{ id, subject, size }` — resolve a `chat.whatsapp.com/` invite code to its group identity via `groupGetInviteInfo` (**no join**; the member list is never available from an invite — that needs the connected number to be a member, via the group sync). Nulls on a revoked/invalid code |

Plus an unauthenticated `GET /health`.

**Outbound webhooks** (POSTed to the per-instance `webhook_url`, i.e.
`APP_URL/webhooks/whatsapp/bridge`):

* `message.received` — `{ event, instance, message: { id, chat_type, from?, group?, sender?, name, type, text, timestamp, media?, location?, historical? } }`.
  `chat_type` ∈ `individual|group`. A **1:1** carries `from` (peer phone) + `name`; a **group** carries
  `group: { id (…@g.us), subject }` + `sender: { phone, name, lid }`. `type` ∈
  `text|image|video|audio|document|sticker|location|contacts|reaction|interactive`, `media` = `{ mime, filename, id, size?, width?, height?, seconds?, voice?, animated? }`,
  `location` = `{ lat, lng, name?, address? }`. The bridge skips our own echoes, status broadcasts and newsletters/channels; **groups (incl. community sub-groups) ARE forwarded** (capture-only downstream).
* `message.status` — `{ event, instance, message_id, status }` where `status` ∈
  `pending|sent|delivered|read|failed` (the numeric Baileys enum is mapped to these labels here).
* `message.revoked` — `{ event, instance, message_id }`. A "delete for everyone" (Baileys
  `protocolMessage` type REVOKE) targeting `message_id`. Laravel flags the existing row deleted.
* `message.edited` — `{ event, instance, message_id, body }`. A message edit (Baileys
  `protocolMessage` type MESSAGE_EDIT); `body` is the new text. Laravel replaces the existing row's
  body in place. Both revoke/edit are detected in `messages.upsert` for **either** the live (`notify`)
  or offline (`append`) stream, so a delete/edit that happened while offline still lands on relink.
* `history.set` — `{ event, instance, sync_type, is_latest, progress, messages: [ …normalized message.received shapes with `historical: true` ] }`. Emitted for the link-time history sync. Sent in chunks.
* `group.updated` — `{ event, instance, group: { id, subject, description?, size?, announce?, is_community?, is_announce?, parent?, owner?, participants: [{ phone, lid?, admin? }] } }`. Group metadata (subject / participants / community links), emitted on `groups.upsert|update` + `group-participants.update`.
* `contacts.updated` — `{ event, instance, contacts: [{ phone, lid?, name? }] }`. Best-effort contact name/phone enrichment (from the history sync + contact events).

### What the bridge handles for you

1. **Normalization.** Baileys' raw message shapes are converted to the clean `message.received`
   payload (see `src/instances.js` `normalizeInbound`), and numeric statuses to clean labels
   (`src/webhook.js`), so the PHP side never parses Baileys structures.
2. **Media by id.** `/instances/:name/media` takes only a message id, but Baileys needs the full
   message to decrypt media — so the bridge keeps a per-instance LRU of recent **live** messages
   (`BRIDGE_MSG_CACHE`, default 2000). **Historical (backfill) messages are NOT cached** — a
   fresh-link sync of thousands of messages would otherwise evict the live cache repeatedly and
   break live media re-download + the getMessage retry-receipt answer (own messages ack'd but never
   delivered). So historical media is not re-downloadable from the cache.
2b. **Reliable webhook delivery + dead-letter replay.** `post()` (`src/webhook.js`) has a 15s
   per-attempt timeout and bounded backoff retries (~81s total) on transport failure or a retryable
   HTTP status (5xx / 429 — e.g. Laravel in maintenance mode). If ALL retries fail (a deploy window
   longer than the budget), the event is **parked in the `wa_bridge_webhook_dlq` table instead of
   dropped**; a flusher replays parked events every 60s once Laravel is reachable again (Laravel's
   inbound pipeline is idempotent, so replays are safe). Parked events are pruned after 48h / 50
   failed replays, and the table is capped at 2000 rows. A 4xx (bad token / validation) is a
   permanent reject, logged and not retried.
3. **Durable sessions.** Auth state is stored in MySQL (`src/auth.js`), **not**
   `useMultiFileAuthState` (demo-only) — a connected number survives bridge restarts without
   re-scanning the QR.
3b. **Bounded, single-flight reconnects.** A non-logout disconnect schedules ONE reconnect
   (exponential backoff, capped at 30s) with a bounded budget (`BRIDGE_MAX_RECONNECT`, default 8).
   Past the budget the instance parks as **`dead`** and stops hammering — an unbounded reconnect
   storm is how a stale session gets the server IP rate-limited/flagged by WhatsApp, killing QR
   pairing for every other instance. Fetching the instance's QR again (the admin re-opening the
   Scan-QR modal) revives it with a fresh budget — and also (re)starts an instance the bridge no
   longer has in memory (e.g. a failed resume after a restart), so Scan-QR is self-healing.
   Deleting/logging out an instance cancels any pending reconnect timer (no zombie resurrection).
   `GET /instances/:name/qr` holds the request up to **20s** waiting for WhatsApp to emit the QR
   (a fresh pairing is slow); the Vue modal **re-polls every ~4s**, so one empty response never
   strands the UI on a spinner.
3c. **Heartbeat with a real pong.** `GET /instances/:name/state` doesn't just read the in-memory
   flag: when the socket claims `open` it sends the same `w:p` ping iq Baileys' keep-alive uses and
   waits (≤8s) for WhatsApp's pong — answering `{ state:'open', ping_ms, last_ping_at }` only on a
   genuine round-trip. A socket that is open on paper but dead on the wire is **ended on the spot**,
   so the bounded reconnect brings up a fresh one (self-healing before the next send would fail).
   Laravel's **`whatsapp:ping-bridge`** (scheduled every minute) drives this per channel and stamps
   `whatsapp_channels.last_ping_at` (shown on the channel's Connection tab), flipping the status
   CONNECTED ↔ DISCONNECTED to match reality — and now also fires an **ops alert**
   (`OpsAlertService` — log + optional mail/webhook, see `config/ops.php`) on the
   CONNECTED → DISCONNECTED transition, on recovery, and when the bridge process itself is
   unreachable. Layers: Baileys' own keep-alive every **15s** (socket level) → the in-bridge
   watchdog every **60s** (see 3e) → the Laravel heartbeat every **60s** (DB + UI + alert level).
   The heartbeat revives a `dead` OR chain-dead `close` instance with a fresh budget, and even
   DB-loads an instance missing from memory — so a long outage never leaves a channel offline
   waiting for a manual re-scan.
3e. **Self-driven watchdog (Laravel-independent).** Every `BRIDGE_WATCHDOG_INTERVAL_MS` (default
   60s) the bridge sweeps its OWN instances: DB instances missing from memory are started; `dead`
   instances and `close` instances whose reconnect chain silently died are revived with a fresh
   budget; a socket stuck in `connecting` with no QR for >5 min is restarted; an `open` socket with
   no pong for >3 min is self-pinged (zombie → ended → reconnected). Before this existed, every
   automatic revive depended on the Laravel scheduler chain (petav3-scheduler → schedule:work →
   `whatsapp:ping-bridge` → HTTP) — the connection now heals itself even with all of that down.
   The sweep also treats **`wa_bridge_instances` as the source of truth in the other direction**:
   an in-memory instance with **no DB row is a GHOST** (a delete that raced its socket teardown, or
   a row removed while the process ran) and is torn down instead of revived — so deleting a row
   from `wa_bridge_instances` is a legitimate ops action that takes effect within one sweep.
   (remove()/logout() also detach the socket's event handlers before ending it, killing the
   original resurrection race at the source: the close event used to re-schedule a reconnect that
   re-created the just-deleted instance, which then churned fresh-QR attempts forever.)
3f. **440 stream-conflict park.** `DisconnectReason.connectionReplaced` (440) means ANOTHER
   connection took over the session — usually a duplicate wa-bridge process on the same creds.
   Reconnect-fighting it is a storm WhatsApp flags, so the instance parks for a **5-minute
   cool-down** (logged loudly) instead; an explicit QR re-scan overrides the cool-down. Check for a
   duplicate process before it revives.
3g. **Retried auth writes.** Signal creds/key writes (`creds.update`, `keys.set`) are the one
   failure the architecture cannot heal — a write lost to a MySQL blip silently corrupts the
   session until a later decrypt fails and the number is force logged-out. They now retry
   (0.5s/2s/5s backoff) and log a CRITICAL line on final failure.
3d. **Live WhatsApp-Web version (avoids the 428 storm).** WhatsApp deprecates old web versions
   fast and REFUSES a stale one — the socket connects, then is terminated at login/registration
   with **code 428** and no QR ever appears. This took down **all** `baileys@7.0.0-rc13` clients at
   once when its baked-in `2.3000.1035194821` was retired (rc13 is the latest npm release, so there
   is nothing to upgrade to). The bridge therefore resolves the version at connect time:
   `BRIDGE_WA_VERSION` pin → the **newest `2.3000.x` from [wppconnect's wa-version tracker](https://github.com/wppconnect-team/wa-version)**
   (a clean GitHub-raw JSON, far more reliable than scraping `web.whatsapp.com/sw.js`) →
   **`fetchLatestWaWebVersion()`** (asks web.whatsapp.com itself) → baileys' baked-in value (absolute
   last resort — per Baileys **#2679**, `fetchLatestBaileysVersion()` just returns the stale baked-in
   value while claiming `isLatest: true`, which now gets pairing refused with "Couldn't link
   device"). The tracker follows WhatsApp's bumps automatically; the resolved version is cached per
   process and re-resolved when an instance parks `dead` or is re-scanned, so a later bump
   self-heals without a restart.
4. **History sync + groups.** On link the bridge forwards the history WhatsApp pushes through the
   `history.set` webhook, and it captures **group / community** messages (`@g.us`) with per-message
   sender resolution + `group.updated` metadata. History defaults to **full** (`syncFullHistory: true`,
   `BRIDGE_SYNC_FULL_HISTORY`) — the only reliable way to backfill **deep + group** history, since
   WhatsApp **silently drops on-demand `fetchMessageHistory`** for linked devices (so there is no
   older-page endpoint). The socket links with **`Browsers.ubuntu('Desktop')`** — the fingerprint
   that threads the needle: `'Ubuntu'` is NOT in Baileys' DARWIN/WIN32 platform map, so
   `webSubPlatform` stays `WEB_BROWSER` (avoiding the 428 refusal WhatsApp applies to
   `Browsers.macOS/windows('Desktop')` fingerprints since ~2026-06-30 — Baileys issue #2677, on
   pairing AND resume), while `browser[1] = 'Desktop'` still advertises a DESKTOP-class
   `platformType` for the fuller on-link history bundle. If WhatsApp ever 428s this combo too, fall
   back to `Browsers.ubuntu('Chrome')` (always pairs, thinner history); `syncFullHistory` still
   applies either way. History fires **only on a fresh
   link** (a reconnect skips it), so re-scan to backfill an existing number; set
   `BRIDGE_SYNC_FULL_HISTORY=false` for the light recent-only sync. A group message is **never dropped
   for an unresolved sender** — a member addressed by `@lid` (no phone yet) is forwarded with
   `sender.lid` / `sender.name` and the phone is backfilled later; only a **1:1** with no resolvable
   phone is skipped. Groups are forwarded but are **capture-only** on the Laravel side (the AI / flow
   engine never runs on a group).

   **Newest messages on a fresh link (the offline flush).** WhatsApp delivers each chat's *newest*
   messages NOT in the `messaging-history.set` snapshot but as a separate **offline-queue flush** —
   Baileys emits these as `messages.upsert` with **`type: 'append'`** (`node.attrs.offline ? 'append'
   : 'notify'`), because on a fresh link the device was never online so the pending messages replay
   from the server queue. Handling only `type === 'notify'` (Baileys' own example pattern) silently
   drops precisely the newest message of every chat — the classic "old chats synced, latest missing"
   symptom. The bridge therefore routes the `'append'` batch through the **same capture-only
   `history.set` path** as `handleHistorySet` (deduped by `provider_message_id` on the Laravel side),
   so the latest messages persist **without** waking the AI / flow / opt-out engine on stale chats.
   Newsletter/channel messages also arrive as `'append'` and are captured the same way.

## Configuration (single source)

The bridge reads the **Laravel root `.env`** (`../.env`) for the things that must match Laravel,
so they live in ONE place and never drift:

| From the ROOT `.env` (shared with Laravel) | From `wa-bridge/.env` (bridge-only) |
|---|---|
| `DB_HOST` / `DB_PORT` / `DB_USERNAME` / `DB_PASSWORD` / `DB_DATABASE` | `BRIDGE_PORT` (default 8088) |
| `BRIDGE_API_KEY` (== `config('whatsapp.bridge.api_key')`) | `BRIDGE_LOG_LEVEL` |
| `BRIDGE_WEBHOOK_TOKEN` (**REQUIRED** — Laravel rejects bridge webhooks when blank) | `BRIDGE_MSG_CACHE` |
| | `BRIDGE_SYNC_FULL_HISTORY` (default `true`; `false` = recent-only) |
| | `BRIDGE_MAX_RECONNECT` (default `8` — reconnect attempts before an instance parks as `dead`) |
| | `BRIDGE_WA_VERSION` (optional OVERRIDE — pin the WhatsApp Web version, e.g. `2,3000,1042527945`; empty = auto-resolve from the wa-version tracker) |
| | `BRIDGE_WATCHDOG_INTERVAL_MS` (default `60000` — the self-watchdog sweep cadence; `0` disables, not recommended) |
| | `BRIDGE_LOG_RETENTION_DAYS` (default `30` — daily wa-bridge-YYYY-MM-DD.log files older than this are deleted on boot + at each midnight rollover; `0` = never delete) |

So you set the DB credentials and the API key **only in the Laravel root `.env`**; the bridge picks
them up automatically (same DB, same key — no duplication).

## Run it

Requires **Node ≥ 20** and the same MySQL as petav3.

```bash
cd wa-bridge
cp .env.example .env          # bridge-only settings (port/log/cache); DB + key come from root .env
npm install
npm start                     # or: npm run dev   (auto-restart on change)
```

On boot it ensures its four tables (`wa_bridge_instances`, `wa_bridge_auth`, `wa_bridge_media`,
`wa_bridge_webhook_dlq`), resumes any previously-connected instances, and starts the self-watchdog
+ the webhook-DLQ flusher. Logs go to stdout AND **one file per day** —
`storage/logs/wa-bridge-YYYY-MM-DD.log` (pino JSON lines with **ISO-8601 timestamps**, viewable
pretty-printed in Manage → System Health → Logs). The date follows the Laravel `APP_TIMEZONE`, so
the day boundary matches `laravel-YYYY-MM-DD.log` exactly, and the rollover happens live at
midnight (no restart needed). Files older than `BRIDGE_LOG_RETENTION_DAYS` (default 30; `0` = keep
forever) are pruned on boot and at each rollover — laravel logs are never touched (Laravel's daily
channel manages its own retention). A legacy un-dated `wa-bridge.log` from before the daily scheme
just stops growing after the restart and ages out via the same retention.

### Wire it to Laravel

Already wired via the root `.env` (one place):

```env
BRIDGE_API_URL=http://localhost:8088      # where Laravel reaches this bridge
BRIDGE_API_KEY=<generated>                 # the bridge reads this SAME value from root .env
# BRIDGE_WEBHOOK_TOKEN=...                 # optional shared token (bridge reads it from root .env too)
```

Then in the Manage portal: **WhatsApp → Channels → Add channel → QR (Bridge)**, open the QR modal,
and scan with WhatsApp (Linked devices). The page polls `state` and flips the channel to
*Connected* when it opens.

> Ports: `8088` keeps clear of Reverb (`8080`) and `artisan serve` (`8001`). In local dev you can
> run everything together, e.g. `concurrently "php artisan serve --port=8001" "php artisan reverb:start" "php artisan queue:work redis" "npm --prefix wa-bridge start"`.

### Production

Run the bridge as a long-lived daemon under **Forge Supervisor** (or a container with a restart
policy). Do **not** shell out per message — the socket and auth state must stay resident.
(Laravel Cloud cannot host this Node sidecar; use a Forge-managed / GCP VM.)

## Layout

```
wa-bridge/
  src/
    server.js      Express app + the clean HTTP routes
    instances.js   Baileys socket lifecycle (connect, QR, reconnect, send, media, normalize)
    auth.js        MySQL-backed Baileys AuthenticationState (retried writes)
    db.js          mysql2 pool + the bridge's four tables (instances/auth/media/webhook DLQ)
    webhook.js     POST normalized events to Laravel (+ DLQ replay) + status → label mapping
    config.js      env config
    logger.js      pino (also used by Baileys)
```

## Bumping Baileys

Pinned to `baileys@7.0.0-rc13` (ESM; the bridge itself is `"type": "module"`). Upgraded from the
6.7.x legacy line on 2026-06-12 because 6.x cannot decrypt own-phone carbons in `@lid` chats
("SessionError: No session record" + retry storms) — v7's LIDMappingStore + automatic Signal
session migration (PR #1694, finalized in rc10) fixes it and was never backported. The MySQL auth
store is category-generic, so the v7 key types (`lid-mapping`, `device-list`, `tctoken`,
`identity-key`) persist without schema changes; existing creds survive (sessions migrate in place
on first boot). A downgrade after migration is unsupported, so before bumping a **major** Baileys
version, take a manual one-off snapshot of the `wa_bridge_auth` table (e.g.
`CREATE TABLE wa_bridge_auth_bak AS SELECT * FROM wa_bridge_auth;`) and drop it once the new
version is confirmed stable — the bridge code never creates such a backup itself
([db.js](src/db.js) creates only its four operational tables, never a backup). When bumping
again: pin a specific version (never float `latest`) and re-test connect + send + status +
phone-app-sync flows.
