// wamail send: neomutt's `sendmail`. Reads the RFC 822 message from stdin and sends it over WhatsApp. // // recipients arguments after `--` (as neomutt passes them) or To/Cc/Bcc; // they must be JIDs (5491122334455@c.us, ...@g.us, ...@lid) or numbers // quote if there is In-Reply-To AND the body keeps quoted text (> ...), // it is sent as a quoted reply and the quote is removed from the text // replies if the original message's chat is among the recipients, it only goes there // (a group-reply does not also write privately to the author) // attachments a single image/video attachment: the text becomes its caption; otherwise the // text, then each attachment (anything not image/video/audio, as a document) import fs from 'node:fs'; import path from 'node:path'; import mime from 'mime'; import { addresses, parseMessage } from './mime.js'; import { STATE_DIR, loadState, stanzaFileId } from './store.js'; const JID_RE = /^[^\s@]+@(c\.us|g\.us|lid|s\.whatsapp\.net)$/; export function toChatId(addr) { const a = addr.trim().replace(/^<|>$/g, ''); if (JID_RE.test(a)) return a.replace(/@s\.whatsapp\.net$/, '@c.us'); const local = a.split('@')[0]; if (/^\+?[\d\s().-]{6,}$/.test(local)) return local.replace(/\D/g, ''); throw new Error(`"${addr}" is not a WhatsApp recipient (use the JID, e.g. 5491122334455@c.us, or the number)`); } export function stripSignature(text) { const lines = text.split('\n'); const i = lines.lastIndexOf('-- '); return (i >= 0 ? lines.slice(0, i) : lines).join('\n'); } // Removes the quoted lines ("> ...") and the attribution line before them ("On ..., X wrote:"). export function stripQuote(text) { const lines = text.split('\n'); const isQuote = (l) => /^ {0,3}>/.test(l); if (!lines.some(isQuote)) return { body: text.trim(), hadQuote: false }; const keep = []; for (let i = 0; i < lines.length; i++) { if (isQuote(lines[i])) continue; const next = lines.slice(i + 1).find((l) => l.trim() !== ''); if (lines[i].trim().endsWith(':') && next !== undefined && isQuote(next)) continue; keep.push(lines[i]); } return { body: keep.join('\n').replace(/\n{3,}/g, '\n\n').trim(), hadQuote: true }; } const htmlToText = (html) => html .replace(/<(br|\/p|\/div|\/li)[^>]*>/gi, '\n').replace(/<[^>]+>/g, '') .replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); // Builds the list of sends without touching WhatsApp (testable). export function planSend({ raw, argv = [], state }) { const msg = parseMessage(raw); const dash = argv.indexOf('--'); let rcpts = (dash >= 0 ? argv.slice(dash + 1) : argv.filter((a) => !a.startsWith('-'))).filter(Boolean); if (!rcpts.length) { rcpts = ['To', 'Cc', 'Bcc'].flatMap((h) => addresses(msg.header(h) || '')); } if (!rcpts.length) throw new Error('the message has no recipients'); const irt = /<([^<>@\s]+)@wa>/.exec(msg.header('In-Reply-To') || ''); const quoted = irt ? (state.msgs[stanzaFileId(irt[1])] ?? state.tombstones[stanzaFileId(irt[1])]) : null; const text = stripSignature(msg.text ?? (msg.html ? htmlToText(msg.html) : '')); // "> " lines are only a quote in a reply; in a new message they are text. const { body, hadQuote } = irt ? stripQuote(text) : { body: text.trim(), hadQuote: false }; const quotedMessageId = hadQuote && quoted ? quoted.id : undefined; let chatIds = [...new Set(rcpts.map(toChatId))]; if (quoted && chatIds.includes(quoted.chat)) chatIds = [quoted.chat]; const atts = msg.attachments.filter((a) => a.data.length); const steps = []; for (const chatId of chatIds) { const visual = (a) => /^(image|video)\//.test(a.contentType); if (atts.length === 1 && visual(atts[0])) { steps.push({ chatId, body: body || undefined, attachment: atts[0], quotedMessageId }); continue; } if (body) steps.push({ chatId, body, quotedMessageId }); atts.forEach((a, i) => steps.push({ chatId, attachment: a, quotedMessageId: !body && i === 0 ? quotedMessageId : undefined })); } if (!steps.length) throw new Error('empty message: no text and no attachments'); return { steps, chatIds, quotedWanted: hadQuote && Boolean(irt), quotedFound: Boolean(quotedMessageId) }; } export async function sendRaw({ account, rpc, raw, argv, log = () => {} }) { const plan = planSend({ raw, argv, state: loadState(account) }); if (plan.quotedWanted && !plan.quotedFound) log('the quoted message is not synced: sending without the quote'); fs.mkdirSync(STATE_DIR, { recursive: true }); const tmp = fs.mkdtempSync(path.join(STATE_DIR, 'send-')); try { let n = 0; for (const step of plan.steps) { const params = { chatId: step.chatId }; if (step.body) params.body = step.body; if (step.quotedMessageId) params.quotedMessageId = step.quotedMessageId; if (step.attachment) { const a = step.attachment; let name = (a.filename || 'adjunto').replace(/[/\0]/g, '_'); if (!path.extname(name)) name += `.${mime.getExtension(a.contentType) || 'bin'}`; // one directory per attachment: WhatsApp shows the file name as is const file = path.join(tmp, String(n++), name); fs.mkdirSync(path.dirname(file)); fs.writeFileSync(file, a.data); params.media = file; if (!/^(image|video|audio)\//.test(a.contentType)) params.asDocument = true; } const sent = await rpc('messages.send', params); log(`sent to ${step.chatId}${step.attachment ? ` (${step.attachment.filename ?? step.attachment.contentType})` : ''} id=${sent?.id ?? '?'}`); } } finally { fs.rmSync(tmp, { recursive: true, force: true }); } return plan.chatIds; }