aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/chats.js85
-rw-r--r--src/api/contacts.js86
-rw-r--r--src/api/index.js111
-rw-r--r--src/api/messages.js255
-rw-r--r--src/api/session.js77
-rw-r--r--src/cli.js379
-rw-r--r--src/compat.js94
-rw-r--r--src/config.js82
-rw-r--r--src/daemon.js452
-rw-r--r--src/format/index.js143
-rw-r--r--src/format/mail.js198
-rw-r--r--src/format/text.js51
-rw-r--r--src/helpers.js165
-rw-r--r--src/hooks.js85
-rw-r--r--src/output.js38
-rw-r--r--src/rpc.js83
-rw-r--r--src/serialize.js158
-rw-r--r--src/session.js69
-rw-r--r--src/trace.js79
-rw-r--r--src/wamail/mime.js180
-rw-r--r--src/wamail/neomutt.js46
-rw-r--r--src/wamail/send.js118
-rw-r--r--src/wamail/store.js172
-rw-r--r--src/wamail/sync.js235
24 files changed, 3441 insertions, 0 deletions
diff --git a/src/api/chats.js b/src/api/chats.js
new file mode 100644
index 0000000..c13564f
--- /dev/null
+++ b/src/api/chats.js
@@ -0,0 +1,85 @@
+import * as ser from '../serialize.js';
+
+const chatParam = { type: 'chat', required: true, description: 'Chat: JID (549...@c.us, ...@g.us), phone number or exact name' };
+
+const simple = (description, fn) => ({
+ description,
+ params: { chatId: chatParam },
+ handler: async (ctx, p) => fn(ctx, p.chatId),
+});
+
+export default {
+ 'chats.list': {
+ description: 'Lists chats, most recent first.',
+ params: {
+ unread: { type: 'boolean', description: 'Only chats with unread messages' },
+ archived: { type: 'boolean', description: 'true: archived only; false: unarchived only' },
+ groups: { type: 'boolean', description: 'true: solo grupos; false: solo individuales' },
+ query: { type: 'string', description: 'Filters by name (case- and accent-insensitive)' },
+ limit: { type: 'integer', description: 'Maximum number of chats to return' },
+ },
+ columns: ['id', 'name', 'unreadCount', 'date', 'lastMessage.body'],
+ handler: async (ctx, p) => {
+ const norm = ctx.normalize;
+ let chats = (await ctx.client.getChats())
+ .filter((c) => p.unread === undefined || (c.unreadCount > 0) === p.unread)
+ .filter((c) => p.archived === undefined || Boolean(c.archived) === p.archived)
+ .filter((c) => p.groups === undefined || Boolean(c.isGroup) === p.groups)
+ .filter((c) => !p.query || norm(c.name || '').includes(norm(p.query)))
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
+ if (p.limit) chats = chats.slice(0, p.limit);
+ return chats.map((c) => ser.chat(c, ctx.ser));
+ },
+ },
+
+ 'chats.get': {
+ description: 'Details of a chat (includes participants for groups).',
+ params: { chatId: chatParam },
+ handler: async (ctx, p) => {
+ const c = await ctx.getChat(p.chatId);
+ const out = ser.chat(c, ctx.ser);
+ if (c.isGroup) Object.assign(out, ser.group(c, ctx.ser));
+ return out;
+ },
+ },
+
+ 'chats.resolve': {
+ description: 'Resolves a JID, number or name to a chatId (useful in scripts).',
+ params: { query: { type: 'string', required: true, description: 'JID, number or name' } },
+ handler: async (ctx, p) => {
+ const id = await ctx.resolveChat(p.query);
+ return { id, name: ctx.names(id) ?? null };
+ },
+ },
+
+ 'chats.read': simple('Marks the chat as read (sends read receipts).', async (ctx, id) => ({
+ ok: await ctx.client.sendSeen(id),
+ })),
+ 'chats.unread': simple('Marks the chat as unread.', async (ctx, id) => {
+ await ctx.client.markChatUnread(id);
+ return { ok: true };
+ }),
+ 'chats.archive': simple('Archives the chat.', async (ctx, id) => ({ archived: await ctx.client.archiveChat(id) })),
+ 'chats.unarchive': simple('Unarchives the chat.', async (ctx, id) => ({ archived: await ctx.client.unarchiveChat(id) })),
+ 'chats.pin': simple('Pins the chat.', async (ctx, id) => ({ pinned: await ctx.client.pinChat(id) })),
+ 'chats.unpin': simple('Unpins the chat.', async (ctx, id) => ({ pinned: await ctx.client.unpinChat(id) })),
+ 'chats.unmute': simple('Unmutes the chat.', async (ctx, id) => ctx.client.unmuteChat(id)),
+ 'chats.sync': simple('Asks the phone for the chat history (for chats with few loaded messages).',
+ async (ctx, id) => ({ ok: await ctx.client.syncHistory(id) })),
+
+ 'chats.mute': {
+ description: 'Mutes the chat, forever or until a date.',
+ params: {
+ chatId: chatParam,
+ until: { type: 'string', description: 'ISO 8601 date or epoch seconds; omitted = forever' },
+ },
+ handler: async (ctx, p) => {
+ let date;
+ if (p.until) {
+ date = /^\d+$/.test(p.until) ? new Date(Number(p.until) * 1000) : new Date(p.until);
+ if (Number.isNaN(date.getTime())) throw new ctx.WaError('EINVAL', `invalid date: ${p.until}`);
+ }
+ return ctx.client.muteChat(p.chatId, date);
+ },
+ },
+};
diff --git a/src/api/contacts.js b/src/api/contacts.js
new file mode 100644
index 0000000..b7ef6df
--- /dev/null
+++ b/src/api/contacts.js
@@ -0,0 +1,86 @@
+import * as ser from '../serialize.js';
+
+const contactParam = { type: 'chat', required: true, description: 'Contact: JID (549...@c.us), phone number or exact name' };
+
+export default {
+ 'contacts.list': {
+ description: 'Lists contacts (users; no groups).',
+ params: {
+ query: { type: 'string', description: 'Filters by name or number' },
+ saved: { type: 'boolean', description: 'true: only contacts saved on the phone' },
+ limit: { type: 'integer', description: 'Maximum to return' },
+ },
+ columns: ['id', 'displayName', 'number', 'isMyContact'],
+ handler: async (ctx, p) => {
+ const q = p.query && ctx.normalize(p.query);
+ let list = (await ctx.client.getContacts())
+ .filter((c) => !c.isGroup && c.id.server !== 'broadcast')
+ .filter((c) => p.saved === undefined || Boolean(c.isMyContact) === p.saved)
+ .map(ser.contact)
+ .filter((c) => !q || [c.name, c.pushname, c.shortName, c.number]
+ .some((v) => v && ctx.normalize(v).includes(q)))
+ .sort((a, b) => (a.displayName || '').localeCompare(b.displayName || ''));
+ if (p.limit) list = list.slice(0, p.limit);
+ return list;
+ },
+ },
+
+ 'contacts.get': {
+ description: 'Details of a contact (includes "about").',
+ params: { contactId: contactParam },
+ handler: async (ctx, p) => {
+ const c = await ctx.client.getContactById(p.contactId);
+ const out = ser.contact(c);
+ out.about = await c.getAbout().catch(() => null);
+ return out;
+ },
+ },
+
+ 'contacts.resolve': {
+ description: 'Checks whether a number is on WhatsApp and returns its JID.',
+ params: { number: { type: 'string', required: true, description: 'International number (e.g. +54 9 11 2233-4455)' } },
+ handler: async (ctx, p) => {
+ const digits = p.number.replace(/\D/g, '');
+ const id = await ctx.client.getNumberId(digits);
+ return { number: digits, registered: Boolean(id), id: id?._serialized ?? null };
+ },
+ },
+
+ 'contacts.picture': {
+ description: 'Profile picture URL (if privacy allows it).',
+ params: { contactId: contactParam },
+ handler: async (ctx, p) => ({ url: (await ctx.client.getProfilePicUrl(p.contactId)) ?? null }),
+ },
+
+ 'groups.list': {
+ description: 'Lists groups.',
+ params: { query: { type: 'string', description: 'Filters by name' } },
+ columns: ['id', 'name', 'participantCount', 'unreadCount', 'date'],
+ handler: async (ctx, p) => (await ctx.client.getChats())
+ .filter((c) => c.isGroup)
+ .filter((c) => !p.query || ctx.normalize(c.name || '').includes(ctx.normalize(p.query)))
+ .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
+ .map((c) => ser.chat(c, ctx.ser)),
+ },
+
+ 'groups.get': {
+ description: 'Details of a group: description, owner and participants.',
+ params: { groupId: { ...contactParam, description: 'Grupo: JID (...@g.us) o nombre exacto' } },
+ handler: async (ctx, p) => {
+ const c = await ctx.getChat(p.groupId);
+ if (!c.isGroup) throw new ctx.WaError('EINVAL', `${p.groupId} is not a group`);
+ return ser.group(c, ctx.ser);
+ },
+ },
+
+ 'groups.invite': {
+ description: 'Group invite link (requires being an admin).',
+ params: { groupId: { ...contactParam, description: 'Grupo: JID (...@g.us) o nombre exacto' } },
+ handler: async (ctx, p) => {
+ const c = await ctx.getChat(p.groupId);
+ if (!c.isGroup) throw new ctx.WaError('EINVAL', `${p.groupId} is not a group`);
+ const code = await c.getInviteCode();
+ return { code, link: `https://chat.whatsapp.com/${code}` };
+ },
+ },
+};
diff --git a/src/api/index.js b/src/api/index.js
new file mode 100644
index 0000000..3fdf430
--- /dev/null
+++ b/src/api/index.js
@@ -0,0 +1,111 @@
+// Registry of `resource.method` methods.
+//
+// Each module exports an object { 'resource.method': definition }. A definition:
+// description text for `wa schema` / --help
+// params { name: { type, required?, default?, description, enum? } }
+// type: string | integer | number | boolean | array | object | path | chat
+// path -> the CLI resolves it to an absolute path (relative to the CLI cwd)
+// chat -> the daemon resolves it to a chatId (JID, number or exact name)
+// columns default columns for --format table/csv
+// ready false if it works without a ready session (default true)
+// stream true if it emits events until the client disconnects
+// handler async (ctx, params, conn) => result (only used by the daemon)
+//
+// Adding a method only takes adding it here: the daemon exposes it on the socket and
+// the CLI lists it in the help and accepts it as `wa <resource> <method>`.
+
+import path from 'node:path';
+import { WaError } from '../rpc.js';
+import chats from './chats.js';
+import contacts from './contacts.js';
+import messages from './messages.js';
+import session from './session.js';
+
+export const METHODS = { ...session, ...chats, ...messages, ...contacts };
+
+export function resources() {
+ const out = {};
+ for (const [name, def] of Object.entries(METHODS)) {
+ const [res, meth] = name.split('.');
+ (out[res] ||= {})[meth] = def;
+ }
+ return out;
+}
+
+export function getMethod(name) {
+ const def = METHODS[name];
+ if (!def) {
+ throw new WaError('EINVAL', `unknown method: ${name} (see \`wa schema\`)`);
+ }
+ return def;
+}
+
+// Serializable description (no handler), for `wa schema`.
+export function schema(name) {
+ const { handler, ...rest } = getMethod(name);
+ return { method: name, ready: rest.ready !== false, ...rest };
+}
+
+// Spanish yes (si/sí) is accepted as well.
+const TRUE = new Set(['true', '1', 'yes', 'si', 'sí']);
+const FALSE = new Set(['false', '0', 'no']);
+
+// Validates and normalizes parameters. `cwd` is used to resolve path-typed ones.
+export function validate(name, params = {}, { cwd } = {}) {
+ const def = getMethod(name);
+ const spec = def.params || {};
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
+ throw new WaError('EINVAL', `${name}: --params must be a JSON object`);
+ }
+ const out = {};
+ for (const key of Object.keys(params)) {
+ if (!spec[key]) {
+ const valid = Object.keys(spec);
+ throw new WaError('EINVAL', `${name}: unknown parameter "${key}"` +
+ (valid.length ? ` (valid: ${valid.join(', ')})` : ' (takes no parameters)'));
+ }
+ }
+ for (const [key, p] of Object.entries(spec)) {
+ let v = params[key];
+ if (v === undefined || v === null) {
+ if (p.required) throw new WaError('EINVAL', `${name}: missing parameter "${key}"`);
+ if (p.default !== undefined) out[key] = p.default;
+ continue;
+ }
+ const bad = () => new WaError('EINVAL', `${name}: "${key}" must be ${p.type}`);
+ switch (p.type) {
+ case 'string':
+ case 'chat':
+ if (typeof v === 'number') v = String(v);
+ if (typeof v !== 'string' || v === '') throw bad();
+ break;
+ case 'path':
+ if (typeof v !== 'string' || v === '') throw bad();
+ if (cwd) v = path.resolve(cwd, v.replace(/^~(?=$|\/)/, process.env.HOME));
+ break;
+ case 'integer':
+ case 'number':
+ if (typeof v === 'string' && v.trim() !== '') v = Number(v);
+ if (typeof v !== 'number' || Number.isNaN(v)) throw bad();
+ if (p.type === 'integer' && !Number.isInteger(v)) throw bad();
+ break;
+ case 'boolean':
+ if (typeof v === 'string' && TRUE.has(v.toLowerCase())) v = true;
+ else if (typeof v === 'string' && FALSE.has(v.toLowerCase())) v = false;
+ if (typeof v !== 'boolean') throw bad();
+ break;
+ case 'array':
+ if (typeof v === 'string') v = v.split(',').map((s) => s.trim()).filter(Boolean);
+ if (!Array.isArray(v)) throw bad();
+ break;
+ case 'object':
+ if (typeof v !== 'object' || Array.isArray(v)) throw bad();
+ break;
+ }
+ if (p.enum && !p.enum.includes(v)) {
+ throw new WaError('EINVAL', `${name}: "${key}" must be one of: ${p.enum.join(', ')}`);
+ }
+ out[key] = v;
+ }
+ return out;
+}
diff --git a/src/api/messages.js b/src/api/messages.js
new file mode 100644
index 0000000..f913af7
--- /dev/null
+++ b/src/api/messages.js
@@ -0,0 +1,255 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import mime from 'mime';
+import wweb from 'whatsapp-web.js';
+import * as ser from '../serialize.js';
+
+const { MessageMedia } = wweb;
+
+const msgParam = { type: 'string', required: true, description: 'Serialized message ID (the "id" field)' };
+const chatParam = { type: 'chat', required: true, description: 'Chat: JID (549...@c.us, ...@g.us), phone number or exact name' };
+const MSG_COLUMNS = ['date', 'senderName', 'type', 'body', 'id'];
+
+// Content parameters shared by send and reply.
+const contentParams = {
+ body: { type: 'string', description: 'Text (or caption when there is media)' },
+ media: { type: 'path', description: 'File to attach' },
+ mediaUrl: { type: 'string', description: 'URL of a file to attach' },
+ asDocument: { type: 'boolean', description: 'Send the media as a document' },
+ asVoice: { type: 'boolean', description: 'Send audio as a voice note' },
+ asSticker: { type: 'boolean', description: 'Send an image as a sticker' },
+ viewOnce: { type: 'boolean', description: 'View once' },
+ linkPreview: { type: 'boolean', description: 'Generate link previews (default true)' },
+ mentions: { type: 'array', description: 'JIDs to mention (the text must include @number)' },
+};
+
+async function buildContent(ctx, p) {
+ let media;
+ if (p.media) media = MessageMedia.fromFilePath(p.media);
+ else if (p.mediaUrl) media = await MessageMedia.fromUrl(p.mediaUrl, { unsafeMime: true });
+ if (!media && !p.body) throw new ctx.WaError('EINVAL', 'nothing to send: "body", "media" or "mediaUrl" is missing');
+ const opts = {};
+ if (media && p.body) opts.caption = p.body;
+ if (p.asDocument) opts.sendMediaAsDocument = true;
+ if (p.asVoice) opts.sendAudioAsVoice = true;
+ if (p.asSticker) opts.sendMediaAsSticker = true;
+ if (p.viewOnce) opts.isViewOnce = true;
+ if (p.linkPreview !== undefined) opts.linkPreview = p.linkPreview;
+ if (p.mentions) opts.mentions = p.mentions;
+ return [media ?? p.body, opts];
+}
+
+// Adds media.data (base64) to the serialized messages that have media.
+async function attachMedia(pairs) {
+ for (const [msg, out] of pairs) {
+ if (!msg.hasMedia) continue;
+ try {
+ const m = await msg.downloadMedia();
+ if (m) Object.assign(out.media, { mimetype: m.mimetype, filename: m.filename || out.media.filename, data: m.data });
+ } catch (e) {
+ out.media.error = e.message;
+ }
+ }
+}
+
+function uniquePath(p) {
+ if (!fs.existsSync(p)) return p;
+ const { dir, name, ext } = path.parse(p);
+ for (let i = 1; ; i++) {
+ const c = path.join(dir, `${name}-${i}${ext}`);
+ if (!fs.existsSync(c)) return c;
+ }
+}
+
+export default {
+ 'messages.list': {
+ description: 'Messages of a chat, in chronological order (the last `limit`).',
+ params: {
+ chatId: chatParam,
+ limit: { type: 'integer', default: 50, description: 'Maximum count (from the most recent backwards)' },
+ since: { type: 'integer', description: 'Only messages with timestamp >= since (epoch s). Loads history until covered' },
+ until: { type: 'integer', description: 'Only messages with timestamp < until (epoch s)' },
+ fromMe: { type: 'boolean', description: 'true: sent only; false: received only' },
+ includeMedia: { type: 'boolean', default: false, description: 'Download media and include it as base64 (media.data)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const chat = await ctx.getChat(p.chatId);
+ const search = p.fromMe === undefined ? {} : { fromMe: p.fromMe };
+ let limit = p.limit;
+ let msgs = await chat.fetchMessages({ ...search, limit });
+ // With `since`, keep loading history until past that date (or exhausted).
+ if (p.since !== undefined) {
+ const oldest = () => msgs.reduce((min, m) => Math.min(min, m.timestamp), Infinity);
+ while (msgs.length >= limit && oldest() >= p.since && limit < 20000) {
+ limit *= 2;
+ msgs = await chat.fetchMessages({ ...search, limit });
+ }
+ msgs = msgs.filter((m) => m.timestamp >= p.since);
+ }
+ if (p.until !== undefined) msgs = msgs.filter((m) => m.timestamp < p.until);
+ const pairs = msgs.map((m) => [m, ser.message(m, ctx.ser)]);
+ if (p.includeMedia) await attachMedia(pairs);
+ return pairs.map(([, out]) => out);
+ },
+ },
+
+ 'messages.get': {
+ description: 'A message by ID.',
+ params: {
+ messageId: msgParam,
+ includeMedia: { type: 'boolean', default: false, description: 'Download media and include it as base64 (media.data)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ const out = ser.message(m, ctx.ser);
+ if (p.includeMedia) await attachMedia([[m, out]]);
+ return out;
+ },
+ },
+
+ 'messages.search': {
+ description: 'Searches messages by text (in all chats or in one).',
+ params: {
+ query: { type: 'string', required: true, description: 'Text to search for' },
+ chatId: { ...chatParam, required: false },
+ page: { type: 'integer', description: 'Results page' },
+ limit: { type: 'integer', description: 'Results per page' },
+ },
+ columns: ['date', 'chatId', 'senderName', 'body', 'id'],
+ handler: async (ctx, p) => {
+ const { query, ...opts } = p;
+ const msgs = await ctx.client.searchMessages(query, opts);
+ return msgs.map((m) => ser.message(m, ctx.ser));
+ },
+ },
+
+ 'messages.send': {
+ description: 'Sends a text message or a file.',
+ params: {
+ chatId: chatParam,
+ ...contentParams,
+ quotedMessageId: { type: 'string', description: 'ID of the message to quote (reply to)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const [content, opts] = await buildContent(ctx, p);
+ if (p.quotedMessageId) opts.quotedMessageId = p.quotedMessageId;
+ const m = await ctx.client.sendMessage(p.chatId, content, opts);
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.reply': {
+ description: 'Replies quoting a message, in the same chat.',
+ params: { messageId: msgParam, ...contentParams },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const orig = await ctx.getMessage(p.messageId);
+ const [content, opts] = await buildContent(ctx, p);
+ const m = await orig.reply(content, undefined, opts);
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.forward': {
+ description: 'Forwards a message to another chat.',
+ params: { messageId: msgParam, chatId: chatParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).forward(p.chatId);
+ return { ok: true };
+ },
+ },
+
+ 'messages.react': {
+ description: 'Reacts to a message with an emoji (no emoji removes the reaction).',
+ params: { messageId: msgParam, emoji: { type: 'string', description: 'Emoji; omitted removes the reaction' } },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).react(p.emoji ?? '');
+ return { ok: true };
+ },
+ },
+
+ 'messages.edit': {
+ description: 'Edits the text of your own message.',
+ params: { messageId: msgParam, body: { type: 'string', required: true, description: 'New text' } },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await (await ctx.getMessage(p.messageId)).edit(p.body);
+ if (!m) throw new ctx.WaError('EAPI', 'WhatsApp did not allow editing the message');
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.delete': {
+ description: 'Deletes a message (for me, or for everyone if it is yours and recent).',
+ params: { messageId: msgParam, everyone: { type: 'boolean', default: false, description: 'Delete for everyone' } },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).delete(p.everyone);
+ return { ok: true };
+ },
+ },
+
+ 'messages.star': {
+ description: 'Stars a message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).star();
+ return { ok: true };
+ },
+ },
+
+ 'messages.unstar': {
+ description: 'Unstars a message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).unstar();
+ return { ok: true };
+ },
+ },
+
+ 'messages.quoted': {
+ description: 'Returns the message quoted by another one.',
+ params: { messageId: msgParam },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ if (!m.hasQuotedMsg) throw new ctx.WaError('ENOTFOUND', 'the message does not quote another one');
+ return ser.message(await m.getQuotedMessage(), ctx.ser);
+ },
+ },
+
+ 'messages.info': {
+ description: 'Deliveries and reads of your own message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => (await ctx.getMessage(p.messageId)).getInfo(),
+ },
+
+ 'messages.download': {
+ description: 'Downloads the media of a message to disk and returns the path.',
+ params: {
+ messageId: msgParam,
+ output: { type: 'path', description: 'Exact output path' },
+ dir: { type: 'path', description: 'Output directory (default: ~/.local/share/wa-cli/<account>/media)' },
+ },
+ columns: ['path', 'mimetype', 'size'],
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ if (!m.hasMedia) throw new ctx.WaError('EINVAL', 'the message has no media');
+ const media = await m.downloadMedia();
+ if (!media) throw new ctx.WaError('EAPI', 'could not download (the media may have expired on the phone)');
+ const ext = mime.getExtension(media.mimetype.split(';')[0]) || 'bin';
+ const name = (media.filename || `${m.id.id}.${ext}`).replace(/[/\0]/g, '_');
+ let out = p.output;
+ if (!out) {
+ const dir = p.dir || ctx.paths.mediaDir;
+ fs.mkdirSync(dir, { recursive: true });
+ out = uniquePath(path.join(dir, name));
+ }
+ const buf = Buffer.from(media.data, 'base64');
+ fs.writeFileSync(out, buf);
+ return { path: out, mimetype: media.mimetype, filename: media.filename ?? null, size: buf.length };
+ },
+ },
+};
diff --git a/src/api/session.js b/src/api/session.js
new file mode 100644
index 0000000..7def14d
--- /dev/null
+++ b/src/api/session.js
@@ -0,0 +1,77 @@
+// auth.*, daemon.*, events.*: they work even when the session is not ready.
+
+import { WaError } from '../rpc.js';
+
+export const EVENTS = [
+ 'qr', 'code', 'authenticated', 'auth_failure', 'ready', 'disconnected', 'change_state', 'loading_screen',
+ 'message', 'message_create', 'message_ack', 'message_edit', 'message_revoke_everyone', 'message_revoke_me',
+ 'message_reaction', 'media_uploaded', 'unread_count', 'chat_archived', 'chat_removed', 'contact_changed',
+ 'group_join', 'group_leave', 'group_update', 'group_admin_changed', 'group_membership_request',
+ 'call', 'vote_update',
+];
+
+export default {
+ 'auth.status': {
+ description: 'WhatsApp session state (qr, code, authenticated, ready, ...) and linked account.',
+ ready: false,
+ handler: async (ctx) => ctx.status(),
+ },
+
+ 'auth.pair': {
+ description: 'Requests a pairing code by phone number (alternative to the QR). ' +
+ 'Enter it on the phone: Linked devices > Link with phone number instead.',
+ ready: false,
+ params: {
+ phone: { type: 'string', required: true, description: 'Number in international format, digits only (e.g. 5491122334455)' },
+ notify: { type: 'boolean', default: true, description: 'Show a notification on the phone' },
+ },
+ handler: async (ctx, p) => {
+ const st = ctx.status().state;
+ if (st === 'ready' || st === 'authenticated') {
+ throw new WaError('EINVAL', 'the session is already linked');
+ }
+ await ctx.waitState(['qr', 'code'], 60000);
+ const code = await ctx.client.requestPairingCode(p.phone.replace(/\D/g, ''), p.notify);
+ return { code };
+ },
+ },
+
+ 'auth.logout': {
+ description: 'Logs out (unlinks this device) and deletes the local session.',
+ handler: async (ctx) => {
+ await ctx.client.logout();
+ return { ok: true };
+ },
+ },
+
+ 'daemon.status': {
+ description: 'Daemon process information.',
+ ready: false,
+ handler: async (ctx) => ctx.daemonInfo(),
+ },
+
+ 'daemon.stop': {
+ description: 'Stops the daemon (closes the browser; the session stays saved).',
+ ready: false,
+ handler: async (ctx) => {
+ setImmediate(() => ctx.shutdown('daemon.stop'));
+ return { ok: true };
+ },
+ },
+
+ 'events.watch': {
+ description: 'Real-time event stream (NDJSON). Each event: {event, account, time, data}.',
+ ready: false,
+ stream: true,
+ params: {
+ events: { type: 'array', default: ['message'], description: `Eventos a escuchar, o ["all"]. Disponibles: ${EVENTS.join(', ')}` },
+ replay: { type: 'boolean', default: false, description: 'On connect, resend the current state (last qr/code, ready)' },
+ },
+ handler: async (ctx, p, conn) => {
+ const unknown = p.events.filter((e) => e !== 'all' && !EVENTS.includes(e));
+ if (unknown.length) throw new WaError('EINVAL', `eventos desconocidos: ${unknown.join(', ')}`);
+ ctx.subscribe(conn, p.events.includes('all') ? null : new Set(p.events), p.replay);
+ return ctx.STREAM;
+ },
+ },
+};
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(value