Skip to content

feat: read-only WhatsApp connector (Baileys) as a channel source - #245

Open
oxedom wants to merge 5 commits into
mainfrom
feat/whatsapp-connector
Open

feat: read-only WhatsApp connector (Baileys) as a channel source#245
oxedom wants to merge 5 commits into
mainfrom
feat/whatsapp-connector

Conversation

@oxedom

@oxedom oxedom commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a read-only WhatsApp connection that runs inside the Cabinet daemon, structured exactly like the Telegram gateway (server/whatsapp/ mirrors server/telegram/). The core (normalize + bus + per-account Baileys connection) is ported from the whatsapp-sidecar package in the moomacha repo; the sidecar's loopback HTTP/SSE layer was dropped because the daemon is Node and consumes the message bus in-process.

  • Off by default — enabled only when WHATSAPP_ACCOUNTS is set in .cabinet.env (id or id:Label, comma-separated). A chokidar watch applies changes live, no daemon restart.
  • Read-only by construction — no sendMessage path exists; the connection never marks chats read and never sends presence. Nothing inbound can drive Cabinet.
  • First slice of the flow: every inbound message (DM/group, all accounts) is normalized and posted to a channel board on the home cabinet (default #whatsapp) via channels-manager.
  • Pairing: QR prints in the daemon log (scan via WhatsApp → Linked Devices); the raw QR payload is mirrored to <data>/.agents/.runtime/whatsapp/qr-<id>.txt so a future settings card can render it. Session keys persist under <data>/.agents/.whatsapp/store/<id>/.
  • An owner.json pid marker (same pattern as Telegram) prevents two daemons (dev + packaged) from corrupting the same Baileys session.

See docs/WHATSAPP_CONNECTOR.md for setup and the ToS caveat (Baileys speaks the unofficial WhatsApp Web protocol — this is an opt-in personal connector; paired accounts carry a nonzero ban risk).

Verification

  • 11 unit tests: npx tsx --test test/whatsapp-*.test.ts (normalize, bus, config parsing) — all pass
  • tsc --noEmit and eslint clean
  • Smoke-booted the gateway through the real import graph (disabled path → clean shutdown)
  • Not verified here: live pairing with a real phone (needs a QR scan)

Next steps (in rough order)

  1. Settings UI connector card (model: cli-proxy-connector-card.tsx): configure accounts, show per-account connection status, and render the pairing QR from the runtime file instead of making users dig through daemon logs. Needs a small daemon endpoint exposing account status + QR.
  2. Route messages into agent runs — the Telegram router (server/telegram/router.ts) is the template. Needs a deliberate gating design first (which chats/senders may trigger runs; fail-closed like Telegram's allowlist).
  3. Group names — boards currently show raw group JIDs; sync group metadata (subjects) to display human names.
  4. Media — captions only today; optionally download image/document payloads into staging attachments.
  5. Send path — decision needed. Deliberately not built: replying raises the ban risk and changes the safety posture (WhatsApp could then be driven by agent output). If ever added, it should be its own gated PR.
  6. Packaging check — confirm the Electron bundle picks up @whiskeysockets/baileys cleanly and measure size impact before a release.

🤖 Generated with Claude Code

Ports the whatsapp-sidecar core from the moomacha repo into the daemon,
running in-process next to the Telegram gateway (server/whatsapp/ mirrors
server/telegram/). One Baileys connection per account, read-only by
construction (no send path, no read receipts, no presence); every inbound
message is normalized and posted to a channel board on the home cabinet.

Off unless WHATSAPP_ACCOUNTS is set in .cabinet.env; a chokidar watch
enables/reconfigures it live, and an owner.json pid marker keeps two
daemons from fighting over the same session files. QR pairing prints in
the daemon log and is mirrored to a runtime file for a future UI card.
Session keys persist under <data>/.agents/.whatsapp/store/<id>/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oxedom
oxedom requested a review from a team as a code owner July 14, 2026 21:52
oxedom and others added 4 commits July 15, 2026 00:59
Baileys lazily require()s link-preview-js, jimp, and sharp for link
previews and media re-encoding — paths the read-only WhatsApp gateway
never invokes and deps we don't install. esbuild resolves requires
eagerly, so mark them external to keep electron:prep building.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds WHATSAPP_PAIRING_PHONE support (requestPairingCode instead of QR)
and a Settings UI card polling connection status, for validating the
onboarding flow before deciding whether to build this properly.
- Fix Baileys 6.x getPlatformId bug that broke pairing-code linking
  (charCode sent instead of the platform enum, WhatsApp closes with an
  immediate 401). Same workaround as moomacha's whatsapp-sidecar this
  connector was ported from. Guard against requesting more than one
  code per Connect click, and delay the request slightly so it doesn't
  race the socket handshake.
- Add WhatsApp as a native integration card in the Integrations Hub
  (logo, blurb, capabilities) with its own settings panel: phone
  number in, live-polled pairing code / connected state out, and an
  unlink action that clears the saved session.
- New routes: POST /api/whatsapp/connect, POST /api/whatsapp/disconnect,
  GET /api/whatsapp/status — backed by a status file the daemon's
  AccountConnection mirrors on every connection.update.

Known issue found during manual testing: the gateway's .cabinet.env
chokidar watcher (server/whatsapp/gateway.ts) stops reacting to file
changes after the first reconcile in a given daemon process — Telegram's
identical watcher keeps working on the same file, so this looks specific
to our watcher instance, not a general chokidar/inotify limit. Live
Connect/Disconnect clicks may silently no-op until the daemon restarts;
verified pairing works when WHATSAPP_ACCOUNTS/WHATSAPP_PAIRING_PHONE are
already set in .cabinet.env before boot (goes through the direct
reconcile("daemon boot") call instead of the watcher). Needs a follow-up
fix before this ships — the watcher failure mode is silent
(envWatcher.on("error") swallows it) which will confuse users the same
way it confused this test session.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant