# WhatsApp Singleton GenServer wrapping a Node.js WhatsApp bridge via MuonTrap + ZeroMQ. ## Overview `WhatsApp` is a GenServer that integrates WhatsApp messaging into the Elixir MCP application using a personal WhatsApp account. It follows the `ZmqCounter` pattern: a Node.js process (managed by `MuonTrap.Daemon`) runs the [Baileys](https://github.com/WhiskeySockets/Baileys) WhatsApp Web library, and ZeroMQ carries messages between Elixir and Node.js over IPC. The bridge uses two ZMQ socket pairs: - **REQ/REP** — synchronous commands (send message, get status, fetch contacts) - **PUB/SUB** — asynchronous inbound events (incoming messages, read receipts, connection state changes) Inbound events are broadcast via `Notifier.notify("whatsapp", msg)` so any module can subscribe and react. Session credentials are persisted to disk so the QR code only needs to be scanned once per device. The Node.js process is monitored and automatically relaunched on crash. ## Features | Feature | Description | |---------|-------------| | Send & Receive Text | Send and receive text messages to any WhatsApp contact or group | | Media Support | Send images, videos, audio, documents. Incoming media auto-saved to `priv/whatsapp_media/` | | Group Management | List groups, create new groups, fetch group participants | | Read Receipts | Track message delivery and read status (delivered, read, played) | | Auto-Recovery | Node.js process monitored via MuonTrap — automatically relaunched on crash | | Event Broadcasting | Incoming messages broadcast via Notifier for ServerChat, Alarm, and other modules | | Session Persistence | Baileys auth state saved to disk — QR code scan only needed once per device | | MCP Tools | Six MCP tools expose WhatsApp to the chat UI with proper permission controls | ## Configuration ### Compile-time (config.exs) ```elixir config :mcp, whatsapp_backend: :erlzmq_dnif, whatsapp_auth_dir: "priv/whatsapp_auth" ``` | Option | Default | Description | |--------|---------|-------------| | `whatsapp_backend` | `:erlzmq_dnif` | ZMQ backend — `:erlzmq_dnif` (NIF, supports IPC) or `:chumak` (pure Erlang, TCP only) | | `whatsapp_auth_dir` | `"priv/whatsapp_auth"` | Directory for Baileys session credentials (created automatically) | ### Runtime (start_link options) ```elixir WhatsApp.start_link(backend: :chumak, auth_dir: "/custom/auth/path") ``` ### Node.js Dependencies Install once in the `scripts/` directory: ```bash cd scripts && npm install ``` | Package | Version | Purpose | |---------|---------|---------| | `@whiskeysockets/baileys` | `^6` | WhatsApp Web protocol client | | `zeromq` | `^6` | ZeroMQ bindings for Node.js | | `pino` | `^9` | Logger (required by Baileys) | ### System Requirements - Node.js (for the Baileys bridge) - `libzmq` (for `:erlzmq_dnif` backend) - `qrencode` (optional, for displaying QR codes in terminal) ## Public API ### start_link/1 Start the WhatsApp GenServer. Registered as a singleton under the name `WhatsApp`. Normally started by the supervision tree. ```elixir {:ok, pid} = WhatsApp.start_link() {:ok, pid} = WhatsApp.start_link(backend: :chumak) ``` ### status/0 Returns the current WhatsApp connection status. ```elixir WhatsApp.status() #=> {:ok, %{"connected" => true, "name" => "James", "phone" => "1234567890:1@s.whatsapp.net", "status" => "connected"}} ``` ### qr/0 Returns QR code data for first-time device linking. If already connected, returns connected status. ```elixir {:ok, %{"data" => qr_data}} = WhatsApp.qr() # Display in terminal (requires qrencode) System.cmd("qrencode", ["-t", "ANSIUTF8", qr_data]) |> elem(0) |> IO.puts() ``` ### send_message/2 Sends a text message to the given JID. ```elixir WhatsApp.send_message("34630805934@s.whatsapp.net", "Hello from Elixir!") #=> {:ok, %{"messageId" => "ABCD1234", "status" => "ok"}} ``` ### send_media/4 Sends a media message (image, video, audio, or document). ```elixir WhatsApp.send_media( "34630805934@s.whatsapp.net", "https://example.com/photo.jpg", "Check this out!", "image" ) #=> {:ok, %{"messageId" => "EFGH5678", "status" => "ok"}} ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `jid` | string | — | Recipient JID | | `media_url` | string | — | URL of the media file to send | | `caption` | string | `""` | Optional caption | | `type` | string | `"image"` | Media type: `"image"`, `"video"`, `"audio"`, or `"document"` | ### get_messages/2 Fetches message history for a contact or group. Message availability depends on Baileys session state. ```elixir WhatsApp.get_messages("34630805934@s.whatsapp.net", 50) #=> {:ok, %{"messages" => [...], "status" => "ok"}} ``` ### get_contacts/0 Lists WhatsApp contacts accumulated during the current session. ```elixir WhatsApp.get_contacts() #=> {:ok, %{"contacts" => [%{"jid" => "...", "name" => "Alice", "phone" => "..."}], "count" => 42}} ``` ### get_groups/0 Lists all WhatsApp groups the account participates in. ```elixir WhatsApp.get_groups() #=> {:ok, %{"groups" => [%{"jid" => "120363012345@g.us", "name" => "Family", "participants" => 5}]}} ``` ### create_group/2 Creates a new WhatsApp group with the given name and participant JIDs. ```elixir WhatsApp.create_group("Project Chat", ["1234567890@s.whatsapp.net", "0987654321@s.whatsapp.net"]) #=> {:ok, %{"groupId" => "120363099999@g.us", "status" => "ok"}} ``` ## GenServer State | Key | Type | Description | |-----|------|-------------| | `:socket` | term | ZMQ REQ socket for sending commands | | `:ctx` | term | ZMQ context (erlzmq_dnif only) | | `:daemon` | pid | MuonTrap.Daemon process managing Node.js | | `:daemon_ref` | reference | Monitor reference for the Daemon process | | `:sub_socket` | term | ZMQ SUB socket for receiving events | | `:sub_ctx` | term | ZMQ SUB context (erlzmq_dnif only) | | `:sub_pid` | pid | Spawned listener process for the SUB socket | | `:backend` | atom | ZMQ backend (`:erlzmq_dnif` or `:chumak`) | | `:auth_dir` | string | Path to Baileys authentication directory | | `:req_endpoint` | string | IPC socket path for REQ/REP commands | | `:pub_endpoint` | string | IPC socket path for PUB/SUB events | | `:started_at` | integer | Monotonic timestamp of GenServer start | ## Inbound Events Events are pushed from Node.js via the ZMQ PUB socket and broadcast via `Notifier.notify("whatsapp", msg)`. Subscribe from any GenServer: ```elixir Notifier.subscribe("whatsapp", :handle_whatsapp) def handle_cast({:handle_whatsapp, %{"event" => "message"} = msg}, state) do IO.puts("From: #{msg["pushName"]} — #{msg["text"]}") {:noreply, state} end ``` ### message Fired when a WhatsApp message is received. Internal `@lid` messages are filtered out. ```elixir %{ "event" => "message", "from" => "34630805934@s.whatsapp.net", "fromMe" => false, "text" => "Hello!", "timestamp" => 1770794586, "messageId" => "ABC123", "type" => "text", # text | image | video | audio | document "pushName" => "María", # sender's display name "mediaPath" => nil # file path if media, nil for text } ``` ### receipt Fired when a message status changes (delivered or read). ```elixir %{ "event" => "receipt", "jid" => "34630805934@s.whatsapp.net", "messageId" => "ABC123", "fromMe" => true, "status" => "read" # delivered | read | played } ``` | Status | WhatsApp UI | Meaning | |--------|------------|---------| | `delivered` | ✓✓ (gray) | Message reached recipient's device | | `read` | ✓✓ (blue) | Recipient opened the chat | | `played` | ✓✓ (blue) | Audio/video was played | ### connection Fired when the WhatsApp connection state changes. ```elixir %{ "event" => "connection", "state" => "open" # open | close | connecting } ``` ### qr Fired when a new QR code is generated for device linking. ```elixir %{ "event" => "qr", "data" => "2@..." # QR code payload } ``` ## JID Formats | Pattern | Example | Description | |---------|---------|-------------| | `@s.whatsapp.net` | `34630805934@s.whatsapp.net` | Individual chat | | `@g.us` | `120363012345@g.us` | Group chat | | `...@lid` | — | Internal linked-device protocol (filtered out) | ## Node.js Bridge Commands (REQ/REP) The Elixir GenServer sends JSON commands over the REQ socket and receives JSON replies: | Command | Args | Response | |---------|------|----------| | `status` | — | `{status, connected, phone, name}` | | `qr` | — | `{status: "qr", data: "..."}` or `{status: "connected"}` | | `send` | `jid`, `text` | `{status: "ok", messageId: "..."}` | | `send_media` | `jid`, `media_url`, `caption`, `type` | `{status: "ok", messageId: "..."}` | | `get_messages` | `jid`, `limit` | `{status: "ok", messages: [...]}` | | `get_contacts` | — | `{status: "ok", contacts: [...], count: N}` | | `get_groups` | — | `{status: "ok", groups: [...]}` | | `create_group` | `name`, `participants` | `{status: "ok", groupId: "..."}` | All error responses follow `{status: "error", message: "..."}`. ## Internals ### Startup Sequence 1. Generate unique IPC socket paths using `System.system_time()` 2. Launch `node scripts/whatsapp_bridge.js ` via `MuonTrap.Daemon` 3. Monitor the Daemon process with `Process.monitor/1` 4. Sleep 2 seconds for Node.js to bind ZMQ sockets and start Baileys 5. Create ZMQ REQ socket and connect to REQ IPC endpoint 6. Create ZMQ SUB socket, subscribe to all topics, connect to PUB IPC endpoint 7. Spawn a linked listener process that loops on SUB `recv` and forwards events to the GenServer ### Crash Recovery When the Node.js process crashes: 1. MuonTrap.Daemon exits → GenServer receives `{:DOWN, ref, :process, _pid, reason}` 2. GenServer cleans up existing ZMQ sockets 3. GenServer calls `start_node_and_connect/4` to relaunch Node.js and reconnect 4. If relaunch fails, GenServer stops and the supervisor restarts it Baileys auth state is persisted to disk, so reconnection after crash does not require a new QR scan. ### ZMQ Backend Abstraction All ZMQ operations are dispatched through private functions that pattern-match on the backend atom: | Function | erlzmq_dnif | chumak | |----------|-------------|--------| | `create_req_socket` | `:erlzmq.context()` → `:erlzmq.socket(ctx, :req)` → `:erlzmq.connect()` | `:chumak.socket(:req, identity)` → `:chumak.connect(:tcp, ...)` | | `create_sub_socket` | `:erlzmq.socket(ctx, :sub)` → `:erlzmq.setsockopt(:subscribe, "")` | `:chumak.socket(:sub, identity)` → `:chumak.subscribe("")` | | `zmq_send` | `:erlzmq.send(socket, msg)` | `:chumak.send(socket, msg)` | | `zmq_recv` | `:erlzmq.recv(socket)` | `:chumak.recv(socket)` | | `sub_recv` | Two-frame recv (topic + data) | `:chumak.recv_multipart(socket)` | ### cgroup Resource Containment (Linux) On Linux systems with cgroup v1 (`/sys/fs/cgroup/cpu` exists), MuonTrap places the Node.js process in `mcp/whatsapp` cgroup with `memory` and `cpu` controllers. This prevents unbounded resource consumption. On macOS or cgroup v2, cgroups are silently omitted. ### Cleanup (terminate/2) 1. Stop the SUB listener process (`Process.exit(sub_pid, :shutdown)`) 2. Close ZMQ REQ and SUB sockets and contexts 3. Stop the MuonTrap Daemon 4. Remove IPC socket files from `/tmp/` ## MCP Tools WhatsApp is exposed to the chat UI via these MCP tools (defined in `lib/my_mcp_server.ex`): | Tool | Permission | Description | |------|-----------|-------------| | `whatsapp_status` | `@view_permission` | Check connection status | | `whatsapp_send` | `@admin_permission` | Send a text message | | `whatsapp_send_media` | `@admin_permission` | Send a media message | | `whatsapp_messages` | `@view_permission` | Read message history from a contact | | `whatsapp_contacts` | `@view_permission` | List contacts | | `whatsapp_groups` | `@view_permission` | List groups | ## Troubleshooting | Issue | Solution | |-------|----------| | Node.js bridge crashes on startup | Run `npm install` in `scripts/` — missing `node_modules/` causes immediate failure | | "Key used already or never filled" errors | Signal protocol noise from libsignal — harmless, logger set to "error" level to suppress | | QR code expired | QR codes expire after ~60 seconds. Call `WhatsApp.qr()` again for a fresh one | | Can't copy session between servers | Each server needs its own QR scan — WhatsApp ties sessions to specific devices (max 4 linked) | | Media download fails | Check that `priv/whatsapp_media/` is writable (created automatically on first download) | ## Dependencies | Module | Purpose | |--------|---------| | `MuonTrap.Daemon` | Manages the Node.js child process lifecycle | | `:erlzmq` / `:chumak` | ZeroMQ bindings for REQ/REP and PUB/SUB communication | | `Notifier` | Pub/sub system for broadcasting inbound WhatsApp events | | `Jason` | JSON encoding/decoding for ZMQ message payloads | | `Logger` | Logging for connection events, errors, and incoming messages | | `scripts/whatsapp_bridge.js` | Node.js bridge script (Baileys + ZeroMQ) |