cli-whatsapp 0.1.1 → 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.
Files changed (45) hide show
  1. package/bin/wa.js +4 -3
  2. package/dist/cli/commands/auth.js +220 -0
  3. package/dist/cli/commands/export.js +51 -0
  4. package/dist/cli/commands/filepicker.js +232 -0
  5. package/dist/cli/commands/group.js +139 -0
  6. package/dist/cli/commands/manage.js +41 -0
  7. package/dist/cli/commands/media.js +105 -0
  8. package/dist/cli/commands/misc.js +112 -0
  9. package/dist/cli/commands/read.js +79 -0
  10. package/dist/cli/commands/send.js +137 -0
  11. package/dist/cli/commands/shell.js +611 -0
  12. package/dist/cli/commands/sync.js +67 -0
  13. package/dist/cli/index.js +174 -0
  14. package/dist/config/index.js +59 -0
  15. package/{src/db/schema.ts → dist/db/schema.js} +0 -1
  16. package/dist/db/sqlite.js +237 -0
  17. package/dist/utils/format.js +89 -0
  18. package/dist/utils/jid.js +40 -0
  19. package/dist/utils/notify.js +29 -0
  20. package/dist/utils/ui.js +29 -0
  21. package/dist/whatsapp/client.js +157 -0
  22. package/dist/whatsapp/events.js +161 -0
  23. package/dist/whatsapp/logger.js +52 -0
  24. package/package.json +5 -3
  25. package/src/cli/commands/auth.ts +0 -236
  26. package/src/cli/commands/export.ts +0 -60
  27. package/src/cli/commands/filepicker.ts +0 -247
  28. package/src/cli/commands/group.ts +0 -139
  29. package/src/cli/commands/manage.ts +0 -46
  30. package/src/cli/commands/media.ts +0 -125
  31. package/src/cli/commands/misc.ts +0 -113
  32. package/src/cli/commands/read.ts +0 -99
  33. package/src/cli/commands/send.ts +0 -201
  34. package/src/cli/commands/shell.ts +0 -596
  35. package/src/cli/commands/sync.ts +0 -80
  36. package/src/cli/index.ts +0 -198
  37. package/src/config/index.ts +0 -92
  38. package/src/db/sqlite.ts +0 -354
  39. package/src/utils/format.ts +0 -102
  40. package/src/utils/jid.ts +0 -51
  41. package/src/utils/notify.ts +0 -29
  42. package/src/utils/ui.ts +0 -35
  43. package/src/whatsapp/client.ts +0 -207
  44. package/src/whatsapp/events.ts +0 -166
  45. package/src/whatsapp/logger.ts +0 -71
package/bin/wa.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Thin launcher. Node (>=22.5 with --experimental-strip-types, default in >=23.6)
3
- // strips TypeScript types at runtime, so no build step is required.
4
- import '../src/cli/index.ts';
2
+ // Entry point. Runs the compiled JavaScript from dist/ (Node does not strip
3
+ // TypeScript types for files under node_modules, so published installs must ship
4
+ // compiled JS). Build with `npm run build`.
5
+ import '../dist/cli/index.js';
@@ -0,0 +1,220 @@
1
+ import { rmSync, existsSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import qrcode from 'qrcode-terminal';
4
+ import { connect, hasSession, clearSession } from "../../whatsapp/client.js";
5
+ import { registerPersistence } from "../../whatsapp/events.js";
6
+ import { loadConfig, saveConfig, PATHS } from "../../config/index.js";
7
+ import { countMessages, listChats } from "../../db/sqlite.js";
8
+ import { ok, info, warn, fail, print } from "../../utils/ui.js";
9
+ import { c } from "../../utils/format.js";
10
+ /** Default country code prepended to bare 10-digit numbers. */
11
+ const DEFAULT_CC = '91';
12
+ /** Normalize user input to an international number (digits only), or null. */
13
+ function normalizePhone(raw) {
14
+ let d = (raw || '').replace(/[^\d]/g, '');
15
+ if (d.length === 11 && d.startsWith('0'))
16
+ d = d.slice(1); // drop trunk 0
17
+ if (d.length === 10)
18
+ d = DEFAULT_CC + d; // bare local → add country code
19
+ return d.length >= 11 ? d : null;
20
+ }
21
+ /** Prompt for a phone number (used by the default pairing-code login). */
22
+ async function promptPhone() {
23
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
24
+ try {
25
+ const ans = await rl.question(`${c.cyan('?')} Enter your WhatsApp number ${c.dim(`(10-digit; +${DEFAULT_CC} added automatically)`)}: `);
26
+ return ans.trim();
27
+ }
28
+ finally {
29
+ rl.close();
30
+ }
31
+ }
32
+ /**
33
+ * wa login → prompt for number, link with a pairing code (default)
34
+ * wa login --phone N → pairing code for N (skips the prompt)
35
+ * wa login qr → link by scanning a QR code
36
+ */
37
+ export async function login(mode, opts = {}) {
38
+ const useQR = (mode ?? '').toLowerCase() === 'qr';
39
+ const hadSession = hasSession();
40
+ // Resolve the number for pairing (prompt if needed). QR mode skips this.
41
+ const resolvePhone = async () => {
42
+ const raw = opts.phone ?? (await promptPhone());
43
+ const norm = normalizePhone(raw);
44
+ if (!norm) {
45
+ fail('Enter a valid WhatsApp number — 10 digits (e.g. 9371432334) or full international (e.g. 919371432334).');
46
+ }
47
+ return norm;
48
+ };
49
+ let pairingPhone;
50
+ if (!hadSession && !useQR)
51
+ pairingPhone = await resolvePhone();
52
+ if (hadSession) {
53
+ info('A session already exists. Verifying…');
54
+ }
55
+ else if (pairingPhone) {
56
+ info(`Requesting a pairing code for +${pairingPhone}…`);
57
+ }
58
+ else {
59
+ info('A QR code will appear — scan it in WhatsApp:');
60
+ print(c.dim(' Phone → Settings → Linked Devices → Link a Device'));
61
+ }
62
+ let shownQR = false;
63
+ const onQR = (qr) => {
64
+ if (!shownQR && hadSession) {
65
+ warn('Your saved session has expired or was unlinked — re-scan to link again:');
66
+ print(c.dim(' Phone → Settings → Linked Devices → Link a Device'));
67
+ }
68
+ shownQR = true;
69
+ print();
70
+ qrcode.generate(qr, { small: true });
71
+ print(c.dim('Scan it — this window will confirm and close automatically. (Ctrl+C to cancel)'));
72
+ };
73
+ const onPairingCode = (code) => {
74
+ shownQR = true; // a fresh link is happening (drives history sync below)
75
+ print();
76
+ print(` ${c.dim('Pairing code:')} ${c.bold(c.green(code))}`);
77
+ print(c.dim(' On your phone: Linked Devices → Link a Device →'));
78
+ print(c.dim(' "Link with phone number instead" → enter this code.'));
79
+ print(c.dim(' Waiting… (Ctrl+C to cancel)'));
80
+ };
81
+ // No valid session but stale files may linger (corrupt/partial creds from an
82
+ // interrupted attempt). Clear them so we always start a fresh link cleanly.
83
+ if (!hadSession)
84
+ clearSession();
85
+ const mkOpts = () => ({
86
+ syncFullHistory: true,
87
+ onQR,
88
+ pairingPhone,
89
+ onPairingCode,
90
+ });
91
+ const linkKind = () => (pairingPhone ? 'pairing code' : 'QR code');
92
+ const retryCmd = () => (pairingPhone ? `wa login --phone ${pairingPhone}` : 'wa login qr');
93
+ const retryHint = () => `Run \`${retryCmd()}\` to get a new ${linkKind()}.`;
94
+ // Establish the connection, tolerating the post-pairing "restart required"
95
+ // (code 515) that WhatsApp sends immediately after a fresh link.
96
+ let conn = await connect(mkOpts());
97
+ registerPersistence(conn.sock); // persist history WhatsApp streams after link
98
+ const MAX_RESTARTS = 4;
99
+ let healedLogout = false;
100
+ for (let restart = 0;; restart++) {
101
+ // A pairing code / QR is only valid for a short window; don't wait forever.
102
+ const timeout = pairingPhone ? 90_000 : 120_000;
103
+ try {
104
+ await withTimeout(conn.ready, timeout, 'LINK_TIMEOUT');
105
+ break; // connection open
106
+ }
107
+ catch (err) {
108
+ conn.close();
109
+ const msg = err.message;
110
+ if (msg === 'LOGGED_OUT') {
111
+ // The stored session is dead server-side. Wipe it and re-link fresh
112
+ // once, so the user doesn't have to run `wa logout` manually.
113
+ if (!healedLogout) {
114
+ healedLogout = true;
115
+ clearSession();
116
+ warn('Previous session was logged out — starting a fresh link…');
117
+ if (!useQR && !pairingPhone)
118
+ pairingPhone = await resolvePhone();
119
+ conn = await connect(mkOpts());
120
+ registerPersistence(conn.sock);
121
+ continue;
122
+ }
123
+ fail(`This device was logged out. ${retryHint()}`);
124
+ }
125
+ if (msg.startsWith('PAIRING_FAILED')) {
126
+ const reason = msg.split(':').slice(1).join(':') || 'unknown error';
127
+ fail(`Pairing failed (${reason}). Make sure ${pairingPhone ? `+${pairingPhone}` : 'the number'} is your own WhatsApp number, then run \`${retryCmd()}\`.`);
128
+ }
129
+ // 515 = restart required (normal right after linking): reconnect silently.
130
+ if (msg.endsWith(':515') && restart < MAX_RESTARTS) {
131
+ conn = await connect(mkOpts());
132
+ registerPersistence(conn.sock);
133
+ continue;
134
+ }
135
+ // Timed out, or the code/QR expired or was rejected before pairing.
136
+ if (msg === 'LINK_TIMEOUT') {
137
+ fail(`Your ${linkKind()} expired — no device linked in time. ${retryHint()}`);
138
+ }
139
+ if (msg.startsWith('CONNECTION_CLOSED')) {
140
+ fail(`Your ${linkKind()} expired or was rejected. ${retryHint()}`);
141
+ }
142
+ fail(msg);
143
+ }
144
+ }
145
+ ok('Logged in successfully.');
146
+ const cfg = loadConfig();
147
+ cfg.session.loggedIn = true;
148
+ // Give WhatsApp a moment to stream initial history into the cache.
149
+ if (shownQR) {
150
+ info('Syncing initial history…');
151
+ await new Promise((r) => setTimeout(r, 6000));
152
+ const chats = listChats().length;
153
+ const msgs = countMessages();
154
+ // The initial history capture is a sync — record it so `status` is accurate.
155
+ cfg.session.lastSyncAt = Date.now();
156
+ ok(`Cached ${chats} chats and ${msgs} messages so far.`);
157
+ info('Run `wa sync` any time to pull more, `wa chats` to browse.');
158
+ }
159
+ saveConfig(cfg);
160
+ conn.close();
161
+ }
162
+ /** wa logout — end the session and delete local credentials. */
163
+ export async function logout() {
164
+ if (!hasSession()) {
165
+ warn('No active session found.');
166
+ }
167
+ else {
168
+ // Best-effort: tell WhatsApp to unlink this device, then wipe creds.
169
+ try {
170
+ const conn = await connect({ requireSession: true });
171
+ await withTimeout(conn.ready, 15_000, 'timeout').catch(() => { });
172
+ try {
173
+ await conn.sock.logout();
174
+ }
175
+ catch {
176
+ /* server-side logout best effort */
177
+ }
178
+ conn.close();
179
+ }
180
+ catch {
181
+ /* offline logout still wipes local creds below */
182
+ }
183
+ rmSync(PATHS.sessions, { recursive: true, force: true });
184
+ ok('Logged out and removed local session.');
185
+ }
186
+ const cfg = loadConfig();
187
+ cfg.session.loggedIn = false;
188
+ saveConfig(cfg);
189
+ }
190
+ /** wa status — show login and cache state without a network round-trip. */
191
+ export function status() {
192
+ const cfg = loadConfig();
193
+ const session = hasSession();
194
+ print(c.bold('WhatsApp CLI status'));
195
+ print();
196
+ // Connection: whether the live link to WhatsApp is established.
197
+ print(c.dim(' Connection'));
198
+ print(` Session: ${session ? c.green('● linked') : c.red('○ not linked')}`);
199
+ if (!session) {
200
+ print(c.dim(' (sending & syncing new messages need a link)'));
201
+ }
202
+ print();
203
+ // Local cache: your data on disk — readable offline, independent of the link.
204
+ print(c.dim(' Local cache') + c.dim(' — readable offline, even when not linked'));
205
+ print(` Messages: ${countMessages()} in ${listChats(100000).length} chats`);
206
+ if (cfg.session.lastSyncAt) {
207
+ print(` Last sync: ${new Date(cfg.session.lastSyncAt).toLocaleString()}`);
208
+ }
209
+ print(` Config: ${existsSync(PATHS.config) ? PATHS.config : c.dim('defaults')}`);
210
+ if (!session) {
211
+ print();
212
+ info('Your cached chats still work (`wa chats`, `wa search`). Run `wa login` to send/receive again.');
213
+ }
214
+ }
215
+ function withTimeout(p, ms, message) {
216
+ return Promise.race([
217
+ p,
218
+ new Promise((_, rej) => setTimeout(() => rej(new Error(message)), ms)),
219
+ ]);
220
+ }
@@ -0,0 +1,51 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { findChat, getMessages } from "../../db/sqlite.js";
4
+ import { displayName, formatTime } from "../../utils/format.js";
5
+ import { ok, fail, print } from "../../utils/ui.js";
6
+ /** wa export <chat> [--format md|txt|json] [--limit N] [--out FILE] */
7
+ export function exportChat(target, opts) {
8
+ const chat = findChat(target);
9
+ if (!chat)
10
+ fail(`No chat matching "${target}".`);
11
+ const fmt = normalizeFmt(opts.format);
12
+ const limit = Math.min(parseInt(opts.limit ?? '1000', 10) || 1000, 100000);
13
+ const msgs = getMessages(chat.jid, limit);
14
+ const name = displayName(chat);
15
+ let body;
16
+ if (fmt === 'json') {
17
+ body = JSON.stringify({ chat: { jid: chat.jid, name }, messages: msgs }, null, 2);
18
+ }
19
+ else if (fmt === 'txt') {
20
+ body = msgs
21
+ .map((m) => `[${formatTime(m.timestamp)}] ${m.fromMe ? 'me' : m.sender ?? '?'}: ${m.text ?? '[' + (m.type ?? 'media') + ']'}`)
22
+ .join('\n');
23
+ }
24
+ else {
25
+ body =
26
+ `# ${name}\n\n` +
27
+ msgs
28
+ .map((m) => `**${m.fromMe ? 'me' : m.sender ?? '?'}** _(${formatTime(m.timestamp)})_ \n${m.text ?? '`[' + (m.type ?? 'media') + ']`'}\n`)
29
+ .join('\n');
30
+ }
31
+ if (opts.out === '-') {
32
+ print(body);
33
+ return;
34
+ }
35
+ const file = opts.out ?? join(process.cwd(), `${slug(name)}.${fmt === 'md' ? 'md' : fmt}`);
36
+ writeFileSync(file, body + '\n', 'utf8');
37
+ ok(`Exported ${msgs.length} messages → ${file}`);
38
+ }
39
+ function normalizeFmt(f) {
40
+ const v = (f ?? 'md').toLowerCase();
41
+ if (v === 'markdown' || v === 'md')
42
+ return 'md';
43
+ if (v === 'txt' || v === 'text')
44
+ return 'txt';
45
+ if (v === 'json')
46
+ return 'json';
47
+ return 'md';
48
+ }
49
+ function slug(s) {
50
+ return s.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'chat';
51
+ }
@@ -0,0 +1,232 @@
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.js";
6
+ const VISIBLE = 12; // rows shown at once
7
+ const WIDTH = 58; // inner box width
8
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
9
+ function pad(s, w) {
10
+ const len = stripAnsi(s).length; // emoji surrogate pairs ≈ 2 cols, matches display
11
+ return len >= w ? s : s + ' '.repeat(w - len);
12
+ }
13
+ /** Green highlight for the selected row (WhatsApp green bg, dark text). */
14
+ function highlight(s) {
15
+ return `\x1b[48;2;37;211;102m\x1b[38;2;7;46;36m${stripAnsi(s)}\x1b[0m`;
16
+ }
17
+ const border = (l, r) => c.teal(l + '─'.repeat(WIDTH) + r);
18
+ /**
19
+ * Generic raw-mode overlay runner: sets up keypress capture (stealing readline's
20
+ * handlers for the duration), renders below the prompt, and restores everything
21
+ * on exit. `render` draws the overlay; `makeOnKey(done)` returns the keypress
22
+ * handler and calls `done(result)` to close. No-op (null) on a non-TTY.
23
+ */
24
+ function runOverlay(render, makeOnKey) {
25
+ const input = process.stdin;
26
+ if (!input.isTTY)
27
+ return Promise.resolve(null);
28
+ readline.emitKeypressEvents(input);
29
+ const wasRaw = !!input.isRaw;
30
+ input.setRawMode(true);
31
+ const saved = input.listeners('keypress');
32
+ input.removeAllListeners('keypress');
33
+ process.stdout.write('\x1b7'); // save cursor at the prompt
34
+ render();
35
+ return new Promise((res) => {
36
+ let onKey;
37
+ const done = (result) => {
38
+ input.removeListener('keypress', onKey);
39
+ process.stdout.write('\x1b8\x1b[0J'); // restore cursor, wipe overlay
40
+ if (input.isTTY)
41
+ input.setRawMode(wasRaw);
42
+ for (const l of saved)
43
+ input.on('keypress', l);
44
+ res(result);
45
+ };
46
+ onKey = makeOnKey(done);
47
+ input.on('keypress', onKey);
48
+ });
49
+ }
50
+ /** Shared list-window math + scroll clamp. */
51
+ function clampScroll(index, scroll, count) {
52
+ if (index < scroll)
53
+ return index;
54
+ if (index >= scroll + VISIBLE)
55
+ return index - VISIBLE + 1;
56
+ return Math.min(scroll, Math.max(0, count - 1));
57
+ }
58
+ /** Interactive file picker: ↑↓ move · type to filter · ⏎ open/pick · ⌫ up · esc. */
59
+ export function pickFile(startDir) {
60
+ let dir = resolve(startDir);
61
+ let filter = '';
62
+ let index = 0;
63
+ let scroll = 0;
64
+ const load = () => {
65
+ let items = [];
66
+ try {
67
+ items = readdirSync(dir).map((name) => {
68
+ let d = false;
69
+ try {
70
+ d = statSync(join(dir, name)).isDirectory();
71
+ }
72
+ catch {
73
+ /* unstatable */
74
+ }
75
+ return { name, dir: d };
76
+ });
77
+ }
78
+ catch {
79
+ items = [];
80
+ }
81
+ const showHidden = filter.startsWith('.');
82
+ const f = filter.toLowerCase();
83
+ items = items
84
+ .filter((e) => (showHidden || !e.name.startsWith('.')) && e.name.toLowerCase().includes(f))
85
+ .sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
86
+ return [{ name: '..', dir: true }, ...items];
87
+ };
88
+ let entries = load();
89
+ const render = () => {
90
+ if (index >= entries.length)
91
+ index = Math.max(0, entries.length - 1);
92
+ scroll = clampScroll(index, scroll, entries.length);
93
+ const rows = [border('╭', '╮')];
94
+ rows.push(c.teal('│') + pad(` ${c.dim('📎 attach a file')} ${c.dim(dir.replace(homedir(), '~'))}`, WIDTH) + c.teal('│'));
95
+ rows.push(border('├', '┤'));
96
+ const shown = entries.slice(scroll, scroll + VISIBLE);
97
+ if (!shown.length)
98
+ rows.push(c.teal('│') + pad(c.dim(' (no matches)'), WIDTH) + c.teal('│'));
99
+ shown.forEach((e, i) => {
100
+ const real = scroll + i;
101
+ const icon = e.dir ? '📁' : '📄';
102
+ const label = pad(` ${icon} ${e.name}${e.dir && e.name !== '..' ? '/' : ''}`, WIDTH);
103
+ rows.push(c.teal('│') + (real === index ? highlight(label) : label) + c.teal('│'));
104
+ });
105
+ rows.push(border('╰', '╯'));
106
+ rows.push(c.dim(` @${filter}${c.gray('▏')} ↑↓ move · ⏎ ${entries[index]?.dir ? 'open' : 'attach'} · ⌫ up · esc cancel`));
107
+ process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
108
+ };
109
+ return runOverlay(render, (done) => (str, key) => {
110
+ if (!key)
111
+ return;
112
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c'))
113
+ return done(null);
114
+ if (key.name === 'up')
115
+ return void ((index = Math.max(0, index - 1)), render());
116
+ if (key.name === 'down')
117
+ return void ((index = Math.min(entries.length - 1, index + 1)), render());
118
+ if (key.name === 'return') {
119
+ const e = entries[index];
120
+ if (!e)
121
+ return;
122
+ if (e.name === '..') {
123
+ dir = resolve(dir, '..');
124
+ filter = '';
125
+ index = 0;
126
+ scroll = 0;
127
+ entries = load();
128
+ return render();
129
+ }
130
+ const full = join(dir, e.name);
131
+ if (e.dir) {
132
+ dir = full;
133
+ filter = '';
134
+ index = 0;
135
+ scroll = 0;
136
+ entries = load();
137
+ return render();
138
+ }
139
+ const rel = relative(process.cwd(), full);
140
+ return done(rel && !rel.startsWith('..') ? rel : full);
141
+ }
142
+ if (key.name === 'backspace') {
143
+ if (filter)
144
+ filter = filter.slice(0, -1);
145
+ else
146
+ dir = resolve(dir, '..');
147
+ index = 0;
148
+ scroll = 0;
149
+ entries = load();
150
+ return render();
151
+ }
152
+ if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
153
+ filter += str;
154
+ index = 0;
155
+ scroll = 0;
156
+ entries = load();
157
+ return render();
158
+ }
159
+ });
160
+ }
161
+ /**
162
+ * Interactive menu over a flat list: ↑↓ move · type to filter · ⏎ select · esc.
163
+ * Returns the chosen item's `value`, or null. `initialFilter` pre-seeds the
164
+ * filter (e.g. what the user already typed after `/`).
165
+ */
166
+ export function pickFromList(title, allItems, initialFilter = '') {
167
+ let filter = initialFilter;
168
+ let index = 0;
169
+ let scroll = 0;
170
+ // Match on the command name only (not its description).
171
+ const match = () => {
172
+ const f = filter.toLowerCase().replace(/^\//, '');
173
+ return allItems.filter((it) => it.value.toLowerCase().replace(/^\//, '').includes(f));
174
+ };
175
+ let items = match();
176
+ const LEFT = 14; // width of the value column
177
+ const render = () => {
178
+ if (index >= items.length)
179
+ index = Math.max(0, items.length - 1);
180
+ scroll = clampScroll(index, scroll, items.length);
181
+ const rows = [border('╭', '╮')];
182
+ rows.push(c.teal('│') + pad(` ${c.dim(title)}`, WIDTH) + c.teal('│'));
183
+ rows.push(border('├', '┤'));
184
+ const shown = items.slice(scroll, scroll + VISIBLE);
185
+ if (!shown.length)
186
+ rows.push(c.teal('│') + pad(c.dim(' (no matches)'), WIDTH) + c.teal('│'));
187
+ shown.forEach((it, i) => {
188
+ const real = scroll + i;
189
+ const left = pad(' ' + it.value, LEFT);
190
+ let desc = it.desc ?? '';
191
+ const room = WIDTH - LEFT - 1;
192
+ if (desc.length > room)
193
+ desc = desc.slice(0, room - 1) + '…';
194
+ const display = left + c.dim(desc);
195
+ const line = display + ' '.repeat(Math.max(0, WIDTH - stripAnsi(display).length));
196
+ rows.push(c.teal('│') + (real === index ? highlight(line) : line) + c.teal('│'));
197
+ });
198
+ rows.push(border('╰', '╯'));
199
+ rows.push(c.dim(` ${filter}${c.gray('▏')} ↑↓ move · ⏎ select · esc cancel`));
200
+ process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
201
+ };
202
+ return runOverlay(render, (done) => (str, key) => {
203
+ if (!key)
204
+ return;
205
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c'))
206
+ return done(null);
207
+ if (key.name === 'up')
208
+ return void ((index = Math.max(0, index - 1)), render());
209
+ if (key.name === 'down')
210
+ return void ((index = Math.min(items.length - 1, index + 1)), render());
211
+ if (key.name === 'return') {
212
+ const it = items[index];
213
+ return done(it ? it.value : null);
214
+ }
215
+ if (key.name === 'backspace') {
216
+ if (!filter)
217
+ return done(null); // nothing left to delete → close the menu
218
+ filter = filter.slice(0, -1);
219
+ index = 0;
220
+ scroll = 0;
221
+ items = match();
222
+ return render();
223
+ }
224
+ if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
225
+ filter += str;
226
+ index = 0;
227
+ scroll = 0;
228
+ items = match();
229
+ return render();
230
+ }
231
+ });
232
+ }
@@ -0,0 +1,139 @@
1
+ import { runWithConnection } from "../../whatsapp/client.js";
2
+ import { findChat, upsertChat } from "../../db/sqlite.js";
3
+ import { phoneToJid } from "../../utils/jid.js";
4
+ import { isGroupJid } from "../../whatsapp/events.js";
5
+ import { print, ok, fail, info } from "../../utils/ui.js";
6
+ import { c } from "../../utils/format.js";
7
+ const USAGE = `Usage:
8
+ wa group info <group>
9
+ wa group invite <group>
10
+ wa group rename <group> <new subject>
11
+ wa group add <group> <number...>
12
+ wa group remove <group> <number...>
13
+ wa group leave <group>
14
+ wa group create <subject> <number...>`;
15
+ /** wa group <action> [args...] — basic group management. */
16
+ export async function group(action, args = []) {
17
+ if (!action) {
18
+ print(USAGE);
19
+ return;
20
+ }
21
+ switch (action.toLowerCase()) {
22
+ case 'info': return info_(args);
23
+ case 'invite': return invite(args);
24
+ case 'rename':
25
+ case 'subject': return rename(args);
26
+ case 'add': return participants(args, 'add');
27
+ case 'remove':
28
+ case 'kick': return participants(args, 'remove');
29
+ case 'promote': return participants(args, 'promote');
30
+ case 'demote': return participants(args, 'demote');
31
+ case 'leave':
32
+ case 'exit': return leave(args);
33
+ case 'create': return create(args);
34
+ default:
35
+ fail(`Unknown group action "${action}".\n${USAGE}`);
36
+ }
37
+ }
38
+ /** Resolve a group target (name or jid) to a group jid. */
39
+ function resolveGroup(target) {
40
+ if (!target)
41
+ fail('Missing <group>.');
42
+ if (isGroupJid(target))
43
+ return target;
44
+ const chat = findChat(target);
45
+ if (!chat)
46
+ fail(`No chat matching "${target}".`);
47
+ if (!chat.isGroup)
48
+ fail(`"${target}" is not a group.`);
49
+ return chat.jid;
50
+ }
51
+ /** Turn phone-number args into WhatsApp jids. */
52
+ function toJids(numbers) {
53
+ const jids = [];
54
+ for (const n of numbers) {
55
+ const jid = isGroupJid(n) ? null : n.includes('@') ? n : phoneToJid(n);
56
+ if (!jid)
57
+ fail(`Invalid phone number: "${n}"`);
58
+ jids.push(jid);
59
+ }
60
+ return jids;
61
+ }
62
+ async function withGroup(fn) {
63
+ return runWithConnection((conn) => fn(conn.sock), { requireSession: true }).catch((err) => fail(err.message));
64
+ }
65
+ async function info_(args) {
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)
72
+ print(c.dim(meta.desc));
73
+ print(` Participants: ${c.cyan(String(meta.participants.length))} · Admins: ${c.cyan(String(admins))}`);
74
+ if (meta.owner)
75
+ print(` Owner: ${c.dim(meta.owner.split('@')[0])}`);
76
+ print(c.dim(' Members:'));
77
+ for (const p of meta.participants.slice(0, 50)) {
78
+ const tag = p.admin ? c.green(p.admin === 'superadmin' ? ' (owner)' : ' (admin)') : '';
79
+ print(` ${p.id.split('@')[0]}${tag}`);
80
+ }
81
+ if (meta.participants.length > 50)
82
+ print(c.dim(` …and ${meta.participants.length - 50} more`));
83
+ }
84
+ async function invite(args) {
85
+ const jid = resolveGroup(args[0]);
86
+ const code = await withGroup((sock) => sock.groupInviteCode(jid));
87
+ ok(`Invite link: https://chat.whatsapp.com/${code}`);
88
+ }
89
+ async function rename(args) {
90
+ const jid = resolveGroup(args[0]);
91
+ const subject = args.slice(1).join(' ').trim();
92
+ if (!subject)
93
+ fail('Missing new subject.');
94
+ await withGroup((sock) => sock.groupUpdateSubject(jid, subject));
95
+ upsertChat({ jid, name: subject });
96
+ ok(`Renamed group to "${subject}".`);
97
+ }
98
+ async function participants(args, action) {
99
+ const jid = resolveGroup(args[0]);
100
+ const jids = toJids(args.slice(1));
101
+ if (jids.length === 0)
102
+ fail('Provide at least one phone number.');
103
+ info(`${action[0].toUpperCase() + action.slice(1)}ing ${jids.length} participant(s)…`);
104
+ const res = await withGroup((sock) => sock.groupParticipantsUpdate(jid, jids, action));
105
+ for (const r of res) {
106
+ const num = (r.jid ?? '').split('@')[0];
107
+ if (r.status === '200')
108
+ ok(`${num}: done`);
109
+ else
110
+ print(` ${c.yellow('!')} ${num}: ${statusHint(r.status ?? '')}`);
111
+ }
112
+ }
113
+ function statusHint(status) {
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
+ async function leave(args) {
123
+ const jid = resolveGroup(args[0]);
124
+ await withGroup((sock) => sock.groupLeave(jid));
125
+ upsertChat({ jid, archived: 1 });
126
+ ok('Left the group.');
127
+ }
128
+ async function create(args) {
129
+ const subject = args[0];
130
+ if (!subject)
131
+ fail('Usage: wa group create <subject> <number...>');
132
+ const jids = toJids(args.slice(1));
133
+ if (jids.length === 0)
134
+ 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
+ }