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.
- 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 +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
|
@@ -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.
|
|
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
|
-
"
|
|
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"
|
package/src/cli/commands/auth.ts
DELETED
|
@@ -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
|
-
}
|