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
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { findChat, getMessages } from '../../db/sqlite.ts';
|
|
4
|
-
import { displayName, formatTime } from '../../utils/format.ts';
|
|
5
|
-
import { ok, fail, print } from '../../utils/ui.ts';
|
|
6
|
-
|
|
7
|
-
type Fmt = 'md' | 'txt' | 'json';
|
|
8
|
-
|
|
9
|
-
/** wa export <chat> [--format md|txt|json] [--limit N] [--out FILE] */
|
|
10
|
-
export function exportChat(
|
|
11
|
-
target: string,
|
|
12
|
-
opts: { format?: string; limit?: string; out?: string },
|
|
13
|
-
): void {
|
|
14
|
-
const chat = findChat(target);
|
|
15
|
-
if (!chat) fail(`No chat matching "${target}".`);
|
|
16
|
-
|
|
17
|
-
const fmt = normalizeFmt(opts.format);
|
|
18
|
-
const limit = Math.min(parseInt(opts.limit ?? '1000', 10) || 1000, 100000);
|
|
19
|
-
const msgs = getMessages(chat.jid, limit);
|
|
20
|
-
const name = displayName(chat);
|
|
21
|
-
|
|
22
|
-
let body: string;
|
|
23
|
-
if (fmt === 'json') {
|
|
24
|
-
body = JSON.stringify({ chat: { jid: chat.jid, name }, messages: msgs }, null, 2);
|
|
25
|
-
} else if (fmt === 'txt') {
|
|
26
|
-
body = msgs
|
|
27
|
-
.map((m) => `[${formatTime(m.timestamp)}] ${m.fromMe ? 'me' : m.sender ?? '?'}: ${m.text ?? '[' + (m.type ?? 'media') + ']'}`)
|
|
28
|
-
.join('\n');
|
|
29
|
-
} else {
|
|
30
|
-
body =
|
|
31
|
-
`# ${name}\n\n` +
|
|
32
|
-
msgs
|
|
33
|
-
.map(
|
|
34
|
-
(m) =>
|
|
35
|
-
`**${m.fromMe ? 'me' : m.sender ?? '?'}** _(${formatTime(m.timestamp)})_ \n${m.text ?? '`[' + (m.type ?? 'media') + ']`'}\n`,
|
|
36
|
-
)
|
|
37
|
-
.join('\n');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (opts.out === '-') {
|
|
41
|
-
print(body);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
const file =
|
|
45
|
-
opts.out ?? join(process.cwd(), `${slug(name)}.${fmt === 'md' ? 'md' : fmt}`);
|
|
46
|
-
writeFileSync(file, body + '\n', 'utf8');
|
|
47
|
-
ok(`Exported ${msgs.length} messages → ${file}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function normalizeFmt(f?: string): Fmt {
|
|
51
|
-
const v = (f ?? 'md').toLowerCase();
|
|
52
|
-
if (v === 'markdown' || v === 'md') return 'md';
|
|
53
|
-
if (v === 'txt' || v === 'text') return 'txt';
|
|
54
|
-
if (v === 'json') return 'json';
|
|
55
|
-
return 'md';
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function slug(s: string): string {
|
|
59
|
-
return s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'chat';
|
|
60
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import readline from 'node:readline';
|
|
2
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { resolve, join, relative } from 'node:path';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { c } from '../../utils/format.ts';
|
|
6
|
-
|
|
7
|
-
const VISIBLE = 12; // rows shown at once
|
|
8
|
-
const WIDTH = 58; // inner box width
|
|
9
|
-
|
|
10
|
-
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
11
|
-
|
|
12
|
-
function pad(s: string, w: number): string {
|
|
13
|
-
const len = stripAnsi(s).length; // emoji surrogate pairs ≈ 2 cols, matches display
|
|
14
|
-
return len >= w ? s : s + ' '.repeat(w - len);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Green highlight for the selected row (WhatsApp green bg, dark text). */
|
|
18
|
-
function highlight(s: string): string {
|
|
19
|
-
return `\x1b[48;2;37;211;102m\x1b[38;2;7;46;36m${stripAnsi(s)}\x1b[0m`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const border = (l: string, r: string): string => c.teal(l + '─'.repeat(WIDTH) + r);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Generic raw-mode overlay runner: sets up keypress capture (stealing readline's
|
|
26
|
-
* handlers for the duration), renders below the prompt, and restores everything
|
|
27
|
-
* on exit. `render` draws the overlay; `makeOnKey(done)` returns the keypress
|
|
28
|
-
* handler and calls `done(result)` to close. No-op (null) on a non-TTY.
|
|
29
|
-
*/
|
|
30
|
-
function runOverlay<T>(
|
|
31
|
-
render: () => void,
|
|
32
|
-
makeOnKey: (done: (r: T | null) => void) => (str: string, key: readline.Key | undefined) => void,
|
|
33
|
-
): Promise<T | null> {
|
|
34
|
-
const input = process.stdin;
|
|
35
|
-
if (!input.isTTY) return Promise.resolve(null);
|
|
36
|
-
readline.emitKeypressEvents(input);
|
|
37
|
-
const wasRaw = !!input.isRaw;
|
|
38
|
-
input.setRawMode(true);
|
|
39
|
-
const saved = input.listeners('keypress');
|
|
40
|
-
input.removeAllListeners('keypress');
|
|
41
|
-
process.stdout.write('\x1b7'); // save cursor at the prompt
|
|
42
|
-
render();
|
|
43
|
-
return new Promise<T | null>((res) => {
|
|
44
|
-
let onKey: (str: string, key: readline.Key | undefined) => void;
|
|
45
|
-
const done = (result: T | null): void => {
|
|
46
|
-
input.removeListener('keypress', onKey);
|
|
47
|
-
process.stdout.write('\x1b8\x1b[0J'); // restore cursor, wipe overlay
|
|
48
|
-
if (input.isTTY) input.setRawMode(wasRaw);
|
|
49
|
-
for (const l of saved) input.on('keypress', l as (...a: unknown[]) => void);
|
|
50
|
-
res(result);
|
|
51
|
-
};
|
|
52
|
-
onKey = makeOnKey(done);
|
|
53
|
-
input.on('keypress', onKey);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Shared list-window math + scroll clamp. */
|
|
58
|
-
function clampScroll(index: number, scroll: number, count: number): number {
|
|
59
|
-
if (index < scroll) return index;
|
|
60
|
-
if (index >= scroll + VISIBLE) return index - VISIBLE + 1;
|
|
61
|
-
return Math.min(scroll, Math.max(0, count - 1));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ---- file picker -----------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
interface Entry {
|
|
67
|
-
name: string;
|
|
68
|
-
dir: boolean;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Interactive file picker: ↑↓ move · type to filter · ⏎ open/pick · ⌫ up · esc. */
|
|
72
|
-
export function pickFile(startDir: string): Promise<string | null> {
|
|
73
|
-
let dir = resolve(startDir);
|
|
74
|
-
let filter = '';
|
|
75
|
-
let index = 0;
|
|
76
|
-
let scroll = 0;
|
|
77
|
-
|
|
78
|
-
const load = (): Entry[] => {
|
|
79
|
-
let items: Entry[] = [];
|
|
80
|
-
try {
|
|
81
|
-
items = readdirSync(dir).map((name) => {
|
|
82
|
-
let d = false;
|
|
83
|
-
try {
|
|
84
|
-
d = statSync(join(dir, name)).isDirectory();
|
|
85
|
-
} catch {
|
|
86
|
-
/* unstatable */
|
|
87
|
-
}
|
|
88
|
-
return { name, dir: d };
|
|
89
|
-
});
|
|
90
|
-
} catch {
|
|
91
|
-
items = [];
|
|
92
|
-
}
|
|
93
|
-
const showHidden = filter.startsWith('.');
|
|
94
|
-
const f = filter.toLowerCase();
|
|
95
|
-
items = items
|
|
96
|
-
.filter((e) => (showHidden || !e.name.startsWith('.')) && e.name.toLowerCase().includes(f))
|
|
97
|
-
.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
|
|
98
|
-
return [{ name: '..', dir: true }, ...items];
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
let entries = load();
|
|
102
|
-
|
|
103
|
-
const render = (): void => {
|
|
104
|
-
if (index >= entries.length) index = Math.max(0, entries.length - 1);
|
|
105
|
-
scroll = clampScroll(index, scroll, entries.length);
|
|
106
|
-
const rows: string[] = [border('╭', '╮')];
|
|
107
|
-
rows.push(c.teal('│') + pad(` ${c.dim('📎 attach a file')} ${c.dim(dir.replace(homedir(), '~'))}`, WIDTH) + c.teal('│'));
|
|
108
|
-
rows.push(border('├', '┤'));
|
|
109
|
-
const shown = entries.slice(scroll, scroll + VISIBLE);
|
|
110
|
-
if (!shown.length) rows.push(c.teal('│') + pad(c.dim(' (no matches)'), WIDTH) + c.teal('│'));
|
|
111
|
-
shown.forEach((e, i) => {
|
|
112
|
-
const real = scroll + i;
|
|
113
|
-
const icon = e.dir ? '📁' : '📄';
|
|
114
|
-
const label = pad(` ${icon} ${e.name}${e.dir && e.name !== '..' ? '/' : ''}`, WIDTH);
|
|
115
|
-
rows.push(c.teal('│') + (real === index ? highlight(label) : label) + c.teal('│'));
|
|
116
|
-
});
|
|
117
|
-
rows.push(border('╰', '╯'));
|
|
118
|
-
rows.push(c.dim(` @${filter}${c.gray('▏')} ↑↓ move · ⏎ ${entries[index]?.dir ? 'open' : 'attach'} · ⌫ up · esc cancel`));
|
|
119
|
-
process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
return runOverlay<string>(render, (done) => (str, key) => {
|
|
123
|
-
if (!key) return;
|
|
124
|
-
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return done(null);
|
|
125
|
-
if (key.name === 'up') return void ((index = Math.max(0, index - 1)), render());
|
|
126
|
-
if (key.name === 'down') return void ((index = Math.min(entries.length - 1, index + 1)), render());
|
|
127
|
-
if (key.name === 'return') {
|
|
128
|
-
const e = entries[index];
|
|
129
|
-
if (!e) return;
|
|
130
|
-
if (e.name === '..') {
|
|
131
|
-
dir = resolve(dir, '..');
|
|
132
|
-
filter = '';
|
|
133
|
-
index = 0;
|
|
134
|
-
scroll = 0;
|
|
135
|
-
entries = load();
|
|
136
|
-
return render();
|
|
137
|
-
}
|
|
138
|
-
const full = join(dir, e.name);
|
|
139
|
-
if (e.dir) {
|
|
140
|
-
dir = full;
|
|
141
|
-
filter = '';
|
|
142
|
-
index = 0;
|
|
143
|
-
scroll = 0;
|
|
144
|
-
entries = load();
|
|
145
|
-
return render();
|
|
146
|
-
}
|
|
147
|
-
const rel = relative(process.cwd(), full);
|
|
148
|
-
return done(rel && !rel.startsWith('..') ? rel : full);
|
|
149
|
-
}
|
|
150
|
-
if (key.name === 'backspace') {
|
|
151
|
-
if (filter) filter = filter.slice(0, -1);
|
|
152
|
-
else dir = resolve(dir, '..');
|
|
153
|
-
index = 0;
|
|
154
|
-
scroll = 0;
|
|
155
|
-
entries = load();
|
|
156
|
-
return render();
|
|
157
|
-
}
|
|
158
|
-
if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
|
|
159
|
-
filter += str;
|
|
160
|
-
index = 0;
|
|
161
|
-
scroll = 0;
|
|
162
|
-
entries = load();
|
|
163
|
-
return render();
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---- generic list picker (slash-command menu, etc.) ------------------------
|
|
169
|
-
|
|
170
|
-
export interface ListItem {
|
|
171
|
-
value: string;
|
|
172
|
-
desc?: string;
|
|
173
|
-
/** Command needs arguments, so selecting it should wait for input, not run. */
|
|
174
|
-
args?: boolean;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Interactive menu over a flat list: ↑↓ move · type to filter · ⏎ select · esc.
|
|
179
|
-
* Returns the chosen item's `value`, or null. `initialFilter` pre-seeds the
|
|
180
|
-
* filter (e.g. what the user already typed after `/`).
|
|
181
|
-
*/
|
|
182
|
-
export function pickFromList(
|
|
183
|
-
title: string,
|
|
184
|
-
allItems: ListItem[],
|
|
185
|
-
initialFilter = '',
|
|
186
|
-
): Promise<string | null> {
|
|
187
|
-
let filter = initialFilter;
|
|
188
|
-
let index = 0;
|
|
189
|
-
let scroll = 0;
|
|
190
|
-
|
|
191
|
-
// Match on the command name only (not its description).
|
|
192
|
-
const match = (): ListItem[] => {
|
|
193
|
-
const f = filter.toLowerCase().replace(/^\//, '');
|
|
194
|
-
return allItems.filter((it) => it.value.toLowerCase().replace(/^\//, '').includes(f));
|
|
195
|
-
};
|
|
196
|
-
let items = match();
|
|
197
|
-
|
|
198
|
-
const LEFT = 14; // width of the value column
|
|
199
|
-
const render = (): void => {
|
|
200
|
-
if (index >= items.length) index = Math.max(0, items.length - 1);
|
|
201
|
-
scroll = clampScroll(index, scroll, items.length);
|
|
202
|
-
const rows: string[] = [border('╭', '╮')];
|
|
203
|
-
rows.push(c.teal('│') + pad(` ${c.dim(title)}`, WIDTH) + c.teal('│'));
|
|
204
|
-
rows.push(border('├', '┤'));
|
|
205
|
-
const shown = items.slice(scroll, scroll + VISIBLE);
|
|
206
|
-
if (!shown.length) rows.push(c.teal('│') + pad(c.dim(' (no matches)'), WIDTH) + c.teal('│'));
|
|
207
|
-
shown.forEach((it, i) => {
|
|
208
|
-
const real = scroll + i;
|
|
209
|
-
const left = pad(' ' + it.value, LEFT);
|
|
210
|
-
let desc = it.desc ?? '';
|
|
211
|
-
const room = WIDTH - LEFT - 1;
|
|
212
|
-
if (desc.length > room) desc = desc.slice(0, room - 1) + '…';
|
|
213
|
-
const display = left + c.dim(desc);
|
|
214
|
-
const line = display + ' '.repeat(Math.max(0, WIDTH - stripAnsi(display).length));
|
|
215
|
-
rows.push(c.teal('│') + (real === index ? highlight(line) : line) + c.teal('│'));
|
|
216
|
-
});
|
|
217
|
-
rows.push(border('╰', '╯'));
|
|
218
|
-
rows.push(c.dim(` ${filter}${c.gray('▏')} ↑↓ move · ⏎ select · esc cancel`));
|
|
219
|
-
process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
return runOverlay<string>(render, (done) => (str, key) => {
|
|
223
|
-
if (!key) return;
|
|
224
|
-
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return done(null);
|
|
225
|
-
if (key.name === 'up') return void ((index = Math.max(0, index - 1)), render());
|
|
226
|
-
if (key.name === 'down') return void ((index = Math.min(items.length - 1, index + 1)), render());
|
|
227
|
-
if (key.name === 'return') {
|
|
228
|
-
const it = items[index];
|
|
229
|
-
return done(it ? it.value : null);
|
|
230
|
-
}
|
|
231
|
-
if (key.name === 'backspace') {
|
|
232
|
-
if (!filter) return done(null); // nothing left to delete → close the menu
|
|
233
|
-
filter = filter.slice(0, -1);
|
|
234
|
-
index = 0;
|
|
235
|
-
scroll = 0;
|
|
236
|
-
items = match();
|
|
237
|
-
return render();
|
|
238
|
-
}
|
|
239
|
-
if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
|
|
240
|
-
filter += str;
|
|
241
|
-
index = 0;
|
|
242
|
-
scroll = 0;
|
|
243
|
-
items = match();
|
|
244
|
-
return render();
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { runWithConnection, type Socket } from '../../whatsapp/client.ts';
|
|
2
|
-
import { findChat, upsertChat } from '../../db/sqlite.ts';
|
|
3
|
-
import { phoneToJid } from '../../utils/jid.ts';
|
|
4
|
-
import { isGroupJid } from '../../whatsapp/events.ts';
|
|
5
|
-
import { print, ok, fail, info } from '../../utils/ui.ts';
|
|
6
|
-
import { c } from '../../utils/format.ts';
|
|
7
|
-
|
|
8
|
-
const USAGE = `Usage:
|
|
9
|
-
wa group info <group>
|
|
10
|
-
wa group invite <group>
|
|
11
|
-
wa group rename <group> <new subject>
|
|
12
|
-
wa group add <group> <number...>
|
|
13
|
-
wa group remove <group> <number...>
|
|
14
|
-
wa group leave <group>
|
|
15
|
-
wa group create <subject> <number...>`;
|
|
16
|
-
|
|
17
|
-
/** wa group <action> [args...] — basic group management. */
|
|
18
|
-
export async function group(action: string | undefined, args: string[] = []): Promise<void> {
|
|
19
|
-
if (!action) {
|
|
20
|
-
print(USAGE);
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
switch (action.toLowerCase()) {
|
|
24
|
-
case 'info': return info_(args);
|
|
25
|
-
case 'invite': return invite(args);
|
|
26
|
-
case 'rename': case 'subject': return rename(args);
|
|
27
|
-
case 'add': return participants(args, 'add');
|
|
28
|
-
case 'remove': case 'kick': return participants(args, 'remove');
|
|
29
|
-
case 'promote': return participants(args, 'promote');
|
|
30
|
-
case 'demote': return participants(args, 'demote');
|
|
31
|
-
case 'leave': case 'exit': return leave(args);
|
|
32
|
-
case 'create': return create(args);
|
|
33
|
-
default:
|
|
34
|
-
fail(`Unknown group action "${action}".\n${USAGE}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Resolve a group target (name or jid) to a group jid. */
|
|
39
|
-
function resolveGroup(target: string | undefined): string {
|
|
40
|
-
if (!target) fail('Missing <group>.');
|
|
41
|
-
if (isGroupJid(target)) return target;
|
|
42
|
-
const chat = findChat(target);
|
|
43
|
-
if (!chat) fail(`No chat matching "${target}".`);
|
|
44
|
-
if (!chat.isGroup) fail(`"${target}" is not a group.`);
|
|
45
|
-
return chat.jid;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Turn phone-number args into WhatsApp jids. */
|
|
49
|
-
function toJids(numbers: string[]): string[] {
|
|
50
|
-
const jids: string[] = [];
|
|
51
|
-
for (const n of numbers) {
|
|
52
|
-
const jid = isGroupJid(n) ? null : n.includes('@') ? n : phoneToJid(n);
|
|
53
|
-
if (!jid) fail(`Invalid phone number: "${n}"`);
|
|
54
|
-
jids.push(jid);
|
|
55
|
-
}
|
|
56
|
-
return jids;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function withGroup<T>(fn: (sock: Socket) => Promise<T>): Promise<T> {
|
|
60
|
-
return runWithConnection((conn) => fn(conn.sock), { requireSession: true }).catch(
|
|
61
|
-
(err) => fail((err as Error).message),
|
|
62
|
-
) as Promise<T>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function info_(args: string[]): Promise<void> {
|
|
66
|
-
const jid = resolveGroup(args[0]);
|
|
67
|
-
info('Fetching group info…');
|
|
68
|
-
const meta = await withGroup((sock) => sock.groupMetadata(jid));
|
|
69
|
-
const admins = meta.participants.filter((p) => p.admin).length;
|
|
70
|
-
print(c.bold(meta.subject) + c.dim(` ${meta.id}`));
|
|
71
|
-
if (meta.desc) print(c.dim(meta.desc));
|
|
72
|
-
print(` Participants: ${c.cyan(String(meta.participants.length))} · Admins: ${c.cyan(String(admins))}`);
|
|
73
|
-
if (meta.owner) print(` Owner: ${c.dim(meta.owner.split('@')[0])}`);
|
|
74
|
-
print(c.dim(' Members:'));
|
|
75
|
-
for (const p of meta.participants.slice(0, 50)) {
|
|
76
|
-
const tag = p.admin ? c.green(p.admin === 'superadmin' ? ' (owner)' : ' (admin)') : '';
|
|
77
|
-
print(` ${p.id.split('@')[0]}${tag}`);
|
|
78
|
-
}
|
|
79
|
-
if (meta.participants.length > 50) print(c.dim(` …and ${meta.participants.length - 50} more`));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function invite(args: string[]): Promise<void> {
|
|
83
|
-
const jid = resolveGroup(args[0]);
|
|
84
|
-
const code = await withGroup((sock) => sock.groupInviteCode(jid));
|
|
85
|
-
ok(`Invite link: https://chat.whatsapp.com/${code}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function rename(args: string[]): Promise<void> {
|
|
89
|
-
const jid = resolveGroup(args[0]);
|
|
90
|
-
const subject = args.slice(1).join(' ').trim();
|
|
91
|
-
if (!subject) fail('Missing new subject.');
|
|
92
|
-
await withGroup((sock) => sock.groupUpdateSubject(jid, subject));
|
|
93
|
-
upsertChat({ jid, name: subject });
|
|
94
|
-
ok(`Renamed group to "${subject}".`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function participants(
|
|
98
|
-
args: string[],
|
|
99
|
-
action: 'add' | 'remove' | 'promote' | 'demote',
|
|
100
|
-
): Promise<void> {
|
|
101
|
-
const jid = resolveGroup(args[0]);
|
|
102
|
-
const jids = toJids(args.slice(1));
|
|
103
|
-
if (jids.length === 0) fail('Provide at least one phone number.');
|
|
104
|
-
info(`${action[0].toUpperCase() + action.slice(1)}ing ${jids.length} participant(s)…`);
|
|
105
|
-
const res = await withGroup((sock) => sock.groupParticipantsUpdate(jid, jids, action));
|
|
106
|
-
for (const r of res) {
|
|
107
|
-
const num = (r.jid ?? '').split('@')[0];
|
|
108
|
-
if (r.status === '200') ok(`${num}: done`);
|
|
109
|
-
else print(` ${c.yellow('!')} ${num}: ${statusHint(r.status ?? '')}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function statusHint(status: string): string {
|
|
114
|
-
switch (status) {
|
|
115
|
-
case '403': return 'not allowed (need admin, or their privacy settings block it)';
|
|
116
|
-
case '404': return 'not on WhatsApp';
|
|
117
|
-
case '408': return 'recently left — try later';
|
|
118
|
-
case '409': return 'already in the group';
|
|
119
|
-
default: return `failed (${status})`;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function leave(args: string[]): Promise<void> {
|
|
124
|
-
const jid = resolveGroup(args[0]);
|
|
125
|
-
await withGroup((sock) => sock.groupLeave(jid));
|
|
126
|
-
upsertChat({ jid, archived: 1 });
|
|
127
|
-
ok('Left the group.');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function create(args: string[]): Promise<void> {
|
|
131
|
-
const subject = args[0];
|
|
132
|
-
if (!subject) fail('Usage: wa group create <subject> <number...>');
|
|
133
|
-
const jids = toJids(args.slice(1));
|
|
134
|
-
if (jids.length === 0) fail('Add at least one participant number to create a group.');
|
|
135
|
-
info(`Creating group "${subject}" with ${jids.length} member(s)…`);
|
|
136
|
-
const meta = await withGroup((sock) => sock.groupCreate(subject, jids));
|
|
137
|
-
upsertChat({ jid: meta.id, name: subject, isGroup: 1, lastTimestamp: Date.now() });
|
|
138
|
-
ok(`Created "${subject}" — ${meta.id}`);
|
|
139
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { findChat, setPinned, setArchived, recentChats } from '../../db/sqlite.ts';
|
|
2
|
-
import { loadConfig } from '../../config/index.ts';
|
|
3
|
-
import { renderChatList, displayName } from '../../utils/format.ts';
|
|
4
|
-
import { print, ok, fail } from '../../utils/ui.ts';
|
|
5
|
-
|
|
6
|
-
/** wa recent [--limit N] — most recently active chats (ignores pins). */
|
|
7
|
-
export function recent(opts: { limit?: string }): void {
|
|
8
|
-
const limit = Math.min(parseInt(opts.limit ?? '15', 10) || 15, 200);
|
|
9
|
-
const cfg = loadConfig();
|
|
10
|
-
print(renderChatList(recentChats(limit), cfg.preferences.time24h));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Pin/archive toggles update the local cache so listings reflect your
|
|
15
|
-
* preference immediately. (They are local-only; they do not change the flag
|
|
16
|
-
* on WhatsApp itself.)
|
|
17
|
-
*/
|
|
18
|
-
export function pin(target: string): void {
|
|
19
|
-
const chat = requireChat(target);
|
|
20
|
-
setPinned(chat.jid, true);
|
|
21
|
-
ok(`Pinned ${displayName(chat)}.`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function unpin(target: string): void {
|
|
25
|
-
const chat = requireChat(target);
|
|
26
|
-
setPinned(chat.jid, false);
|
|
27
|
-
ok(`Unpinned ${displayName(chat)}.`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function archive(target: string): void {
|
|
31
|
-
const chat = requireChat(target);
|
|
32
|
-
setArchived(chat.jid, true);
|
|
33
|
-
ok(`Archived ${displayName(chat)}. (Hidden from \`wa chats\`; use \`-a\` to show.)`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function unarchive(target: string): void {
|
|
37
|
-
const chat = requireChat(target);
|
|
38
|
-
setArchived(chat.jid, false);
|
|
39
|
-
ok(`Unarchived ${displayName(chat)}.`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function requireChat(target: string) {
|
|
43
|
-
const chat = findChat(target);
|
|
44
|
-
if (!chat) fail(`No chat matching "${target}". Try \`wa chats\`.`);
|
|
45
|
-
return chat;
|
|
46
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
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.ts';
|
|
5
|
-
import { logger } from '../../whatsapp/logger.ts';
|
|
6
|
-
import {
|
|
7
|
-
listMedia,
|
|
8
|
-
getMessageById,
|
|
9
|
-
setMediaPath,
|
|
10
|
-
findChat,
|
|
11
|
-
} from '../../db/sqlite.ts';
|
|
12
|
-
import { PATHS, ensureDirs, loadConfig } from '../../config/index.ts';
|
|
13
|
-
import { c, formatTime, displayName, truncate } from '../../utils/format.ts';
|
|
14
|
-
import { print, ok, fail, info } from '../../utils/ui.ts';
|
|
15
|
-
|
|
16
|
-
const TYPE_ALIASES: Record<string, string[]> = {
|
|
17
|
-
photos: ['image'],
|
|
18
|
-
photo: ['image'],
|
|
19
|
-
images: ['image'],
|
|
20
|
-
image: ['image'],
|
|
21
|
-
videos: ['video'],
|
|
22
|
-
video: ['video'],
|
|
23
|
-
audio: ['audio'],
|
|
24
|
-
voice: ['audio'],
|
|
25
|
-
docs: ['document'],
|
|
26
|
-
documents: ['document'],
|
|
27
|
-
document: ['document'],
|
|
28
|
-
stickers: ['sticker'],
|
|
29
|
-
all: [],
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** wa media [type] [--limit N] — list media messages from the cache. */
|
|
33
|
-
export function media(type: string | undefined, opts: { limit?: string }): void {
|
|
34
|
-
const limit = Math.min(parseInt(opts.limit ?? '30', 10) || 30, 1000);
|
|
35
|
-
let types: string[] | null = null;
|
|
36
|
-
if (type) {
|
|
37
|
-
const mapped = TYPE_ALIASES[type.toLowerCase()];
|
|
38
|
-
if (mapped === undefined) {
|
|
39
|
-
fail(`Unknown media type "${type}". Try: photos, videos, audio, documents, stickers, all.`);
|
|
40
|
-
}
|
|
41
|
-
types = mapped.length ? mapped : null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const rows = listMedia(types, limit);
|
|
45
|
-
if (rows.length === 0) {
|
|
46
|
-
print(c.dim('No media cached. Run `wa sync` after logging in.'));
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const cfg = loadConfig();
|
|
50
|
-
const width = String(rows[rows.length - 1].id).length;
|
|
51
|
-
for (const m of rows) {
|
|
52
|
-
const chat = findChat(m.chatJid);
|
|
53
|
-
const where = c.magenta(chat ? displayName(chat) : m.chatJid.split('@')[0]);
|
|
54
|
-
const time = c.gray(formatTime(m.timestamp, cfg.preferences.time24h));
|
|
55
|
-
const kind = c.cyan(`[${m.type}]`);
|
|
56
|
-
const caption = m.text ? ' ' + c.dim(truncate(m.text, 40)) : '';
|
|
57
|
-
const saved = m.mediaPath ? c.green(' ✓saved') : '';
|
|
58
|
-
print(`${c.dim('#' + String(m.id).padStart(width))} ${kind} ${where} ${time}${caption}${saved}`);
|
|
59
|
-
}
|
|
60
|
-
print();
|
|
61
|
-
info('Download one with `wa download <id>`.');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** wa download <id> [--out FILE] — fetch & decrypt a cached media message. */
|
|
65
|
-
export async function download(idRaw: string, opts: { out?: string }): Promise<void> {
|
|
66
|
-
const id = parseInt(idRaw, 10);
|
|
67
|
-
if (!Number.isFinite(id)) fail(`Invalid id "${idRaw}".`);
|
|
68
|
-
const row = getMessageById(id);
|
|
69
|
-
if (!row) fail(`No cached message with id #${id}.`);
|
|
70
|
-
if (!row.raw) fail(`Message #${id} has no downloadable media (text message?).`);
|
|
71
|
-
|
|
72
|
-
ensureDirs();
|
|
73
|
-
info(`Downloading media from #${id}…`);
|
|
74
|
-
|
|
75
|
-
const content = proto.Message.decode(Buffer.from(row.raw, 'base64'));
|
|
76
|
-
const waMsg = {
|
|
77
|
-
key: {
|
|
78
|
-
remoteJid: row.chatJid,
|
|
79
|
-
id: row.messageId,
|
|
80
|
-
fromMe: !!row.fromMe,
|
|
81
|
-
},
|
|
82
|
-
message: content,
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const buffer = await runWithConnection(
|
|
86
|
-
async (conn) =>
|
|
87
|
-
(await downloadMediaMessage(
|
|
88
|
-
waMsg as never,
|
|
89
|
-
'buffer',
|
|
90
|
-
{},
|
|
91
|
-
{ logger: logger as never, reuploadRequest: conn.sock.updateMediaMessage },
|
|
92
|
-
)) as Buffer,
|
|
93
|
-
{ requireSession: true },
|
|
94
|
-
).catch((err) => fail(`Download failed: ${(err as Error).message}`));
|
|
95
|
-
|
|
96
|
-
const ext = extensionFor(content, row.type);
|
|
97
|
-
const file = opts.out ?? join(PATHS.media, `${id}${ext}`);
|
|
98
|
-
writeFileSync(file, buffer as Buffer);
|
|
99
|
-
setMediaPath(id, file);
|
|
100
|
-
ok(`Saved → ${file} (${((buffer as Buffer).length / 1024).toFixed(1)} KB)`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function extensionFor(content: proto.IMessage, type: string | null): string {
|
|
104
|
-
const mime =
|
|
105
|
-
content.imageMessage?.mimetype ||
|
|
106
|
-
content.videoMessage?.mimetype ||
|
|
107
|
-
content.audioMessage?.mimetype ||
|
|
108
|
-
content.documentMessage?.mimetype ||
|
|
109
|
-
content.stickerMessage?.mimetype ||
|
|
110
|
-
'';
|
|
111
|
-
const fromMime = mime.split(';')[0].split('/')[1];
|
|
112
|
-
if (fromMime) return '.' + fromMime.replace('jpeg', 'jpg');
|
|
113
|
-
switch (type) {
|
|
114
|
-
case 'image':
|
|
115
|
-
return '.jpg';
|
|
116
|
-
case 'video':
|
|
117
|
-
return '.mp4';
|
|
118
|
-
case 'audio':
|
|
119
|
-
return '.ogg';
|
|
120
|
-
case 'sticker':
|
|
121
|
-
return '.webp';
|
|
122
|
-
default:
|
|
123
|
-
return '.bin';
|
|
124
|
-
}
|
|
125
|
-
}
|