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 ++++++++++++++++ 5 files changed, 614 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 (limited to 'src/api') 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; + }, + }, +}; -- cgit v1.2.3