aboutsummaryrefslogtreecommitdiffstats
path: root/src/format
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/format
downloadwa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz
wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/format')
-rw-r--r--src/format/index.js143
-rw-r--r--src/format/mail.js198
-rw-r--r--src/format/text.js51
3 files changed, 392 insertions, 0 deletions
diff --git a/src/format/index.js b/src/format/index.js
new file mode 100644
index 0000000..5c77cfa
--- /dev/null
+++ b/src/format/index.js
@@ -0,0 +1,143 @@
+// Output formats. They all get the JSON result from the daemon; none talks to WhatsApp.
+// To add a format, add it to FORMATS as (data, opts) => string.
+
+import { toEml, toMbox } from './mail.js';
+import { toText } from './text.js';
+
+export const FORMATS = {
+ json: (data) => JSON.stringify(data, null, 2),
+ ndjson: (data) => (Array.isArray(data) ? data : [data]).map((d) => JSON.stringify(d)).join('\n'),
+ table: (data, opts) => table(data, opts),
+ csv: (data, opts) => csv(data, opts),
+ yaml: (data) => yaml(data).replace(/\n$/, ''),
+ text: (data, opts) => toText(data, opts) ?? table(data, opts),
+ eml: (data) => toEml(data),
+ mbox: (data) => toMbox(data),
+};
+
+export function format(data, name, opts = {}) {
+ const fn = FORMATS[name];
+ if (!fn) throw Object.assign(new Error(`formato desconocido: ${name} (${Object.keys(FORMATS).join(', ')})`), { code: 'EINVAL' });
+ return fn(data, opts);
+}
+
+// ---------------------------------------------------------------- helpers
+
+export function getPath(obj, key) {
+ return key.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
+}
+
+function flatten(obj, prefix = '', out = {}) {
+ for (const [k, v] of Object.entries(obj ?? {})) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out);
+ else out[key] = v;
+ }
+ return out;
+}
+
+function cell(v) {
+ if (v === null || v === undefined) return '';
+ if (Array.isArray(v)) return v.map((x) => (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(', ');
+ if (typeof v === 'object') return JSON.stringify(v);
+ return String(v);
+}
+
+function rowsAndColumns(data, columns) {
+ if (!Array.isArray(data)) {
+ const flat = flatten(data);
+ return { columns: ['key', 'value'], rows: Object.entries(flat).map(([key, value]) => ({ key, value })) };
+ }
+ if (!columns) {
+ const seen = new Set();
+ for (const r of data.slice(0, 50)) {
+ for (const [k, v] of Object.entries(flatten(r))) if (typeof v !== 'object' || v === null) seen.add(k);
+ }
+ columns = [...seen];
+ }
+ return { columns, rows: data };
+}
+
+// Approximate terminal width (emoji and CJK take 2 columns).
+const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1f300}-\u{1faff}]/u;
+function width(s) {
+ let w = 0;
+ for (const ch of s) w += WIDE.test(ch) ? 2 : /\p{M}|‍|️/u.test(ch) ? 0 : 1;
+ return w;
+}
+function truncate(s, max) {
+ if (width(s) <= max) return s;
+ let out = '';
+ let w = 0;
+ for (const ch of s) {
+ const cw = width(ch);
+ if (w + cw > max - 1) break;
+ out += ch;
+ w += cw;
+ }
+ return out + '…';
+}
+const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)));
+
+function table(data, { columns } = {}) {
+ if (data === null || data === undefined) return '';
+ if (typeof data !== 'object') return String(data);
+ const { columns: cols, rows } = rowsAndColumns(data, columns);
+ if (!rows.length) return '';
+ const cells = rows.map((r) => cols.map((c) => cell(getPath(r, c)).replace(/\s*\n\s*/g, ' ⏎ ')));
+ const widths = cols.map((c, i) => Math.max(width(c), ...cells.map((r) => width(r[i]))));
+ // Fit the terminal width by shrinking the widest columns.
+ const max = process.stdout.columns || 160;
+ const sep = 2;
+ while (widths.reduce((a, b) => a + b, 0) + sep * (cols.length - 1) > max) {
+ const i = widths.indexOf(Math.max(...widths));
+ if (widths[i] <= 12) break;
+ widths[i]--;
+ }
+ const line = (vals) => vals.map((v, i) => pad(truncate(v, widths[i]), widths[i])).join(' '.repeat(sep)).trimEnd();
+ return [line(cols.map((c) => c.toUpperCase())), ...cells.map(line)].join('\n');
+}
+
+function csv(data, { columns } = {}) {
+ if (data === null || data === undefined || typeof data !== 'object') return cell(data);
+ const { columns: cols, rows } = rowsAndColumns(data, columns);
+ const esc = (s) => (/[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
+ return [cols.join(','), ...rows.map((r) => cols.map((c) => esc(cell(getPath(r, c)))).join(','))].join('\n');
+}
+
+function yamlScalar(v) {
+ if (v === null || v === undefined) return 'null';
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
+ const s = String(v);
+ if (s === '' || /^[\s-?:,[\]{}#&*!|>'"%@`]|[:#]\s|\s$|\n/.test(s) ||
+ /^(true|false|null|yes|no|on|off|~|[-+]?\d[\d_.eE+-]*)$/i.test(s)) {
+ return JSON.stringify(s);
+ }
+ return s;
+}
+
+function yaml(v, indent = '') {
+ if (Array.isArray(v)) {
+ if (!v.length) return `${indent}[]\n`;
+ return v.map((item) => {
+ if (item && typeof item === 'object' && Object.keys(item).length) {
+ const inner = yaml(item, `${indent} `);
+ return `${indent}- ${inner.slice(indent.length + 2)}`;
+ }
+ return `${indent}- ${item && typeof item === 'object' ? (Array.isArray(item) ? '[]' : '{}') : yamlScalar(item)}\n`;
+ }).join('');
+ }
+ if (v && typeof v === 'object') {
+ const entries = Object.entries(v);
+ if (!entries.length) return `${indent}{}\n`;
+ return entries.map(([k, val]) => {
+ const key = yamlScalar(k);
+ if (val && typeof val === 'object' && (Array.isArray(val) ? val.length : Object.keys(val).length)) {
+ return `${indent}${key}:\n${yaml(val, `${indent} `)}`;
+ }
+ const scalar = val && typeof val === 'object' ? (Array.isArray(val) ? '[]' : '{}') : yamlScalar(val);
+ return `${indent}${key}: ${scalar}\n`;
+ }).join('');
+ }
+ return `${indent}${yamlScalar(v)}\n`;
+}
diff --git a/src/format/mail.js b/src/format/mail.js
new file mode 100644
index 0000000..ca412f4
--- /dev/null
+++ b/src/format/mail.js
@@ -0,0 +1,198 @@
+// WhatsApp messages -> RFC 5322 (eml) / mboxrd. The base piece for neomutt:
+// `--format mbox` opens directly with `neomutt -f`, and a Maildir bridge (like gwsmail)
+// only has to write the `--format eml` output to ~/Mail/<account>/<folder>/new.
+//
+// Conventions (the sending side must follow them):
+// addresses the JID as is: 5491122334455@c.us, 1203630...@g.us, 1234@lid
+// Message-ID <stanzaId@wa> (stanzaId = the "stanzaId" JSON field)
+// In-Reply-To <quotedStanzaId@wa> -> neomutt threads quoted replies
+// Reply-To in groups, the group (so `r` answers the group, not privately)
+// X-WA-Id serialized ID, for later actions (react, delete, download)
+// X-WA-Chat-Id chat it belongs to
+
+import crypto from 'node:crypto';
+import { describeContent } from './text.js';
+
+const ASCII = /^[\x20-\x7e]*$/;
+
+function encodedWords(s) {
+ const words = [];
+ let chunk = '';
+ for (const ch of s) {
+ if (Buffer.byteLength(chunk + ch) > 45) {
+ words.push(chunk);
+ chunk = '';
+ }
+ chunk += ch;
+ }
+ if (chunk) words.push(chunk);
+ return words.map((w) => `=?UTF-8?B?${Buffer.from(w).toString('base64')}?=`).join('\n ');
+}
+
+const headerText = (s) => (ASCII.test(s) ? s : encodedWords(s));
+
+function address(name, addr) {
+ if (!addr) return 'undisclosed-recipients:;';
+ if (!name || name === addr.split('@')[0]) return `<${addr}>`;
+ const display = ASCII.test(name) ? `"${name.replace(/[\\"]/g, '\\$&')}"` : encodedWords(name);
+ return `${display} <${addr}>`;
+}
+
+function rfc2822Date(ts) {
+ const d = new Date(ts * 1000);
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ const p = (n) => String(n).padStart(2, '0');
+ const off = -d.getTimezoneOffset();
+ const sign = off >= 0 ? '+' : '-';
+ return `${days[d.getDay()]}, ${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()} ` +
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ` +
+ `${sign}${p(Math.floor(Math.abs(off) / 60))}${p(Math.abs(off) % 60)}`;
+}
+
+function quotedPrintable(text) {
+ const out = [];
+ for (const line of text.split('\n')) {
+ const bytes = Buffer.from(line);
+ let cur = '';
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ const last = i === bytes.length - 1;
+ let tok;
+ // "From " at the start of a line becomes =46rom: the mbox then needs no escaping
+ // (and mboxo readers such as mutt do not show ">From").
+ const fromLine = i === 0 && b === 70 && line.startsWith('From ');
+ if (!fromLine && ((b >= 33 && b <= 126 && b !== 61) || ((b === 32 || b === 9) && !last))) {
+ tok = String.fromCharCode(b);
+ }
+ if (!tok) tok = `=${b.toString(16).toUpperCase().padStart(2, '0')}`;
+ if (cur.length + tok.length > 75) {
+ out.push(`${cur}=`);
+ cur = '';
+ }
+ cur += tok;
+ }
+ out.push(cur);
+ }
+ return out.join('\n');
+}
+
+const base64Lines = (b64) => b64.replace(/\s+/g, '').match(/.{1,76}/g)?.join('\n') ?? '';
+
+function filenameParams(name) {
+ if (ASCII.test(name)) return `; name="${name.replace(/"/g, '')}"`;
+ return `; name="${encodedWords(name).replace(/\n /g, '')}"`;
+}
+function dispositionParams(name) {
+ if (ASCII.test(name)) return `; filename="${name.replace(/"/g, '')}"`;
+ return `; filename*=UTF-8''${encodeURIComponent(name)}`;
+}
+
+function bodyText(m) {
+ const lines = [];
+ const content = describeContent(m);
+ if (m.hasMedia && !m.media?.data) {
+ const size = m.media?.size ? `, ${Math.round(m.media.size / 1024)} KB` : '';
+ lines.push(`[attachment not downloaded: ${m.media?.mimetype ?? m.type}${size}]`);
+ lines.push(`[wa messages download --params '{"messageId":"${m.id}"}']`);
+ if (m.body) lines.push('', m.body);
+ } else {
+ lines.push(m.hasMedia ? m.body : content);
+ }
+ if (m.location) lines.push('', `https://maps.google.com/?q=${m.location.latitude},${m.location.longitude}`);
+ if (m.links?.length && m.type !== 'chat') lines.push('', ...m.links);
+ return lines.join('\n');
+}
+
+export function messageToEml(m) {
+ const isGroup = m.chatId?.endsWith('@g.us');
+ const subject = describeContent(m).split('\n')[0].slice(0, 80) || '(no text)';
+ const from = address(m.senderName, m.sender);
+ const to = isGroup ? address(m.chatName, m.chatId) : address(m.fromMe ? m.chatName : null, m.fromMe ? m.chatId : m.to);
+
+ const h = [
+ `From: ${from}`,
+ `To: ${to}`,
+ ...(isGroup ? [`Reply-To: ${address(m.chatName, m.chatId)}`] : []),
+ `Subject: ${headerText(subject)}`,
+ `Date: ${rfc2822Date(m.timestamp)}`,
+ `Message-ID: <${m.stanzaId}@wa>`,
+ ...(m.quotedStanzaId ? [`In-Reply-To: <${m.quotedStanzaId}@wa>`, `References: <${m.quotedStanzaId}@wa>`] : []),
+ `X-WA-Id: ${m.id}`,
+ `X-WA-Chat-Id: ${m.chatId}`,
+ ...(m.chatName ? [`X-WA-Chat-Name: ${headerText(m.chatName)}`] : []),
+ `X-WA-Type: ${m.type}`,
+ `X-WA-From-Me: ${m.fromMe ? 'yes' : 'no'}`,
+ ...(m.isForwarded ? ['X-WA-Forwarded: yes'] : []),
+ 'MIME-Version: 1.0',
+ ];
+
+ const textPart = [
+ 'Content-Type: text/plain; charset=utf-8',
+ 'Content-Transfer-Encoding: quoted-printable',
+ '',
+ quotedPrintable(bodyText(m)),
+ ].join('\n');
+
+ const attachments = [];
+ if (m.media?.data) {
+ const name = m.media.filename || `${m.stanzaId}`;
+ const mimetype = (m.media.mimetype || 'application/octet-stream').split(';')[0];
+ attachments.push([
+ `Content-Type: ${mimetype}${filenameParams(name)}`,
+ 'Content-Transfer-Encoding: base64',
+ `Content-Disposition: ${mimetype.startsWith('image/') ? 'inline' : 'attachment'}${dispositionParams(name)}`,
+ '',
+ base64Lines(m.media.data),
+ ].join('\n'));
+ }
+ (m.vCards || []).forEach((vc, i) => attachments.push([
+ `Content-Type: text/vcard; charset=utf-8; name="contacto-${i + 1}.vcf"`,
+ 'Content-Transfer-Encoding: quoted-printable',
+ `Content-Disposition: attachment; filename="contacto-${i + 1}.vcf"`,
+ '',
+ quotedPrintable(vc.replace(/\r\n/g, '\n')),
+ ].join('\n')));
+
+ if (!attachments.length) return `${h.join('\n')}\n${textPart}\n`;
+
+ const boundary = `wa-${crypto.randomBytes(12).toString('hex')}`;
+ return [
+ ...h,
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
+ '',
+ ...[textPart, ...attachments].map((part) => `--${boundary}\n${part}`),
+ `--${boundary}--`,
+ '',
+ ].join('\n');
+}
+
+function asMessages(data) {
+ const list = Array.isArray(data) ? data : [data];
+ if (!list.every((m) => m && typeof m === 'object' && 'stanzaId' in m)) {
+ throw Object.assign(new Error('the eml/mbox formats only apply to messages'), { code: 'EINVAL' });
+ }
+ return list;
+}
+
+export function toEml(data) {
+ const list = asMessages(data);
+ if (list.length !== 1) {
+ throw Object.assign(new Error(`eml is for a single message (there are ${list.length}); use --format mbox`), { code: 'EINVAL' });
+ }
+ return messageToEml(list[0]).replace(/\n$/, '');
+}
+
+export function toMbox(data) {
+ return asMessages(data)
+ .slice()
+ .sort((a, b) => a.timestamp - b.timestamp)
+ .map((m) => {
+ const asctime = new Date(m.timestamp * 1000).toUTCString()
+ .replace(/^(\w+), (\d+) (\w+) (\d+) ([\d:]+) GMT$/, (_, dow, d, mon, y, t) => `${dow} ${mon} ${d.padStart(2)} ${t} ${y}`);
+ const body = messageToEml(m).replace(/^(>*From )/gm, '>$1');
+ return `From ${m.sender || 'unknown@wa'} ${asctime}\n${body}`;
+ })
+ .join('\n')
+ .replace(/\n$/, '');
+}
diff --git a/src/format/text.js b/src/format/text.js
new file mode 100644
index 0000000..4cd1ab1
--- /dev/null
+++ b/src/format/text.js
@@ -0,0 +1,51 @@
+// "text" format: a human-readable conversation. Returns null if the data is not messages
+// (and the caller falls back to table).
+
+const isMessage = (m) => m && typeof m === 'object' && 'stanzaId' in m && 'chatId' in m;
+
+function when(ts) {
+ const d = new Date(ts * 1000);
+ const p = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+export function describeContent(m) {
+ const parts = [];
+ if (m.hasMedia) {
+ const name = m.media?.filename ? ` ${m.media.filename}` : '';
+ parts.push(`[${m.type}${name}]`);
+ } else if (m.type === 'location' && m.location) {
+ parts.push(`[location ${m.location.latitude},${m.location.longitude}${m.location.name ? ` ${m.location.name}` : ''}]`);
+ } else if (m.type === 'vcard' || m.type === 'multi_vcard') {
+ parts.push('[contact]');
+ } else if (m.type === 'revoked') {
+ parts.push('[deleted message]');
+ } else if (m.poll) {
+ parts.push(`[poll] ${m.poll.name}: ${m.poll.options.join(' / ')}`);
+ return parts.join(' ');
+ } else if (!['chat', 'text'].includes(m.type) && !m.body) {
+ parts.push(`[${m.type}]`);
+ }
+ if (m.body) parts.push(m.body);
+ return parts.join(' ');
+}
+
+export function messageLine(m, { chatName } = {}) {
+ const who = m.fromMe ? 'me' : (m.senderName || m.sender);
+ const chat = chatName ? `${chatName} | ` : '';
+ const flags = `${m.isForwarded ? '↪ ' : ''}${m.hasQuotedMsg ? '↩ ' : ''}`;
+ const body = describeContent(m).replace(/\n/g, '\n ');
+ return `[${when(m.timestamp)}] ${chat}${who}: ${flags}${body}`;
+}
+
+export function toText(data, opts = {}) {
+ // +watch event: {event, data}
+ if (data && data.event && data.data !== undefined) {
+ const inner = data.data.message ?? data.data;
+ if (isMessage(inner)) return messageLine(inner, { chatName: inner.chatId });
+ return null;
+ }
+ const list = Array.isArray(data) ? data : [data];
+ if (!list.length || !list.every(isMessage)) return null;
+ return list.map((m) => messageLine(m, opts)).join('\n');
+}