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 }; }, }, };