cli-whatsapp 0.1.5 → 0.1.8

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
@@ -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 ||
@@ -4,13 +4,14 @@ import { homedir } from 'node:os';
4
4
  import { join } 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, 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
17
  import { c, displayName, chatNumber, formatTime, truncate, renderMessages } from "../../utils/format.js";
@@ -187,7 +188,16 @@ function onIncoming(state, msg) {
187
188
  const jid = msg.key.remoteJid;
188
189
  if (!jid || jid === 'status@broadcast')
189
190
  return;
190
- const text = extractText(msg.message) || c.dim('[media]');
191
+ let text = extractText(msg.message);
192
+ if (!text) {
193
+ // Media: show its kind and a one-liner to download & open it.
194
+ const kind = messageType(msg.message);
195
+ const localId = msg.key.id ? localIdForWaMessage(jid, msg.key.id) : null;
196
+ text =
197
+ localId != null
198
+ ? c.dim(`[${kind}] `) + c.green(`/dl ${localId}`) + c.dim(' to open')
199
+ : c.dim(`[${kind}]`);
200
+ }
191
201
  const chat = findChat(jid);
192
202
  const who = isGroupJid(jid) && msg.pushName
193
203
  ? `${chat ? displayName(chat) : jid.split('@')[0]}/${msg.pushName}`
@@ -266,6 +276,31 @@ async function sendTo(state, jid, name, content, meta, replaceInput = false) {
266
276
  warn(`Send failed: ${err.message}`);
267
277
  }
268
278
  }
279
+ /** Download a media message by #id over the live connection, then open it. */
280
+ async function downloadAndOpen(state, idRaw) {
281
+ const id = parseInt(idRaw, 10);
282
+ if (!Number.isFinite(id))
283
+ return warn(`Invalid id "${idRaw}".`);
284
+ const row = getMessageById(id);
285
+ if (!row)
286
+ return warn(`No message #${id}.`);
287
+ if (!row.raw)
288
+ return warn(`#${id} isn't a downloadable media message.`);
289
+ const conn = await ensureConnected(state);
290
+ if (!conn)
291
+ return;
292
+ printAbove(state, c.dim(`Downloading #${id}…`));
293
+ try {
294
+ const file = await downloadWithSocket(conn.sock, id);
295
+ if (!file)
296
+ return warn(`Couldn't download #${id}.`);
297
+ openFile(file);
298
+ printAbove(state, `${c.green('✓')} Opened ${c.dim(file)}`);
299
+ }
300
+ catch (err) {
301
+ warn(`Download failed: ${err.message}`);
302
+ }
303
+ }
269
304
  // ---- chat context ----------------------------------------------------------
270
305
  function openChat(state, target) {
271
306
  // Resolve against chats, contacts, or a raw number/jid.
@@ -342,6 +377,7 @@ const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)
342
377
  ${c.cyan('/sendfile')} <chat> <path> [caption] send media
343
378
  ${c.cyan('/reply')} <id> <msg> reply to a message by #id
344
379
  ${c.cyan('/media')} [type] list media
380
+ ${c.cyan('/dl')} <id> download a media message and open it
345
381
  ${c.cyan('/pin')} · ${c.cyan('/unpin')} · ${c.cyan('/archive')} · ${c.cyan('/unarchive')} <chat>
346
382
  ${c.cyan('/connect')} · ${c.cyan('/disconnect')} go live (receive) / drop connection
347
383
  ${c.cyan('/status')} · ${c.cyan('/settings')} · ${c.cyan('/set')} <k> <v> status / settings
@@ -435,7 +471,14 @@ async function handle(state, raw) {
435
471
  search(args.join(' '), {});
436
472
  return;
437
473
  case 'media':
438
- media(args[0], {});
474
+ // Inside a chat → only that chat's media; outside → everything.
475
+ media(args[0], state.active ? { chat: { jid: state.active.jid, name: displayName(state.active) } } : {});
476
+ return;
477
+ case 'download':
478
+ case 'dl':
479
+ case 'open-media':
480
+ if (need(args[0], 'id'))
481
+ await downloadAndOpen(state, args[0]);
439
482
  return;
440
483
  case 'status':
441
484
  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,19 @@ 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) {
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
+ params.push(limit);
262
+ return getDb()
263
+ .prepare(`SELECT * FROM messages WHERE ${where} ORDER BY timestamp DESC LIMIT ?`)
264
+ .all(...params);
266
265
  }
267
266
  /** Recent chats by activity, ignoring pin priority. */
268
267
  export function recentChats(limit = 15) {
@@ -286,6 +285,13 @@ export function findContact(query) {
286
285
  .get(likePattern(query), query);
287
286
  return row ?? null;
288
287
  }
288
+ /** Local numeric id for a WhatsApp message (by chat + WA message id). */
289
+ export function localIdForWaMessage(chatJid, waId) {
290
+ const row = getDb()
291
+ .prepare('SELECT id FROM messages WHERE chatJid = ? AND messageId = ?')
292
+ .get(chatJid, waId);
293
+ return row?.id ?? null;
294
+ }
289
295
  export function getMessageById(id) {
290
296
  const row = getDb()
291
297
  .prepare('SELECT * FROM messages WHERE id = ?')
@@ -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.8",
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"