wa-cli
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: 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).
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):
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
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):
{
"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:
{"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:
{
"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
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:
# 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:
{ "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
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→$1en IDs de mensaje (wwebjs#201830). Síntoma:r: renchats list,sendMessagedevolviendoundefined.- 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,exposeFunctionfalla conTarget closedyreadyno 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
npm test # formatos, parser MIME (con mails reales de neomutt), sync/send de wamail contra un WhatsApp falso
Licencia
MIT