aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: 3f402a218e9e0e8f1012925da339e5b1e61cdf92 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# wa-cli

[EspaΓ±ol](README.es.md)

WhatsApp from the terminal, shaped like `gws`:

```
wa <resource> <method> [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox]
wa +<helper> ...
wa schema <resource.method>
```

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 "<messageId>" sure
wa +watch --format text                      # incoming messages, live
wa +watch --events message_create | jq -r .data.body
wa +download "<messageId>" -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/<event>
                                              └──▢ 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/<account>/`             |
| Downloaded media    | `~/.local/share/wa-cli/<account>/media/`       |
| Log (autostart)     | `~/.local/state/wa-cli/<account>/daemon.log`   |
| Socket              | `$XDG_RUNTIME_DIR/wa-cli/<account>.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/<resource>.js`. It becomes available on the
socket, as `wa <resource> <method>`, 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/<event>`,
`<event>.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/<chat>/{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 <stanzaId@wa>`, `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 <f6> '<enter-command>source ~/.config/neomutt/accounts/wa/default.muttrc<enter><change-folder>!<enter>' "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/<account>.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