πŸ“„ raw markdown for agents/LLMs: /MESSENGER-E2EE-ALERTS.md Β· all docs

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 (the identity platform we consume), messenger/e2ee/PLAN.md (the E2EE device-registration design), and the messenger subsystem in the root 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)

1b. E2EE companion device (type-15)

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

3c. The fail-closed egress guard (the hard guarantee)

bin/bluebut-adapter.mjs, right after cdpLoad:

3d. The cloakbox identity gate (platform side)

cloak-cdp-server.py _cdp_load_blocking (in devdashco/gologin, on pbox):

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.


5. 24/7 operation


6. Telegram alerts

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

Two fire sites:

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)

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)

node bin/bluebut-adapter.mjs messenger/jobs/sync-inbox.js --account philip

Connect / heal an account's E2EE device

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)

Pending / backlog