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
@@ -0,0 +1,157 @@
1
+ import makeWASocket, { useMultiFileAuthState, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, Browsers, DisconnectReason, } from '@whiskeysockets/baileys';
2
+ import { existsSync, readFileSync, rmSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { PATHS, ensureDirs } from "../config/index.js";
5
+ import { logger } from "./logger.js";
6
+ /** Extract the numeric status code from a Baileys disconnect error. */
7
+ function statusCode(err) {
8
+ return err?.output?.statusCode;
9
+ }
10
+ /**
11
+ * Serialized chain of pending credential writes. WhatsApp streams `creds.update`
12
+ * events whose `saveCreds()` writes to disk asynchronously; exiting the process
13
+ * mid-write corrupts the session. Callers must `await whenWritesSettled()`
14
+ * before `process.exit()`.
15
+ */
16
+ let pendingWrites = Promise.resolve();
17
+ export function whenWritesSettled() {
18
+ return pendingWrites;
19
+ }
20
+ /**
21
+ * A session counts as linked when creds.json exists, parses, and carries the
22
+ * account identity (`me.id`) — that's set only after a device is fully linked
23
+ * and it persists. (The `registered` flag is unreliable: it can remain false
24
+ * after a successful QR link due to write ordering across the 515 reconnect.)
25
+ * A missing, corrupt, or half-written creds file has no `me.id` → "not linked".
26
+ */
27
+ export function hasSession() {
28
+ const p = join(PATHS.sessions, 'creds.json');
29
+ if (!existsSync(p))
30
+ return false;
31
+ try {
32
+ const creds = JSON.parse(readFileSync(p, 'utf8'));
33
+ return !!creds?.me?.id || creds?.registered === true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /** Delete the local session (used when it's dead / logged out server-side). */
40
+ export function clearSession() {
41
+ rmSync(PATHS.sessions, { recursive: true, force: true });
42
+ mkdirSync(PATHS.sessions, { recursive: true });
43
+ }
44
+ /**
45
+ * Establish a connection with a single QR/open lifecycle. Reconnection on
46
+ * transient drops is handled by callers via {@link runWithConnection}, which
47
+ * recreates the connection when appropriate.
48
+ */
49
+ export async function connect(opts = {}) {
50
+ ensureDirs();
51
+ if (opts.requireSession && !hasSession()) {
52
+ throw new Error('Not logged in. Run `wa login` first.');
53
+ }
54
+ const { state, saveCreds } = await useMultiFileAuthState(PATHS.sessions);
55
+ const { version } = await fetchLatestBaileysVersion();
56
+ const sock = makeWASocket({
57
+ version,
58
+ logger: logger,
59
+ printQRInTerminal: false,
60
+ auth: {
61
+ creds: state.creds,
62
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
63
+ },
64
+ browser: Browsers.macOS('WhatsApp CLI'),
65
+ syncFullHistory: opts.syncFullHistory ?? false,
66
+ markOnlineOnConnect: false,
67
+ generateHighQualityLinkPreview: false,
68
+ });
69
+ // Serialize creds writes and track them so the process never exits mid-write.
70
+ sock.ev.on('creds.update', () => {
71
+ pendingWrites = pendingWrites.then(() => saveCreds()).catch(() => { });
72
+ });
73
+ let resolveReady;
74
+ let rejectReady;
75
+ const ready = new Promise((res, rej) => {
76
+ resolveReady = res;
77
+ rejectReady = rej;
78
+ });
79
+ let pairingRequested = false;
80
+ sock.ev.on('connection.update', (update) => {
81
+ const { connection, lastDisconnect, qr } = update;
82
+ if (qr) {
83
+ if (opts.pairingPhone) {
84
+ // Pairing-code mode: request the code once on the first `qr` (which
85
+ // signals the socket is ready to pair). Ignore all later qr refreshes —
86
+ // never render a QR, and never re-request (that would change the code).
87
+ if (!pairingRequested) {
88
+ pairingRequested = true;
89
+ void sock
90
+ .requestPairingCode(opts.pairingPhone)
91
+ .then((code) => opts.onPairingCode?.(code))
92
+ .catch((e) => rejectReady(new Error(`PAIRING_FAILED:${e.message}`)));
93
+ }
94
+ }
95
+ else if (opts.onQR) {
96
+ opts.onQR(qr);
97
+ }
98
+ }
99
+ if (connection === 'open') {
100
+ resolveReady();
101
+ }
102
+ else if (connection === 'close') {
103
+ const code = statusCode(lastDisconnect?.error);
104
+ const loggedOut = code === DisconnectReason.loggedOut;
105
+ const err = new Error(loggedOut
106
+ ? 'LOGGED_OUT'
107
+ : `CONNECTION_CLOSED:${code ?? 'unknown'}`);
108
+ rejectReady(err);
109
+ }
110
+ });
111
+ return {
112
+ sock,
113
+ saveCreds,
114
+ ready,
115
+ close: () => {
116
+ try {
117
+ sock.end(undefined);
118
+ }
119
+ catch {
120
+ /* already closed */
121
+ }
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * Run a task with an open connection, auto-reconnecting on transient drops.
127
+ * Rejects on logout or after exhausting retries. Always closes the socket.
128
+ */
129
+ export async function runWithConnection(task, opts = {}) {
130
+ const maxRetries = opts.maxRetries ?? 3;
131
+ let attempt = 0;
132
+ // eslint-disable-next-line no-constant-condition
133
+ while (true) {
134
+ const conn = await connect(opts);
135
+ try {
136
+ await conn.ready;
137
+ const result = await task(conn);
138
+ return result;
139
+ }
140
+ catch (err) {
141
+ const msg = err.message ?? '';
142
+ if (msg === 'LOGGED_OUT') {
143
+ conn.close();
144
+ throw new Error('Session logged out. Run `wa logout` then `wa login` again.');
145
+ }
146
+ if (attempt++ >= maxRetries) {
147
+ conn.close();
148
+ throw err;
149
+ }
150
+ // brief backoff before reconnecting
151
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
152
+ }
153
+ finally {
154
+ conn.close();
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,161 @@
1
+ import { proto } from '@whiskeysockets/baileys';
2
+ import { insertMessage, upsertChat, upsertContact } from "../db/sqlite.js";
3
+ /** Baileys timestamps are seconds (sometimes Long); normalize to ms. */
4
+ export function toMs(ts) {
5
+ if (ts == null)
6
+ return 0;
7
+ const n = typeof ts === 'number' ? ts : ts.toNumber();
8
+ if (!Number.isFinite(n) || n <= 0)
9
+ return 0;
10
+ return n < 1e12 ? n * 1000 : n; // treat >=1e12 as already-ms
11
+ }
12
+ export function isGroupJid(jid) {
13
+ return !!jid && jid.endsWith('@g.us');
14
+ }
15
+ /** Best-effort plain-text extraction from any WhatsApp message content. */
16
+ export function extractText(message) {
17
+ if (!message)
18
+ return '';
19
+ return (message.conversation ||
20
+ message.extendedTextMessage?.text ||
21
+ message.imageMessage?.caption ||
22
+ message.videoMessage?.caption ||
23
+ message.documentMessage?.caption ||
24
+ message.documentWithCaptionMessage?.message?.documentMessage?.caption ||
25
+ message.buttonsResponseMessage?.selectedDisplayText ||
26
+ message.listResponseMessage?.title ||
27
+ message.templateButtonReplyMessage?.selectedDisplayText ||
28
+ (message.reactionMessage?.text
29
+ ? `reacted ${message.reactionMessage.text}`
30
+ : '') ||
31
+ '');
32
+ }
33
+ /** Classify a message by its dominant content type (for filtering/media). */
34
+ export function messageType(message) {
35
+ if (!message)
36
+ return 'unknown';
37
+ if (message.conversation || message.extendedTextMessage)
38
+ return 'text';
39
+ if (message.imageMessage)
40
+ return 'image';
41
+ if (message.videoMessage)
42
+ return 'video';
43
+ if (message.audioMessage)
44
+ return 'audio';
45
+ if (message.documentMessage || message.documentWithCaptionMessage)
46
+ return 'document';
47
+ if (message.stickerMessage)
48
+ return 'sticker';
49
+ if (message.locationMessage)
50
+ return 'location';
51
+ if (message.contactMessage)
52
+ return 'contact';
53
+ if (message.reactionMessage)
54
+ return 'reaction';
55
+ return 'other';
56
+ }
57
+ /** Persist a single Baileys message into the local cache. */
58
+ export function persistMessage(msg) {
59
+ const jid = msg.key.remoteJid;
60
+ if (!jid || jid === 'status@broadcast')
61
+ return;
62
+ const text = extractText(msg.message);
63
+ const type = messageType(msg.message);
64
+ const ts = toMs(msg.messageTimestamp);
65
+ const fromMe = !!msg.key.fromMe;
66
+ const sender = fromMe ? 'me' : msg.pushName || msg.key.participant || jid;
67
+ // For media, keep the raw content so `wa download` can decrypt it later.
68
+ const isMedia = ['image', 'video', 'audio', 'document', 'sticker'].includes(type);
69
+ let raw = null;
70
+ if (isMedia && msg.message) {
71
+ try {
72
+ // Protobuf-encode so binary fields (mediaKey, etc.) survive round-trips.
73
+ raw = Buffer.from(proto.Message.encode(msg.message).finish()).toString('base64');
74
+ }
75
+ catch {
76
+ raw = null;
77
+ }
78
+ }
79
+ insertMessage({
80
+ chatJid: jid,
81
+ messageId: msg.key.id ?? `${ts}`,
82
+ sender,
83
+ text: text || null,
84
+ timestamp: ts,
85
+ status: undefined,
86
+ type,
87
+ fromMe,
88
+ raw,
89
+ });
90
+ // Keep the chat row's "last message" preview fresh.
91
+ upsertChat({
92
+ jid,
93
+ name: isGroupJid(jid) ? undefined : !fromMe ? msg.pushName || undefined : undefined,
94
+ lastMessage: text || `[${type}]`,
95
+ lastTimestamp: ts,
96
+ isGroup: isGroupJid(jid) ? 1 : 0,
97
+ });
98
+ // Record the direct contact's push name when we learn it.
99
+ if (!isGroupJid(jid) && !fromMe && msg.pushName) {
100
+ upsertContact({ jid, name: msg.pushName });
101
+ }
102
+ }
103
+ /**
104
+ * Register all persistence handlers on a live socket. Used by `sync` (batch
105
+ * history) and `watch` (live stream). Returns nothing; handlers persist to DB.
106
+ */
107
+ export function registerPersistence(sock, hooks = {}) {
108
+ // Live + recent messages.
109
+ sock.ev.on('messages.upsert', ({ messages }) => {
110
+ for (const msg of messages) {
111
+ persistMessage(msg);
112
+ hooks.onMessage?.(msg);
113
+ }
114
+ });
115
+ // Bulk history sync delivered after login / on demand.
116
+ sock.ev.on('messaging-history.set', ({ chats, contacts, messages }) => {
117
+ for (const c of chats) {
118
+ if (!c.id)
119
+ continue;
120
+ upsertChat({
121
+ jid: c.id,
122
+ name: c.name ?? undefined,
123
+ unreadCount: c.unreadCount ?? 0,
124
+ archived: c.archived ? 1 : 0,
125
+ pinned: c.pinned ? 1 : 0,
126
+ lastTimestamp: toMs(c.conversationTimestamp),
127
+ isGroup: isGroupJid(c.id) ? 1 : 0,
128
+ });
129
+ }
130
+ for (const ct of contacts) {
131
+ if (ct.id)
132
+ upsertContact({ jid: ct.id, name: ct.name ?? ct.notify ?? undefined });
133
+ }
134
+ for (const msg of messages)
135
+ persistMessage(msg);
136
+ });
137
+ // Chat metadata updates.
138
+ sock.ev.on('chats.upsert', (chats) => {
139
+ for (const c of chats) {
140
+ if (!c.id)
141
+ continue;
142
+ upsertChat({
143
+ jid: c.id,
144
+ name: c.name ?? undefined,
145
+ unreadCount: c.unreadCount ?? 0,
146
+ archived: c.archived ? 1 : 0,
147
+ pinned: c.pinned ? 1 : 0,
148
+ isGroup: isGroupJid(c.id) ? 1 : 0,
149
+ });
150
+ }
151
+ });
152
+ // Contact names.
153
+ const onContacts = (contacts) => {
154
+ for (const ct of contacts) {
155
+ if (ct.id)
156
+ upsertContact({ jid: ct.id, name: ct.name ?? ct.notify ?? undefined });
157
+ }
158
+ };
159
+ sock.ev.on('contacts.upsert', onContacts);
160
+ sock.ev.on('contacts.update', onContacts);
161
+ }
@@ -0,0 +1,52 @@
1
+ const DEBUG = !!process.env.WA_DEBUG;
2
+ function make(level) {
3
+ const emit = (tag) => (...args) => {
4
+ if (!DEBUG)
5
+ return;
6
+ // Baileys calls logger.error(obj, msg); print compactly.
7
+ process.stderr.write(`[wa:${tag}] ${args.map(fmt).join(' ')}\n`);
8
+ };
9
+ return {
10
+ level,
11
+ trace: emit('trace'),
12
+ debug: emit('debug'),
13
+ info: emit('info'),
14
+ warn: emit('warn'),
15
+ error: emit('error'),
16
+ fatal: emit('fatal'),
17
+ child: () => make(level),
18
+ };
19
+ }
20
+ function fmt(v) {
21
+ if (typeof v === 'string')
22
+ return v;
23
+ try {
24
+ return JSON.stringify(v);
25
+ }
26
+ catch {
27
+ return String(v);
28
+ }
29
+ }
30
+ export const logger = make(DEBUG ? 'debug' : 'silent');
31
+ /**
32
+ * Baileys' vendored libsignal logs session churn via `console.log`/`console.error`
33
+ * directly, bypassing the logger above and cluttering the terminal (e.g.
34
+ * "Closing session: SessionEntry { … }"). Filter those specific lines out
35
+ * unless debugging. Call once at startup.
36
+ */
37
+ // libsignal (node_modules/libsignal/src/session_record.js) logs session churn
38
+ // directly via console.info/warn/error, bypassing the logger above.
39
+ const NOISE = /^(Closing (open |stale )?session|Opening session|Session already (closed|open)|Migrating session|Removing old closed session|V1 session storage migration|Session error|Bad MAC|SessionEntry)/;
40
+ export function installConsoleFilter() {
41
+ if (DEBUG)
42
+ return;
43
+ for (const method of ['log', 'info', 'error', 'warn']) {
44
+ const original = console[method].bind(console);
45
+ console[method] = (...args) => {
46
+ const first = args[0];
47
+ if (typeof first === 'string' && NOISE.test(first))
48
+ return;
49
+ original(...args);
50
+ };
51
+ }
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-whatsapp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A lightweight, fast, terminal-first WhatsApp client. Read, search, send, media, groups, and live notifications — from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "src/",
11
+ "dist/",
12
12
  "README.md",
13
13
  "COMMANDS.md",
14
14
  "LICENSE"
@@ -25,10 +25,12 @@
25
25
  "local-first"
26
26
  ],
27
27
  "scripts": {
28
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
29
+ "dev": "node src/cli/index.ts",
28
30
  "wa": "node bin/wa.js",
29
31
  "typecheck": "tsc --noEmit",
30
32
  "test": "node --test tests/",
31
- "prepublishOnly": "npm run typecheck"
33
+ "prepublishOnly": "npm run typecheck && npm run build"
32
34
  },
33
35
  "engines": {
34
36
  "node": ">=22.6.0"
@@ -1,236 +0,0 @@
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.ts';
5
- import { registerPersistence } from '../../whatsapp/events.ts';
6
- import { loadConfig, saveConfig, PATHS } from '../../config/index.ts';
7
- import { countMessages, listChats } from '../../db/sqlite.ts';
8
- import { ok, info, warn, fail, print } from '../../utils/ui.ts';
9
- import { c } from '../../utils/format.ts';
10
-
11
- /** Default country code prepended to bare 10-digit numbers. */
12
- const DEFAULT_CC = '91';
13
-
14
- /** Normalize user input to an international number (digits only), or null. */
15
- function normalizePhone(raw: string): string | null {
16
- let d = (raw || '').replace(/[^\d]/g, '');
17
- if (d.length === 11 && d.startsWith('0')) d = d.slice(1); // drop trunk 0
18
- if (d.length === 10) d = DEFAULT_CC + d; // bare local → add country code
19
- return d.length >= 11 ? d : null;
20
- }
21
-
22
- /** Prompt for a phone number (used by the default pairing-code login). */
23
- async function promptPhone(): Promise<string> {
24
- const rl = createInterface({ input: process.stdin, output: process.stdout });
25
- try {
26
- const ans = await rl.question(
27
- `${c.cyan('?')} Enter your WhatsApp number ${c.dim(`(10-digit; +${DEFAULT_CC} added automatically)`)}: `,
28
- );
29
- return ans.trim();
30
- } finally {
31
- rl.close();
32
- }
33
- }
34
-
35
- /**
36
- * wa login → prompt for number, link with a pairing code (default)
37
- * wa login --phone N → pairing code for N (skips the prompt)
38
- * wa login qr → link by scanning a QR code
39
- */
40
- export async function login(
41
- mode?: string,
42
- opts: { phone?: string } = {},
43
- ): Promise<void> {
44
- const useQR = (mode ?? '').toLowerCase() === 'qr';
45
- const hadSession = hasSession();
46
-
47
- // Resolve the number for pairing (prompt if needed). QR mode skips this.
48
- const resolvePhone = async (): Promise<string> => {
49
- const raw = opts.phone ?? (await promptPhone());
50
- const norm = normalizePhone(raw);
51
- if (!norm) {
52
- fail('Enter a valid WhatsApp number — 10 digits (e.g. 9371432334) or full international (e.g. 919371432334).');
53
- }
54
- return norm;
55
- };
56
-
57
- let pairingPhone: string | undefined;
58
- if (!hadSession && !useQR) pairingPhone = await resolvePhone();
59
-
60
- if (hadSession) {
61
- info('A session already exists. Verifying…');
62
- } else if (pairingPhone) {
63
- info(`Requesting a pairing code for +${pairingPhone}…`);
64
- } else {
65
- info('A QR code will appear — scan it in WhatsApp:');
66
- print(c.dim(' Phone → Settings → Linked Devices → Link a Device'));
67
- }
68
-
69
- let shownQR = false;
70
- const onQR = (qr: string) => {
71
- if (!shownQR && hadSession) {
72
- warn('Your saved session has expired or was unlinked — re-scan to link again:');
73
- print(c.dim(' Phone → Settings → Linked Devices → Link a Device'));
74
- }
75
- shownQR = true;
76
- print();
77
- qrcode.generate(qr, { small: true });
78
- print(c.dim('Scan it — this window will confirm and close automatically. (Ctrl+C to cancel)'));
79
- };
80
-
81
- const onPairingCode = (code: string) => {
82
- shownQR = true; // a fresh link is happening (drives history sync below)
83
- print();
84
- print(` ${c.dim('Pairing code:')} ${c.bold(c.green(code))}`);
85
- print(c.dim(' On your phone: Linked Devices → Link a Device →'));
86
- print(c.dim(' "Link with phone number instead" → enter this code.'));
87
- print(c.dim(' Waiting… (Ctrl+C to cancel)'));
88
- };
89
-
90
- // No valid session but stale files may linger (corrupt/partial creds from an
91
- // interrupted attempt). Clear them so we always start a fresh link cleanly.
92
- if (!hadSession) clearSession();
93
-
94
- const mkOpts = () => ({
95
- syncFullHistory: true,
96
- onQR,
97
- pairingPhone,
98
- onPairingCode,
99
- });
100
-
101
- const linkKind = () => (pairingPhone ? 'pairing code' : 'QR code');
102
- const retryCmd = () => (pairingPhone ? `wa login --phone ${pairingPhone}` : 'wa login qr');
103
- const retryHint = () => `Run \`${retryCmd()}\` to get a new ${linkKind()}.`;
104
-
105
- // Establish the connection, tolerating the post-pairing "restart required"
106
- // (code 515) that WhatsApp sends immediately after a fresh link.
107
- let conn = await connect(mkOpts());
108
- registerPersistence(conn.sock); // persist history WhatsApp streams after link
109
-
110
- const MAX_RESTARTS = 4;
111
- let healedLogout = false;
112
- for (let restart = 0; ; restart++) {
113
- // A pairing code / QR is only valid for a short window; don't wait forever.
114
- const timeout = pairingPhone ? 90_000 : 120_000;
115
- try {
116
- await withTimeout(conn.ready, timeout, 'LINK_TIMEOUT');
117
- break; // connection open
118
- } catch (err) {
119
- conn.close();
120
- const msg = (err as Error).message;
121
- if (msg === 'LOGGED_OUT') {
122
- // The stored session is dead server-side. Wipe it and re-link fresh
123
- // once, so the user doesn't have to run `wa logout` manually.
124
- if (!healedLogout) {
125
- healedLogout = true;
126
- clearSession();
127
- warn('Previous session was logged out — starting a fresh link…');
128
- if (!useQR && !pairingPhone) pairingPhone = await resolvePhone();
129
- conn = await connect(mkOpts());
130
- registerPersistence(conn.sock);
131
- continue;
132
- }
133
- fail(`This device was logged out. ${retryHint()}`);
134
- }
135
- if (msg.startsWith('PAIRING_FAILED')) {
136
- const reason = msg.split(':').slice(1).join(':') || 'unknown error';
137
- fail(
138
- `Pairing failed (${reason}). Make sure ${pairingPhone ? `+${pairingPhone}` : 'the number'} is your own WhatsApp number, then run \`${retryCmd()}\`.`,
139
- );
140
- }
141
- // 515 = restart required (normal right after linking): reconnect silently.
142
- if (msg.endsWith(':515') && restart < MAX_RESTARTS) {
143
- conn = await connect(mkOpts());
144
- registerPersistence(conn.sock);
145
- continue;
146
- }
147
- // Timed out, or the code/QR expired or was rejected before pairing.
148
- if (msg === 'LINK_TIMEOUT') {
149
- fail(`Your ${linkKind()} expired — no device linked in time. ${retryHint()}`);
150
- }
151
- if (msg.startsWith('CONNECTION_CLOSED')) {
152
- fail(`Your ${linkKind()} expired or was rejected. ${retryHint()}`);
153
- }
154
- fail(msg);
155
- }
156
- }
157
-
158
- ok('Logged in successfully.');
159
- const cfg = loadConfig();
160
- cfg.session.loggedIn = true;
161
-
162
- // Give WhatsApp a moment to stream initial history into the cache.
163
- if (shownQR) {
164
- info('Syncing initial history…');
165
- await new Promise((r) => setTimeout(r, 6000));
166
- const chats = listChats().length;
167
- const msgs = countMessages();
168
- // The initial history capture is a sync — record it so `status` is accurate.
169
- cfg.session.lastSyncAt = Date.now();
170
- ok(`Cached ${chats} chats and ${msgs} messages so far.`);
171
- info('Run `wa sync` any time to pull more, `wa chats` to browse.');
172
- }
173
-
174
- saveConfig(cfg);
175
- conn.close();
176
- }
177
-
178
- /** wa logout — end the session and delete local credentials. */
179
- export async function logout(): Promise<void> {
180
- if (!hasSession()) {
181
- warn('No active session found.');
182
- } else {
183
- // Best-effort: tell WhatsApp to unlink this device, then wipe creds.
184
- try {
185
- const conn = await connect({ requireSession: true });
186
- await withTimeout(conn.ready, 15_000, 'timeout').catch(() => {});
187
- try {
188
- await conn.sock.logout();
189
- } catch {
190
- /* server-side logout best effort */
191
- }
192
- conn.close();
193
- } catch {
194
- /* offline logout still wipes local creds below */
195
- }
196
- rmSync(PATHS.sessions, { recursive: true, force: true });
197
- ok('Logged out and removed local session.');
198
- }
199
-
200
- const cfg = loadConfig();
201
- cfg.session.loggedIn = false;
202
- saveConfig(cfg);
203
- }
204
-
205
- /** wa status — show login and cache state without a network round-trip. */
206
- export function status(): void {
207
- const cfg = loadConfig();
208
- const session = hasSession();
209
- print(c.bold('WhatsApp CLI status'));
210
- print();
211
- // Connection: whether the live link to WhatsApp is established.
212
- print(c.dim(' Connection'));
213
- print(` Session: ${session ? c.green('● linked') : c.red('○ not linked')}`);
214
- if (!session) {
215
- print(c.dim(' (sending & syncing new messages need a link)'));
216
- }
217
- print();
218
- // Local cache: your data on disk — readable offline, independent of the link.
219
- print(c.dim(' Local cache') + c.dim(' — readable offline, even when not linked'));
220
- print(` Messages: ${countMessages()} in ${listChats(100000).length} chats`);
221
- if (cfg.session.lastSyncAt) {
222
- print(` Last sync: ${new Date(cfg.session.lastSyncAt).toLocaleString()}`);
223
- }
224
- print(` Config: ${existsSync(PATHS.config) ? PATHS.config : c.dim('defaults')}`);
225
- if (!session) {
226
- print();
227
- info('Your cached chats still work (`wa chats`, `wa search`). Run `wa login` to send/receive again.');
228
- }
229
- }
230
-
231
- function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
232
- return Promise.race([
233
- p,
234
- new Promise<T>((_, rej) => setTimeout(() => rej(new Error(message)), ms)),
235
- ]);
236
- }