import { parseArgs } from 'node:util'; import qrcode from 'qrcode-terminal'; import { METHODS, getMethod, resources, schema, validate } from './api/index.js'; import { accountName, paths, VERSION } from './config.js'; import { FORMATS } from './format/index.js'; import { HELPERS } from './helpers.js'; import { parseJsonArg, print } from './output.js'; import { WaError, call, ping } from './rpc.js'; import { Session } from './session.js'; // Same exit codes as gws, plus 4 = daemon. const EXIT = { EAPI: 1, ENOTFOUND: 1, ETIMEOUT: 1, EAUTH: 2, EINVAL: 3, EAMBIGUOUS: 3, EDAEMON: 4, EINTERNAL: 5 }; const GLOBAL_OPTIONS = { account: { type: 'string', short: 'a' }, format: { type: 'string' }, params: { type: 'string' }, json: { type: 'string' }, output: { type: 'string', short: 'o' }, 'no-autostart': { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, }; // CLI commands that are not daemon methods. const LOCAL_COMMANDS = { 'auth.login': 'Link this device (QR in the terminal, or --phone for a code)', 'daemon.start': 'Start the daemon in the background (--foreground for systemd)', }; const LOCAL_ONLY = [ ' wa format < data.json', ]; const out = (s = '') => process.stdout.write(`${s}\n`); const err = (s = '') => process.stderr.write(`${s}\n`); export async function main(argv) { process.stdout.on('error', (e) => { if (e.code === 'EPIPE') process.exit(0); }); try { return await run(argv); } catch (e) { return fail(e); } } function fail(e) { const known = e.code in EXIT; err(`wa: ${e.message}`); if (Array.isArray(e.data)) for (const c of e.data) err(` ${c.name ?? ''} <${c.id}>`); if (!known && process.env.WA_CLI_DEBUG) err(e.stack); return known ? EXIT[e.code] : (e.name === 'TypeError' || e.name === 'ReferenceError' ? 5 : 1); } function parse(argv, extra = {}) { try { return parseArgs({ args: argv, options: { ...GLOBAL_OPTIONS, ...extra }, strict: true, allowPositionals: true }); } catch (e) { throw new WaError('EINVAL', e.message); } } async function run(argv) { const pre = parseArgs({ args: argv, options: GLOBAL_OPTIONS, strict: false, allowPositionals: true }); const [cmd, sub] = pre.positionals; if (pre.values.version) return out(VERSION), 0; if (!cmd || cmd === 'help') { if (sub && resources()[sub]) return resourceHelp(sub), 0; return mainHelp(), 0; } if (pre.values.format && !FORMATS[pre.values.format]) { throw new WaError('EINVAL', `unknown format: ${pre.values.format} (${Object.keys(FORMATS).join(', ')})`); } // ---- helpers if (cmd.startsWith('+')) { const helper = HELPERS[cmd]; if (!helper) throw new WaError('EINVAL', `unknown helper: ${cmd} (${Object.keys(HELPERS).join(', ')})`); const spec = Object.fromEntries(Object.entries(helper.options || {}).map(([k, { description, ...o }]) => [k, o])); const { values, positionals } = parse(argv, spec); if (values.help) return helperHelp(cmd), 0; return helper.run(sessionFor(values), positionals.slice(1), values); } // ---- schema if (cmd === 'schema') { const { positionals } = parse(argv); if (positionals[1]) return print(schema(positionals[1]), pre.values.format || 'json'), 0; const all = Object.fromEntries(Object.entries(METHODS).map(([k, d]) => [k, d.description])); return print(all, pre.values.format || 'json'), 0; } // ---- format: JSON on stdin -> another format, without the daemon (e.g. in hooks) if (cmd === 'format') { const { values, positionals } = parse(argv, { columns: { type: 'string' } }); const fmt = positionals[1] || values.format; if (!fmt || values.help) { out('Usage: wa format [--columns a,b.c] < data.json\n'); out('Converts JSON (the output of another `wa`) to another format without talking to the daemon.'); out('E.g.: jq .data | wa format eml (inside a hook)'); return fmt || values.help ? 0 : 3; } const columns = values.columns?.split(','); print(parseJsonArg('-', 'stdin'), fmt, { columns, output: values.output }); return 0; } // ---- auth login if (cmd === 'auth' && sub === 'login') { const { values } = parse(argv, { phone: { type: 'string' } }); if (values.help) { out('Links this device to WhatsApp.\n'); out('Usage: wa auth login [--phone NUMBER]\n'); out('Without --phone it shows a QR: WhatsApp > Settings > Linked devices > Link a device.'); out('With --phone (e.g. 5491122334455) it shows an 8-character code for'); out('"Link with phone number instead" on that same screen.'); return 0; } return authLogin(sessionFor(values), values.phone); } // ---- daemon if (cmd === 'daemon') return daemonCommand(argv, sub); // ---- generic API: wa const res = resources()[cmd]; if (!res) throw new WaError('EINVAL', `unknown resource: ${cmd} (see \`wa --help\`)`); if (!sub) return resourceHelp(cmd), pre.values.help ? 0 : 3; const method = `${cmd}.${sub}`; const def = getMethod(method); const { values, positionals } = parse(argv); if (values.help) return methodHelp(method), 0; if (positionals.length > 2) { throw new WaError('EINVAL', `unexpected argument: ${positionals[2]} (parameters go in --params '{...}')`); } const params = validate(method, { ...parseJsonArg(values.params, '--params'), ...parseJsonArg(values.json, '--json'), }, { cwd: process.cwd() }); const session = sessionFor(values); if (def.stream) { await session.call(method, params, { onEvent: (ev) => print(ev, values.format || 'ndjson') }); return 0; } const result = await session.call(method, params); print(result, values.format || 'json', { columns: def.columns, output: values.output }); return 0; } function sessionFor(values) { return new Session(accountName(values.account), values['no-autostart'] ? { autostart: false } : {}); } // ---------------------------------------------------------------- auth login function authLogin(session, phone) { return new Promise((resolve, reject) => { let pairing = false; let lastCode = null; let result = 0; const tty = process.stdout.isTTY; session.call('events.watch', { events: ['qr', 'code', 'authenticated', 'ready', 'auth_failure', 'disconnected'], replay: true, }, { onEvent: (ev, close) => { switch (ev.event) { case 'qr': if (phone) { if (pairing) return; pairing = true; err('wa: requesting a pairing code…'); session.call('auth.pair', { phone }).catch((e) => { close(); reject(e); }); return; } if (tty) process.stdout.write('\x1b[2J\x1b[H'); out('Scan the QR with WhatsApp: Settings > Linked devices > Link a device'); out('(it refreshes every ~20 s; Ctrl-C to cancel)\n'); qrcode.generate(ev.data.qr, { small: true }, (s) => out(s)); return; case 'code': if (ev.data.code === lastCode) return; lastCode = ev.data.code; out(`Pairing code: ${ev.data.code.replace(/^(.{4})(.{4})$/, '$1-$2')}`); out('On the phone: Linked devices > Link a device > Link with phone number instead'); return; case 'authenticated': err('wa: authenticated, syncing chats (this may take a while)…'); return; case 'auth_failure': err(`wa: authentication failed: ${ev.data.message}`); return; case 'ready': out(`Ready: ${ev.data.me?.name ?? ''} <${ev.data.me?.id ?? '?'}> (account ${session.account})`); close(); return; case 'disconnected': err(`wa: disconnected (${ev.data.reason})`); result = 4; close(); } }, }).then(() => resolve(result), reject); }); } // ---------------------------------------------------------------- daemon async function daemonCommand(argv, sub) { const { values } = parse(argv, { foreground: { type: 'boolean' } }); const account = accountName(values.account); const P = paths(account); const fmt = values.format || 'json'; if (!sub || values.help) { out('Usage: wa daemon [--account ACCOUNT]\n'); out(' start [--foreground] starts the daemon (--foreground: in the foreground, for systemd)'); out(' stop stops it (the session stays saved)'); out(' status process and session status'); out(`\nWith autostart (the default) any command starts it. Log: ${P.logFile}`); return sub || values.help ? 0 : 3; } const notRunning = { running: false, account, socket: P.socket }; switch (sub) { case 'start': { if (values.foreground) { const { runDaemon } = await import('./daemon.js'); await runDaemon(account); return undefined; // the process stays alive with the socket } const session = new Session(account, { autostart: true }); if (!(await session.running())) await session.startBackground(); print(await session.call('daemon.status'), fmt); return 0; } case 'stop': { if (!(await ping(P.socket))) return print(notRunning, fmt), 0; const { pid } = await call(P.socket, 'daemon.status'); await call(P.socket, 'daemon.stop'); // Wait for the process to exit (it closes Chrome), not just the socket. const alive = () => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }; for (let i = 0; i < 200 && alive(); i++) await new Promise((r) => setTimeout(r, 100)); if (alive()) throw new WaError('EDAEMON', `the daemon (pid ${pid}) did not exit within 20 s`); print({ running: false, stopped: true, account, pid }, fmt); return 0; } case 'status': { if (!(await ping(P.socket))) return print(notRunning, fmt), 0; print(await call(P.socket, 'daemon.status'), fmt); return 0; } default: throw new WaError('EINVAL', `daemon: unknown subcommand: ${sub}`); } } // ---------------------------------------------------------------- help function mainHelp() { const res = resources(); const lines = [ 'wa — WhatsApp from the terminal (whatsapp-web.js)', '', 'USAGE:', ' wa [--params JSON] [--format FMT]', ' wa + [arguments] [flags]', ' wa schema []', ' wa auth login [--phone NUMBER]', ' wa daemon start|stop|status', ...LOCAL_ONLY, '', 'EXAMPLES:', ' wa auth login', ' wa chats list --params \'{"unread": true}\' --format table', ' wa messages list --params \'{"chatId": "5491122334455@c.us", "limit": 20}\'', ' wa +send "Mom" there in 10', ' wa +read "Family" -n 50', ' wa +watch --format text', ' wa +export "Family" --media -o family.mbox && neomutt -R -f family.mbox', '', 'FLAGS:', ' -a, --account Account (session) to use; default "default" or $WA_CLI_ACCOUNT', ' --params Method parameters (JSON, @file or - for stdin)', ' --json Alias of --params (they are merged)', ` --format ${Object.keys(FORMATS).join(', ')} (default: json)`, ' -o, --output Write the output to a file', ' --no-autostart Do not start the daemon if it is not running', ' -h, --help Help (also: wa --help, wa --help)', '', 'RESOURCES:', ...Object.entries(res).map(([name, methods]) => { const extra = Object.keys(LOCAL_COMMANDS).filter((k) => k.startsWith(`${name}.`)).map((k) => k.split('.')[1]); return ` ${name.padEnd(10)} ${[...extra, ...Object.keys(methods)].join(', ')}`; }), '', 'HELPERS:', ...Object.entries(HELPERS).map(([name, h]) => ` ${name.padEnd(10)} ${h.summary}`), '', 'ENVIRONMENT:', ' WA_CLI_ACCOUNT Default account', ' WA_CLI_CONFIG_DIR Config and hooks directory (default: ~/.config/wa-cli)', ' WA_CLI_DATA_DIR Sessions directory (default: ~/.local/share/wa-cli)', ' WA_CLI_SOCKET Daemon socket (default: $XDG_RUNTIME_DIR/wa-cli/.sock)', ' WA_CLI_AUTOSTART 0 to not start the daemon automatically', '', 'EXIT CODES:', ' 0 Success', ' 1 WhatsApp error / not found', ' 2 No session — run `wa auth login`', ' 3 Validation — invalid arguments or parameters', ' 4 Daemon — could not start or connect', ' 5 Internal', ]; out(lines.join('\n')); } function resourceHelp(name) { const methods = resources()[name]; out(`Usage: wa ${name} [--params JSON] [--format FMT]\n`); out('Methods:'); for (const [k, d] of Object.entries(LOCAL_COMMANDS)) { if (k.startsWith(`${name}.`)) out(` ${k.split('.')[1].padEnd(12)} ${d}`); } for (const [m, def] of Object.entries(methods)) out(` ${m.padEnd(12)} ${def.description}`); out(`\nDetails: wa ${name} --help | wa schema ${name}.`); } function methodHelp(method) { const def = getMethod(method); const [res, m] = method.split('.'); out(`${def.description}\n`); out(`Usage: wa ${res} ${m}${def.params ? ' --params \'{...}\'' : ''}${def.stream ? ' (NDJSON stream)' : ''}`); if (def.params) { out('\nParameters:'); const rows = Object.entries(def.params).map(([k, p]) => { const type = [p.type, p.required ? 'required' : null, p.default !== undefined ? `= ${JSON.stringify(p.default)}` : null] .filter(Boolean).join(', '); return [k, type, p.description]; }); const w0 = Math.max(...rows.map((r) => r[0].length)); const w1 = Math.max(...rows.map((r) => r[1].length)); for (const [k, t, d] of rows) out(` ${k.padEnd(w0)} ${t.padEnd(w1)} ${d}`); } if (def.columns) out(`\nColumns (--format table/csv): ${def.columns.join(', ')}`); if (def.ready === false) out('\nDoes not need a ready session.'); } function helperHelp(name) { const h = HELPERS[name]; out(`${h.summary}\n`); out(`Usage: ${h.usage}`); if (h.details) out(`\n${h.details}`); const opts = Object.entries(h.options || {}); if (opts.length) { out('\nOptions:'); for (const [k, o] of opts) { const flag = `${o.short ? `-${o.short}, ` : ' '}--${k}${o.type === 'string' ? ' ' : ''}`; out(` ${flag.padEnd(26)} ${o.description}`); } } out(` ${'--format '.padEnd(26)} Output format`); if (h.examples) { out('\nExamples:'); for (const e of h.examples) out(` ${e}`); } }