aboutsummaryrefslogtreecommitdiffstats
path: root/README.es.md
blob: 34c0b824db0d599594d5d599c7a7ea718f8fc916 (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
255
# wa-cli

[English](README.md)

WhatsApp desde la terminal, con la misma forma que `gws`:

```
wa <recurso> <método> [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox]
wa +<helper> ...
wa schema <recurso.método>
```

Usa [whatsapp-web.js](https://github.com/wwebjs/whatsapp-web.js): un Chrome headless con
WhatsApp Web, vinculado a tu teléfono como un dispositivo más. **No es un cliente oficial**:
WhatsApp puede bloquear cuentas que usan clientes no oficiales. Usarlo con moderación
(nada de envíos masivos).

## Instalación

Conviene tener el proyecto fuera de `/tmp` (systemd-tmpfiles borra lo que no se usa en 10 días).

```sh
git clone https://git.all.ar/pub/wa-cli.git && cd wa-cli
npm install                    # baja Chrome para puppeteer si no está en ~/.cache/puppeteer
ln -s "$PWD/bin/wa.js" ~/.local/bin/wa

wa auth login                  # QR en la terminal
wa auth login --phone 549...   # o código de 8 caracteres
```

Opcional, daemon permanente (necesario para recibir mensajes con la terminal cerrada):

```sh
cp systemd/wa-daemon@.service ~/.config/systemd/user/
systemctl --user enable --now wa-daemon@default
```

Sin systemd, el primer comando `wa` levanta el daemon solo (`WA_CLI_AUTOSTART=0` lo desactiva;
con systemd conviene `"autostart": false` en `config.json` para no tener dos compitiendo).

## Uso

```sh
wa +triage                                   # chats con no leídos
wa +read "Familia" -n 50 --mark-read         # conversación legible
wa +send "Mamá" llego en 10                  # por nombre exacto, número o JID
wa +send +5491122334455 -f informe.pdf "ahí va"
wa +reply "<messageId>" dale
wa +watch --format text                      # mensajes entrantes en vivo
wa +watch --events message_create | jq -r .data.body
wa +download "<messageId>" -d ~/Descargas

wa chats list --params '{"unread": true}' --format table
wa messages list --params '{"chatId": "Familia", "limit": 200, "since": 1788000000}'
wa messages search --params '{"query": "factura"}' --format table
wa messages react --params '{"messageId": "...", "emoji": "👍"}'
wa contacts resolve --params '{"number": "+54 9 11 2233-4455"}'
wa schema messages.send                      # parámetros de un método
wa messages send --help
```

Varias cuentas (como `gws` con varias cuentas): `-a trabajo` o `WA_CLI_ACCOUNT=trabajo`.
Cada cuenta tiene su sesión, su socket y su daemon.

Códigos de salida: `0` ok, `1` error de WhatsApp / no encontrado, `2` sin sesión,
`3` validación, `4` daemon, `5` interno.

## Arquitectura

```
wa (CLI) ──JSON por línea──▶ socket Unix ──▶ daemon ──▶ whatsapp-web.js ──▶ Chrome headless
                                              │
                                              ├──▶ hooks  ~/.config/wa-cli/hooks/<evento>
                                              └──▶ events.watch (stream NDJSON)
```

WhatsApp Web tarda 10–30 s en arrancar y solo recibe mensajes mientras está abierto, por eso
hay un daemon por cuenta y el CLI es un cliente liviano.

| Qué                 | Dónde                                          |
| ------------------- | ---------------------------------------------- |
| Sesión (perfil)     | `~/.local/share/wa-cli/<cuenta>/`              |
| Media descargada    | `~/.local/share/wa-cli/<cuenta>/media/`        |
| Log (autostart)     | `~/.local/state/wa-cli/<cuenta>/daemon.log`    |
| Socket              | `$XDG_RUNTIME_DIR/wa-cli/<cuenta>.sock`        |
| Config y hooks      | `~/.config/wa-cli/{config.json,hooks/}`        |

`config.json` (todo opcional):

```json
{
  "autostart": true,
  "readyTimeoutMs": 120000,
  "hooks": true,
  "puppeteer": { "headless": true, "executablePath": "/usr/bin/chromium" },
  "accounts": { "trabajo": { "hooks": false } }
}
```

### Código

| Archivo                 | Rol                                                                  |
| ----------------------- | -------------------------------------------------------------------- |
| `src/api/*.js`          | Métodos `recurso.método`: parámetros, columnas y handler             |
| `src/serialize.js`      | Objetos de whatsapp-web.js → JSON estable (el contrato hacia afuera) |
| `src/daemon.js`         | Sesión, socket, eventos, hooks, resolución de chats por nombre       |
| `src/cli.js`            | Parser estilo gws, ayuda, `auth login`, `daemon`                     |
| `src/helpers.js`        | `+send`, `+read`, `+watch`, ...                                      |
| `src/format/`           | json/table/csv/yaml/ndjson/text/eml/mbox                             |
| `src/compat.js`         | Parches para cambios de WhatsApp Web (ver Problemas conocidos)       |
| `src/wamail/`           | Puente Maildir: sync, send (parser MIME), lista de buzones           |

**Agregar un método** = sumar una entrada en `src/api/<recurso>.js`. Queda disponible por el
socket, en `wa <recurso> <método>`, en `--help` y en `wa schema`. Los parámetros de tipo `chat`
aceptan JID, número o nombre exacto; los de tipo `path` se resuelven contra el cwd del CLI.

**Agregar un formato** = una función en `FORMATS` (`src/format/index.js`).

## Eventos y hooks

`wa events watch --params '{"events": ["all"]}'` (o `wa +watch`) emite una línea por evento:

```json
{"event": "message", "account": "default", "time": "2026-09-16T21:30:00.000Z", "data": { ...mensaje }}
```

| Evento                                               | `data`                                      |
| ---------------------------------------------------- | ------------------------------------------- |
| `message` (entrantes), `message_create` (todos)      | mensaje                                     |
| `message_ack`                                        | `{message, ack}` (1 servidor, 2 entregado, 3 leído) |
| `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`, ...     | notificación de grupo                       |
| `qr`, `code`, `authenticated`, `ready`, `disconnected` | estado de la sesión                       |

Los mismos eventos disparan **hooks**: ejecutables en `~/.config/wa-cli/hooks/<evento>`,
`<evento>.d/*` o `all.d/*`. Reciben el evento por stdin, el nombre como `$1` y
`WA_CLI_ACCOUNT` / `WA_CLI_SOCKET` en el entorno (pueden llamar a `wa`). Se ejecutan en orden
por hook y se re-escanean en cada evento (no hace falta reiniciar).

Un mensaje serializado:

```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, ...
}
```

## Integración con neomutt: wamail

`wamail` es el puente WhatsApp ↔ Maildir, análogo a `gwsmail` para Gmail: una **carpeta por chat**
en `~/Mail/whatsapp/`, y neomutt la lee como cualquier cuenta.

```
wa-daemon ──hook (tiempo real)──▶ wamail sync --chat X ──▶ ~/Mail/whatsapp/<chat>/{new,cur}
          ◀──────────────────── wamail sync (timer 5 min: leídos, estrellas, borrados)
neomutt ──sendmail──▶ wamail send ──▶ wa messages send
```

| En neomutt                            | En WhatsApp                                                    |
| ------------------------------------- | -------------------------------------------------------------- |
| carpeta del sidebar                   | chat (orden de WhatsApp: fijados, recientes; archivados al final) |
| leer todos los no leídos de un chat   | chat marcado como leído (confirmación de lectura)              |
| marcar un mensaje como no leído       | chat marcado como no leído                                     |
| flag (`F`)                            | mensaje destacado                                              |
| borrar                                | **solo local** (no se vuelve a bajar)                          |
| `r` dejando la cita (`> ...`)         | respuesta citada; si borrás la cita, mensaje normal            |
| `r` / `g` en un grupo                 | va solo al grupo (nunca en privado al autor)                   |
| adjuntos                              | un solo adjunto imagen/video: con el texto de caption; si no, texto + documentos |
| `gR` / `gX` / `gA` / `gG`             | reaccionar / borrar (todos o para mí) / archivar chat / sincronizar ya |
| `Tab` en To:                          | buscar contactos y grupos (`wamail query`)                     |
| `F6`                                  | cambiar a la cuenta WhatsApp                                   |

Recibido en WhatsApp → mail: remitente = JID (`5491122334455@c.us`, grupos `...@g.us`),
`Message-ID <stanzaId@wa>`, `In-Reply-To` para respuestas citadas, `X-WA-Id`/`X-WA-Chat-Id` para
las acciones, media como adjunto (hasta `mediaMaxBytes`; si no, una nota con el comando para
bajarla). Los mensajes borrados para todos desaparecen del Maildir (hook `message_revoke_everyone`).

### Instalación

```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            # primera vez: últimos 30 días, hasta 100 mensajes por chat

mkdir -p ~/.config/neomutt/accounts/wa
cp contrib/neomutt/wa-default.muttrc ~/.config/neomutt/accounts/wa/default.muttrc
```

y en la config de neomutt:

```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 (lo que la cuenta WhatsApp cambia)
unmacro index,pager gR
unmacro index,pager gX
set query_command = "khard email --parsable --search-in-source-files '%s'"
```

Config opcional en `~/.config/wa-cli/config.json`:

```json
{ "wamail": { "backfillDays": 30, "backfillLimit": 100, "mediaMaxBytes": 16777216, "skipChats": ["120363...@g.us"] } }
```

Estado en `~/.local/state/wamail/<cuenta>.json` (borrarlo junto con el Maildir fuerza un backfill
limpio); envíos en `~/.local/state/wamail/send.log`.

### Exportar un chat sin sincronizar

```sh
wa +export "Familia" -n 500 --media -o /tmp/familia.mbox && neomutt -R -f /tmp/familia.mbox
```

## Problemas conocidos

WhatsApp Web cambia seguido y whatsapp-web.js tarda en seguirlo. `src/compat.js` parchea en
caliente, sin tocar `node_modules`:

- **`_serialized` → `$1`** en IDs de mensaje (wwebjs#201830). Síntoma: `r: r` en `chats list`,
  `sendMessage` devolviendo `undefined`.
- **Re-inyección en navegaciones SPA** (wwebjs#201653, arreglado en `main`, sin release).
- **Iframe de otro origen** (`pdf-viewer`): con site isolation es un proceso aparte y, si navega
  mientras se registran los bindings, `exposeFunction` falla con `Target closed` y `ready` no llega
  nunca. El daemon lanza Chrome con `--disable-site-isolation-trials`.

Si igual se cuelga, el watchdog del daemon sale con código 1 (60 s en `authenticated`,
`startTimeoutMs` en `starting`) y systemd lo relanza. Para diagnosticar: `WA_CLI_TRACE=1` (o
`"trace": true` en `config.json`) loguea llamadas a Chrome pendientes, excepciones dentro de
bindings y navegaciones (`journalctl --user -u wa-daemon@default`).

Chrome headless usa ~1 GB (picos de 2 GB en la sincronización inicial).

## Tests

```sh
npm test    # formatos, parser MIME (con mails reales de neomutt), sync/send de wamail contra un WhatsApp falso
```

## Licencia

MIT