#!/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; } }