aboutsummaryrefslogtreecommitdiffstats
path: root/src/wamail/send.js
blob: 7ce1c0d999961c03973071ecf89dc3144eba8abf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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;
}