From d80640d01eeb84907e36ff4af33add9081eda17c Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 19:26:33 -0300 Subject: wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridge --- src/api/chats.js | 85 ++++++++++ src/api/contacts.js | 86 ++++++++++ src/api/index.js | 111 +++++++++++++ src/api/messages.js | 255 ++++++++++++++++++++++++++++ src/api/session.js | 77 +++++++++ src/cli.js | 379 ++++++++++++++++++++++++++++++++++++++++++ src/compat.js | 94 +++++++++++ src/config.js | 82 +++++++++ src/daemon.js | 452 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/format/index.js | 143 ++++++++++++++++ src/format/mail.js | 198 ++++++++++++++++++++++ src/format/text.js | 51 ++++++ src/helpers.js | 165 ++++++++++++++++++ src/hooks.js | 85 ++++++++++ src/output.js | 38 +++++ src/rpc.js | 83 +++++++++ src/serialize.js | 158 ++++++++++++++++++ src/session.js | 69 ++++++++ src/trace.js | 79 +++++++++ src/wamail/mime.js | 180 ++++++++++++++++++++ src/wamail/neomutt.js | 46 +++++ src/wamail/send.js | 118 +++++++++++++ src/wamail/store.js | 172 +++++++++++++++++++ src/wamail/sync.js | 235 ++++++++++++++++++++++++++ 24 files changed, 3441 insertions(+) create mode 100644 src/api/chats.js create mode 100644 src/api/contacts.js create mode 100644 src/api/index.js create mode 100644 src/api/messages.js create mode 100644 src/api/session.js create mode 100644 src/cli.js create mode 100644 src/compat.js create mode 100644 src/config.js create mode 100644 src/daemon.js create mode 100644 src/format/index.js create mode 100644 src/format/mail.js create mode 100644 src/format/text.js create mode 100644 src/helpers.js create mode 100644 src/hooks.js create mode 100644 src/output.js create mode 100644 src/rpc.js create mode 100644 src/serialize.js create mode 100644 src/session.js create mode 100644 src/trace.js create mode 100644 src/wamail/mime.js create mode 100644 src/wamail/neomutt.js create mode 100644 src/wamail/send.js create mode 100644 src/wamail/store.js create mode 100644 src/wamail/sync.js (limited to 'src') 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 `. + +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//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 < 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 [--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 + 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 [--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 [--params JSON] [--format FMT]', + ' wa + [arguments] [flags]', + ' wa schema []', + ' 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 Account (session) to use; default "default" or $WA_CLI_ACCOUNT', + ' --params Method parameters (JSON, @file or - for stdin)', + ' --json Alias of --params (they are merged)', + ` --format ${Object.keys(FORMATS).join(', ')} (default: json)`, + ' -o, --output Write the output to a file', + ' --no-autostart Do not start the daemon if it is not running', + ' -h, --help Help (also: wa --help, wa --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/.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} [--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} --help | wa schema ${name}.`); +} + +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' ? ' ' : ''}`; + out(` ${flag.padEnd(26)} ${o.description}`); + } + } + out(` ${'--format '.padEnd(26)} Output format`); + if (h.examples) { + out('\nExamples:'); + for (const e of h.examples) out(` ${e}`); + } +} diff --git a/src/compat.js b/src/compat.js new file mode 100644 index 0000000..600c60a --- /dev/null +++ b/src/compat.js @@ -0,0 +1,94 @@ +// Patches for WhatsApp Web changes that whatsapp-web.js does not follow yet. +// They are applied from here (not by editing node_modules) so they survive `npm install`. +// Once upstream fixes them, each patch only needs to detect it is no longer needed. +// +// Re-injection on SPA navigations (fixed on main by wwebjs#201653, not released) +// whatsapp-web.js runs inject() on every `framenavigated`, which also fires for +// same-document navigations and iframes. Two concurrent inject() calls register the +// same bindings: exposeFunction throws "already exists", puppeteer swallows the error +// and `ready` is never emitted (the daemon stays in "authenticated"). +// +// MsgKey._serialized -> $1 (WA Web 2.3000.104x; wwebjs#201830, no release with the fix) +// Message IDs lost `_serialized` (now `$1`, same format). whatsapp-web.js reads it in +// the page (getChats, sendMessage, downloadMedia...) and in Node (Message.id). +// Symptoms: `r: r` in getChats(), sendMessage() returns undefined, undefined IDs. + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +// Fills in `_serialized` on any MsgKey inside the data coming from the browser. +function fillSerialized(value, depth = 0) { + if (!value || typeof value !== 'object' || depth > 5) return; + if (Array.isArray(value)) { + for (const v of value) fillSerialized(v, depth + 1); + return; + } + if (value._serialized === undefined && typeof value.$1 === 'string') value._serialized = value.$1; + for (const v of Object.values(value)) if (v && typeof v === 'object') fillSerialized(v, depth + 1); +} + +// Inject once per document: the marker lives on `window`, so a real reload (new +// document) injects again and an SPA navigation does not. It is checked and set in a +// single evaluate so two simultaneous events cannot both get through. +export function patchClient(Client) { + const original = Client.prototype.inject; + if (original.waCompat) return; + Client.prototype.inject = async function (...args) { + let first = false; + for (let i = 0; i < 3; i++) { + try { + first = await this.pupPage.evaluate(() => { + if (window.__waCliInjected) return false; + window.__waCliInjected = true; + return true; + }); + break; + } catch { + // context in transition (reload in progress): the new document's + // framenavigated will call inject() again + await new Promise((r) => setTimeout(r, 500)); + } + } + if (first) return original.apply(this, args); + }; + Client.prototype.inject.waCompat = true; +} + +// Node side: every structure receives the page data in _patch(data). +export function patchStructures() { + const structures = require('whatsapp-web.js/src/structures/index.js'); + for (const Cls of new Set(Object.values(structures))) { + const proto = Cls?.prototype; + if (!proto || !Object.prototype.hasOwnProperty.call(proto, '_patch') || proto._patch.waCompat) continue; + const original = proto._patch; + proto._patch = function (data) { + fillSerialized(data); + return original.call(this, data); + }; + proto._patch.waCompat = true; + } +} + +// Page side: `_serialized` getter on MsgKey. It has to be reapplied on every WhatsApp Web +// load (whatsapp-web.js re-injects and emits `ready` again). +export async function patchPage(page) { + return page.evaluate(() => { + const applied = []; + const MsgKey = window.require('WAWebMsgKey'); + const proto = MsgKey?.prototype; + if (proto && !('_serialized' in proto)) { + Object.defineProperty(proto, '_serialized', { + configurable: true, + get() { + return this.$1; + }, + set(v) { + Object.defineProperty(this, '_serialized', { value: v, writable: true, configurable: true, enumerable: true }); + }, + }); + applied.push('MsgKey._serialized'); + } + return applied; + }); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..d29ada4 --- /dev/null +++ b/src/config.js @@ -0,0 +1,82 @@ +// Paths, accounts and user config. +// +// Everything follows XDG, split per account (like gws with several accounts): +// config ~/.config/wa-cli/config.json, hooks/ +// session ~/.local/share/wa-cli// (LocalAuth: Chrome profile) +// state ~/.local/state/wa-cli// (pid, daemon log) +// media ~/.local/share/wa-cli//media/ +// socket $XDG_RUNTIME_DIR/wa-cli/.sock + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const env = process.env; +const HOME = os.homedir(); +const XDG_CONFIG = env.XDG_CONFIG_HOME || path.join(HOME, '.config'); +const XDG_DATA = env.XDG_DATA_HOME || path.join(HOME, '.local/share'); +const XDG_STATE = env.XDG_STATE_HOME || path.join(HOME, '.local/state'); +const XDG_CACHE = env.XDG_CACHE_HOME || path.join(HOME, '.cache'); +const RUNTIME = env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), `wa-cli-${process.getuid()}`); + +const ACCOUNT_RE = /^[A-Za-z0-9_-]+$/; + +export const VERSION = JSON.parse( + fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + +export function accountName(flag) { + const name = flag || env.WA_CLI_ACCOUNT || 'default'; + if (!ACCOUNT_RE.test(name)) { + throw Object.assign(new Error(`invalid account name: ${name} (only [A-Za-z0-9_-])`), { code: 'EINVAL' }); + } + return name; +} + +export function paths(account) { + const configDir = env.WA_CLI_CONFIG_DIR || path.join(XDG_CONFIG, 'wa-cli'); + const dataDir = path.join(env.WA_CLI_DATA_DIR || path.join(XDG_DATA, 'wa-cli'), account); + const stateDir = path.join(XDG_STATE, 'wa-cli', account); + return { + configDir, + configFile: path.join(configDir, 'config.json'), + hooksDir: path.join(configDir, 'hooks'), + dataDir, + mediaDir: path.join(dataDir, 'media'), + stateDir, + pidFile: path.join(stateDir, 'daemon.pid'), + logFile: path.join(stateDir, 'daemon.log'), + cacheDir: path.join(XDG_CACHE, 'wa-cli'), + socket: env.WA_CLI_SOCKET || path.join(RUNTIME, 'wa-cli', `${account}.sock`), + }; +} + +const DEFAULTS = { + autostart: true, // the CLI starts the daemon if it is not running + readyTimeoutMs: 120000, // how long a command waits for the session to be ready + startTimeoutMs: 180000, // if WhatsApp Web does not load within this time, the daemon exits (and is restarted) + hooks: true, // ejecutar ~/.config/wa-cli/hooks/ + hookTimeoutMs: 60000, + browserName: 'wa-cli', // shows as "wa-cli (Linux)" under Linked devices + deviceName: 'Linux', + puppeteer: { headless: true }, +}; + +// config.json: { ...globals, "accounts": { "": { ...overrides } } } +export function loadConfig(account) { + const p = paths(account); + let file = {}; + try { + file = JSON.parse(fs.readFileSync(p.configFile, 'utf8')); + } catch (e) { + if (e.code !== 'ENOENT') { + throw Object.assign(new Error(`${p.configFile}: ${e.message}`), { code: 'EINVAL' }); + } + } + const { accounts = {}, ...global } = file; + const cfg = { ...DEFAULTS, ...global, ...(accounts[account] || {}) }; + cfg.puppeteer = { ...DEFAULTS.puppeteer, ...global.puppeteer, ...accounts[account]?.puppeteer }; + cfg.wamail = { ...global.wamail, ...accounts[account]?.wamail }; + if (env.WA_CLI_AUTOSTART !== undefined) cfg.autostart = !/^(0|false|no)$/i.test(env.WA_CLI_AUTOSTART); + return cfg; +} diff --git a/src/daemon.js b/src/daemon.js new file mode 100644 index 0000000..f74d7cd --- /dev/null +++ b/src/daemon.js @@ -0,0 +1,452 @@ +// Daemon: keeps the WhatsApp Web session open (headless Chrome through whatsapp-web.js) +// and exposes it on a Unix socket. WhatsApp Web takes 10-30 s to start and only receives +// messages while it is open, so one process per command does not work. + +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import wweb from 'whatsapp-web.js'; +import { getMethod, validate } from './api/index.js'; +import { patchClient, patchPage, patchStructures } from './compat.js'; +import { paths, loadConfig, VERSION } from './config.js'; +import { Hooks } from './hooks.js'; +import { WaError, onLines, send, ping } from './rpc.js'; +import * as ser from './serialize.js'; + +const { Client, LocalAuth } = wweb; +const STREAM = Symbol('stream'); +const KNOWN_CODES = new Set(['EINVAL', 'EAUTH', 'ENOTFOUND', 'EAMBIGUOUS', 'ETIMEOUT', 'EAPI', 'EDAEMON', 'EINTERNAL']); +const JID_RE = /^[^\s@]+@(c\.us|g\.us|lid|s\.whatsapp\.net|newsletter|broadcast)$/; + +const normalize = (s) => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim(); + +// The same person can show up as a number (@c.us) and as a LID (@lid): prefer the number. +function dedupe(entries) { + if (entries.length < 2) return entries; + const phone = entries.filter(([id]) => !id.endsWith('@lid')); + return phone.length === 1 ? phone : entries; +} + +function toRpcError(e) { + if (KNOWN_CODES.has(e.code)) return { code: e.code, message: e.message, ...(e.data ? { data: e.data } : {}) }; + return { code: 'EAPI', message: e.message || String(e) }; +} + +function processAlive(pid, needle) { + try { + process.kill(pid, 0); + } catch (e) { + if (e.code !== 'EPERM') return false; + } + try { + return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').includes(needle); + } catch { + return true; + } +} + +function acquirePidFile(file) { + for (let i = 0; i < 3; i++) { + try { + fs.writeFileSync(file, `${process.pid}\n`, { flag: 'wx' }); + return; + } catch (e) { + if (e.code !== 'EEXIST') throw e; + const pid = Number.parseInt(fs.readFileSync(file, 'utf8'), 10); + if (pid && pid !== process.pid && processAlive(pid, 'daemon')) { + throw new WaError('EDAEMON', `a daemon is already running for this account (pid ${pid})`); + } + fs.rmSync(file, { force: true }); + } + } +} + +// If a previous daemon died from SIGKILL, its (detached) Chrome may still be alive +// holding the profile. It is detected through the profile SingletonLock. +function killOrphanChrome(profileDir, log) { + let target; + try { + target = fs.readlinkSync(path.join(profileDir, 'SingletonLock')); + } catch { + return; + } + const pid = Number.parseInt(target.split('-').pop(), 10); + if (pid && processAlive(pid, profileDir)) { + log(`killing orphan Chrome from a previous run (pid ${pid})`); + try { + process.kill(pid, 'SIGKILL'); + } catch { /* already dead */ } + } +} + +export async function runDaemon(account) { + const P = paths(account); + const cfg = loadConfig(account); + const log = (...a) => console.error(new Date().toISOString(), `[${account}]`, ...a); + + for (const d of [P.stateDir, P.dataDir]) fs.mkdirSync(d, { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.dirname(P.socket), { recursive: true, mode: 0o700 }); + acquirePidFile(P.pidFile); + if (await ping(P.socket)) { + fs.rmSync(P.pidFile, { force: true }); + throw new WaError('EDAEMON', `a daemon is already listening on ${P.socket}`); + } + fs.rmSync(P.socket, { force: true }); + killOrphanChrome(path.join(P.dataDir, 'session'), log); + patchStructures(); + patchClient(Client); + if (cfg.trace || process.env.WA_CLI_TRACE) (await import('./trace.js')).enableTrace(log); + + const startedAt = new Date(); + const hooks = new Hooks({ + dir: P.hooksDir, account, socket: P.socket, timeoutMs: cfg.hookTimeoutMs, enabled: cfg.hooks, log, + }); + + // ---------------------------------------------------------------- session state + + const state = { state: 'starting', since: Date.now(), progress: Date.now(), qr: null, code: null, reason: null }; + const waiters = new Set(); + let wwebVersion = null; + + function setState(s, extra = {}) { + Object.assign(state, { state: s, since: Date.now(), progress: Date.now() }, extra); + for (const w of waiters) if (w.states.includes(s)) w.done(); + } + + function waitState(states, timeoutMs) { + if (states.includes(state.state)) return Promise.resolve(); + return new Promise((resolve, reject) => { + const w = { + states, + done: () => { + clearTimeout(w.timer); + waiters.delete(w); + resolve(); + }, + }; + w.timer = setTimeout(() => { + waiters.delete(w); + reject(new WaError('ETIMEOUT', `timed out waiting for state ${states.join('/')} (current: ${state.state})`)); + }, timeoutMs); + waiters.add(w); + }); + } + + async function ensureReady() { + const needAuth = () => new WaError('EAUTH', 'no linked session: run `wa auth login`'); + if (state.state === 'ready') return; + if (state.state === 'qr' || state.state === 'code') throw needAuth(); + await waitState(['ready', 'qr', 'code'], cfg.readyTimeoutMs); + if (state.state !== 'ready') throw needAuth(); + } + + // ---------------------------------------------------------------- client + + const client = new Client({ + authStrategy: new LocalAuth({ dataPath: P.dataDir }), + // No cache: WhatsApp Web changes version often and the local cache listener does + // `await res.text()` without a catch (it used to crash the process). + webVersionCache: { type: 'none' }, + puppeteer: { + ...cfg.puppeteer, + // No site isolation: the cross-origin WhatsApp Web iframe (pdf-viewer) runs in the + // same process. Otherwise it is a separate target, and when it navigates while the + // bindings are registered, exposeFunction fails with "Target closed" and `ready` never comes. + args: [...(cfg.puppeteer.args || []), '--disable-site-isolation-trials', '--disable-features=IsolateOrigins,site-per-process'], + }, + deviceName: cfg.deviceName, + browserName: cfg.browserName, + ...(cfg.webVersion ? { webVersion: cfg.webVersion } : {}), + }); + + const me = () => (client.info?.wid + ? { id: client.info.wid._serialized, name: client.info.pushname ?? null } + : null); + + // Contact names (address book or pushname) and chat names, for senderName/chatName. + // Loaded in the background once ready. Chats cover the @lid IDs, which + // getContacts() returns translated to @c.us. + const names = new Map(); + async function refreshNames() { + try { + for (const c of await client.getContacts()) { + const n = c.name || c.pushname || c.shortName || c.verifiedName; + if (n) names.set(c.id._serialized, n); + } + for (const c of await client.getChats()) { + if (c.name && (c.isGroup || !names.has(c.id._serialized))) names.set(c.id._serialized, c.name); + } + } catch (e) { + log('could not load contacts:', e.message); + } + } + + async function resolveChat(query) { + const q = String(query).trim(); + if (JID_RE.test(q)) return q.replace(/@s\.whatsapp\.net$/, '@c.us'); + if (/^\+?[\d\s().-]{6,}$/.test(q)) { + const id = await client.getNumberId(q.replace(/\D/g, '')); + if (!id) throw new WaError('ENOTFOUND', `the number ${q} is not on WhatsApp`); + return id._serialized; + } + // Exact name: first the contacts/groups cache (fast); otherwise getChats(), + // which can take several seconds on accounts with many groups. + const n = normalize(q); + const exactIn = (entries) => dedupe(entries.filter(([, name]) => normalize(name || '') === n)); + let exact = exactIn([...names]); + let chats = null; + if (!exact.length) { + chats = await client.getChats(); + exact = exactIn(chats.map((c) => [c.id._serialized, c.name])); + } + if (exact.length === 1) return exact[0][0]; + if (exact.length > 1) { + throw new WaError('EAMBIGUOUS', `"${q}" matches several chats; use the JID`, + exact.map(([id, name]) => ({ id, name }))); + } + const partial = chats.filter((c) => normalize(c.name || '').includes(n)).slice(0, 10) + .map((c) => ({ id: c.id._serialized, name: c.name })); + throw new WaError('ENOTFOUND', `no chat or contact named "${q}"` + + (partial.length ? `; similar: ${partial.map((c) => `${c.name} <${c.id}>`).join(', ')}` : ''