aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
commitd80640d01eeb84907e36ff4af33add9081eda17c (patch)
tree27698b34e06e28d111c2196700015d9c82f78274 /bin
downloadwa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz
wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'bin')
-rwxr-xr-xbin/wa.js5
-rwxr-xr-xbin/wamail.js216
2 files changed, 221 insertions, 0 deletions
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 <account> <stanzaId> removes the local copy of a message deleted for everyone
+ wamail send <account> [-- recipients...] sendmail: reads the RFC 822 message from stdin
+ wamail query <account> <text> neomutt query_command (contacts and groups)
+ wamail react <account> [emoji] reacts to the message on stdin (pipe-message)
+ wamail delete <account> deletes the message on stdin (asks: everyone / me)
+ wamail archive <account> archives/unarchives the chat of the message on stdin
+
+Maildir: ~/Mail/whatsapp (account "default") or ~/Mail/whatsapp-<account>, 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;
+ }
+}