1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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');
},
},
};
|