π 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 themessengersubsystem 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:
- 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-messagepath. - E2EE companion device β a whatsmeow socket (the
bluebut-e2eedaemon) 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.jsobserves the page's ownlightspeed_web_requestpayload (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_participantin 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 doc.)
1b. E2EE companion device (type-15)
- Where it runs: the
bluebut-e2eedaemon β 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_idis 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
POSTs each to the bridge/messenger/e2ee/ingestβfb_messagewithis_e2ee=true,decrypted_by='daemon'. - Send:
messenger/jobs/send-message.jsauto-detects a type-15 thread (fb_thread.thread_type) and routes to the daemon'sPOST /accounts/{slug}/e2ee-send, which builds awaConsumerApplication{MessageText}and calls whatsmeowSendFBMessage(<peer>@msgr)β the mautrixENCRYPTED_OVER_WA_ONE_TO_ONEpath. 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:pickis 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 inadapters/_lib/proxy-url.jsβ keyvault.proxyβ whatsmeowSetProxy. Read per connect, so adding accounts needs no daemon change. - Cloakbox browser:
cdp_loadegress-verifies the platform-pinned proxy; the runner re-pins the keyvault proxy into the platform record on everycdp_save(adapters/_lib/cloak-client.jscdpSave(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_ipmatches 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. shouldBlockNoProxyhard-blocks a proxy-required account that resolved no proxy.- Default is warn;
BLUEBUT_ENFORCE_EGRESS=1makes it fail-closed. Shared logic lives inadapters/_lib/proxy-url.js(resolveKvProxyBinding,expectedExitFromRow,shouldBlockNoProxy), tested inproxy-url.test.mjs.
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 returningREFUSED_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 forcdp_loadcan also POST the daemon. - Tunnel-free CAT minting:
messenger/lib/cat-connect.js mintCatAndConnectmints the CAT via thecloak-cdpcdp_driveMCP (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 loopbackresolvePageWspath only works pbox-local/tunneled). - Local-run workarounds (dev mac only, NOT prod): an
/etc/hostsentry (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 (
RefreshCATis a stub). Durability = the reconnect sweep (bin/e2ee-reconnect-sweep.mjsβcat-connect.js), tunnel-free, run by two Coolify scheduled tasks onbluebut-bridge:Cron Command Purpose */15 * * * *node bin/e2ee-reconnect-sweep.mjsreactive β heal any down session fast 0 */6 * * *node bin/e2ee-reconnect-sweep.mjs --forceproactive β 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)β POSTssendMessageto the devdash channel. Config (alert_sender_token+chat_id) from keyvaulttelegram/devdash-channel, cached per process. Best-effort by contract: a Telegram hiccup never blocks a persist or a send. Opt-outBLUEBUT_TG_ALERTS=0.resolveFbName(account, fbId)β turns an fb_id into a human name viafb_contact.name(falls back to the id), cached per(account,id). This is what makes alerts readpojtie β Philip RΓΆnnmark, notpojtie β 1159930757.
Two fire sites:
- RECEIVE β bridge
/messenger/e2ee/ingest, per inbound message whereis_self=falseand 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)
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)
- philip β E2EE device 960, connected; web+E2EE coherent on the pbox-relay line (
80.217.106.60). Manages thebofrid.sepage (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'infb_message), and philip β William Wiklund. - Alerts β send + receive, name-resolved, live on the runner (send) and the deployed bridge
(receive, commit
e704e2b; confirmed by theself=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=0once thebluebut-e2ee.blpk.cccert is confirmed issued. - Simultaneous-instance act-as-page (blocked on
cdp_loadinstance arg β GH issue #8).