cli-whatsapp 0.1.5 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -210,7 +210,7 @@ export function pickFromList(title, allItems, initialFilter = '') {
210
210
  return void ((index = Math.min(items.length - 1, index + 1)), render());
211
211
  if (key.name === 'return') {
212
212
  const it = items[index];
213
- return done(it ? it.value : null);
213
+ return done(it ? (it.id ?? it.value) : null);
214
214
  }
215
215
  if (key.name === 'backspace') {
216
216
  if (!filter)
@@ -1,8 +1,9 @@
1
- import { writeFileSync } from 'node:fs';
1
+ import { writeFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { proto, downloadMediaMessage } from '@whiskeysockets/baileys';
4
4
  import { runWithConnection } from "../../whatsapp/client.js";
5
5
  import { logger } from "../../whatsapp/logger.js";
6
+ import { openFile } from "../../utils/open.js";
6
7
  import { listMedia, getMessageById, setMediaPath, findChat, } from "../../db/sqlite.js";
7
8
  import { PATHS, ensureDirs, loadConfig } from "../../config/index.js";
8
9
  import { c, formatTime, displayName, truncate } from "../../utils/format.js";
@@ -22,7 +23,8 @@ const TYPE_ALIASES = {
22
23
  stickers: ['sticker'],
23
24
  all: [],
24
25
  };
25
- /** wa media [type] [--limit N] — list media messages from the cache. */
26
+ /** wa media [type] [--limit N] — list media messages from the cache. In the
27
+ * interactive app, `opts.chat` scopes it to the open chat. */
26
28
  export function media(type, opts) {
27
29
  const limit = Math.min(parseInt(opts.limit ?? '30', 10) || 30, 1000);
28
30
  let types = null;
@@ -33,11 +35,15 @@ export function media(type, opts) {
33
35
  }
34
36
  types = mapped.length ? mapped : null;
35
37
  }
36
- const rows = listMedia(types, limit);
38
+ const rows = listMedia(types, limit, opts.chat?.jid);
37
39
  if (rows.length === 0) {
38
- print(c.dim('No media cached. Run `wa sync` after logging in.'));
40
+ print(c.dim(opts.chat
41
+ ? `No media in this chat with ${opts.chat.name}.`
42
+ : 'No media cached. Run `wa sync` after logging in.'));
39
43
  return;
40
44
  }
45
+ if (opts.chat)
46
+ print(c.dim(`Media with ${c.bold(opts.chat.name)}:`));
41
47
  const cfg = loadConfig();
42
48
  const width = String(rows[rows.length - 1].id).length;
43
49
  for (const m of rows) {
@@ -52,7 +58,32 @@ export function media(type, opts) {
52
58
  print();
53
59
  info('Download one with `wa download <id>`.');
54
60
  }
55
- /** wa download <id> [--out FILE] — fetch & decrypt a cached media message. */
61
+ /**
62
+ * Fetch & decrypt a cached media message over an already-open socket, save it,
63
+ * and return the file path. Reused by the CLI `download` and the interactive
64
+ * `/download` (which reuses the live connection). Returns null if #id has no
65
+ * downloadable media; caches an already-downloaded file's path.
66
+ */
67
+ export async function downloadWithSocket(sock, id, out) {
68
+ const row = getMessageById(id);
69
+ if (!row || !row.raw)
70
+ return null;
71
+ if (!out && row.mediaPath && existsSync(row.mediaPath))
72
+ return row.mediaPath; // already saved
73
+ ensureDirs();
74
+ const content = proto.Message.decode(Buffer.from(row.raw, 'base64'));
75
+ const waMsg = {
76
+ key: { remoteJid: row.chatJid, id: row.messageId, fromMe: !!row.fromMe },
77
+ message: content,
78
+ };
79
+ const buffer = (await downloadMediaMessage(waMsg, 'buffer', {}, { logger: logger, reuploadRequest: sock.updateMediaMessage }));
80
+ const ext = extensionFor(content, row.type);
81
+ const file = out ?? join(PATHS.media, `${id}${ext}`);
82
+ writeFileSync(file, buffer);
83
+ setMediaPath(id, file);
84
+ return file;
85
+ }
86
+ /** wa download <id> [--out FILE] [--open] — fetch & decrypt a cached media message. */
56
87
  export async function download(idRaw, opts) {
57
88
  const id = parseInt(idRaw, 10);
58
89
  if (!Number.isFinite(id))
@@ -62,23 +93,13 @@ export async function download(idRaw, opts) {
62
93
  fail(`No cached message with id #${id}.`);
63
94
  if (!row.raw)
64
95
  fail(`Message #${id} has no downloadable media (text message?).`);
65
- ensureDirs();
66
96
  info(`Downloading media from #${id}…`);
67
- const content = proto.Message.decode(Buffer.from(row.raw, 'base64'));
68
- const waMsg = {
69
- key: {
70
- remoteJid: row.chatJid,
71
- id: row.messageId,
72
- fromMe: !!row.fromMe,
73
- },
74
- message: content,
75
- };
76
- const buffer = await runWithConnection(async (conn) => (await downloadMediaMessage(waMsg, 'buffer', {}, { logger: logger, reuploadRequest: conn.sock.updateMediaMessage })), { requireSession: true }).catch((err) => fail(`Download failed: ${err.message}`));
77
- const ext = extensionFor(content, row.type);
78
- const file = opts.out ?? join(PATHS.media, `${id}${ext}`);
79
- writeFileSync(file, buffer);
80
- setMediaPath(id, file);
81
- ok(`Saved → ${file} (${(buffer.length / 1024).toFixed(1)} KB)`);
97
+ const file = await runWithConnection((conn) => downloadWithSocket(conn.sock, id, opts.out), { requireSession: true }).catch((err) => fail(`Download failed: ${err.message}`));
98
+ if (!file)
99
+ fail('Nothing to download.');
100
+ ok(`Saved → ${file}`);
101
+ if (opts.open)
102
+ openFile(file);
82
103
  }
83
104
  function extensionFor(content, type) {
84
105
  const mime = content.imageMessage?.mimetype ||
@@ -1,19 +1,20 @@
1
1
  import readline, { createInterface } from 'node:readline';
2
2
  import { readdirSync, statSync, existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { join, basename } from 'node:path';
5
5
  import { chats, contacts, search, history } from "./read.js";
6
6
  import { reply, buildMediaContent, sendContent } from "./send.js";
7
- import { media } from "./media.js";
7
+ import { media, downloadWithSocket } from "./media.js";
8
8
  import { pickFile, pickFromList } from "./filepicker.js";
9
+ import { openFile } from "../../utils/open.js";
9
10
  import { recent, pin, unpin, archive, unarchive } from "./manage.js";
10
11
  import { status } from "./auth.js";
11
12
  import { connect, hasSession } from "../../whatsapp/client.js";
12
- import { registerPersistence, extractText, isGroupJid } from "../../whatsapp/events.js";
13
- import { listChats, listContacts, findChat, upsertChat, getMessages, countMessages, } from "../../db/sqlite.js";
13
+ import { registerPersistence, extractText, isGroupJid, messageType } from "../../whatsapp/events.js";
14
+ import { listChats, listContacts, listMedia, findChat, upsertChat, getMessages, getMessageById, localIdForWaMessage, countMessages, } from "../../db/sqlite.js";
14
15
  import { resolveTarget } from "../../utils/jid.js";
15
16
  import { loadConfig, saveConfig } from "../../config/index.js";
16
- import { c, displayName, chatNumber, formatTime, truncate, renderMessages } from "../../utils/format.js";
17
+ import { c, displayName, chatNumber, cleanSender, formatTime, truncate, renderMessages } from "../../utils/format.js";
17
18
  import { print, warn, printError } from "../../utils/ui.js";
18
19
  import { notify } from "../../utils/notify.js";
19
20
  // ---- tab completion --------------------------------------------------------
@@ -28,7 +29,8 @@ const SLASH_ITEMS = [
28
29
  { value: '/send', desc: 'send a message to a chat', args: true },
29
30
  { value: '/sendfile', desc: 'send a file (media)', args: true },
30
31
  { value: '/reply', desc: 'reply to a message by #id', args: true },
31
- { value: '/media', desc: 'list cached media' },
32
+ { value: '/media', desc: 'list cached media (this chat, or all)' },
33
+ { value: '/dl', desc: 'pick recent media to download & open' },
32
34
  { value: '/pin', desc: 'pin a chat to the top', args: true },
33
35
  { value: '/unpin', desc: 'unpin a chat', args: true },
34
36
  { value: '/archive', desc: 'archive (hide) a chat', args: true },
@@ -187,7 +189,16 @@ function onIncoming(state, msg) {
187
189
  const jid = msg.key.remoteJid;
188
190
  if (!jid || jid === 'status@broadcast')
189
191
  return;
190
- const text = extractText(msg.message) || c.dim('[media]');
192
+ let text = extractText(msg.message);
193
+ if (!text) {
194
+ // Media: show its kind and a one-liner to download & open it.
195
+ const kind = messageType(msg.message);
196
+ const localId = msg.key.id ? localIdForWaMessage(jid, msg.key.id) : null;
197
+ text =
198
+ localId != null
199
+ ? c.dim(`[${kind}] `) + c.green(`/dl ${localId}`) + c.dim(' to open')
200
+ : c.dim(`[${kind}]`);
201
+ }
191
202
  const chat = findChat(jid);
192
203
  const who = isGroupJid(jid) && msg.pushName
193
204
  ? `${chat ? displayName(chat) : jid.split('@')[0]}/${msg.pushName}`
@@ -253,7 +264,20 @@ async function sendTo(state, jid, name, content, meta, replaceInput = false) {
253
264
  return;
254
265
  try {
255
266
  await sendContent(conn.sock, jid, content, meta);
256
- const echo = meta.type === 'text' ? meta.previewText : `[${meta.type}] ${meta.previewText}`;
267
+ // Build the echo: text the message; media "[type] filename" plus any
268
+ // caption (never "[image] [image]"). The filename tells you which file.
269
+ let echo;
270
+ if (meta.type === 'text') {
271
+ echo = meta.previewText;
272
+ }
273
+ else {
274
+ const fname = meta.mediaPath ? basename(meta.mediaPath) : '';
275
+ const caption = meta.previewText.startsWith('[') ? '' : meta.previewText;
276
+ echo =
277
+ `${c.dim(`[${meta.type}]`)}` +
278
+ (fname ? ` ${fname}` : '') +
279
+ (caption ? ` ${c.dim('—')} ${caption}` : '');
280
+ }
257
281
  // Replace the just-typed input with the bubble so it shows once — but only
258
282
  // when we were already live (no "connecting…" lines were printed in between).
259
283
  if (replaceInput && wasLive)
@@ -266,6 +290,59 @@ async function sendTo(state, jid, name, content, meta, replaceInput = false) {
266
290
  warn(`Send failed: ${err.message}`);
267
291
  }
268
292
  }
293
+ /** Download a media message by #id over the live connection, then open it. */
294
+ async function downloadAndOpen(state, idRaw) {
295
+ const id = parseInt(idRaw, 10);
296
+ if (!Number.isFinite(id))
297
+ return warn(`Invalid id "${idRaw}".`);
298
+ const row = getMessageById(id);
299
+ if (!row)
300
+ return warn(`No message #${id}.`);
301
+ if (!row.raw)
302
+ return warn(`#${id} isn't a downloadable media message.`);
303
+ const conn = await ensureConnected(state);
304
+ if (!conn)
305
+ return;
306
+ printAbove(state, c.dim(`Downloading #${id}…`));
307
+ try {
308
+ const file = await downloadWithSocket(conn.sock, id);
309
+ if (!file)
310
+ return warn(`Couldn't download #${id}.`);
311
+ openFile(file);
312
+ printAbove(state, `${c.green('✓')} Opened ${c.dim(file)}`);
313
+ }
314
+ catch (err) {
315
+ warn(`Download failed: ${err.message}`);
316
+ }
317
+ }
318
+ /** `/dl` with no id: pick from recent, not-yet-downloaded media (this chat if
319
+ * open, else all), newest first — arrow-select, no id to type. */
320
+ async function pickMediaToDownload(state) {
321
+ const rows = listMedia(null, 100, state.active?.jid, true); // undownloaded only
322
+ if (!rows.length) {
323
+ warn(state.active ? 'No new media in this chat to download.' : 'No new media to download.');
324
+ return;
325
+ }
326
+ const time24h = loadConfig().preferences.time24h;
327
+ const items = rows.map((m) => {
328
+ const label = m.text
329
+ ? truncate(m.text.replace(/\s+/g, ' '), 32)
330
+ : m.mediaPath
331
+ ? basename(m.mediaPath)
332
+ : '';
333
+ return {
334
+ value: `[${m.type}]${label ? ' ' + label : ''}`,
335
+ desc: `${m.fromMe ? 'you' : cleanSender(m.sender)} · ${formatTime(m.timestamp, time24h)}`,
336
+ id: String(m.id),
337
+ };
338
+ });
339
+ const title = state.active
340
+ ? `Media with ${displayName(state.active)} — ⏎ download & open`
341
+ : 'All media — ⏎ download & open';
342
+ const picked = await pickFromList(title, items, '');
343
+ if (picked)
344
+ await downloadAndOpen(state, picked);
345
+ }
269
346
  // ---- chat context ----------------------------------------------------------
270
347
  function openChat(state, target) {
271
348
  // Resolve against chats, contacts, or a raw number/jid.
@@ -342,6 +419,7 @@ const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)
342
419
  ${c.cyan('/sendfile')} <chat> <path> [caption] send media
343
420
  ${c.cyan('/reply')} <id> <msg> reply to a message by #id
344
421
  ${c.cyan('/media')} [type] list media
422
+ ${c.cyan('/dl')} <id> download a media message and open it
345
423
  ${c.cyan('/pin')} · ${c.cyan('/unpin')} · ${c.cyan('/archive')} · ${c.cyan('/unarchive')} <chat>
346
424
  ${c.cyan('/connect')} · ${c.cyan('/disconnect')} go live (receive) / drop connection
347
425
  ${c.cyan('/status')} · ${c.cyan('/settings')} · ${c.cyan('/set')} <k> <v> status / settings
@@ -435,7 +513,16 @@ async function handle(state, raw) {
435
513
  search(args.join(' '), {});
436
514
  return;
437
515
  case 'media':
438
- media(args[0], {});
516
+ // Inside a chat → only that chat's media; outside → everything.
517
+ media(args[0], state.active ? { chat: { jid: state.active.jid, name: displayName(state.active) } } : {});
518
+ return;
519
+ case 'download':
520
+ case 'dl':
521
+ case 'open-media':
522
+ if (args[0])
523
+ await downloadAndOpen(state, args[0]);
524
+ else
525
+ await pickMediaToDownload(state); // no id → pick from recent media
439
526
  return;
440
527
  case 'status':
441
528
  status();
package/dist/cli/index.js CHANGED
@@ -126,6 +126,7 @@ program
126
126
  .command('download <id>')
127
127
  .description('Download & decrypt a cached media message by #id')
128
128
  .option('-o, --out <file>', 'output file')
129
+ .option('--open', 'open the file after downloading')
129
130
  .action(run(download));
130
131
  // ---- Organize (local) ------------------------------------------------------
131
132
  program
package/dist/db/sqlite.js CHANGED
@@ -249,20 +249,21 @@ export function importLidMappings() {
249
249
  return n;
250
250
  }
251
251
  /** Media messages, optionally filtered by type (image/video/audio/document/sticker). */
252
- export function listMedia(types, limit = 50) {
253
- const dbc = getDb();
254
- if (types && types.length) {
255
- const placeholders = types.map(() => '?').join(',');
256
- return dbc
257
- .prepare(`SELECT * FROM messages WHERE type IN (${placeholders})
258
- ORDER BY timestamp DESC LIMIT ?`)
259
- .all(...types, limit);
252
+ export function listMedia(types, limit = 50, chatJid, undownloadedOnly = false) {
253
+ const kinds = types && types.length ? types : ['image', 'video', 'audio', 'document', 'sticker'];
254
+ const placeholders = kinds.map(() => '?').join(',');
255
+ const params = [...kinds];
256
+ let where = `type IN (${placeholders})`;
257
+ if (chatJid) {
258
+ where += ' AND chatJid = ?';
259
+ params.push(chatJid);
260
260
  }
261
- return dbc
262
- .prepare(`SELECT * FROM messages
263
- WHERE type IN ('image','video','audio','document','sticker')
264
- ORDER BY timestamp DESC LIMIT ?`)
265
- .all(limit);
261
+ if (undownloadedOnly)
262
+ where += " AND (mediaPath IS NULL OR mediaPath = '')";
263
+ params.push(limit);
264
+ return getDb()
265
+ .prepare(`SELECT * FROM messages WHERE ${where} ORDER BY timestamp DESC LIMIT ?`)
266
+ .all(...params);
266
267
  }
267
268
  /** Recent chats by activity, ignoring pin priority. */
268
269
  export function recentChats(limit = 15) {
@@ -286,6 +287,13 @@ export function findContact(query) {
286
287
  .get(likePattern(query), query);
287
288
  return row ?? null;
288
289
  }
290
+ /** Local numeric id for a WhatsApp message (by chat + WA message id). */
291
+ export function localIdForWaMessage(chatJid, waId) {
292
+ const row = getDb()
293
+ .prepare('SELECT id FROM messages WHERE chatJid = ? AND messageId = ?')
294
+ .get(chatJid, waId);
295
+ return row?.id ?? null;
296
+ }
289
297
  export function getMessageById(id) {
290
298
  const row = getDb()
291
299
  .prepare('SELECT * FROM messages WHERE id = ?')
@@ -79,13 +79,16 @@ export function renderChatList(chats, time24h = true) {
79
79
  export function renderMessages(messages, time24h = true) {
80
80
  if (messages.length === 0)
81
81
  return c.dim('No messages.');
82
+ const MEDIA = ['image', 'video', 'audio', 'document', 'sticker'];
82
83
  return messages
83
84
  .map((m) => {
84
85
  const time = c.gray(formatTime(m.timestamp, time24h));
85
86
  const who = m.fromMe ? c.green('me') : c.cyan(cleanSender(m.sender));
87
+ const isMedia = m.type ? MEDIA.includes(m.type) : false;
86
88
  const body = m.text ?? (m.type ? c.dim(`[${m.type}]`) : c.dim('[no text]'));
87
- const id = c.dim(`#${m.id}`);
88
- return `${time} ${who}: ${body} ${id}`;
89
+ // Media → an actionable "/dl <id>"; text → a dim #id (for /reply).
90
+ const tail = isMedia && !m.fromMe ? c.green(`/dl ${m.id}`) : c.dim(`#${m.id}`);
91
+ return `${time} ${who}: ${body} ${tail}`;
89
92
  })
90
93
  .join('\n');
91
94
  }
@@ -1,23 +1,73 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, execFileSync } from 'node:child_process';
2
2
  import { platform } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { existsSync } from 'node:fs';
5
+ // The branded "wa" icon, shipped in assets/. Two levels up works from both
6
+ // dist/utils (published) and src/utils (dev).
7
+ const ICON = (() => {
8
+ try {
9
+ const p = fileURLToPath(new URL('../../assets/wa-icon.png', import.meta.url));
10
+ return existsSync(p) ? p : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ })();
16
+ let cached = null;
17
+ /** Pick the best available notifier once. terminal-notifier lets us show the
18
+ * branded icon on macOS; osascript is the zero-dependency fallback. */
19
+ function detect() {
20
+ if (cached)
21
+ return cached;
22
+ const p = platform();
23
+ if (p === 'darwin') {
24
+ try {
25
+ execFileSync('which', ['terminal-notifier'], { stdio: 'ignore' });
26
+ cached = 'terminal-notifier';
27
+ }
28
+ catch {
29
+ cached = 'osascript';
30
+ }
31
+ }
32
+ else if (p === 'linux') {
33
+ cached = 'notify-send';
34
+ }
35
+ else {
36
+ cached = 'none';
37
+ }
38
+ return cached;
39
+ }
3
40
  /**
4
- * Fire a native desktop notification. Best-effort and non-blocking: shells out
5
- * to the platform notifier (macOS `osascript`, Linux `notify-send`). Any
6
- * failure (tool missing, no display) is silently ignored notifications must
7
- * never break the CLI.
41
+ * Fire a native desktop notification. Best-effort and non-blocking. Uses the
42
+ * branded icon where the platform allows it (terminal-notifier on macOS,
43
+ * notify-send on Linux); plain macOS `osascript` can't set a custom icon and
44
+ * shows the terminal's.
8
45
  */
9
46
  export function notify(title, body) {
10
47
  try {
11
- const p = platform();
12
- if (p === 'darwin') {
13
- const esc = (s) => s.replace(/[\\"]/g, '\\$&');
14
- const script = `display notification "${esc(clip(body))}" with title "${esc(clip(title))}"`;
15
- execFile('osascript', ['-e', script], () => { });
16
- }
17
- else if (p === 'linux') {
18
- execFile('notify-send', ['-a', 'WhatsApp CLI', clip(title), clip(body)], () => { });
48
+ const t = clip(title);
49
+ const b = clip(body);
50
+ switch (detect()) {
51
+ case 'terminal-notifier': {
52
+ const args = ['-title', t, '-message', b, '-group', 'cli-whatsapp'];
53
+ if (ICON)
54
+ args.push('-appIcon', ICON, '-contentImage', ICON);
55
+ execFile('terminal-notifier', args, () => { });
56
+ break;
57
+ }
58
+ case 'osascript': {
59
+ const esc = (s) => s.replace(/[\\"]/g, '\\$&');
60
+ execFile('osascript', ['-e', `display notification "${esc(b)}" with title "${esc(t)}"`], () => { });
61
+ break;
62
+ }
63
+ case 'notify-send': {
64
+ const args = ['-a', 'WhatsApp CLI'];
65
+ if (ICON)
66
+ args.push('-i', ICON);
67
+ execFile('notify-send', [...args, t, b], () => { });
68
+ break;
69
+ }
19
70
  }
20
- // Windows: no reliable dependency-free path; skipped intentionally.
21
71
  }
22
72
  catch {
23
73
  /* notifications are best-effort */
@@ -0,0 +1,17 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { platform } from 'node:os';
3
+ /** Open a file with the OS default application (best-effort, non-blocking). */
4
+ export function openFile(path) {
5
+ try {
6
+ const p = platform();
7
+ if (p === 'darwin')
8
+ execFile('open', [path], () => { });
9
+ else if (p === 'win32')
10
+ execFile('cmd', ['/c', 'start', '', path], () => { });
11
+ else
12
+ execFile('xdg-open', [path], () => { });
13
+ }
14
+ catch {
15
+ /* best-effort */
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-whatsapp",
3
- "version": "0.1.5",
3
+ "version": "0.1.12",
4
4
  "description": "A lightweight, fast, terminal-first WhatsApp client. Read, search, send, media, groups, and live notifications — from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "dist/",
12
+ "assets/",
12
13
  "README.md",
13
14
  "COMMANDS.md",
14
15
  "LICENSE"