aboutsummaryrefslogtreecommitdiffstats
path: root/src/wamail/send.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/wamail/send.js')
-rw-r--r--src/wamail/send.js118
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(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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;
+}