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.
- package/assets/wa-icon.png +0 -0
- package/dist/cli/commands/filepicker.js +1 -1
- package/dist/cli/commands/media.js +42 -21
- package/dist/cli/commands/shell.js +96 -9
- package/dist/cli/index.js +1 -0
- package/dist/db/sqlite.js +21 -13
- package/dist/utils/format.js +5 -2
- package/dist/utils/notify.js +64 -14
- package/dist/utils/open.js +17 -0
- package/package.json +2 -1
|
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(
|
|
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
|
-
/**
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
.
|
|
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 = ?')
|
package/dist/utils/format.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
}
|
package/dist/utils/notify.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
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"
|