# Messenger E2EE + Telegram alerts — setup & architecture

> **What this covers:** how Bluebut reads, decrypts, sends, and **alerts on** Facebook
> Messenger messages — including the end-to-end-encrypted (type-15) threads FB's web
> client can't decrypt on its own — and how it keeps that running 24/7 with the account's
> web session and its E2EE companion device on **one coherent egress IP**.
>
> Companion docs: [CLOAKBOX-CONSUMER.md](CLOAKBOX-CONSUMER.md) (the identity platform we
> consume), `messenger/e2ee/PLAN.md` (the E2EE device-registration design), and the
> `messenger` subsystem in the root [CLAUDE.md](../CLAUDE.md).

---

## 0. TL;DR — the mental model

A Facebook account's Messenger is **two wires that FB correlates**, and Bluebut speaks both:

1. **Web / LightSpeed** — the account's browser (a cloakbox seed on **pbox**) reads Messenger
   off FB's own LightSpeed sync payload. Handles thread/contact metadata and **plaintext bodies
   for non-E2EE (type-1) threads**. This is the `messenger/sync-inbox` + `send-message` path.
2. **E2EE companion device** — a **whatsmeow** socket (the `bluebut-e2ee` daemon) registered as
   an extra device on the account, exactly like linking WhatsApp Web. This is the **only** way to
   read or send **type-15 (E2EE) bodies** — FB never puts those plaintext on the web wire.

Because FB sees both, the **golden rule** is: **both wires must egress the SAME IP.** A web session
on IP-A and an E2EE device on IP-B = "new device on a new IP," a double account-integrity flag.
Everything in §3 exists to guarantee that one IP.

On top of both, a **Telegram alert** fires the moment any connected account **sends or receives**
a message — resolved to a human **name**, not an fb_id.

```
                    ┌────────────────────── ONE egress IP (per account) ──────────────────────┐
                    │                                                                          │
  ┌─────────────┐   │   ┌──────────────── pbox (cloakbox / gologin) ────────────────┐         │
  │  operator   │   │   │  cloakserve seed browser  ── LightSpeed wire ──► Facebook  │         │
  │  / cron /   │───┼──►│  (cdp_load: proxy-gated, session injected)                 │         │
  │  bluebut UI │   │   └───────────────────────────────────────────────────────────┘         │
  └─────────────┘   │                                                                          │
        │           │   ┌──────────── hexabyte (Coolify) ────────────┐                         │
        │  E2EE      │   │  bluebut-e2ee daemon (Go / whatsmeow)      │                         │
        └───────────┼──►│  registered companion device  ── Noise ────┼────────────► Facebook   │
                    │   │  decrypt in ▲   send out ▼                  │                         │
                    │   └──────┬──────────────────────┬──────────────┘                         │
                    └──────────┼──────────────────────┼────────────────────────────────────────┘
                               │ POST /e2ee/ingest     │ (send)
                               ▼                       │
                    ┌──────────────────────────────────┴────────┐
                    │  bluebut-bridge (hexabyte)                 │
                    │   • fb_message ◄── persist                 │
                    │   • Telegram alert (send + receive, names) │
                    └────────────────────────────────────────────┘
```

---

## 1. The two wires, in detail

### 1a. Web / LightSpeed (non-E2EE)
- **Where it runs:** a cloakbox seed browser on **pbox**, launched by the bluebut runner via
  `cdp_load(slug)` (the platform gates the proxy, egress-verifies, injects the keyvault session).
- **What it reads:** `messenger/jobs/sync-inbox.js` observes the page's own `lightspeed_web_request`
  payload (no hardcoded doc_id — it rotates; the tap harvests it live) and decodes it with the
  mautrix-meta-ported step-VM (`messenger/decode.js`). All folders, incl. hidden Requests/Spam/Archived.
- **What it writes:** `fb_thread`, `fb_message`, `fb_contact`, `fb_thread_participant` in bluebut-db.
- **Plaintext scope:** type-1 threads only. For a **type-15 (E2EE)** thread the LightSpeed wire
  carries thread/contact **metadata + the encrypted envelope**, never a readable body.
- **Why LightSpeed is still needed alongside whatsmeow:** whatsmeow decrypts complete E2EE
  *messages* (self-contained: thread_key + sender + ts + text) but gives you **nothing else** — no
  thread list, no folders, no contact **names**, no participant rosters, no **plain (type-1)** bodies,
  and no **history**. LightSpeed is the **inbox + address book + plain traffic + history**; whatsmeow
  is only the **decryptor** for the encrypted bodies. bluebut needs both. (Full model: the public
  [How E2EE works](https://bluebut-docs.blpk.cc/HOW-E2EE-WORKS.html) doc.)

### 1b. E2EE companion device (type-15)
- **Where it runs:** the `bluebut-e2ee` daemon — a **Go/whatsmeow** service on the **hexabyte**
  Coolify box (NOT pbox), `https://bluebut-e2ee.blpk.cc` (internal `:8099`). It opens its **own**
  Noise socket straight to Facebook through the account's proxy — it does **not** use the browser.
- **Registration (once per account):** the daemon registers a companion E2EE device via an
  **ICDC handshake** that needs a short-lived **CAT** (crypto-auth token) minted from a live
  browser session. The device is **register-once** — the `wa_device_id` is stored so reconnects
  reuse the same device and never trip a "new device" alert.
  Current devices: **philip = 960, pojtie = 580**.
- **Receive/decrypt:** the daemon holds the socket, decrypts inbound type-15 messages, and
  `POST`s each to the bridge `/messenger/e2ee/ingest` → `fb_message` with `is_e2ee=true`,
  `decrypted_by='daemon'`.
- **Send:** `messenger/jobs/send-message.js` auto-detects a type-15 thread (`fb_thread.thread_type`)
  and routes to the daemon's `POST /accounts/{slug}/e2ee-send`, which builds a
  `waConsumerApplication{MessageText}` and calls whatsmeow `SendFBMessage(<peer>@msgr)` — the
  mautrix `ENCRYPTED_OVER_WA_ONE_TO_ONE` path. **A plain LightSpeed task into a type-15 thread is
  accepted-then-silently-dropped by FB** — E2EE must go through whatsmeow, which is why the daemon
  exists on the send side too.

**Send routing** (in `send-message.js`): `--transport=e2ee` forces the daemon path;
`--transport=dom` forces the composer fallback; default = auto by thread type.

---

## 2. Components & where they live

| Component | Host | Role |
|---|---|---|
| **cloakbox / cloakserve** | pbox (gologin platform) | Runs the seed browsers + the identity gate. Driven via the `cloak-cdp` MCP (`cdp_load`/`cdp_save`/`cdp_drive`). Owns fingerprint, proxy pinning, egress-verify, session injection. |
| **bluebut runner** | anywhere (dev mac / bridge / cron) | `bin/bluebut-adapter.mjs` — runs adapters, drives the seed over CDP, threads the `job_id`, writes the `job` ledger + HAR. |
| **bluebut-e2ee daemon** | hexabyte Coolify | Go/whatsmeow. Per-account E2EE device: register, connect, **decrypt inbound**, **send outbound**. `bluebut-e2ee.blpk.cc:8099`, token-gated. |
| **bluebut-bridge** | hexabyte Coolify | The opencli bridge + `/messenger/e2ee/ingest` (persist decrypted msg → `fb_message` → **receive alert**) + `/messenger/ls-ingest`. `bluebut-bridge.blpk.cc`. |
| **keyvault** | keyvault.hostbun.cc | Account store (`facebook/accounts/<slug>`: proxy, creds, session), E2EE register-once (`bluebut/e2ee-device/<slug>`), Telegram config (`telegram/devdash-channel`). |
| **bluebut-db** | hexabyte Coolify (Postgres via `bluebut-rest`) | `fb_message`, `fb_thread`, `fb_contact`, `fb_thread_participant`, `fb_message_reaction`. |

---

## 3. Proxy coherence — the one-IP guarantee

**The invariant:** for a given account, the cloakbox browser (web wire) and the E2EE daemon
(companion device) egress **the same IP** — the one stored in keyvault.

### 3a. The single source of truth
`keyvault facebook/accounts/<slug>.proxy` holds **one** proxy per account. It is a **proxybun
relay** endpoint (`57.129.79.254:<port>` on the lprod relay host) that forwards to a backend line:

| Account | keyvault proxy (relay) | Backend egress IP | Line |
|---|---|---|---|
| **philip** | `57.129.79.254:30026` | `80.217.106.60` | pbox residential (Com Hem) |
| **pojtie** | `57.129.79.254:30012` | `194.104.94.173` | hexabyte datacenter |

> **⛔ Never switch an established account's proxy.** An account's IP is part of its identity to
> FB; changing it looks like a takeover. `proxy:pick` is for **no-proxy** accounts only. (This rule
> was learned the hard way this session — a proxy swap on pojtie was reverted immediately.)

### 3b. Both wires read that one proxy, JIT
- **E2EE daemon:** `messenger/lib/proxy.js resolveAccountProxy(slug)` → the shared resolver in
  `adapters/_lib/proxy-url.js` → keyvault `.proxy` → whatsmeow `SetProxy`. Read **per connect**,
  so adding accounts needs no daemon change.
- **Cloakbox browser:** `cdp_load` egress-verifies the platform-pinned proxy; the runner re-pins
  the keyvault proxy into the platform record on every `cdp_save` (`adapters/_lib/cloak-client.js`
  `cdpSave(slug,{proxy})`), so subsequent loads verify against the canonical value.

### 3c. The fail-closed egress guard (the hard guarantee)
`bin/bluebut-adapter.mjs`, right after `cdpLoad`:
- If the account has a keyvault proxy, assert `loaded.egress_ip` matches its expected exit.
- On mismatch — or if the browser egressed a known **box/pbox** IP while a proxy is set — the job
  **fails closed** (`PROXY_EGRESS_MISMATCH`), so it can never burn the account on the wrong IP.
- `shouldBlockNoProxy` hard-blocks a proxy-required account that resolved **no** proxy.
- Default is warn; `BLUEBUT_ENFORCE_EGRESS=1` makes it fail-closed. Shared logic lives in
  `adapters/_lib/proxy-url.js` (`resolveKvProxyBinding`, `expectedExitFromRow`, `shouldBlockNoProxy`),
  tested in `proxy-url.test.mjs`.

### 3d. The cloakbox identity gate (platform side)
`cloak-cdp-server.py _cdp_load_blocking` (in `devdashco/gologin`, on pbox):
```python
pinned = bool("://" in (fp.proxy or "") or getattr(fp, "proxy_id", ""))
# ... browser does an in-page fetch to ipify to read its real egress ...
if pinned and not out.get("egress_ip"):
    out["identity_gate"] = "REFUSED_EGRESS"     # fail-closed: pinned proxy, but browser got no egress
```
This is a **coherence** check (pinned proxy must actually carry traffic), **not** an IP-reputation
check. It refuses to hand back a browser that would silently egress the wrong way.

> **Known gotcha — warm-seed reuse.** On a **fresh** seed spawn the in-page egress fetch succeeds
> and the gate PASSES. On a **reused (warm)** seed the fetch can come back empty even though the
> proxy is fine, tripping a false `REFUSED_EGRESS`. Workaround today: kill the warm seed to force a
> fresh spawn. **Proper fix (pending, platform-side):** on egress-fail, force a fresh respawn +
> re-verify before returning `REFUSED_EGRESS`. A fetch-retry patch was tried and did **not** fix it
> (the fetch, not the retry, is the wrong layer).

---

## 4. Reachability / network topology

The full **e2ee-connect** needs to reach **two** hosts at once: **pbox** (to mint the CAT from a
live browser) and the **daemon** (to hand it the CAT). Neither the dev mac nor the hosted bridge
could reach both by default — that was the original blocker.

- **The fix:** the daemon got a public FQDN **`bluebut-e2ee.blpk.cc`** (Traefik-routed, token-gated).
  Now any caller that can reach pbox for `cdp_load` can also POST the daemon.
- **Tunnel-free CAT minting:** `messenger/lib/cat-connect.js mintCatAndConnect` mints the CAT via
  the **`cloak-cdp` `cdp_drive`** MCP (`adapters/_lib/cloak-client.js cdpDrive`) — which runs
  **server-side on pbox** and returns the eval over the **public** MCP. So it works from the hosted
  bridge with **no ssh tunnel** (the loopback `resolvePageWs` path only works pbox-local/tunneled).
- **Local-run workarounds (dev mac only, NOT prod):** an `/etc/hosts` entry (mac negative-DNS
  cache) + `NODE_TLS_REJECT_UNAUTHORIZED=0` (Let's Encrypt cert provisioning lag on the new FQDN).
  These are dev conveniences; the deployed bridge needs neither. **Cleanup pending:** drop the
  `/etc/hosts` + TLS-bypass once the cert is confirmed issued.

---

## 5. 24/7 operation

- **Receive is always-on:** the daemon is a persistent Coolify app holding the whatsmeow socket;
  inbound type-15 decrypts and ingests continuously with no polling.
- **The CAT expires (~24h)** and only a live browser can mint it (`RefreshCAT` is a stub). Durability
  = the reconnect sweep (`bin/e2ee-reconnect-sweep.mjs` → `cat-connect.js`), tunnel-free, run by
  **two Coolify scheduled tasks on `bluebut-bridge`**:

  | Cron | Command | Purpose |
  |---|---|---|
  | `*/15 * * * *` | `node bin/e2ee-reconnect-sweep.mjs` | **reactive** — heal any down session fast |
  | `0 */6 * * *` | `node bin/e2ee-reconnect-sweep.mjs --force` | **proactive** — refresh every CAT < 24h to expiry → no dark window |

- **Register-once** (`keyvault bluebut/e2ee-device/<slug>`, `wa_device_id` + allowlist) means a
  reconnect reuses the same device → no new-device alert on FB.

---

## 6. Telegram alerts

`adapters/_lib/tg-notify.js` — one shared helper, used by both wires, generic on the account slug:

- **`notifyTelegram(text)`** — POSTs `sendMessage` to the devdash channel. Config
  (`alert_sender_token` + `chat_id`) from keyvault `telegram/devdash-channel`, cached per process.
  **Best-effort by contract:** a Telegram hiccup never blocks a persist or a send. Opt-out
  `BLUEBUT_TG_ALERTS=0`.
- **`resolveFbName(account, fbId)`** — turns an fb_id into a human name via `fb_contact.name`
  (falls back to the id), cached per `(account,id)`. This is what makes alerts read
  `pojtie ← Philip Rönnmark`, not `pojtie ← 1159930757`.

**Two fire sites:**
- **RECEIVE** — bridge `/messenger/e2ee/ingest`, per inbound message where `is_self=false` and it
  has text: `📩 <account> ← <name>\n<text>`.
- **SEND** — `messenger/jobs/send-message.js`, after a **verified** E2EE send:
  `📤 <account> → <name>\n<text>`.

Both truncate the body to ~350 chars. `is_self` comes from the daemon's `DecryptedMessage.IsSelf`
(the truth ladder — never a DOM guess), so our own echoes don't self-alert.

---

## 7. How to operate

### Send a message (auto-routes plain vs E2EE)
```bash
node bin/bluebut-adapter.mjs messenger/jobs/send-message.js \
  --account philip --to <peer_fb_id> --text "hej"
# type-15 thread → daemon whatsmeow E2EE send; type-1 → LightSpeed task.
# On success: fb_message row (is_self=true) + a 📤 Telegram send-alert.
```
E2EE needs the daemon URL/token in env when run off-bridge:
`E2EE_DAEMON_URL=https://bluebut-e2ee.blpk.cc E2EE_DAEMON_TOKEN=<token>`.

### Sync the inbox (all folders, incl. hidden)
```bash
node bin/bluebut-adapter.mjs messenger/jobs/sync-inbox.js --account philip
```

### Connect / heal an account's E2EE device
```bash
node bin/e2ee-reconnect-sweep.mjs            # heal any down session
node bin/e2ee-reconnect-sweep.mjs --force    # refresh all CATs proactively
```

Everything runs as a **job** — steps ship to HyperDX by `job_id`, the terminal state lands in the
`job` ledger, and a per-job HAR is captured (the encrypted publish+ack frames are proof-of-send).

---

## 8. Data model (bluebut-db)

`messenger/SCHEMA.sql` — FB-faithful, per-viewer-scoped by `account`:

| Table | Key columns for E2EE |
|---|---|
| `fb_message` | `is_e2ee`, `decrypted_by` (`daemon`), `is_self`, `sender_id`, `text`, `timestamp_ms` |
| `fb_thread` | `thread_type` (15 = E2EE — drives send routing), folder |
| `fb_contact` | `id`, `name` (← what `resolveFbName` reads for alerts) |
| `fb_thread_participant` | thread ↔ contact edges |
| `fb_message_reaction` | reactions |

Writes go through the shared `_lib/db.js` client (each write logs a step); `rpc/fb_schema_ingest`
self-applies any new column the wire surfaces.

### ⚠ Thread-key identity — the two wires MUST agree
The two wires disagree on how to key a 1:1 thread, and reconciling them is mandatory:

| Writer | Keys a 1:1 thread by | Example |
|---|---|---|
| **LightSpeed / sync-inbox** (`fb_thread`, the UI thread list) | FB's **thread FBID** | `836822922565696` |
| **E2EE daemon / whatsmeow** (`fb_message`) | the **peer user fb_id** (all it knows) | `100003115085298` (William) |

Left unreconciled, decrypted E2EE bodies land under a `thread_key` that has **no `fb_thread`
row** → the thread opens **empty in the UI** even though the messages exist. The fix
(`messenger/lib/thread-key.js canonicalE2eeThreadKey`) resolves peer → the 1:1 non-group
type-15 thread via `fb_thread_participant` and is wired into the bridge `e2ee-ingest`, so every
decrypted message persists under the **FBID**; a straggler `PATCH` collapses any earlier
peer-keyed rows once the thread syncs. `is_self` is persisted here too (it drives bubble
alignment). Non-E2EE threads never hit this — sync-inbox writes `fb_thread` and `fb_message`
with the same LightSpeed key.

---

## 9. Failure modes & the correct diagnosis

| Symptom | Real cause | Fix |
|---|---|---|
| `REFUSED_EGRESS` on `cdp_load` | Pinned proxy, but the browser (usually a **warm-reused** seed) got no in-page egress. **Not** an IP-reputation reject. | Kill the warm seed → fresh spawn PASSES. Proper fix pending platform-side (force respawn on egress-fail). |
| E2EE send "succeeds" but no message appears | A plain LightSpeed task was sent into a **type-15** thread → FB accepts-then-drops it. | Route type-15 through the daemon (`sendViaE2EE`) — this is automatic now. |
| `daemon unreachable …:8099` | Daemon only on the Coolify network; caller can't reach it. | Use the FQDN `bluebut-e2ee.blpk.cc` (+ token). |
| Web IP ≠ E2EE IP | The two wires resolved different proxies. | The shared resolver (§3b) + egress guard (§3c) — both read keyvault `.proxy`. |
| Alerts show raw fb_ids | `resolveFbName` not wired / `rest()` called wrong. | Fixed: `rest('GET', path)` + `fb_contact.name` lookup, cached. |
| Thread opens **empty** in the UI (E2EE) | The daemon keyed the message by peer id; the thread list keys by FB thread FBID → no join. | `canonicalE2eeThreadKey` in the bridge ingest maps peer → FBID (§8). |

**Anti-patterns to avoid** (all hit this session): switching an established proxy; blaming
"datacenter IP reputation" for a coherence-gate refusal; patching the retry instead of the spawn;
gating account data on box reachability instead of reading keyvault.

---

## 10. Current state (2026-07-07)

- **philip** — E2EE device 960, connected; web+E2EE coherent on the pbox-relay line (`80.217.106.60`).
  Manages the `bofrid.se` page (act-as via `--as-page`).
- **pojtie** — E2EE device 580, connected on its line (`194.104.94.173`).
- **Verified bidirectional E2EE** philip ↔ pojtie (both `is_e2ee=true`, `decrypted_by='daemon'` in
  `fb_message`), and philip → William Wiklund.
- **Alerts** — send + receive, name-resolved, live on the runner (send) and the deployed bridge
  (receive, commit `e704e2b`; confirmed by the `self=` field in the ingest log).

### Pending / backlog
- Platform: force fresh-respawn on warm-seed egress fail (kill the false `REFUSED_EGRESS`).
- Remove the dev-mac `/etc/hosts` + `NODE_TLS_REJECT_UNAUTHORIZED=0` once the
  `bluebut-e2ee.blpk.cc` cert is confirmed issued.
- Simultaneous-instance act-as-page (blocked on `cdp_load` instance arg — GH issue #8).
