From d80640d01eeb84907e36ff4af33add9081eda17c Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 19:26:33 -0300 Subject: wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridge --- .gitignore | 1 + LICENSE | 21 + README.es.md | 255 ++++ README.md | 254 ++++ bin/wa.js | 5 + bin/wamail.js | 216 ++++ contrib/hooks/wamail | 16 + contrib/neomutt/wa-default.muttrc | 36 + package-lock.json | 2302 +++++++++++++++++++++++++++++++++++ package.json | 28 + src/api/chats.js | 85 ++ src/api/contacts.js | 86 ++ src/api/index.js | 111 ++ src/api/messages.js | 255 ++++ src/api/session.js | 77 ++ src/cli.js | 379 ++++++ src/compat.js | 94 ++ src/config.js | 82 ++ src/daemon.js | 452 +++++++ src/format/index.js | 143 +++ src/format/mail.js | 198 +++ src/format/text.js | 51 + src/helpers.js | 165 +++ src/hooks.js | 85 ++ src/output.js | 38 + src/rpc.js | 83 ++ src/serialize.js | 158 +++ src/session.js | 69 ++ src/trace.js | 79 ++ src/wamail/mime.js | 180 +++ src/wamail/neomutt.js | 46 + src/wamail/send.js | 118 ++ src/wamail/store.js | 172 +++ src/wamail/sync.js | 235 ++++ systemd/wa-daemon@.service | 15 + systemd/wamail-sync.service | 8 + systemd/wamail-sync.timer | 11 + test/compat.test.js | 25 + test/fixtures.js | 30 + test/fixtures/neomutt-adjunto.eml | 34 + test/fixtures/neomutt-respuesta.eml | 16 + test/format.test.js | 66 + test/wamail-mime.test.js | 48 + test/wamail-sync.test.js | 172 +++ 44 files changed, 7000 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.es.md create mode 100644 README.md create mode 100755 bin/wa.js create mode 100755 bin/wamail.js create mode 100755 contrib/hooks/wamail create mode 100644 contrib/neomutt/wa-default.muttrc create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api/chats.js create mode 100644 src/api/contacts.js create mode 100644 src/api/index.js create mode 100644 src/api/messages.js create mode 100644 src/api/session.js create mode 100644 src/cli.js create mode 100644 src/compat.js create mode 100644 src/config.js create mode 100644 src/daemon.js create mode 100644 src/format/index.js create mode 100644 src/format/mail.js create mode 100644 src/format/text.js create mode 100644 src/helpers.js create mode 100644 src/hooks.js create mode 100644 src/output.js create mode 100644 src/rpc.js create mode 100644 src/serialize.js create mode 100644 src/session.js create mode 100644 src/trace.js create mode 100644 src/wamail/mime.js create mode 100644 src/wamail/neomutt.js create mode 100644 src/wamail/send.js create mode 100644 src/wamail/store.js create mode 100644 src/wamail/sync.js create mode 100644 systemd/wa-daemon@.service create mode 100644 systemd/wamail-sync.service create mode 100644 systemd/wamail-sync.timer create mode 100644 test/compat.test.js create mode 100644 test/fixtures.js create mode 100644 test/fixtures/neomutt-adjunto.eml create mode 100644 test/fixtures/neomutt-respuesta.eml create mode 100644 test/format.test.js create mode 100644 test/wamail-mime.test.js create mode 100644 test/wamail-sync.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..acdf183 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Elvis Claros Castro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..34c0b82 --- /dev/null +++ b/README.es.md @@ -0,0 +1,255 @@ +# wa-cli + +[English](README.md) + +WhatsApp desde la terminal, con la misma forma que `gws`: + +``` +wa [--params JSON] [--format json|table|csv|yaml|ndjson|text|eml|mbox] +wa + ... +wa schema +``` + +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 "" dale +wa +watch --format text # mensajes entrantes en vivo +wa +watch --events message_create | jq -r .data.body +wa +download "" -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/ + └──▶ 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//` | +| Media descargada | `~/.local/share/wa-cli//media/` | +| Log (autostart) | `~/.local/state/wa-cli//daemon.log` | +| Socket | `$XDG_RUNTIME_DIR/wa-cli/.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/.js`. Queda disponible por el +socket, en `wa `, 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/`, +`.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//{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 `, `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 'source ~/.config/neomutt/accounts/wa/default.muttrc!' "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/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f402a2 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# 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 diff --git a/bin/wa.js b/bin/wa.js new file mode 100755 index 0000000..e854ded --- /dev/null +++ b/bin/wa.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { main } from '../src/cli.js'; + +const code = await main(process.argv.slice(2)); +if (code !== undefined) process.exitCode = code; diff --git a/bin/wamail.js b/bin/wamail.js new file mode 100755 index 0000000..b81673e --- /dev/null +++ b/bin/wamail.js @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// wamail — bridge between WhatsApp (the wa-cli daemon) and a Maildir for neomutt, like gwsmail. + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadConfig, paths } from '../src/config.js'; +import { call } from '../src/rpc.js'; +import { parseHeaders } from '../src/wamail/mime.js'; +import { sendRaw } from '../src/wamail/send.js'; +import { STATE_DIR, withLock } from '../src/wamail/store.js'; +import { dropLocal, syncAccount, wamailConfig } from '../src/wamail/sync.js'; + +const USAGE = `wamail — WhatsApp <-> Maildir for neomutt + +Usage: + wamail sync [account ...] [--chat CHAT] [--quiet] syncs (default: accounts with a running daemon) + wamail revoked removes the local copy of a message deleted for everyone + wamail send [-- recipients...] sendmail: reads the RFC 822 message from stdin + wamail query neomutt query_command (contacts and groups) + wamail react [emoji] reacts to the message on stdin (pipe-message) + wamail delete deletes the message on stdin (asks: everyone / me) + wamail archive archives/unarchives the chat of the message on stdin + +Maildir: ~/Mail/whatsapp (account "default") or ~/Mail/whatsapp-, one folder per chat. +Config: ~/.config/wa-cli/config.json -> "wamail": { maildir, backfillDays, backfillLimit, mediaMaxBytes, skipChats } +`; + +const SELF = fileURLToPath(import.meta.url); + +const rpcFor = (account) => (method, params) => call(paths(account).socket, method, params); + +const logger = (account, quiet) => ({ + info: (msg) => { if (!quiet) console.log(`wamail[${account}]: ${msg}`); }, + error: (msg) => console.error(`wamail[${account}]: ${msg}`), +}); + +function runningAccounts() { + const dir = path.dirname(paths('default').socket); + try { + return fs.readdirSync(dir).filter((f) => f.endsWith('.sock')).map((f) => f.slice(0, -5)).sort(); + } catch { + return []; + } +} + +function requireAccount(account) { + if (!account) throw new Error('missing the account (e.g. default)'); + return account; +} + +// neomutt message on stdin (pipe-message without pipe_decode): X-WA-* headers. +function stdinHeaders() { + const raw = fs.readFileSync(0).toString('latin1'); + const end = raw.search(/\r?\n\r?\n/); + const headers = parseHeaders(end >= 0 ? raw.slice(0, end) : raw); + const get = (n) => headers.get(n.toLowerCase())?.[0]; + const id = get('X-WA-Id'); + if (!id) throw new Error('the message has no X-WA-Id (pipe_decode on, or not a WhatsApp message?)'); + return { id, chatId: get('X-WA-Chat-Id'), stanza: /<([^>@]+)@wa>/.exec(get('Message-ID') || '')?.[1] }; +} + +// Asks on the terminal even though stdin is the message. +function ask(prompt) { + const fd = fs.openSync('/dev/tty', 'r+'); + try { + fs.writeSync(fd, prompt); + const buf = Buffer.alloc(256); + let out = ''; + for (;;) { + const n = fs.readSync(fd, buf, 0, buf.length, null); + if (n <= 0) break; + out += buf.subarray(0, n).toString('utf8'); + if (out.includes('\n')) break; + } + return out.split('\n')[0].trim(); + } finally { + fs.closeSync(fd); + } +} + +function backgroundSync(account, chatIds) { + for (const chat of chatIds) { + spawn(process.execPath, [SELF, 'sync', account, '--chat', chat, '--quiet'], { detached: true, stdio: 'ignore' }).unref(); + } +} + +async function cmdSync(args) { + const quiet = args.includes('--quiet'); + const chatIdx = args.indexOf('--chat'); + const chat = chatIdx >= 0 ? args[chatIdx + 1] : undefined; + if (chatIdx >= 0 && !chat) throw new Error('--chat needs a chat'); + const accounts = args.filter((a, i) => !a.startsWith('--') && i !== chatIdx + 1); + const targets = accounts.length ? accounts : runningAccounts(); + if (!targets.length) { + console.error('wamail: no wa-cli daemons running (systemctl --user start wa-daemon@default)'); + return 1; + } + let rc = 0; + for (const account of targets) { + const log = logger(account, quiet); + const config = wamailConfig(account, loadConfig(account).wamail); + try { + const stats = await withLock(account, { onWait: (pid) => log.info(`waiting for another sync (pid ${pid})`) }, + () => syncAccount({ account, rpc: rpcFor(account), config, onlyChat: chat, log: log.info })); + const changes = Object.entries(stats).filter(([, v]) => v).map(([k, v]) => `${v} ${k}`); + log.info(changes.length ? `ok: ${changes.join(', ')}` : 'ok: no changes'); + } catch (e) { + log.error(`ERROR: ${e.message}`); + rc = 1; + } + } + return rc; +} + +async function cmdRevoked([account, stanzaId]) { + requireAccount(account); + if (!stanzaId) throw new Error('missing the stanzaId'); + const config = wamailConfig(account, loadConfig(account).wamail); + const dropped = await withLock(account, {}, () => dropLocal({ account, config, stanzaId })); + if (dropped) console.log(`wamail[${account}]: message deleted for everyone, local copy removed`); + return 0; +} + +async function cmdSend([account, ...argv]) { + requireAccount(account); + const raw = fs.readFileSync(0); + const logFile = path.join(STATE_DIR, 'send.log'); + fs.mkdirSync(STATE_DIR, { recursive: true }); + const lines = []; + const log = (msg) => lines.push(msg); + try { + const chats = await sendRaw({ account, rpc: rpcFor(account), raw, argv, log }); + fs.appendFileSync(logFile, lines.map((l) => `${new Date().toISOString()} ${account} ${l}\n`).join('')); + backgroundSync(account, chats); + return 0; + } catch (e) { + const sent = lines.filter((l) => l.startsWith('sent')); + const msg = `${e.message}${sent.length ? ` (already sent: ${sent.length} part(s))` : ''}`; + fs.appendFileSync(logFile, `${new Date().toISOString()} ${account} ERROR ${msg}\n`); + console.error(`wamail send: ${msg}`); + return 1; + } +} + +async function cmdQuery([account, ...words]) { + requireAccount(account); + const query = words.join(' ').trim(); + const rpc = rpcFor(account); + const [contacts, groups] = await Promise.all([ + rpc('contacts.list', { query, limit: 50 }), + rpc('groups.list', { query }), + ]); + const rows = [ + ...groups.map((g) => [g.id, g.name, 'group']), + ...contacts.filter((c) => c.displayName).map((c) => [c.id, c.displayName, c.isMyContact ? 'contact' : `+${c.number}`]), + ]; + console.log(`WhatsApp: ${rows.length} result(s)`); + for (const r of rows) console.log(r.map((v) => String(v).replace(/\t/g, ' ')).join('\t')); + return 0; +} + +async function cmdReact([account, emoji]) { + requireAccount(account); + const { id } = stdinHeaders(); + const reaction = emoji ?? ask('Reaction (emoji, empty to remove): '); + await rpcFor(account)('messages.react', reaction ? { messageId: id, emoji: reaction } : { messageId: id }); + console.log(reaction ? `reacted ${reaction}` : 'reaction removed'); + return 0; +} + +async function cmdDelete([account]) { + requireAccount(account); + const { id, stanza } = stdinHeaders(); + const answer = ask('Delete WhatsApp message: for (e)veryone, for (m)e, (c)ancel [c]: ').toLowerCase(); + if (answer !== 'e' && answer !== 'm') { + console.log('cancelled'); + return 0; + } + await rpcFor(account)('messages.delete', { messageId: id, everyone: answer === 'e' }); + // remove the local copy and do not download it again + if (stanza) { + const config = wamailConfig(account, loadConfig(account).wamail); + await withLock(account, {}, () => dropLocal({ account, config, stanzaId: stanza })); + } + console.log(answer === 'e' ? 'deleted for everyone' : 'deleted for you'); + return 0; +} + +async function cmdArchive([account]) { + requireAccount(account); + const { chatId } = stdinHeaders(); + const rpc = rpcFor(account); + const chat = await rpc('chats.get', { chatId }); + await rpc(chat.archived ? 'chats.unarchive' : 'chats.archive', { chatId }); + console.log(`${chat.name}: ${chat.archived ? 'unarchived' : 'archived'}`); + const config = wamailConfig(account, loadConfig(account).wamail); + await withLock(account, {}, () => syncAccount({ account, rpc, config, onlyChat: chatId })); + return 0; +} + +const COMMANDS = { sync: cmdSync, revoked: cmdRevoked, send: cmdSend, query: cmdQuery, react: cmdReact, delete: cmdDelete, archive: cmdArchive }; + +const [cmd, ...args] = process.argv.slice(2); +if (!COMMANDS[cmd]) { + process.stderr.write(USAGE); + process.exitCode = cmd === undefined || cmd === '-h' || cmd === '--help' ? 0 : 2; +} else { + try { + process.exitCode = await COMMANDS[cmd](args); + } catch (e) { + console.error(`wamail ${cmd}: ${e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? 'the wa-cli daemon is not running' : e.message}`); + process.exitCode = 1; + } +} diff --git a/contrib/hooks/wamail b/contrib/hooks/wamail new file mode 100755 index 0000000..192589b --- /dev/null +++ b/contrib/hooks/wamail @@ -0,0 +1,16 @@ +#!/bin/sh +# wa-cli -> wamail hook: real-time delivery to the neomutt Maildir. +# Install: ln -s /path/to/wa-cli/contrib/hooks/wamail ~/.config/wa-cli/hooks/all.d/50-wamail +# The daemon runs hooks one at a time, in order; wamail has its own lock against the timer. +case "$1" in + message_create|unread_count|chat_archived) ;; + message_revoke_everyone) + stanza=$(jq -r '.data.message.stanzaId // empty') + [ -n "$stanza" ] && exec "$HOME/.local/bin/wamail" revoked "$WA_CLI_ACCOUNT" "$stanza" + exit 0 ;; + *) exit 0 ;; +esac +chat=$(jq -r '.data.chatId // .data.message.chatId // .data.chat.id // .data.id // empty') +case "$chat" in + *@c.us|*@g.us|*@lid) exec "$HOME/.local/bin/wamail" sync "$WA_CLI_ACCOUNT" --chat "$chat" --quiet ;; +esac diff --git a/contrib/neomutt/wa-default.muttrc b/contrib/neomutt/wa-default.muttrc new file mode 100644 index 0000000..39f2062 --- /dev/null +++ b/contrib/neomutt/wa-default.muttrc @@ -0,0 +1,36 @@ +# vim: filetype=muttrc +# WhatsApp account through wamail (wa-cli): one Maildir folder per chat in ~/Mail/whatsapp. +# Real-time delivery through the daemon hook (wa-daemon@default) + the wamail-sync systemd timer. +# +# Read = mark read on WhatsApp · flag = star · delete = local only +# Reply: keep the quote (> ...) for a quoted reply; delete it for a normal message. + +source ~/.config/neomutt/switch.muttrc + +set realname = "WhatsApp" +set from = "5491125555635@c.us" +set use_from = yes + +set mbox_type = Maildir +set folder = ~/Mail/whatsapp +set postponed = +Drafts # borradores locales +unset trash # delete = remove the local copy (does not delete on WhatsApp) +set copy = no # sent messages come back through the chat +set sendmail = "~/.local/bin/wamail send default" +set header_cache = ~/.cache/neomutt/whatsapp +set mail_check = 5 +set query_command = "~/.local/bin/wamail query default '%s'" + +# One mailbox per chat (WhatsApp order), spoolfile = most recent chat, alternates: +# generated by `wamail sync`. +source ~/.cache/wamail/default/mailboxes.muttrc + +# Ask whether to quote when replying (default no) and no Cc. The "." send-hook restores +# the settings values for the mail accounts. +send-hook . 'set include askcc' +send-hook '~t "@(c.us|g.us|lid)$"' 'set include=ask-no; unset askcc' + +macro index gG "~/.local/bin/wamail sync defaultsource ~/.config/neomutt/accounts/wa/default.muttrc" "WhatsApp: sincronizar ahora" +macro index,pager gA "unset pipe_decode~/.local/bin/wamail archive defaultset pipe_decodesource ~/.config/neomutt/accounts/wa/default.muttrc" "WhatsApp: archive/unarchive the chat" +macro index,pager gR "unset pipe_decode~/.local/bin/wamail react defaultset pipe_decode" "WhatsApp: react to the message" +macro index,pager gX "unset pipe_decode~/.local/bin/wamail delete defaultset pipe_decode" "WhatsApp: delete message (everyone / me)" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e2961f8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2302 @@ +{ + "name": "whatsapp-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "whatsapp-cli", + "version": "1.0.0", + "dependencies": { + "mime": "^3.0.0", + "qrcode-terminal": "^0.12.0", + "whatsapp-web.js": "^1.34.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT", + "optional": true + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT", + "optional": true + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "licens