cli-whatsapp 0.1.1 → 0.1.3
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 +655 -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 +5 -3
- 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 -92
- 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
|
@@ -1,596 +0,0 @@
|
|
|
1
|
-
import readline, { createInterface, type Interface } from 'node:readline';
|
|
2
|
-
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { chats, contacts, search, history } from './read.ts';
|
|
6
|
-
import { reply, buildMediaContent, sendContent } from './send.ts';
|
|
7
|
-
import { media } from './media.ts';
|
|
8
|
-
import { pickFile, pickFromList, type ListItem } from './filepicker.ts';
|
|
9
|
-
import { recent, pin, unpin, archive, unarchive } from './manage.ts';
|
|
10
|
-
import { status } from './auth.ts';
|
|
11
|
-
import { connect, hasSession, type Connection } from '../../whatsapp/client.ts';
|
|
12
|
-
import { registerPersistence, extractText, isGroupJid } from '../../whatsapp/events.ts';
|
|
13
|
-
import {
|
|
14
|
-
listChats,
|
|
15
|
-
findChat,
|
|
16
|
-
getMessages,
|
|
17
|
-
countMessages,
|
|
18
|
-
type ChatRow,
|
|
19
|
-
} from '../../db/sqlite.ts';
|
|
20
|
-
import { resolveTarget } from '../../utils/jid.ts';
|
|
21
|
-
import { loadConfig, saveConfig } from '../../config/index.ts';
|
|
22
|
-
import { c, displayName, formatTime, truncate, renderMessages } from '../../utils/format.ts';
|
|
23
|
-
import { print, warn, printError } from '../../utils/ui.ts';
|
|
24
|
-
import { notify } from '../../utils/notify.ts';
|
|
25
|
-
|
|
26
|
-
type Status = 'offline' | 'connecting' | 'live';
|
|
27
|
-
|
|
28
|
-
interface State {
|
|
29
|
-
active: ChatRow | null;
|
|
30
|
-
conn: Connection | null;
|
|
31
|
-
status: Status;
|
|
32
|
-
rl: Interface;
|
|
33
|
-
quitting: boolean;
|
|
34
|
-
/** Set when the user closes the connection on purpose (/disconnect, /quit),
|
|
35
|
-
* so the connection-drop listener doesn't also print a message. */
|
|
36
|
-
manualClose: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ---- tab completion --------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
const SLASH_ITEMS: ListItem[] = [
|
|
42
|
-
{ value: '/open', desc: 'open a chat — then just type to send', args: true },
|
|
43
|
-
{ value: '/close', desc: 'leave the current chat' },
|
|
44
|
-
{ value: '/chats', desc: 'list conversations' },
|
|
45
|
-
{ value: '/recent', desc: 'recently active chats' },
|
|
46
|
-
{ value: '/contacts', desc: 'list contacts' },
|
|
47
|
-
{ value: '/history', desc: 'extended message history', args: true },
|
|
48
|
-
{ value: '/search', desc: 'search messages', args: true },
|
|
49
|
-
{ value: '/send', desc: 'send a message to a chat', args: true },
|
|
50
|
-
{ value: '/sendfile', desc: 'send a file (media)', args: true },
|
|
51
|
-
{ value: '/reply', desc: 'reply to a message by #id', args: true },
|
|
52
|
-
{ value: '/media', desc: 'list cached media' },
|
|
53
|
-
{ value: '/pin', desc: 'pin a chat to the top', args: true },
|
|
54
|
-
{ value: '/unpin', desc: 'unpin a chat', args: true },
|
|
55
|
-
{ value: '/archive', desc: 'archive (hide) a chat', args: true },
|
|
56
|
-
{ value: '/unarchive', desc: 'unarchive a chat', args: true },
|
|
57
|
-
{ value: '/connect', desc: 'go live — receive messages' },
|
|
58
|
-
{ value: '/disconnect', desc: 'drop the live connection' },
|
|
59
|
-
{ value: '/status', desc: 'session & cache status' },
|
|
60
|
-
{ value: '/settings', desc: 'view settings' },
|
|
61
|
-
{ value: '/set', desc: 'change a setting', args: true },
|
|
62
|
-
{ value: '/clear', desc: 'clear the screen' },
|
|
63
|
-
{ value: '/help', desc: 'list commands' },
|
|
64
|
-
{ value: '/quit', desc: 'exit' },
|
|
65
|
-
];
|
|
66
|
-
const SLASH = SLASH_ITEMS.map((it) => it.value);
|
|
67
|
-
const CHAT_CMDS = new Set([
|
|
68
|
-
'/open', '/send', '/sendfile', '/pin', '/unpin', '/archive', '/unarchive', '/history',
|
|
69
|
-
]);
|
|
70
|
-
|
|
71
|
-
let nameCache: string[] = [];
|
|
72
|
-
function refreshNames(): void {
|
|
73
|
-
nameCache = listChats(1000, true).map(displayName);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function completer(line: string): [string[], string] {
|
|
77
|
-
// `@…` anywhere in the line → complete a filesystem path to attach.
|
|
78
|
-
const lastTok = line.slice(line.lastIndexOf(' ') + 1);
|
|
79
|
-
if (lastTok.startsWith('@')) return completeAtPath(lastTok);
|
|
80
|
-
|
|
81
|
-
const parts = line.split(/\s+/);
|
|
82
|
-
if (parts.length <= 1) {
|
|
83
|
-
if (!line.startsWith('/')) return [[], line];
|
|
84
|
-
const hits = SLASH.filter((cmd) => cmd.startsWith(line));
|
|
85
|
-
return [hits.length ? hits : SLASH, line];
|
|
86
|
-
}
|
|
87
|
-
if (CHAT_CMDS.has(parts[0])) {
|
|
88
|
-
const partial = parts[parts.length - 1].toLowerCase();
|
|
89
|
-
const hits = nameCache.filter((n) => n.toLowerCase().startsWith(partial)).slice(0, 25);
|
|
90
|
-
return [hits, parts[parts.length - 1]];
|
|
91
|
-
}
|
|
92
|
-
return [[], line];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Expand a leading ~ to the home directory. */
|
|
96
|
-
function expandHome(p: string): string {
|
|
97
|
-
return p.replace(/^~(?=\/|$)/, homedir());
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Tab-completion for an `@<path>` token: lists matching files/dirs so you can
|
|
102
|
-
* browse the filesystem to attach a file (directories get a trailing /).
|
|
103
|
-
*/
|
|
104
|
-
function completeAtPath(token: string): [string[], string] {
|
|
105
|
-
const raw = token.slice(1); // drop the leading '@'
|
|
106
|
-
const slash = raw.lastIndexOf('/');
|
|
107
|
-
const dirPart = slash >= 0 ? raw.slice(0, slash + 1) : ''; // keeps trailing /
|
|
108
|
-
const basePart = slash >= 0 ? raw.slice(slash + 1) : raw;
|
|
109
|
-
const fsDir = expandHome(dirPart || '.');
|
|
110
|
-
let entries: string[];
|
|
111
|
-
try {
|
|
112
|
-
entries = readdirSync(fsDir);
|
|
113
|
-
} catch {
|
|
114
|
-
return [[token], token];
|
|
115
|
-
}
|
|
116
|
-
const showHidden = basePart.startsWith('.');
|
|
117
|
-
const hits = entries
|
|
118
|
-
.filter((e) => e.startsWith(basePart) && (showHidden || !e.startsWith('.')))
|
|
119
|
-
.sort()
|
|
120
|
-
.slice(0, 100)
|
|
121
|
-
.map((e) => {
|
|
122
|
-
let suffix = '';
|
|
123
|
-
try {
|
|
124
|
-
if (statSync(join(fsDir, e)).isDirectory()) suffix = '/';
|
|
125
|
-
} catch {
|
|
126
|
-
/* ignore unstatable entries */
|
|
127
|
-
}
|
|
128
|
-
return '@' + dirPart + e + suffix;
|
|
129
|
-
});
|
|
130
|
-
return [hits.length ? hits : [token], token];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Find an `@<path>` attachment in a composed message, if the file exists.
|
|
135
|
-
* Supports `@"quoted paths with spaces"` as well as bare `@path` tokens.
|
|
136
|
-
*/
|
|
137
|
-
function extractAttachment(
|
|
138
|
-
line: string,
|
|
139
|
-
): { file: string; caption: string } | null {
|
|
140
|
-
const m = line.match(/(?:^|\s)@(?:"([^"]+)"|(\S+))/);
|
|
141
|
-
if (!m || m.index === undefined) return null;
|
|
142
|
-
const file = expandHome(m[1] ?? m[2]);
|
|
143
|
-
if (!existsSync(file) || !statSync(file).isFile()) return null;
|
|
144
|
-
const caption = (line.slice(0, m.index) + line.slice(m.index + m[0].length)).trim();
|
|
145
|
-
return { file, caption };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ---- rendering -------------------------------------------------------------
|
|
149
|
-
|
|
150
|
-
function banner(): void {
|
|
151
|
-
const linked = hasSession();
|
|
152
|
-
const dot = linked ? c.green('●') : c.red('○');
|
|
153
|
-
const state = linked ? c.green('linked') : c.red('not linked');
|
|
154
|
-
const stats = c.dim(`${listChats(5000).length} chats · ${countMessages()} messages`);
|
|
155
|
-
const lines = [
|
|
156
|
-
`${c.green('✆')} ${c.bold(c.green('WhatsApp'))} ${c.bold('CLI')} ${c.dim('interactive')}`,
|
|
157
|
-
`${dot} ${state} ${stats}`,
|
|
158
|
-
linked
|
|
159
|
-
? c.dim('Type /open <chat> then just type to chat. /help for commands.')
|
|
160
|
-
: c.dim('Run /connect after `wa login` to link this device.'),
|
|
161
|
-
];
|
|
162
|
-
const width = Math.min(
|
|
163
|
-
64,
|
|
164
|
-
Math.max(...lines.map((l) => stripAnsi(l).length)) + 2,
|
|
165
|
-
);
|
|
166
|
-
const bar = (l: string, r: string) => c.teal(l + '─'.repeat(width) + r);
|
|
167
|
-
print(bar('╭', '╮'));
|
|
168
|
-
for (const l of lines) {
|
|
169
|
-
const pad = ' '.repeat(Math.max(0, width - 1 - stripAnsi(l).length));
|
|
170
|
-
print(c.teal('│') + ' ' + l + pad + c.teal('│'));
|
|
171
|
-
}
|
|
172
|
-
print(bar('╰', '╯'));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function stripAnsi(s: string): string {
|
|
176
|
-
// eslint-disable-next-line no-control-regex
|
|
177
|
-
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function statusDot(s: Status): string {
|
|
181
|
-
return s === 'live' ? c.green('●') : s === 'connecting' ? c.yellow('◐') : c.dim('○');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function setPrompt(state: State): void {
|
|
185
|
-
const where = state.active ? c.bold(displayName(state.active)) : c.dim('wa');
|
|
186
|
-
state.rl.setPrompt(`${statusDot(state.status)} ${where} ${c.dim('›')} `);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Print a line above the input prompt without disturbing what's being typed. */
|
|
190
|
-
function printAbove(state: State, text: string): void {
|
|
191
|
-
readline.cursorTo(process.stdout, 0);
|
|
192
|
-
readline.clearLine(process.stdout, 0);
|
|
193
|
-
process.stdout.write(text + '\n');
|
|
194
|
-
state.rl.prompt(true);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function bubble(who: string, text: string, incoming: boolean): string {
|
|
198
|
-
const time = c.gray(formatTime(Date.now(), loadConfig().preferences.time24h));
|
|
199
|
-
const name = incoming ? c.cyan(who) : c.green('me');
|
|
200
|
-
return `${time} ${name}: ${text}`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ---- connection ------------------------------------------------------------
|
|
204
|
-
|
|
205
|
-
function onIncoming(state: State, msg: import('@whiskeysockets/baileys').WAMessage): void {
|
|
206
|
-
if (msg.key.fromMe) return;
|
|
207
|
-
const jid = msg.key.remoteJid;
|
|
208
|
-
if (!jid || jid === 'status@broadcast') return;
|
|
209
|
-
const text = extractText(msg.message) || c.dim('[media]');
|
|
210
|
-
const chat = findChat(jid);
|
|
211
|
-
const who =
|
|
212
|
-
isGroupJid(jid) && msg.pushName
|
|
213
|
-
? `${chat ? displayName(chat) : jid.split('@')[0]}/${msg.pushName}`
|
|
214
|
-
: chat
|
|
215
|
-
? displayName(chat)
|
|
216
|
-
: msg.pushName ?? jid.split('@')[0];
|
|
217
|
-
if (state.active && jid === state.active.jid) {
|
|
218
|
-
printAbove(state, bubble(who, text, true));
|
|
219
|
-
} else {
|
|
220
|
-
printAbove(state, c.dim(`🔔 ${who}: ${truncate(stripAnsi(text), 60)}`));
|
|
221
|
-
}
|
|
222
|
-
if (loadConfig().preferences.notifications) {
|
|
223
|
-
notify(who, extractText(msg.message) || 'sent media');
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function ensureConnected(state: State): Promise<Connection | null> {
|
|
228
|
-
if (state.conn && state.status === 'live') return state.conn;
|
|
229
|
-
if (!hasSession()) {
|
|
230
|
-
warn('Not linked. Run `wa login` in another terminal first.');
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
state.status = 'connecting';
|
|
234
|
-
setPrompt(state);
|
|
235
|
-
printAbove(state, c.dim('connecting…'));
|
|
236
|
-
try {
|
|
237
|
-
const conn = await connect({ requireSession: true });
|
|
238
|
-
registerPersistence(conn.sock, { onMessage: (m) => onIncoming(state, m) });
|
|
239
|
-
conn.sock.ev.on('connection.update', (u) => {
|
|
240
|
-
if (u.connection !== 'close' || state.quitting) return;
|
|
241
|
-
if (state.manualClose) {
|
|
242
|
-
// User-initiated (/disconnect) — that handler already reported it.
|
|
243
|
-
state.manualClose = false;
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
state.status = 'offline';
|
|
247
|
-
state.conn = null;
|
|
248
|
-
setPrompt(state);
|
|
249
|
-
printAbove(state, c.dim('disconnected. Type /connect to reconnect.'));
|
|
250
|
-
});
|
|
251
|
-
await conn.ready;
|
|
252
|
-
state.conn = conn;
|
|
253
|
-
state.status = 'live';
|
|
254
|
-
setPrompt(state);
|
|
255
|
-
printAbove(state, c.green('● live — receiving messages'));
|
|
256
|
-
return conn;
|
|
257
|
-
} catch (err) {
|
|
258
|
-
state.status = 'offline';
|
|
259
|
-
state.conn = null;
|
|
260
|
-
setPrompt(state);
|
|
261
|
-
warn(`Could not connect: ${(err as Error).message}`);
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ---- sending ---------------------------------------------------------------
|
|
267
|
-
|
|
268
|
-
async function sendTo(
|
|
269
|
-
state: State,
|
|
270
|
-
jid: string,
|
|
271
|
-
name: string,
|
|
272
|
-
content: import('@whiskeysockets/baileys').AnyMessageContent,
|
|
273
|
-
meta: { previewText: string; type: string; mediaPath?: string },
|
|
274
|
-
): Promise<void> {
|
|
275
|
-
const conn = await ensureConnected(state);
|
|
276
|
-
if (!conn) return;
|
|
277
|
-
try {
|
|
278
|
-
await sendContent(conn.sock, jid, content, meta);
|
|
279
|
-
const echo = meta.type === 'text' ? meta.previewText : `[${meta.type}] ${meta.previewText}`;
|
|
280
|
-
printAbove(state, bubble(name, echo, false));
|
|
281
|
-
refreshNames();
|
|
282
|
-
} catch (err) {
|
|
283
|
-
warn(`Send failed: ${(err as Error).message}`);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// ---- chat context ----------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
function openChat(state: State, target: string): void {
|
|
290
|
-
const chat = findChat(target);
|
|
291
|
-
if (!chat) {
|
|
292
|
-
warn(`No chat matching "${target}". Try /chats or /search.`);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
state.active = chat;
|
|
296
|
-
setPrompt(state);
|
|
297
|
-
print(c.dim('─'.repeat(40)));
|
|
298
|
-
print(c.bold(displayName(chat)) + c.dim(` ${chat.jid}`));
|
|
299
|
-
print(renderMessages(getMessages(chat.jid, 15), loadConfig().preferences.time24h));
|
|
300
|
-
print(c.dim(`─── in this chat: type to send · @ opens a file picker to attach · /close ───`));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ---- settings --------------------------------------------------------------
|
|
304
|
-
|
|
305
|
-
function showSettings(): void {
|
|
306
|
-
const cfg = loadConfig();
|
|
307
|
-
print(c.bold('Settings'));
|
|
308
|
-
print(` openLimit ${c.cyan(String(cfg.preferences.openLimit))} ${c.dim('messages shown by /open')}`);
|
|
309
|
-
print(` time24h ${c.cyan(String(cfg.preferences.time24h))} ${c.dim('24-hour clock')}`);
|
|
310
|
-
print(` notifications ${c.cyan(String(cfg.preferences.notifications))} ${c.dim('desktop alerts on new messages')}`);
|
|
311
|
-
print(` autoConnect ${c.cyan(String(cfg.preferences.autoConnect))} ${c.dim('go live automatically on start')}`);
|
|
312
|
-
print(` ai.enabled ${c.cyan(String(cfg.ai.enabled))} ${c.dim('(AI plugin)')}`);
|
|
313
|
-
print(c.dim(' Change with: /set <key> <value> e.g. /set autoConnect false'));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function setSetting(key: string, value: string): void {
|
|
317
|
-
const cfg = loadConfig();
|
|
318
|
-
switch (key) {
|
|
319
|
-
case 'openLimit':
|
|
320
|
-
cfg.preferences.openLimit = Math.max(1, parseInt(value, 10) || cfg.preferences.openLimit);
|
|
321
|
-
break;
|
|
322
|
-
case 'time24h':
|
|
323
|
-
cfg.preferences.time24h = value === 'true' || value === '1';
|
|
324
|
-
break;
|
|
325
|
-
case 'notifications':
|
|
326
|
-
cfg.preferences.notifications = value === 'true' || value === '1';
|
|
327
|
-
break;
|
|
328
|
-
case 'autoConnect':
|
|
329
|
-
cfg.preferences.autoConnect = value === 'true' || value === '1';
|
|
330
|
-
break;
|
|
331
|
-
case 'ai.enabled':
|
|
332
|
-
cfg.ai.enabled = value === 'true' || value === '1';
|
|
333
|
-
break;
|
|
334
|
-
default:
|
|
335
|
-
warn(`Unknown key "${key}". Editable: openLimit, time24h, notifications, autoConnect, ai.enabled`);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
saveConfig(cfg);
|
|
339
|
-
print(`${c.green('✓')} set ${key} = ${value}`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// ---- help ------------------------------------------------------------------
|
|
343
|
-
|
|
344
|
-
const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)')}
|
|
345
|
-
${c.cyan('/open')} <chat> open a chat — then just type to send
|
|
346
|
-
${c.green('@')} open a file picker to attach (↑↓ to choose, ⏎ to pick)
|
|
347
|
-
${c.cyan('/close')} leave the current chat
|
|
348
|
-
${c.cyan('/chats')} · ${c.cyan('/recent')} · ${c.cyan('/contacts')} list chats / recent / contacts
|
|
349
|
-
${c.cyan('/history')} [n] · ${c.cyan('/search')} <text> more history / search
|
|
350
|
-
${c.cyan('/send')} <chat> <msg> send without opening
|
|
351
|
-
${c.cyan('/sendfile')} <chat> <path> [caption] send media
|
|
352
|
-
${c.cyan('/reply')} <id> <msg> reply to a message by #id
|
|
353
|
-
${c.cyan('/media')} [type] list media
|
|
354
|
-
${c.cyan('/pin')} · ${c.cyan('/unpin')} · ${c.cyan('/archive')} · ${c.cyan('/unarchive')} <chat>
|
|
355
|
-
${c.cyan('/connect')} · ${c.cyan('/disconnect')} go live (receive) / drop connection
|
|
356
|
-
${c.cyan('/status')} · ${c.cyan('/settings')} · ${c.cyan('/set')} <k> <v> status / settings
|
|
357
|
-
${c.cyan('/clear')} · ${c.cyan('/help')} · ${c.cyan('/quit')}`;
|
|
358
|
-
|
|
359
|
-
// ---- command dispatch ------------------------------------------------------
|
|
360
|
-
|
|
361
|
-
function tokenize(line: string): string[] {
|
|
362
|
-
const out: string[] = [];
|
|
363
|
-
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
364
|
-
let m: RegExpExecArray | null;
|
|
365
|
-
while ((m = re.exec(line))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
366
|
-
return out;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async function handle(state: State, raw: string): Promise<void> {
|
|
370
|
-
const line = raw.trim();
|
|
371
|
-
if (!line) return;
|
|
372
|
-
|
|
373
|
-
// Message composed with a chat open (no leading slash).
|
|
374
|
-
if (!line.startsWith('/') && state.active) {
|
|
375
|
-
const jid = state.active.jid;
|
|
376
|
-
const name = displayName(state.active);
|
|
377
|
-
// `@<path>` attaches a file (rest of the line becomes the caption).
|
|
378
|
-
const at = extractAttachment(line);
|
|
379
|
-
if (at) {
|
|
380
|
-
try {
|
|
381
|
-
const built = buildMediaContent(at.file, at.caption || undefined);
|
|
382
|
-
await sendTo(state, jid, name, built.content, {
|
|
383
|
-
previewText: at.caption || `[${built.kind}]`,
|
|
384
|
-
type: built.kind,
|
|
385
|
-
mediaPath: built.path,
|
|
386
|
-
});
|
|
387
|
-
} catch (err) {
|
|
388
|
-
printError(err);
|
|
389
|
-
}
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
// Otherwise plain text.
|
|
393
|
-
await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' });
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const [cmdRaw, ...args] = tokenize(line);
|
|
398
|
-
const cmd = cmdRaw.replace(/^\//, '').toLowerCase();
|
|
399
|
-
const need = (v: string | undefined, n: string): boolean => {
|
|
400
|
-
if (!v) {
|
|
401
|
-
warn(`Missing <${n}>.`);
|
|
402
|
-
return false;
|
|
403
|
-
}
|
|
404
|
-
return true;
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
switch (cmd) {
|
|
408
|
-
case 'quit': case 'exit': case 'q':
|
|
409
|
-
state.quitting = true;
|
|
410
|
-
state.rl.close();
|
|
411
|
-
return;
|
|
412
|
-
case 'help': case 'h': case '?':
|
|
413
|
-
print(HELP); return;
|
|
414
|
-
case 'clear':
|
|
415
|
-
process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); banner(); return;
|
|
416
|
-
case 'open':
|
|
417
|
-
if (need(args[0], 'chat')) openChat(state, args.join(' ')); return;
|
|
418
|
-
case 'close': case 'back':
|
|
419
|
-
state.active = null; setPrompt(state); print(c.dim('left chat.')); return;
|
|
420
|
-
case 'chats': chats({ limit: args[0] }); refreshNames(); return;
|
|
421
|
-
case 'recent': recent({ limit: args[0] }); return;
|
|
422
|
-
case 'contacts': contacts({ limit: args[0] }); return;
|
|
423
|
-
case 'history':
|
|
424
|
-
if (state.active && (!args[0] || /^\d+$/.test(args[0])))
|
|
425
|
-
history(state.active.jid, { limit: args[0] });
|
|
426
|
-
else if (need(args[0], 'chat')) history(args[0], { limit: args[1] });
|
|
427
|
-
return;
|
|
428
|
-
case 'search':
|
|
429
|
-
if (need(args[0], 'text')) search(args.join(' '), {}); return;
|
|
430
|
-
case 'media': media(args[0], {}); return;
|
|
431
|
-
case 'status': status(); return;
|
|
432
|
-
case 'settings': case 'config': showSettings(); return;
|
|
433
|
-
case 'set':
|
|
434
|
-
if (need(args[0], 'key') && need(args[1], 'value')) setSetting(args[0], args[1]); return;
|
|
435
|
-
case 'pin': if (need(args[0], 'chat')) { pin(args.join(' ')); refreshNames(); } return;
|
|
436
|
-
case 'unpin': if (need(args[0], 'chat')) unpin(args.join(' ')); return;
|
|
437
|
-
case 'archive': if (need(args[0], 'chat')) archive(args.join(' ')); return;
|
|
438
|
-
case 'unarchive': if (need(args[0], 'chat')) unarchive(args.join(' ')); return;
|
|
439
|
-
case 'connect': case 'watch': case 'live':
|
|
440
|
-
await ensureConnected(state); return;
|
|
441
|
-
case 'disconnect':
|
|
442
|
-
if (state.conn) {
|
|
443
|
-
state.manualClose = true; // suppress the drop-listener's message
|
|
444
|
-
state.conn.close();
|
|
445
|
-
state.conn = null;
|
|
446
|
-
state.status = 'offline';
|
|
447
|
-
setPrompt(state);
|
|
448
|
-
print(c.dim('disconnected.'));
|
|
449
|
-
} else print(c.dim('not connected.'));
|
|
450
|
-
return;
|
|
451
|
-
case 'send': {
|
|
452
|
-
if (!need(args[0], 'chat') || !need(args[1], 'message')) return;
|
|
453
|
-
const t = resolveTarget(args[0]);
|
|
454
|
-
if (!t) { warn(`Could not resolve "${args[0]}".`); return; }
|
|
455
|
-
const text = args.slice(1).join(' ');
|
|
456
|
-
await sendTo(state, t.jid, t.name, { text }, { previewText: text, type: 'text' });
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
case 'sendfile': {
|
|
460
|
-
if (!need(args[0], 'chat') || !need(args[1], 'path')) return;
|
|
461
|
-
const t = resolveTarget(args[0]);
|
|
462
|
-
if (!t) { warn(`Could not resolve "${args[0]}".`); return; }
|
|
463
|
-
try {
|
|
464
|
-
const caption = args.slice(2).join(' ') || undefined;
|
|
465
|
-
const built = buildMediaContent(args[1], caption);
|
|
466
|
-
await sendTo(state, t.jid, t.name, built.content, {
|
|
467
|
-
previewText: caption || `[${built.kind}]`,
|
|
468
|
-
type: built.kind,
|
|
469
|
-
mediaPath: built.path,
|
|
470
|
-
});
|
|
471
|
-
} catch (err) {
|
|
472
|
-
printError(err);
|
|
473
|
-
}
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
case 'reply':
|
|
477
|
-
if (need(args[0], 'id') && need(args[1], 'message')) await reply(args[0], args.slice(1).join(' '));
|
|
478
|
-
return;
|
|
479
|
-
default:
|
|
480
|
-
warn(`Unknown command "${cmd}". Type /help.`);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// ---- entry -----------------------------------------------------------------
|
|
485
|
-
|
|
486
|
-
/** wa shell — the interactive client. Also runs when `wa` is given no command. */
|
|
487
|
-
export async function shell(): Promise<void> {
|
|
488
|
-
banner();
|
|
489
|
-
refreshNames();
|
|
490
|
-
const rl = createInterface({
|
|
491
|
-
input: process.stdin,
|
|
492
|
-
output: process.stdout,
|
|
493
|
-
completer,
|
|
494
|
-
historySize: 200,
|
|
495
|
-
});
|
|
496
|
-
const state: State = { active: null, conn: null, status: 'offline', rl, quitting: false, manualClose: false };
|
|
497
|
-
setPrompt(state);
|
|
498
|
-
rl.prompt();
|
|
499
|
-
|
|
500
|
-
// Go live automatically on start (unless disabled or not linked). Fire-and-
|
|
501
|
-
// forget so browsing stays instant while the socket connects in the background.
|
|
502
|
-
if (process.stdin.isTTY && hasSession() && loadConfig().preferences.autoConnect) {
|
|
503
|
-
void ensureConnected(state);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
let busy = false;
|
|
507
|
-
const queue: string[] = [];
|
|
508
|
-
const pump = async () => {
|
|
509
|
-
if (busy) return;
|
|
510
|
-
busy = true;
|
|
511
|
-
while (queue.length) {
|
|
512
|
-
const line = queue.shift()!;
|
|
513
|
-
try {
|
|
514
|
-
await handle(state, line);
|
|
515
|
-
} catch (err) {
|
|
516
|
-
printError(err);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
busy = false;
|
|
520
|
-
if (!state.quitting) rl.prompt();
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
rl.on('line', (line) => {
|
|
524
|
-
queue.push(line);
|
|
525
|
-
void pump();
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// Overlays open synchronously on the trigger key so they reliably capture the
|
|
529
|
-
// keys you type next (no race with the line being submitted). `/` at the start
|
|
530
|
-
// of the line opens the slash-command menu; `@` (with a chat open, at a token
|
|
531
|
-
// boundary) opens the file picker. readline processes the keypress before our
|
|
532
|
-
// listener, so rl.line already includes the just-typed character.
|
|
533
|
-
readline.emitKeypressEvents(process.stdin);
|
|
534
|
-
let pickerOpen = false;
|
|
535
|
-
process.stdin.on('keypress', (str: string, _key: unknown) => {
|
|
536
|
-
void _key;
|
|
537
|
-
if (!process.stdin.isTTY || pickerOpen || busy) return;
|
|
538
|
-
|
|
539
|
-
// `/` menu — only when it's the first character on the line.
|
|
540
|
-
if (str === '/' && rl.line === '/') {
|
|
541
|
-
pickerOpen = true;
|
|
542
|
-
pickFromList('Slash Commands', SLASH_ITEMS, '')
|
|
543
|
-
.then((picked) => {
|
|
544
|
-
rl.write(null as never, { ctrl: true, name: 'u' }); // clear the '/'
|
|
545
|
-
if (!picked) return; // cancelled (esc / backspace) — leave the line empty
|
|
546
|
-
const item = SLASH_ITEMS.find((i) => i.value === picked);
|
|
547
|
-
if (item && !item.args) {
|
|
548
|
-
// No arguments needed → run it right away (no second Enter).
|
|
549
|
-
queue.push(picked);
|
|
550
|
-
void pump();
|
|
551
|
-
} else {
|
|
552
|
-
rl.write(picked + ' '); // needs args → wait for input
|
|
553
|
-
}
|
|
554
|
-
})
|
|
555
|
-
.finally(() => {
|
|
556
|
-
pickerOpen = false;
|
|
557
|
-
});
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// `@` file picker — only with a chat open and at a fresh token boundary.
|
|
562
|
-
if (str === '@' && state.active) {
|
|
563
|
-
const before = rl.line.slice(0, Math.max(0, rl.cursor - 1)).replace(/@$/, '');
|
|
564
|
-
if (before !== '' && !before.endsWith(' ')) return;
|
|
565
|
-
pickerOpen = true;
|
|
566
|
-
pickFile(process.cwd())
|
|
567
|
-
.then((picked) => {
|
|
568
|
-
// Quote paths with spaces so the whole path is treated as one token.
|
|
569
|
-
if (picked) rl.write(picked.includes(' ') ? `"${picked}"` : picked);
|
|
570
|
-
else rl.prompt(true);
|
|
571
|
-
})
|
|
572
|
-
.finally(() => {
|
|
573
|
-
pickerOpen = false;
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
rl.on('SIGINT', () => {
|
|
579
|
-
if (rl.line) {
|
|
580
|
-
// Clear the current input on first Ctrl+C.
|
|
581
|
-
rl.write(null as never, { ctrl: true, name: 'u' });
|
|
582
|
-
} else {
|
|
583
|
-
state.quitting = true;
|
|
584
|
-
rl.close();
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
await new Promise<void>((resolve) => {
|
|
589
|
-
rl.on('close', () => {
|
|
590
|
-
state.quitting = true;
|
|
591
|
-
state.conn?.close();
|
|
592
|
-
print('\n' + c.dim('Bye.'));
|
|
593
|
-
resolve();
|
|
594
|
-
});
|
|
595
|
-
});
|
|
596
|
-
}
|
package/src/cli/commands/sync.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { connect, runWithConnection } from '../../whatsapp/client.ts';
|
|
2
|
-
import { registerPersistence, extractText, isGroupJid } from '../../whatsapp/events.ts';
|
|
3
|
-
import { countMessages, listChats, findChat } from '../../db/sqlite.ts';
|
|
4
|
-
import { loadConfig, saveConfig } from '../../config/index.ts';
|
|
5
|
-
import { ok, info, print } from '../../utils/ui.ts';
|
|
6
|
-
import { c, formatTime, displayName } from '../../utils/format.ts';
|
|
7
|
-
import { notify } from '../../utils/notify.ts';
|
|
8
|
-
|
|
9
|
-
/** wa sync [--seconds N] — pull recent chats/messages into the local cache. */
|
|
10
|
-
export async function sync(opts: { seconds?: string }): Promise<void> {
|
|
11
|
-
const seconds = Math.min(Math.max(parseInt(opts.seconds ?? '12', 10) || 12, 3), 120);
|
|
12
|
-
const before = { chats: listChats(5000).length, msgs: countMessages() };
|
|
13
|
-
info(`Syncing for ~${seconds}s… (WhatsApp streams history in the background)`);
|
|
14
|
-
|
|
15
|
-
await runWithConnection(
|
|
16
|
-
async (conn) => {
|
|
17
|
-
registerPersistence(conn.sock);
|
|
18
|
-
// Periodic progress so long syncs don't look frozen.
|
|
19
|
-
const started = Date.now();
|
|
20
|
-
const timer = setInterval(() => {
|
|
21
|
-
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
22
|
-
process.stdout.write(
|
|
23
|
-
`\r${c.dim(` ${elapsed}s — ${countMessages()} messages cached`)} `,
|
|
24
|
-
);
|
|
25
|
-
}, 1000);
|
|
26
|
-
await new Promise((r) => setTimeout(r, seconds * 1000));
|
|
27
|
-
clearInterval(timer);
|
|
28
|
-
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
29
|
-
},
|
|
30
|
-
{ requireSession: true, syncFullHistory: true },
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
const after = { chats: listChats(5000).length, msgs: countMessages() };
|
|
34
|
-
const cfg = loadConfig();
|
|
35
|
-
cfg.session.lastSyncAt = Date.now();
|
|
36
|
-
saveConfig(cfg);
|
|
37
|
-
|
|
38
|
-
ok(
|
|
39
|
-
`Sync complete. +${after.chats - before.chats} chats, +${after.msgs - before.msgs} messages ` +
|
|
40
|
-
`(${after.chats} chats, ${after.msgs} messages total).`,
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** wa watch — stream incoming messages live until interrupted. */
|
|
45
|
-
export async function watch(): Promise<void> {
|
|
46
|
-
const cfg = loadConfig();
|
|
47
|
-
info('Watching for incoming messages. Press Ctrl+C to stop.');
|
|
48
|
-
print(c.dim('─'.repeat(40)));
|
|
49
|
-
|
|
50
|
-
const conn = await connect({ requireSession: true });
|
|
51
|
-
registerPersistence(conn.sock, {
|
|
52
|
-
onMessage: (msg) => {
|
|
53
|
-
if (msg.key.fromMe) return;
|
|
54
|
-
const jid = msg.key.remoteJid;
|
|
55
|
-
if (!jid || jid === 'status@broadcast') return;
|
|
56
|
-
const text = extractText(msg.message) || c.dim('[media]');
|
|
57
|
-
const chat = findChat(jid);
|
|
58
|
-
const name = chat ? displayName(chat) : (msg.pushName ?? jid.split('@')[0]);
|
|
59
|
-
const sender =
|
|
60
|
-
isGroupJid(jid) && msg.pushName ? `${name}/${msg.pushName}` : name;
|
|
61
|
-
const time = c.gray(formatTime(Date.now(), cfg.preferences.time24h));
|
|
62
|
-
print(`${time} ${c.cyan(sender)}: ${text}`);
|
|
63
|
-
if (cfg.preferences.notifications) notify(sender, extractText(msg.message) || 'sent media');
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
await conn.ready;
|
|
68
|
-
ok('Connected. Live.');
|
|
69
|
-
|
|
70
|
-
// Keep the process alive until the user interrupts.
|
|
71
|
-
await new Promise<void>((resolve) => {
|
|
72
|
-
const stop = () => {
|
|
73
|
-
conn.close();
|
|
74
|
-
print('\n' + c.dim('Stopped watching.'));
|
|
75
|
-
resolve();
|
|
76
|
-
};
|
|
77
|
-
process.on('SIGINT', stop);
|
|
78
|
-
process.on('SIGTERM', stop);
|
|
79
|
-
});
|
|
80
|
-
}
|