aboutsummaryrefslogtreecommitdiffstats
path: root/src/helpers.js
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 /src/helpers.js
downloadwa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz
wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/helpers.js')
-rw-r--r--src/helpers.js165
1 files changed, 165 insertions, 0 deletions
diff --git a/src/helpers.js b/src/helpers.js
new file mode 100644
index 0000000..e902768
--- /dev/null
+++ b/src/helpers.js
@@ -0,0 +1,165 @@
+// `wa +something` helpers: shortcuts for interactive use on top of the generic API
+// (like `gws gmail +send`). Everything they do can be done with `wa <resource> <method>`.
+
+import { getMethod, validate } from './api/index.js';
+import { print, readStdin } from './output.js';
+import { WaError } from './rpc.js';
+
+const cwd = process.cwd();
+const need = (v, what) => {
+ if (!v) throw new WaError('EINVAL', `missing ${what}`);
+ return v;
+};
+
+// Text from the arguments, or stdin if it is "-" or there is no text and stdin is not a terminal.
+function bodyFrom(words, { allowEmpty }) {
+ const text = words.join(' ');
+ if (text === '-' || (!text && !allowEmpty && !process.stdin.isTTY)) return readStdin();
+ return text || undefined;
+}
+
+async function callAndPrint(session, method, params, values, defaultFormat = 'json') {
+ const p = validate(method, params, { cwd });
+ const result = await session.call(method, p);
+ print(result, values.format || defaultFormat, { columns: getMethod(method).columns, output: values.output });
+ return 0;
+}
+
+const contentOptions = {
+ file: { type: 'string', short: 'f', description: 'Attach a file (the text becomes its caption)' },
+ document: { type: 'boolean', description: 'Send the file as a document' },
+ voice: { type: 'boolean', description: 'Send audio as a voice note' },
+};
+
+export const HELPERS = {
+ '+send': {
+ summary: 'Send a message or a file',
+ usage: 'wa +send <chat> [text...] [--file PATH] [--reply-to MSG_ID]',
+ details: '<chat> can be a JID, a phone number (+54 9 11 ...) or the exact chat name.\n' +
+ 'Without text (or with "-") it reads the text from stdin.',
+ examples: [
+ 'wa +send "Mom" there in 10',
+ 'wa +send +5491122334455 --file report.pdf "here is the report"',
+ 'fortune | wa +send 5491122334455@c.us',
+ ],
+ options: { ...contentOptions, 'reply-to': { type: 'string', short: 'r', description: 'ID of the message to quote' } },
+ run: (session, [chat, ...words], v) => callAndPrint(session, 'messages.send', {
+ chatId: need(chat, 'the target chat'),
+ body: bodyFrom(words, { allowEmpty: Boolean(v.file) }),
+ media: v.file,
+ quotedMessageId: v['reply-to'],
+ asDocument: v.document,
+ asVoice: v.voice,
+ }, v),
+ },
+
+ '+reply': {
+ summary: 'Reply quoting a message',
+ usage: 'wa +reply <messageId> [text...] [--file PATH]',
+ examples: ['wa +reply "false_5491122334455@c.us_3EB0C4..." sure, see you'],
+ options: contentOptions,
+ run: (session, [id, ...words], v) => callAndPrint(session, 'messages.reply', {
+ messageId: need(id, 'the message ID'),
+ body: bodyFrom(words, { allowEmpty: Boolean(v.file) }),
+ media: v.file,
+ asDocument: v.document,
+ asVoice: v.voice,
+ }, v),
+ },
+
+ '+read': {
+ summary: 'Read a conversation',
+ usage: 'wa +read <chat> [-n N] [--mark-read]',
+ examples: ['wa +read "Family" -n 30', 'wa +read 5491122334455 --format json | jq .[].body'],
+ options: {
+ limit: { type: 'string', short: 'n', description: 'Number of messages (default 30)' },
+ 'mark-read': { type: 'boolean', description: 'Mark the chat as read' },
+ },
+ run: async (session, [chat], v) => {
+ const chatId = need(chat, 'the chat');
+ await callAndPrint(session, 'messages.list', { chatId, limit: v.limit ?? 30 }, v, 'text');
+ if (v['mark-read']) await session.call('chats.read', validate('chats.read', { chatId }));
+ return 0;
+ },
+ },
+
+ '+triage': {
+ summary: 'Summary of chats with unread messages',
+ usage: 'wa +triage [-n N]',
+ examples: ['wa +triage', 'wa +triage --format json'],
+ options: { limit: { type: 'string', short: 'n', description: 'Maximum number of chats' } },
+ run: async (session, _pos, v) => {
+ const p = validate('chats.list', { unread: true, archived: false, limit: v.limit }, { cwd });
+ const result = await session.call('chats.list', p);
+ print(result, v.format || 'table', {
+ columns: ['name', 'unreadCount', 'date', 'lastMessage.body', 'id'],
+ output: v.output,
+ });
+ return 0;
+ },
+ },
+
+ '+watch': {
+ summary: 'Stream of new messages (NDJSON)',
+ usage: 'wa +watch [--events LIST] [--chat CHAT]',
+ details: 'Default: the "message" event (incoming). "message_create" includes sent ones; "all" everything.\n' +
+ 'With --format text it prints one readable line per message. Ctrl-C to stop.',
+ examples: ['wa +watch | jq -r .data.body', 'wa +watch --events message_create --format text', 'wa +watch --chat "Family"'],
+ options: {
+ events: { type: 'string', short: 'e', description: 'Comma-separated events' },
+ chat: { type: 'string', short: 'c', description: 'Only events from this chat' },
+ },
+ run: async (session, _pos, v) => {
+ const chatId = v.chat ? (await session.call('chats.resolve', { query: v.chat })).id : null;
+ const p = validate('events.watch', { events: v.events }, { cwd });
+ const fmt = v.format || 'ndjson';
+ await session.call('events.watch', p, {
+ onEvent: (ev) => {
+ const d = ev.data || {};
+ const evChat = d.chatId ?? d.message?.chatId ?? d.chat?.id;
+ if (chatId && evChat !== chatId) return;
+ print(ev, fmt === 'text' ? 'text' : fmt === 'json' ? 'ndjson' : fmt);
+ },
+ });
+ return 0;
+ },
+ },
+
+ '+download': {
+ summary: 'Download the media of a message',
+ usage: 'wa +download <messageId> [-d DIR | -o FILE]',
+ examples: ['wa +download "false_...@c.us_3EB0..." -d ~/Downloads'],
+ options: { dir: { type: 'string', short: 'd', description: 'Target directory' } },
+ run: async (session, [id], v) => {
+ const p = validate('messages.download', { messageId: need(id, 'the message ID'), dir: v.dir, output: v.output }, { cwd });
+ print(await session.call('messages.download', p), v.format || 'json');
+ return 0;
+ },
+ },
+
+ '+export': {
+ summary: 'Export a chat as mbox (for neomutt)',
+ usage: 'wa +export <chat> [-n N] [--since DATE] [--media] [-o FILE]',
+ details: 'mboxrd output: each message is an email (From = sender, Message-ID/In-Reply-To\n' +
+ 'build threads from quoted replies). --media includes attachments (slower).',
+ examples: ['wa +export "Family" -n 500 --media -o family.mbox && neomutt -R -f family.mbox'],
+ options: {
+ limit: { type: 'string', short: 'n', description: 'Number of messages (default 200)' },
+ since: { type: 'string', description: 'Since a date (ISO 8601, e.g. 2026-09-01) or epoch s' },
+ media: { type: 'boolean', description: 'Download and include attachments' },
+ },
+ run: async (session, [chat], v) => {
+ let since;
+ if (v.since) {
+ since = /^\d+$/.test(v.since) ? Number(v.since) : Math.floor(new Date(v.since).getTime() / 1000);
+ if (Number.isNaN(since)) throw new WaError('EINVAL', `--since: invalid date: ${v.since}`);
+ }
+ return callAndPrint(session, 'messages.list', {
+ chatId: need(chat, 'the chat'),
+ limit: v.limit ?? 200,
+ since,
+ includeMedia: Boolean(v.media),
+ }, v, 'mbox');
+ },
+ },
+};