diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /bin | |
| download | wa-cli-main.tar.gz wa-cli-main.zip | |
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/wa.js | 5 | ||||
| -rwxr-xr-x | bin/wamail.js | 216 |
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; + } +} |