diff options
Diffstat (limited to 'src/wamail/send.js')
| -rw-r--r-- | src/wamail/send.js | 118 |
1 files changed, 118 insertions, 0 deletions
diff --git a/src/wamail/send.js b/src/wamail/send.js new file mode 100644 index 0000000..7ce1c0d --- /dev/null +++ b/src/wamail/send.js @@ -0,0 +1,118 @@ +// 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 <stanzaId@wa> 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; +} |