cli-whatsapp 0.1.0 → 0.1.2
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/bin/wa.js +4 -3
- package/dist/cli/commands/auth.js +220 -0
- package/dist/cli/commands/export.js +51 -0
- package/dist/cli/commands/filepicker.js +232 -0
- package/dist/cli/commands/group.js +139 -0
- package/dist/cli/commands/manage.js +41 -0
- package/dist/cli/commands/media.js +105 -0
- package/dist/cli/commands/misc.js +112 -0
- package/dist/cli/commands/read.js +79 -0
- package/dist/cli/commands/send.js +137 -0
- package/dist/cli/commands/shell.js +611 -0
- package/dist/cli/commands/sync.js +67 -0
- package/dist/cli/index.js +174 -0
- package/dist/config/index.js +59 -0
- package/{src/db/schema.ts → dist/db/schema.js} +0 -1
- package/dist/db/sqlite.js +237 -0
- package/dist/utils/format.js +89 -0
- package/dist/utils/jid.js +40 -0
- package/dist/utils/notify.js +29 -0
- package/dist/utils/ui.js +29 -0
- package/dist/whatsapp/client.js +157 -0
- package/dist/whatsapp/events.js +161 -0
- package/dist/whatsapp/logger.js +52 -0
- package/package.json +6 -4
- package/src/cli/commands/auth.ts +0 -236
- package/src/cli/commands/export.ts +0 -60
- package/src/cli/commands/filepicker.ts +0 -247
- package/src/cli/commands/group.ts +0 -139
- package/src/cli/commands/manage.ts +0 -46
- package/src/cli/commands/media.ts +0 -125
- package/src/cli/commands/misc.ts +0 -113
- package/src/cli/commands/read.ts +0 -99
- package/src/cli/commands/send.ts +0 -201
- package/src/cli/commands/shell.ts +0 -596
- package/src/cli/commands/sync.ts +0 -80
- package/src/cli/index.ts +0 -198
- package/src/config/index.ts +0 -91
- package/src/db/sqlite.ts +0 -354
- package/src/utils/format.ts +0 -102
- package/src/utils/jid.ts +0 -51
- package/src/utils/notify.ts +0 -29
- package/src/utils/ui.ts +0 -35
- package/src/whatsapp/client.ts +0 -207
- package/src/whatsapp/events.ts +0 -166
- package/src/whatsapp/logger.ts +0 -71
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { findChat, setPinned, setArchived, recentChats } from "../../db/sqlite.js";
|
|
2
|
+
import { loadConfig } from "../../config/index.js";
|
|
3
|
+
import { renderChatList, displayName } from "../../utils/format.js";
|
|
4
|
+
import { print, ok, fail } from "../../utils/ui.js";
|
|
5
|
+
/** wa recent [--limit N] — most recently active chats (ignores pins). */
|
|
6
|
+
export function recent(opts) {
|
|
7
|
+
const limit = Math.min(parseInt(opts.limit ?? '15', 10) || 15, 200);
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
print(renderChatList(recentChats(limit), cfg.preferences.time24h));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Pin/archive toggles update the local cache so listings reflect your
|
|
13
|
+
* preference immediately. (They are local-only; they do not change the flag
|
|
14
|
+
* on WhatsApp itself.)
|
|
15
|
+
*/
|
|
16
|
+
export function pin(target) {
|
|
17
|
+
const chat = requireChat(target);
|
|
18
|
+
setPinned(chat.jid, true);
|
|
19
|
+
ok(`Pinned ${displayName(chat)}.`);
|
|
20
|
+
}
|
|
21
|
+
export function unpin(target) {
|
|
22
|
+
const chat = requireChat(target);
|
|
23
|
+
setPinned(chat.jid, false);
|
|
24
|
+
ok(`Unpinned ${displayName(chat)}.`);
|
|
25
|
+
}
|
|
26
|
+
export function archive(target) {
|
|
27
|
+
const chat = requireChat(target);
|
|
28
|
+
setArchived(chat.jid, true);
|
|
29
|
+
ok(`Archived ${displayName(chat)}. (Hidden from \`wa chats\`; use \`-a\` to show.)`);
|
|
30
|
+
}
|
|
31
|
+
export function unarchive(target) {
|
|
32
|
+
const chat = requireChat(target);
|
|
33
|
+
setArchived(chat.jid, false);
|
|
34
|
+
ok(`Unarchived ${displayName(chat)}.`);
|
|
35
|
+
}
|
|
36
|
+
function requireChat(target) {
|
|
37
|
+
const chat = findChat(target);
|
|
38
|
+
if (!chat)
|
|
39
|
+
fail(`No chat matching "${target}". Try \`wa chats\`.`);
|
|
40
|
+
return chat;
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { proto, downloadMediaMessage } from '@whiskeysockets/baileys';
|
|
4
|
+
import { runWithConnection } from "../../whatsapp/client.js";
|
|
5
|
+
import { logger } from "../../whatsapp/logger.js";
|
|
6
|
+
import { listMedia, getMessageById, setMediaPath, findChat, } from "../../db/sqlite.js";
|
|
7
|
+
import { PATHS, ensureDirs, loadConfig } from "../../config/index.js";
|
|
8
|
+
import { c, formatTime, displayName, truncate } from "../../utils/format.js";
|
|
9
|
+
import { print, ok, fail, info } from "../../utils/ui.js";
|
|
10
|
+
const TYPE_ALIASES = {
|
|
11
|
+
photos: ['image'],
|
|
12
|
+
photo: ['image'],
|
|
13
|
+
images: ['image'],
|
|
14
|
+
image: ['image'],
|
|
15
|
+
videos: ['video'],
|
|
16
|
+
video: ['video'],
|
|
17
|
+
audio: ['audio'],
|
|
18
|
+
voice: ['audio'],
|
|
19
|
+
docs: ['document'],
|
|
20
|
+
documents: ['document'],
|
|
21
|
+
document: ['document'],
|
|
22
|
+
stickers: ['sticker'],
|
|
23
|
+
all: [],
|
|
24
|
+
};
|
|
25
|
+
/** wa media [type] [--limit N] — list media messages from the cache. */
|
|
26
|
+
export function media(type, opts) {
|
|
27
|
+
const limit = Math.min(parseInt(opts.limit ?? '30', 10) || 30, 1000);
|
|
28
|
+
let types = null;
|
|
29
|
+
if (type) {
|
|
30
|
+
const mapped = TYPE_ALIASES[type.toLowerCase()];
|
|
31
|
+
if (mapped === undefined) {
|
|
32
|
+
fail(`Unknown media type "${type}". Try: photos, videos, audio, documents, stickers, all.`);
|
|
33
|
+
}
|
|
34
|
+
types = mapped.length ? mapped : null;
|
|
35
|
+
}
|
|
36
|
+
const rows = listMedia(types, limit);
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
print(c.dim('No media cached. Run `wa sync` after logging in.'));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const cfg = loadConfig();
|
|
42
|
+
const width = String(rows[rows.length - 1].id).length;
|
|
43
|
+
for (const m of rows) {
|
|
44
|
+
const chat = findChat(m.chatJid);
|
|
45
|
+
const where = c.magenta(chat ? displayName(chat) : m.chatJid.split('@')[0]);
|
|
46
|
+
const time = c.gray(formatTime(m.timestamp, cfg.preferences.time24h));
|
|
47
|
+
const kind = c.cyan(`[${m.type}]`);
|
|
48
|
+
const caption = m.text ? ' ' + c.dim(truncate(m.text, 40)) : '';
|
|
49
|
+
const saved = m.mediaPath ? c.green(' ✓saved') : '';
|
|
50
|
+
print(`${c.dim('#' + String(m.id).padStart(width))} ${kind} ${where} ${time}${caption}${saved}`);
|
|
51
|
+
}
|
|
52
|
+
print();
|
|
53
|
+
info('Download one with `wa download <id>`.');
|
|
54
|
+
}
|
|
55
|
+
/** wa download <id> [--out FILE] — fetch & decrypt a cached media message. */
|
|
56
|
+
export async function download(idRaw, opts) {
|
|
57
|
+
const id = parseInt(idRaw, 10);
|
|
58
|
+
if (!Number.isFinite(id))
|
|
59
|
+
fail(`Invalid id "${idRaw}".`);
|
|
60
|
+
const row = getMessageById(id);
|
|
61
|
+
if (!row)
|
|
62
|
+
fail(`No cached message with id #${id}.`);
|
|
63
|
+
if (!row.raw)
|
|
64
|
+
fail(`Message #${id} has no downloadable media (text message?).`);
|
|
65
|
+
ensureDirs();
|
|
66
|
+
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)`);
|
|
82
|
+
}
|
|
83
|
+
function extensionFor(content, type) {
|
|
84
|
+
const mime = content.imageMessage?.mimetype ||
|
|
85
|
+
content.videoMessage?.mimetype ||
|
|
86
|
+
content.audioMessage?.mimetype ||
|
|
87
|
+
content.documentMessage?.mimetype ||
|
|
88
|
+
content.stickerMessage?.mimetype ||
|
|
89
|
+
'';
|
|
90
|
+
const fromMime = mime.split(';')[0].split('/')[1];
|
|
91
|
+
if (fromMime)
|
|
92
|
+
return '.' + fromMime.replace('jpeg', 'jpg');
|
|
93
|
+
switch (type) {
|
|
94
|
+
case 'image':
|
|
95
|
+
return '.jpg';
|
|
96
|
+
case 'video':
|
|
97
|
+
return '.mp4';
|
|
98
|
+
case 'audio':
|
|
99
|
+
return '.ogg';
|
|
100
|
+
case 'sticker':
|
|
101
|
+
return '.webp';
|
|
102
|
+
default:
|
|
103
|
+
return '.bin';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, accessSync, constants, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig, saveConfig, PATHS, ensureDirs } from "../../config/index.js";
|
|
4
|
+
import { hasSession } from "../../whatsapp/client.js";
|
|
5
|
+
import { countMessages, listChats, getDb } from "../../db/sqlite.js";
|
|
6
|
+
import { print, ok, warn, info, fail } from "../../utils/ui.js";
|
|
7
|
+
import { c } from "../../utils/format.js";
|
|
8
|
+
function pkgVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const p = join(new URL('../../../', import.meta.url).pathname, 'package.json');
|
|
11
|
+
return JSON.parse(readFileSync(p, 'utf8')).version ?? '0.0.0';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function version() {
|
|
18
|
+
print(`wa ${pkgVersion()}`);
|
|
19
|
+
}
|
|
20
|
+
/** wa config [set <key> <value>] — view or edit preferences. */
|
|
21
|
+
export function config(action, key, value) {
|
|
22
|
+
const cfg = loadConfig();
|
|
23
|
+
if (!action) {
|
|
24
|
+
print(c.bold('Config') + c.dim(` (${PATHS.config})`));
|
|
25
|
+
print(JSON.stringify(cfg, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (action !== 'set' || !key || value === undefined) {
|
|
29
|
+
fail('Usage: wa config set <openLimit|time24h|notifications|autoConnect|ai.enabled> <value>');
|
|
30
|
+
}
|
|
31
|
+
switch (key) {
|
|
32
|
+
case 'openLimit':
|
|
33
|
+
cfg.preferences.openLimit = Math.max(1, parseInt(value, 10) || cfg.preferences.openLimit);
|
|
34
|
+
break;
|
|
35
|
+
case 'time24h':
|
|
36
|
+
cfg.preferences.time24h = value === 'true' || value === '1';
|
|
37
|
+
break;
|
|
38
|
+
case 'notifications':
|
|
39
|
+
cfg.preferences.notifications = value === 'true' || value === '1';
|
|
40
|
+
break;
|
|
41
|
+
case 'autoConnect':
|
|
42
|
+
cfg.preferences.autoConnect = value === 'true' || value === '1';
|
|
43
|
+
break;
|
|
44
|
+
case 'ai.enabled':
|
|
45
|
+
cfg.ai.enabled = value === 'true' || value === '1';
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
fail(`Unknown key "${key}". Editable: openLimit, time24h, notifications, autoConnect, ai.enabled`);
|
|
49
|
+
}
|
|
50
|
+
saveConfig(cfg);
|
|
51
|
+
ok(`Set ${key} = ${value}`);
|
|
52
|
+
}
|
|
53
|
+
/** wa doctor — self-diagnostics for a healthy install. */
|
|
54
|
+
export function doctor() {
|
|
55
|
+
print(c.bold('WhatsApp CLI — doctor'));
|
|
56
|
+
let problems = 0;
|
|
57
|
+
const nodeOk = satisfiesNode(process.versions.node, 22, 5);
|
|
58
|
+
line(nodeOk, `Node.js ${process.versions.node}`, 'Needs Node >= 22.5 (built-in node:sqlite + TS).');
|
|
59
|
+
if (!nodeOk)
|
|
60
|
+
problems++;
|
|
61
|
+
ensureDirs();
|
|
62
|
+
for (const [label, dir] of [
|
|
63
|
+
['sessions dir', PATHS.sessions],
|
|
64
|
+
['data dir', PATHS.data],
|
|
65
|
+
['media dir', PATHS.media],
|
|
66
|
+
]) {
|
|
67
|
+
const writable = isWritable(dir);
|
|
68
|
+
line(writable, `${label} writable ${c.dim(dir)}`);
|
|
69
|
+
if (!writable)
|
|
70
|
+
problems++;
|
|
71
|
+
}
|
|
72
|
+
let dbOk = true;
|
|
73
|
+
try {
|
|
74
|
+
getDb();
|
|
75
|
+
print(` ${c.green('✓')} database ok ${c.dim(`${countMessages()} messages, ${listChats(5000).length} chats`)}`);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
dbOk = false;
|
|
79
|
+
problems++;
|
|
80
|
+
print(` ${c.red('✗')} database error: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
if (hasSession())
|
|
83
|
+
line(true, 'session linked');
|
|
84
|
+
else
|
|
85
|
+
print(` ${c.dim('○')} no session ${c.dim('(run `wa login` to link)')}`);
|
|
86
|
+
print();
|
|
87
|
+
if (problems === 0)
|
|
88
|
+
ok('All systems go.');
|
|
89
|
+
else
|
|
90
|
+
warn(`${problems} issue${problems === 1 ? '' : 's'} found.`);
|
|
91
|
+
void dbOk;
|
|
92
|
+
}
|
|
93
|
+
function line(good, msg, hint) {
|
|
94
|
+
print(` ${good ? c.green('✓') : c.red('✗')} ${msg}`);
|
|
95
|
+
if (!good && hint)
|
|
96
|
+
print(` ${c.dim(hint)}`);
|
|
97
|
+
}
|
|
98
|
+
function satisfiesNode(v, major, minor) {
|
|
99
|
+
const [maj, min] = v.split('.').map((x) => parseInt(x, 10));
|
|
100
|
+
return maj > major || (maj === major && min >= minor);
|
|
101
|
+
}
|
|
102
|
+
function isWritable(dir) {
|
|
103
|
+
try {
|
|
104
|
+
if (!existsSync(dir))
|
|
105
|
+
return false;
|
|
106
|
+
accessSync(dir, constants.W_OK);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { listChats, listContacts, getMessages, searchMessages, findChat, } from "../../db/sqlite.js";
|
|
2
|
+
import { loadConfig } from "../../config/index.js";
|
|
3
|
+
import { renderChatList, renderMessages, displayName, formatTime, truncate, c, } from "../../utils/format.js";
|
|
4
|
+
import { print, info, fail } from "../../utils/ui.js";
|
|
5
|
+
/** wa chats [--all] [--limit N] */
|
|
6
|
+
export function chats(opts) {
|
|
7
|
+
const limit = clampLimit(opts.limit, 50);
|
|
8
|
+
const rows = listChats(limit, !!opts.all);
|
|
9
|
+
const cfg = loadConfig();
|
|
10
|
+
print(renderChatList(rows, cfg.preferences.time24h));
|
|
11
|
+
}
|
|
12
|
+
/** wa contacts [--limit N] */
|
|
13
|
+
export function contacts(opts) {
|
|
14
|
+
const limit = clampLimit(opts.limit, 500);
|
|
15
|
+
const rows = listContacts(limit);
|
|
16
|
+
if (rows.length === 0) {
|
|
17
|
+
print(c.dim('No contacts cached yet. Run `wa sync`.'));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const width = String(rows.length).length;
|
|
21
|
+
print(rows
|
|
22
|
+
.map((r, i) => {
|
|
23
|
+
const idx = c.dim(String(i + 1).padStart(width) + '.');
|
|
24
|
+
const num = c.gray(r.jid.split('@')[0]);
|
|
25
|
+
return `${idx} ${c.bold(r.name ?? '(unknown)')} ${num}`;
|
|
26
|
+
})
|
|
27
|
+
.join('\n'));
|
|
28
|
+
}
|
|
29
|
+
/** wa open <chat> [--limit N] */
|
|
30
|
+
export function open(target, opts) {
|
|
31
|
+
const chat = findChat(target);
|
|
32
|
+
if (!chat) {
|
|
33
|
+
fail(`No chat matching "${target}". Try \`wa chats\` or \`wa sync\`.`);
|
|
34
|
+
}
|
|
35
|
+
const cfg = loadConfig();
|
|
36
|
+
const limit = clampLimit(opts.limit, cfg.preferences.openLimit);
|
|
37
|
+
print(c.bold(displayName(chat)) + c.dim(` ${chat.jid}`));
|
|
38
|
+
print(c.dim('─'.repeat(40)));
|
|
39
|
+
print(renderMessages(getMessages(chat.jid, limit), cfg.preferences.time24h));
|
|
40
|
+
}
|
|
41
|
+
/** wa history <chat> [--limit N] — alias of open with a larger default. */
|
|
42
|
+
export function history(target, opts) {
|
|
43
|
+
open(target, { limit: opts.limit ?? '200' });
|
|
44
|
+
}
|
|
45
|
+
/** wa search <text> [--limit N] */
|
|
46
|
+
export function search(term, opts) {
|
|
47
|
+
const limit = clampLimit(opts.limit, 50);
|
|
48
|
+
const results = searchMessages(term, limit);
|
|
49
|
+
const cfg = loadConfig();
|
|
50
|
+
if (results.length === 0) {
|
|
51
|
+
print(c.dim(`No messages matching "${term}".`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
info(`${results.length} match${results.length === 1 ? '' : 'es'} for "${term}":`);
|
|
55
|
+
print();
|
|
56
|
+
for (const m of results) {
|
|
57
|
+
const chat = findChat(m.chatJid);
|
|
58
|
+
const where = c.magenta(chat ? displayName(chat) : m.chatJid.split('@')[0]);
|
|
59
|
+
const time = c.gray(formatTime(m.timestamp, cfg.preferences.time24h));
|
|
60
|
+
const who = m.fromMe ? c.green('me') : c.cyan(m.sender ?? '');
|
|
61
|
+
const body = highlight(truncate(m.text ?? '', 80), term);
|
|
62
|
+
print(`${where} ${time} ${who}: ${body} ${c.dim(`#${m.id}`)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function highlight(text, term) {
|
|
66
|
+
try {
|
|
67
|
+
const re = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
|
|
68
|
+
return text.replace(re, (m) => c.yellow(m));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function clampLimit(raw, fallback) {
|
|
75
|
+
const n = raw ? parseInt(raw, 10) : fallback;
|
|
76
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
77
|
+
return fallback;
|
|
78
|
+
return Math.min(n, 2000);
|
|
79
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, resolve } from 'node:path';
|
|
3
|
+
import { runWithConnection } from "../../whatsapp/client.js";
|
|
4
|
+
import { resolveTarget } from "../../utils/jid.js";
|
|
5
|
+
import { getMessageById, insertMessage, upsertChat, findChat } from "../../db/sqlite.js";
|
|
6
|
+
import { toMs } from "../../whatsapp/events.js";
|
|
7
|
+
import { ok, fail, info } from "../../utils/ui.js";
|
|
8
|
+
import { displayName } from "../../utils/format.js";
|
|
9
|
+
/** wa send <chat> [message] [--file <path>] */
|
|
10
|
+
export async function send(target, message, opts = {}) {
|
|
11
|
+
const resolved = resolveTarget(target);
|
|
12
|
+
if (!resolved) {
|
|
13
|
+
fail(`Could not resolve "${target}". Use a known chat name or a phone number.`);
|
|
14
|
+
}
|
|
15
|
+
if (opts.file) {
|
|
16
|
+
const built = buildMediaContent(opts.file, message);
|
|
17
|
+
info(`Sending ${built.kind} to ${resolved.name}…`);
|
|
18
|
+
await deliver(resolved.jid, built.content, resolved.name, {
|
|
19
|
+
previewText: message?.trim() || `[${built.kind}]`,
|
|
20
|
+
type: built.kind,
|
|
21
|
+
mediaPath: built.path,
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!message || !message.trim()) {
|
|
26
|
+
fail('Nothing to send. Provide a message, or --file <path> for media.');
|
|
27
|
+
}
|
|
28
|
+
info(`Sending to ${resolved.name}…`);
|
|
29
|
+
await deliver(resolved.jid, { text: message }, resolved.name, {
|
|
30
|
+
previewText: message,
|
|
31
|
+
type: 'text',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** wa reply <id> <message> — reply into the chat of message #id, quoting it. */
|
|
35
|
+
export async function reply(idRaw, message) {
|
|
36
|
+
const id = parseInt(idRaw, 10);
|
|
37
|
+
if (!Number.isFinite(id))
|
|
38
|
+
fail(`Invalid message id "${idRaw}".`);
|
|
39
|
+
const original = getMessageById(id);
|
|
40
|
+
if (!original)
|
|
41
|
+
fail(`No cached message with id #${id}. Open the chat first.`);
|
|
42
|
+
if (!message || !message.trim())
|
|
43
|
+
fail('Message text is empty.');
|
|
44
|
+
const chat = findChat(original.chatJid);
|
|
45
|
+
const name = chat ? displayName(chat) : original.chatJid;
|
|
46
|
+
info(`Replying in ${name}…`);
|
|
47
|
+
// Reconstruct a minimal quoted message for the reply context.
|
|
48
|
+
const quoted = {
|
|
49
|
+
key: {
|
|
50
|
+
remoteJid: original.chatJid,
|
|
51
|
+
id: original.messageId,
|
|
52
|
+
fromMe: !!original.fromMe,
|
|
53
|
+
},
|
|
54
|
+
message: { conversation: original.text ?? '' },
|
|
55
|
+
};
|
|
56
|
+
await deliver(original.chatJid, { text: message }, name, { previewText: message, type: 'text' }, quoted);
|
|
57
|
+
}
|
|
58
|
+
const EXT_KIND = {
|
|
59
|
+
'.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.gif': 'image',
|
|
60
|
+
'.mp4': 'video', '.mov': 'video', '.mkv': 'video', '.webm': 'video',
|
|
61
|
+
'.mp3': 'audio', '.ogg': 'audio', '.m4a': 'audio', '.opus': 'audio', '.wav': 'audio',
|
|
62
|
+
'.webp': 'sticker',
|
|
63
|
+
'.pdf': 'document', '.doc': 'document', '.docx': 'document',
|
|
64
|
+
'.xls': 'document', '.xlsx': 'document', '.ppt': 'document', '.pptx': 'document',
|
|
65
|
+
'.txt': 'document', '.zip': 'document', '.csv': 'document',
|
|
66
|
+
};
|
|
67
|
+
const EXT_MIME = {
|
|
68
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif',
|
|
69
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
70
|
+
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.opus': 'audio/ogg; codecs=opus',
|
|
71
|
+
'.webp': 'image/webp', '.pdf': 'application/pdf', '.zip': 'application/zip',
|
|
72
|
+
'.txt': 'text/plain', '.csv': 'text/csv',
|
|
73
|
+
};
|
|
74
|
+
export function buildMediaContent(file, caption) {
|
|
75
|
+
const path = resolve(file);
|
|
76
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
77
|
+
fail(`File not found: ${file}`);
|
|
78
|
+
}
|
|
79
|
+
const ext = extname(path).toLowerCase();
|
|
80
|
+
const kind = EXT_KIND[ext] ?? 'document';
|
|
81
|
+
const mimetype = EXT_MIME[ext];
|
|
82
|
+
const cap = caption?.trim() || undefined;
|
|
83
|
+
let content;
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'image':
|
|
86
|
+
content = { image: { url: path }, caption: cap };
|
|
87
|
+
break;
|
|
88
|
+
case 'video':
|
|
89
|
+
content = { video: { url: path }, caption: cap };
|
|
90
|
+
break;
|
|
91
|
+
case 'audio':
|
|
92
|
+
content = { audio: { url: path }, mimetype: mimetype ?? 'audio/mp4' };
|
|
93
|
+
break;
|
|
94
|
+
case 'sticker':
|
|
95
|
+
// Stickers can't carry a caption; if one was given, send as an image.
|
|
96
|
+
content = cap
|
|
97
|
+
? { image: { url: path }, caption: cap }
|
|
98
|
+
: { sticker: { url: path } };
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
content = {
|
|
102
|
+
document: { url: path },
|
|
103
|
+
mimetype: mimetype ?? 'application/octet-stream',
|
|
104
|
+
fileName: basename(path),
|
|
105
|
+
caption: cap,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { content, kind: kind === 'sticker' && cap ? 'image' : kind, path };
|
|
109
|
+
}
|
|
110
|
+
// ---- delivery --------------------------------------------------------------
|
|
111
|
+
/** Send content over an already-open socket and cache it locally. Reusable by
|
|
112
|
+
* both the one-shot CLI path and the persistent interactive session. */
|
|
113
|
+
export async function sendContent(sock, jid, content, meta, quoted) {
|
|
114
|
+
const sent = (await sock.sendMessage(jid, content, quoted ? { quoted: quoted } : undefined));
|
|
115
|
+
cacheOutgoing(jid, content, sent, meta);
|
|
116
|
+
}
|
|
117
|
+
/** Optimistically cache an outgoing message we just sent. */
|
|
118
|
+
export function cacheOutgoing(jid, content, sent, meta) {
|
|
119
|
+
const ts = toMs(sent?.messageTimestamp ?? Date.now() / 1000);
|
|
120
|
+
insertMessage({
|
|
121
|
+
chatJid: jid,
|
|
122
|
+
messageId: sent?.key?.id ?? `local-${ts}`,
|
|
123
|
+
sender: 'me',
|
|
124
|
+
text: meta.type === 'text' ? meta.previewText : content.caption ?? null,
|
|
125
|
+
timestamp: ts,
|
|
126
|
+
fromMe: true,
|
|
127
|
+
type: meta.type,
|
|
128
|
+
status: 'sent',
|
|
129
|
+
mediaPath: meta.mediaPath ?? null,
|
|
130
|
+
});
|
|
131
|
+
upsertChat({ jid, lastMessage: meta.previewText, lastTimestamp: ts });
|
|
132
|
+
}
|
|
133
|
+
/** One-shot delivery for the CLI: opens a connection, sends, closes. */
|
|
134
|
+
async function deliver(jid, content, name, meta, quoted) {
|
|
135
|
+
await runWithConnection(async (conn) => sendContent(conn.sock, jid, content, meta, quoted), { requireSession: true }).catch((err) => fail(err.message));
|
|
136
|
+
ok(`Sent to ${name}.`);
|
|
137
|
+
}
|