cli-whatsapp 0.1.4 → 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";
@@ -162,6 +163,19 @@ function printAbove(state, text) {
162
163
  process.stdout.write(text + '\n');
163
164
  state.rl.prompt(true);
164
165
  }
166
+ /**
167
+ * Replace the line the user just submitted (their echoed input) with `text`, so
168
+ * an outgoing message shows once as a bubble instead of appearing twice. Called
169
+ * synchronously while processing the line, when the cursor sits just below the
170
+ * submitted input.
171
+ */
172
+ function replaceSubmitted(state, text) {
173
+ readline.moveCursor(process.stdout, 0, -1); // up onto the submitted-input line
174
+ readline.cursorTo(process.stdout, 0);
175
+ readline.clearLine(process.stdout, 0);
176
+ process.stdout.write(text + '\n');
177
+ state.rl.prompt(true);
178
+ }
165
179
  function bubble(who, text, incoming) {
166
180
  const time = c.gray(formatTime(Date.now(), loadConfig().preferences.time24h));
167
181
  const name = incoming ? c.cyan(who) : c.green('me');
@@ -174,7 +188,16 @@ function onIncoming(state, msg) {
174
188
  const jid = msg.key.remoteJid;
175
189
  if (!jid || jid === 'status@broadcast')
176
190
  return;
177
- 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
+ }
178
201
  const chat = findChat(jid);
179
202
  const who = isGroupJid(jid) && msg.pushName
180
203
  ? `${chat ? displayName(chat) : jid.split('@')[0]}/${msg.pushName}`
@@ -233,20 +256,51 @@ async function ensureConnected(state) {
233
256
  }
234
257
  }
235
258
  // ---- sending ---------------------------------------------------------------
236
- async function sendTo(state, jid, name, content, meta) {
259
+ async function sendTo(state, jid, name, content, meta, replaceInput = false) {
260
+ const wasLive = state.status === 'live';
237
261
  const conn = await ensureConnected(state);
238
262
  if (!conn)
239
263
  return;
240
264
  try {
241
265
  await sendContent(conn.sock, jid, content, meta);
242
266
  const echo = meta.type === 'text' ? meta.previewText : `[${meta.type}] ${meta.previewText}`;
243
- printAbove(state, bubble(name, echo, false));
267
+ // Replace the just-typed input with the bubble so it shows once — but only
268
+ // when we were already live (no "connecting…" lines were printed in between).
269
+ if (replaceInput && wasLive)
270
+ replaceSubmitted(state, bubble(name, echo, false));
271
+ else
272
+ printAbove(state, bubble(name, echo, false));
244
273
  refreshNames();
245
274
  }
246
275
  catch (err) {
247
276
  warn(`Send failed: ${err.message}`);
248
277
  }
249
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
+ }
250
304
  // ---- chat context ----------------------------------------------------------
251
305
  function openChat(state, target) {
252
306
  // Resolve against chats, contacts, or a raw number/jid.
@@ -323,6 +377,7 @@ const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)
323
377
  ${c.cyan('/sendfile')} <chat> <path> [caption] send media
324
378
  ${c.cyan('/reply')} <id> <msg> reply to a message by #id
325
379
  ${c.cyan('/media')} [type] list media
380
+ ${c.cyan('/dl')} <id> download a media message and open it
326
381
  ${c.cyan('/pin')} · ${c.cyan('/unpin')} · ${c.cyan('/archive')} · ${c.cyan('/unarchive')} <chat>
327
382
  ${c.cyan('/connect')} · ${c.cyan('/disconnect')} go live (receive) / drop connection
328
383
  ${c.cyan('/status')} · ${c.cyan('/settings')} · ${c.cyan('/set')} <k> <v> status / settings
@@ -349,11 +404,7 @@ async function handle(state, raw) {
349
404
  if (at) {
350
405
  try {
351
406
  const built = buildMediaContent(at.file, at.caption || undefined);
352
- await sendTo(state, jid, name, built.content, {
353
- previewText: at.caption || `[${built.kind}]`,
354
- type: built.kind,
355
- mediaPath: built.path,
356
- });
407
+ await sendTo(state, jid, name, built.content, { previewText: at.caption || `[${built.kind}]`, type: built.kind, mediaPath: built.path }, true);
357
408
  }
358
409
  catch (err) {
359
410
  printError(err);
@@ -361,7 +412,7 @@ async function handle(state, raw) {
361
412
  return;
362
413
  }
363
414
  // Otherwise plain text.
364
- await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' });
415
+ await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' }, true);
365
416
  return;
366
417
  }
367
418
  const [cmdRaw, ...args] = tokenize(line);
@@ -420,7 +471,14 @@ async function handle(state, raw) {
420
471
  search(args.join(' '), {});
421
472
  return;
422
473
  case 'media':
423
- 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]);
424
482
  return;
425
483
  case 'status':
426
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.4",
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"