// `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 `. 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 [text...] [--file PATH] [--reply-to MSG_ID]', details: ' 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 [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 [-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 [-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 [-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'); }, }, };