aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/messages.js
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
commitd80640d01eeb84907e36ff4af33add9081eda17c (patch)
tree27698b34e06e28d111c2196700015d9c82f78274 /src/api/messages.js
downloadwa-cli-main.tar.gz
wa-cli-main.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/api/messages.js')
-rw-r--r--src/api/messages.js255
1 files changed, 255 insertions, 0 deletions
diff --git a/src/api/messages.js b/src/api/messages.js
new file mode 100644
index 0000000..f913af7
--- /dev/null
+++ b/src/api/messages.js
@@ -0,0 +1,255 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import mime from 'mime';
+import wweb from 'whatsapp-web.js';
+import * as ser from '../serialize.js';
+
+const { MessageMedia } = wweb;
+
+const msgParam = { type: 'string', required: true, description: 'Serialized message ID (the "id" field)' };
+const chatParam = { type: 'chat', required: true, description: 'Chat: JID (549...@c.us, ...@g.us), phone number or exact name' };
+const MSG_COLUMNS = ['date', 'senderName', 'type', 'body', 'id'];
+
+// Content parameters shared by send and reply.
+const contentParams = {
+ body: { type: 'string', description: 'Text (or caption when there is media)' },
+ media: { type: 'path', description: 'File to attach' },
+ mediaUrl: { type: 'string', description: 'URL of a file to attach' },
+ asDocument: { type: 'boolean', description: 'Send the media as a document' },
+ asVoice: { type: 'boolean', description: 'Send audio as a voice note' },
+ asSticker: { type: 'boolean', description: 'Send an image as a sticker' },
+ viewOnce: { type: 'boolean', description: 'View once' },
+ linkPreview: { type: 'boolean', description: 'Generate link previews (default true)' },
+ mentions: { type: 'array', description: 'JIDs to mention (the text must include @number)' },
+};
+
+async function buildContent(ctx, p) {
+ let media;
+ if (p.media) media = MessageMedia.fromFilePath(p.media);
+ else if (p.mediaUrl) media = await MessageMedia.fromUrl(p.mediaUrl, { unsafeMime: true });
+ if (!media && !p.body) throw new ctx.WaError('EINVAL', 'nothing to send: "body", "media" or "mediaUrl" is missing');
+ const opts = {};
+ if (media && p.body) opts.caption = p.body;
+ if (p.asDocument) opts.sendMediaAsDocument = true;
+ if (p.asVoice) opts.sendAudioAsVoice = true;
+ if (p.asSticker) opts.sendMediaAsSticker = true;
+ if (p.viewOnce) opts.isViewOnce = true;
+ if (p.linkPreview !== undefined) opts.linkPreview = p.linkPreview;
+ if (p.mentions) opts.mentions = p.mentions;
+ return [media ?? p.body, opts];
+}
+
+// Adds media.data (base64) to the serialized messages that have media.
+async function attachMedia(pairs) {
+ for (const [msg, out] of pairs) {
+ if (!msg.hasMedia) continue;
+ try {
+ const m = await msg.downloadMedia();
+ if (m) Object.assign(out.media, { mimetype: m.mimetype, filename: m.filename || out.media.filename, data: m.data });
+ } catch (e) {
+ out.media.error = e.message;
+ }
+ }
+}
+
+function uniquePath(p) {
+ if (!fs.existsSync(p)) return p;
+ const { dir, name, ext } = path.parse(p);
+ for (let i = 1; ; i++) {
+ const c = path.join(dir, `${name}-${i}${ext}`);
+ if (!fs.existsSync(c)) return c;
+ }
+}
+
+export default {
+ 'messages.list': {
+ description: 'Messages of a chat, in chronological order (the last `limit`).',
+ params: {
+ chatId: chatParam,
+ limit: { type: 'integer', default: 50, description: 'Maximum count (from the most recent backwards)' },
+ since: { type: 'integer', description: 'Only messages with timestamp >= since (epoch s). Loads history until covered' },
+ until: { type: 'integer', description: 'Only messages with timestamp < until (epoch s)' },
+ fromMe: { type: 'boolean', description: 'true: sent only; false: received only' },
+ includeMedia: { type: 'boolean', default: false, description: 'Download media and include it as base64 (media.data)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const chat = await ctx.getChat(p.chatId);
+ const search = p.fromMe === undefined ? {} : { fromMe: p.fromMe };
+ let limit = p.limit;
+ let msgs = await chat.fetchMessages({ ...search, limit });
+ // With `since`, keep loading history until past that date (or exhausted).
+ if (p.since !== undefined) {
+ const oldest = () => msgs.reduce((min, m) => Math.min(min, m.timestamp), Infinity);
+ while (msgs.length >= limit && oldest() >= p.since && limit < 20000) {
+ limit *= 2;
+ msgs = await chat.fetchMessages({ ...search, limit });
+ }
+ msgs = msgs.filter((m) => m.timestamp >= p.since);
+ }
+ if (p.until !== undefined) msgs = msgs.filter((m) => m.timestamp < p.until);
+ const pairs = msgs.map((m) => [m, ser.message(m, ctx.ser)]);
+ if (p.includeMedia) await attachMedia(pairs);
+ return pairs.map(([, out]) => out);
+ },
+ },
+
+ 'messages.get': {
+ description: 'A message by ID.',
+ params: {
+ messageId: msgParam,
+ includeMedia: { type: 'boolean', default: false, description: 'Download media and include it as base64 (media.data)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ const out = ser.message(m, ctx.ser);
+ if (p.includeMedia) await attachMedia([[m, out]]);
+ return out;
+ },
+ },
+
+ 'messages.search': {
+ description: 'Searches messages by text (in all chats or in one).',
+ params: {
+ query: { type: 'string', required: true, description: 'Text to search for' },
+ chatId: { ...chatParam, required: false },
+ page: { type: 'integer', description: 'Results page' },
+ limit: { type: 'integer', description: 'Results per page' },
+ },
+ columns: ['date', 'chatId', 'senderName', 'body', 'id'],
+ handler: async (ctx, p) => {
+ const { query, ...opts } = p;
+ const msgs = await ctx.client.searchMessages(query, opts);
+ return msgs.map((m) => ser.message(m, ctx.ser));
+ },
+ },
+
+ 'messages.send': {
+ description: 'Sends a text message or a file.',
+ params: {
+ chatId: chatParam,
+ ...contentParams,
+ quotedMessageId: { type: 'string', description: 'ID of the message to quote (reply to)' },
+ },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const [content, opts] = await buildContent(ctx, p);
+ if (p.quotedMessageId) opts.quotedMessageId = p.quotedMessageId;
+ const m = await ctx.client.sendMessage(p.chatId, content, opts);
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.reply': {
+ description: 'Replies quoting a message, in the same chat.',
+ params: { messageId: msgParam, ...contentParams },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const orig = await ctx.getMessage(p.messageId);
+ const [content, opts] = await buildContent(ctx, p);
+ const m = await orig.reply(content, undefined, opts);
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.forward': {
+ description: 'Forwards a message to another chat.',
+ params: { messageId: msgParam, chatId: chatParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).forward(p.chatId);
+ return { ok: true };
+ },
+ },
+
+ 'messages.react': {
+ description: 'Reacts to a message with an emoji (no emoji removes the reaction).',
+ params: { messageId: msgParam, emoji: { type: 'string', description: 'Emoji; omitted removes the reaction' } },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).react(p.emoji ?? '');
+ return { ok: true };
+ },
+ },
+
+ 'messages.edit': {
+ description: 'Edits the text of your own message.',
+ params: { messageId: msgParam, body: { type: 'string', required: true, description: 'New text' } },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await (await ctx.getMessage(p.messageId)).edit(p.body);
+ if (!m) throw new ctx.WaError('EAPI', 'WhatsApp did not allow editing the message');
+ return ser.message(m, ctx.ser);
+ },
+ },
+
+ 'messages.delete': {
+ description: 'Deletes a message (for me, or for everyone if it is yours and recent).',
+ params: { messageId: msgParam, everyone: { type: 'boolean', default: false, description: 'Delete for everyone' } },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).delete(p.everyone);
+ return { ok: true };
+ },
+ },
+
+ 'messages.star': {
+ description: 'Stars a message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).star();
+ return { ok: true };
+ },
+ },
+
+ 'messages.unstar': {
+ description: 'Unstars a message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => {
+ await (await ctx.getMessage(p.messageId)).unstar();
+ return { ok: true };
+ },
+ },
+
+ 'messages.quoted': {
+ description: 'Returns the message quoted by another one.',
+ params: { messageId: msgParam },
+ columns: MSG_COLUMNS,
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ if (!m.hasQuotedMsg) throw new ctx.WaError('ENOTFOUND', 'the message does not quote another one');
+ return ser.message(await m.getQuotedMessage(), ctx.ser);
+ },
+ },
+
+ 'messages.info': {
+ description: 'Deliveries and reads of your own message.',
+ params: { messageId: msgParam },
+ handler: async (ctx, p) => (await ctx.getMessage(p.messageId)).getInfo(),
+ },
+
+ 'messages.download': {
+ description: 'Downloads the media of a message to disk and returns the path.',
+ params: {
+ messageId: msgParam,
+ output: { type: 'path', description: 'Exact output path' },
+ dir: { type: 'path', description: 'Output directory (default: ~/.local/share/wa-cli/<account>/media)' },
+ },
+ columns: ['path', 'mimetype', 'size'],
+ handler: async (ctx, p) => {
+ const m = await ctx.getMessage(p.messageId);
+ if (!m.hasMedia) throw new ctx.WaError('EINVAL', 'the message has no media');
+ const media = await m.downloadMedia();
+ if (!media) throw new ctx.WaError('EAPI', 'could not download (the media may have expired on the phone)');
+ const ext = mime.getExtension(media.mimetype.split(';')[0]) || 'bin';
+ const name = (media.filename || `${m.id.id}.${ext}`).replace(/[/\0]/g, '_');
+ let out = p.output;
+ if (!out) {
+ const dir = p.dir || ctx.paths.mediaDir;
+ fs.mkdirSync(dir, { recursive: true });
+ out = uniquePath(path.join(dir, name));
+ }
+ const buf = Buffer.from(media.data, 'base64');
+ fs.writeFileSync(out, buf);
+ return { path: out, mimetype: media.mimetype, filename: media.filename ?? null, size: buf.length };
+ },
+ },
+};