# wa-cli [EspaΓ±ol](README.es.md) WhatsApp from the terminal, shaped like `gws`: ``` wa [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox] wa + ... wa schema ``` It uses [whatsapp-web.js](https://github.com/wwebjs/whatsapp-web.js): a headless Chrome running WhatsApp Web, linked to your phone as one more device. **It is not an official client**: WhatsApp may ban accounts that use unofficial clients. Use it in moderation (no bulk sending). ## Install Keep the project outside `/tmp` (systemd-tmpfiles deletes whatever is unused for 10 days). ```sh git clone https://git.all.ar/pub/wa-cli.git && cd wa-cli npm install # downloads Chrome for puppeteer if it is not in ~/.cache/puppeteer ln -s "$PWD/bin/wa.js" ~/.local/bin/wa wa auth login # QR in the terminal wa auth login --phone 549... # or an 8-character code ``` Optional, a permanent daemon (needed to receive messages with the terminal closed): ```sh cp systemd/wa-daemon@.service ~/.config/systemd/user/ systemctl --user enable --now wa-daemon@default ``` Without systemd, the first `wa` command starts the daemon by itself (`WA_CLI_AUTOSTART=0` disables that; with systemd set `"autostart": false` in `config.json` so two do not compete). ## Usage ```sh wa +triage # chats with unread messages wa +read "Family" -n 50 --mark-read # readable conversation wa +send "Mom" there in 10 # by exact name, number or JID wa +send +5491122334455 -f report.pdf "here it is" wa +reply "" sure wa +watch --format text # incoming messages, live wa +watch --events message_create | jq -r .data.body wa +download "" -d ~/Downloads wa chats list --params '{"unread": true}' --format table wa messages list --params '{"chatId": "Family", "limit": 200, "since": 1788000000}' wa messages search --params '{"query": "invoice"}' --format table wa messages react --params '{"messageId": "...", "emoji": "πŸ‘"}' wa contacts resolve --params '{"number": "+54 9 11 2233-4455"}' wa schema messages.send # parameters of a method wa messages send --help ``` Several accounts: `-a work` or `WA_CLI_ACCOUNT=work`. Each account has its own session, socket and daemon. Exit codes: `0` ok, `1` WhatsApp error / not found, `2` no session, `3` validation, `4` daemon, `5` internal. ## Architecture ``` wa (CLI) ──JSON per line──▢ Unix socket ──▢ daemon ──▢ whatsapp-web.js ──▢ headless Chrome β”‚ β”œβ”€β”€β–Ά hooks ~/.config/wa-cli/hooks/ └──▢ events.watch (NDJSON stream) ``` WhatsApp Web takes 10–30 s to start and only receives messages while it is open, so there is one daemon per account and the CLI is a thin client. | What | Where | | ------------------- | ---------------------------------------------- | | Session (profile) | `~/.local/share/wa-cli//` | | Downloaded media | `~/.local/share/wa-cli//media/` | | Log (autostart) | `~/.local/state/wa-cli//daemon.log` | | Socket | `$XDG_RUNTIME_DIR/wa-cli/.sock` | | Config and hooks | `~/.config/wa-cli/{config.json,hooks/}` | `config.json` (all optional): ```json { "autostart": true, "readyTimeoutMs": 120000, "hooks": true, "puppeteer": { "headless": true, "executablePath": "/usr/bin/chromium" }, "accounts": { "work": { "hooks": false } } } ``` ### Code | File | Role | | ----------------------- | -------------------------------------------------------------------- | | `src/api/*.js` | `resource.method` methods: parameters, columns and handler | | `src/serialize.js` | whatsapp-web.js objects β†’ stable JSON (the external contract) | | `src/daemon.js` | Session, socket, events, hooks, chat resolution by name | | `src/cli.js` | gws-style parser, help, `auth login`, `daemon` | | `src/helpers.js` | `+send`, `+read`, `+watch`, ... | | `src/format/` | json/table/csv/yaml/ndjson/text/eml/mbox | | `src/compat.js` | Patches for WhatsApp Web changes (see Known issues) | | `src/wamail/` | Maildir bridge: sync, send (MIME parser), mailbox list | **Adding a method** = adding an entry to `src/api/.js`. It becomes available on the socket, as `wa `, in `--help` and in `wa schema`. `chat` parameters accept a JID, a number or an exact name; `path` parameters are resolved against the CLI cwd. **Adding a format** = a function in `FORMATS` (`src/format/index.js`). ## Events and hooks `wa events watch --params '{"events": ["all"]}'` (or `wa +watch`) prints one line per event: ```json {"event": "message", "account": "default", "time": "2026-09-16T21:30:00.000Z", "data": { ...message }} ``` | Event | `data` | | ---------------------------------------------------- | ------------------------------------------- | | `message` (incoming), `message_create` (all) | message | | `message_ack` | `{message, ack}` (1 server, 2 delivered, 3 read) | | `message_edit` | `{message, newBody, prevBody}` | | `message_revoke_everyone` | `{message, revoked}` | | `message_reaction` | `{messageId, chatId, senderId, reaction}` | | `unread_count`, `chat_removed` | chat | | `group_join`, `group_leave`, `group_update`, ... | group notification | | `qr`, `code`, `authenticated`, `ready`, `disconnected` | session state | The same events trigger **hooks**: executables in `~/.config/wa-cli/hooks/`, `.d/*` or `all.d/*`. They get the event on stdin, its name as `$1` and `WA_CLI_ACCOUNT` / `WA_CLI_SOCKET` in the environment (so they can call `wa`). They run in order per hook and are rescanned on every event (no restart needed). A serialized message: ```json { "id": "false_5491122334455@c.us_3EB0C4...", "stanzaId": "3EB0C4...", "chatId": "5491122334455@c.us", "chatName": "Juan PΓ©rez", "fromMe": false, "sender": "5491122334455@c.us", "senderName": "Juan PΓ©rez", "timestamp": 1789500000, "date": "2026-09-15T19:20:00.000Z", "type": "chat", "body": "hola", "hasMedia": false, "media": null, "hasQuotedMsg": false, "quotedStanzaId": null, "ack": 1, ... } ``` ## neomutt integration: wamail `wamail` is the WhatsApp ↔ Maildir bridge, like `gwsmail` for Gmail: **one folder per chat** in `~/Mail/whatsapp/`, and neomutt reads it like any other account. ``` wa-daemon ──hook (real time)──▢ wamail sync --chat X ──▢ ~/Mail/whatsapp//{new,cur} ◀──────────────────── wamail sync (5 min timer: read state, stars, deletions) neomutt ──sendmail──▢ wamail send ──▢ wa messages send ``` | In neomutt | On WhatsApp | | ------------------------------------- | -------------------------------------------------------------- | | sidebar folder | chat (WhatsApp order: pinned, recent; archived at the end) | | read every unread message of a chat | chat marked as read (read receipts) | | mark a message as unread | chat marked as unread | | flag (`F`) | starred message | | delete | **local only** (not downloaded again) | | `r` keeping the quote (`> ...`) | quoted reply; delete the quote for a normal message | | `r` / `g` in a group | goes to the group only (never privately to the author) | | attachments | a single image/video: with the text as caption; otherwise text + documents | | `gR` / `gX` / `gA` / `gG` | react / delete (everyone or me) / archive chat / sync now | | `Tab` in To: | search contacts and groups (`wamail query`) | | `F6` | switch to the WhatsApp account | Received on WhatsApp β†’ email: sender = JID (`5491122334455@c.us`, groups `...@g.us`), `Message-ID `, `In-Reply-To` for quoted replies, `X-WA-Id`/`X-WA-Chat-Id` for the actions, media as an attachment (up to `mediaMaxBytes`; otherwise a note with the command to download it). Messages deleted for everyone disappear from the Maildir (`message_revoke_everyone` hook). ### Install ```sh ln -s "$PWD/bin/wamail.js" ~/.local/bin/wamail mkdir -p ~/.config/wa-cli/hooks/all.d ln -s "$PWD/contrib/hooks/wamail" ~/.config/wa-cli/hooks/all.d/50-wamail cp systemd/wamail-sync.{service,timer} ~/.config/systemd/user/ systemctl --user daemon-reload && systemctl --user enable --now wamail-sync.timer wamail sync default # first time: last 30 days, up to 100 messages per chat mkdir -p ~/.config/neomutt/accounts/wa cp contrib/neomutt/wa-default.muttrc ~/.config/neomutt/accounts/wa/default.muttrc ``` and in the neomutt config: ```muttrc # neomuttrc folder-hook 'Mail/whatsapp' 'source ~/.config/neomutt/accounts/wa/default.muttrc' # keys/bind.muttrc macro index,pager 'source ~/.config/neomutt/accounts/wa/default.muttrc!' "WhatsApp (wamail)" # switch.muttrc (what the WhatsApp account changes) unmacro index,pager gR unmacro index,pager gX set query_command = "khard email --parsable --search-in-source-files '%s'" ``` Optional config in `~/.config/wa-cli/config.json`: ```json { "wamail": { "backfillDays": 30, "backfillLimit": 100, "mediaMaxBytes": 16777216, "skipChats": ["120363...@g.us"] } } ``` State lives in `~/.local/state/wamail/.json` (deleting it together with the Maildir forces a clean backfill); sends are logged in `~/.local/state/wamail/send.log`. ### Export a chat without syncing ```sh wa +export "Family" -n 500 --media -o /tmp/family.mbox && neomutt -R -f /tmp/family.mbox ``` ## Known issues WhatsApp Web changes often and whatsapp-web.js takes a while to catch up. `src/compat.js` patches at runtime, without touching `node_modules`: - **`_serialized` β†’ `$1`** in message IDs (wwebjs#201830). Symptom: `r: r` in `chats list`, `sendMessage` returning `undefined`. - **Re-injection on SPA navigations** (wwebjs#201653, fixed on `main`, not released). - **Cross-origin iframe** (`pdf-viewer`): with site isolation it is a separate process and, if it navigates while the bindings are registered, `exposeFunction` fails with `Target closed` and `ready` never comes. The daemon launches Chrome with `--disable-site-isolation-trials`. If it still hangs, the daemon watchdog exits with code 1 (60 s in `authenticated`, `startTimeoutMs` in `starting`) and systemd restarts it. To diagnose: `WA_CLI_TRACE=1` (or `"trace": true` in `config.json`) logs pending Chrome calls, exceptions inside bindings and navigations (`journalctl --user -u wa-daemon@default`). Headless Chrome uses ~1 GB (2 GB peaks during the initial sync). ## Tests ```sh npm test # formats, MIME parser (with real neomutt emails), wamail sync/send against a fake WhatsApp ``` ## License MIT