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/messages.js | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/api/messages.js (limited to 'src/api/messages.js') 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 }; + }, + }, +}; -- cgit v1.2.3