aboutsummaryrefslogtreecommitdiffstats
path: root/src/cli.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli.js')
-rw-r--r--src/cli.js379
1 files changed, 379 insertions, 0 deletions
diff --git a/src/cli.js b/src/cli.js
new file mode 100644
index 0000000..8324522
--- /dev/null
+++ b/src/cli.js
@@ -0,0 +1,379 @@
+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 <FMT> < 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 <FMT> [--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 <resource> <method>
+ 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 <start|stop|status> [--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 <resource> <method> [--params JSON] [--format FMT]',
+ ' wa +<helper> [arguments] [flags]',
+ ' wa schema [<resource.method>]',
+ ' 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 <NAME> Account (session) to use; default "default" or $WA_CLI_ACCOUNT',
+ ' --params <JSON> Method parameters (JSON, @file or - for stdin)',
+ ' --json <JSON> Alias of --params (they are merged)',
+ ` --format <FMT> ${Object.keys(FORMATS).join(', ')} (default: json)`,
+ ' -o, --output <PATH> Write the output to a file',
+ ' --no-autostart Do not start the daemon if it is not running',
+ ' -h, --help Help (also: wa <resource> --help, wa <resource> <method> --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/<account>.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} <method> [--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} <method> --help | wa schema ${name}.<method>`);
+}
+
+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' ? ' <value>' : ''}`;
+ out(` ${flag.padEnd(26)} ${o.description}`);
+ }
+ }
+ out(` ${'--format <FMT>'.padEnd(26)} Output format`);
+ if (h.examples) {
+ out('\nExamples:');
+ for (const e of h.examples) out(` ${e}`);
+ }
+}