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.
- package/assets/wa-icon.png +0 -0
- package/dist/cli/commands/media.js +42 -21
- package/dist/cli/commands/shell.js +48 -5
- package/dist/cli/index.js +1 -0
- package/dist/db/sqlite.js +19 -13
- package/dist/utils/notify.js +64 -14
- package/dist/utils/open.js +17 -0
- package/package.json +2 -1
|
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(
|
|
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 ||
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 = ?')
|
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.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"
|