cli-whatsapp 0.1.3 → 0.1.4
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/dist/cli/commands/read.js +3 -3
- package/dist/cli/commands/shell.js +33 -8
- package/dist/cli/commands/sync.js +5 -1
- package/dist/db/schema.js +11 -1
- package/dist/db/sqlite.js +78 -15
- package/dist/utils/format.js +31 -5
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { listChats, listContacts, getMessages, searchMessages, findChat, } from "../../db/sqlite.js";
|
|
2
2
|
import { loadConfig } from "../../config/index.js";
|
|
3
|
-
import { renderChatList, renderMessages, displayName, formatTime, truncate, c, } from "../../utils/format.js";
|
|
3
|
+
import { renderChatList, renderMessages, displayName, chatNumber, cleanSender, formatTime, truncate, c, } from "../../utils/format.js";
|
|
4
4
|
import { print, info, fail } from "../../utils/ui.js";
|
|
5
5
|
/** wa chats [--all] [--limit N] */
|
|
6
6
|
export function chats(opts) {
|
|
@@ -34,7 +34,7 @@ export function open(target, opts) {
|
|
|
34
34
|
}
|
|
35
35
|
const cfg = loadConfig();
|
|
36
36
|
const limit = clampLimit(opts.limit, cfg.preferences.openLimit);
|
|
37
|
-
print(c.bold(displayName(chat)) + c.dim(` ${chat
|
|
37
|
+
print(c.bold(displayName(chat)) + (chatNumber(chat) ? c.dim(` ${chatNumber(chat)}`) : ''));
|
|
38
38
|
print(c.dim('─'.repeat(40)));
|
|
39
39
|
print(renderMessages(getMessages(chat.jid, limit), cfg.preferences.time24h));
|
|
40
40
|
}
|
|
@@ -57,7 +57,7 @@ export function search(term, opts) {
|
|
|
57
57
|
const chat = findChat(m.chatJid);
|
|
58
58
|
const where = c.magenta(chat ? displayName(chat) : m.chatJid.split('@')[0]);
|
|
59
59
|
const time = c.gray(formatTime(m.timestamp, cfg.preferences.time24h));
|
|
60
|
-
const who = m.fromMe ? c.green('me') : c.cyan(m.sender
|
|
60
|
+
const who = m.fromMe ? c.green('me') : c.cyan(cleanSender(m.sender));
|
|
61
61
|
const body = highlight(truncate(m.text ?? '', 80), term);
|
|
62
62
|
print(`${where} ${time} ${who}: ${body} ${c.dim(`#${m.id}`)}`);
|
|
63
63
|
}
|
|
@@ -10,10 +10,10 @@ import { recent, pin, unpin, archive, unarchive } from "./manage.js";
|
|
|
10
10
|
import { status } from "./auth.js";
|
|
11
11
|
import { connect, hasSession } from "../../whatsapp/client.js";
|
|
12
12
|
import { registerPersistence, extractText, isGroupJid } from "../../whatsapp/events.js";
|
|
13
|
-
import { listChats, findChat, getMessages, countMessages, } from "../../db/sqlite.js";
|
|
13
|
+
import { listChats, listContacts, findChat, upsertChat, getMessages, countMessages, } from "../../db/sqlite.js";
|
|
14
14
|
import { resolveTarget } from "../../utils/jid.js";
|
|
15
15
|
import { loadConfig, saveConfig } from "../../config/index.js";
|
|
16
|
-
import { c, displayName, formatTime, truncate, renderMessages } from "../../utils/format.js";
|
|
16
|
+
import { c, displayName, chatNumber, formatTime, truncate, renderMessages } from "../../utils/format.js";
|
|
17
17
|
import { print, warn, printError } from "../../utils/ui.js";
|
|
18
18
|
import { notify } from "../../utils/notify.js";
|
|
19
19
|
// ---- tab completion --------------------------------------------------------
|
|
@@ -249,16 +249,31 @@ async function sendTo(state, jid, name, content, meta) {
|
|
|
249
249
|
}
|
|
250
250
|
// ---- chat context ----------------------------------------------------------
|
|
251
251
|
function openChat(state, target) {
|
|
252
|
-
|
|
252
|
+
// Resolve against chats, contacts, or a raw number/jid.
|
|
253
|
+
const resolved = resolveTarget(target);
|
|
254
|
+
let chat = resolved ? findChat(resolved.jid) : findChat(target);
|
|
255
|
+
if (!chat && resolved) {
|
|
256
|
+
// Known contact / number with no conversation yet — start one.
|
|
257
|
+
upsertChat({
|
|
258
|
+
jid: resolved.jid,
|
|
259
|
+
name: resolved.name,
|
|
260
|
+
isGroup: resolved.jid.endsWith('@g.us') ? 1 : 0,
|
|
261
|
+
});
|
|
262
|
+
chat = findChat(resolved.jid);
|
|
263
|
+
}
|
|
253
264
|
if (!chat) {
|
|
254
|
-
warn(`No chat matching "${target}". Try /chats or /search.`);
|
|
265
|
+
warn(`No chat or contact matching "${target}". Try /chats or /search.`);
|
|
255
266
|
return;
|
|
256
267
|
}
|
|
257
268
|
state.active = chat;
|
|
258
269
|
setPrompt(state);
|
|
259
270
|
print(c.dim('─'.repeat(40)));
|
|
260
|
-
|
|
261
|
-
print(
|
|
271
|
+
const sub = chatNumber(chat);
|
|
272
|
+
print(c.bold(displayName(chat)) + (sub ? c.dim(` ${sub}`) : ''));
|
|
273
|
+
const msgs = getMessages(chat.jid, 15);
|
|
274
|
+
print(msgs.length
|
|
275
|
+
? renderMessages(msgs, loadConfig().preferences.time24h)
|
|
276
|
+
: c.dim('No messages yet — say hi 👋'));
|
|
262
277
|
print(c.dim(`─── in this chat: type to send · @ opens a file picker to attach · /close ───`));
|
|
263
278
|
}
|
|
264
279
|
// ---- settings --------------------------------------------------------------
|
|
@@ -551,11 +566,21 @@ export async function shell() {
|
|
|
551
566
|
const clearLine = () => rl.write(null, { ctrl: true, name: 'u' });
|
|
552
567
|
// Open the chat picker for a command, then insert/run with the chosen chat.
|
|
553
568
|
const chatPickInto = async (cmd) => {
|
|
554
|
-
|
|
569
|
+
// Existing chats first (names resolved via contacts + the LID map)…
|
|
570
|
+
const items = listChats(5000, true).map((ch) => ({
|
|
555
571
|
value: displayName(ch),
|
|
556
572
|
desc: truncate((ch.lastMessage ?? '').replace(/\s+/g, ' '), 30),
|
|
557
573
|
}));
|
|
558
|
-
|
|
574
|
+
// …then every other contact, so nobody is missing. Dedupe by name so a
|
|
575
|
+
// contact already shown as a chat doesn't appear twice.
|
|
576
|
+
const seen = new Set(items.map((it) => it.value.toLowerCase()));
|
|
577
|
+
for (const ct of listContacts(50000)) {
|
|
578
|
+
if (ct.name && !seen.has(ct.name.toLowerCase())) {
|
|
579
|
+
items.push({ value: ct.name, desc: 'contact' });
|
|
580
|
+
seen.add(ct.name.toLowerCase());
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const chat = await pickFromList('Select a chat or contact', items, '');
|
|
559
584
|
clearLine();
|
|
560
585
|
if (!chat)
|
|
561
586
|
return; // cancelled → empty line
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { connect, runWithConnection } from "../../whatsapp/client.js";
|
|
2
2
|
import { registerPersistence, extractText, isGroupJid } from "../../whatsapp/events.js";
|
|
3
|
-
import { countMessages, listChats, findChat } from "../../db/sqlite.js";
|
|
3
|
+
import { countMessages, listChats, findChat, importLidMappings } from "../../db/sqlite.js";
|
|
4
4
|
import { loadConfig, saveConfig } from "../../config/index.js";
|
|
5
5
|
import { ok, info, print } from "../../utils/ui.js";
|
|
6
6
|
import { c, formatTime, displayName } from "../../utils/format.js";
|
|
@@ -22,10 +22,14 @@ export async function sync(opts) {
|
|
|
22
22
|
clearInterval(timer);
|
|
23
23
|
process.stdout.write('\r' + ' '.repeat(50) + '\r');
|
|
24
24
|
}, { requireSession: true, syncFullHistory: true });
|
|
25
|
+
// Bridge WhatsApp's @lid ids to phone numbers so contact names resolve.
|
|
26
|
+
const mapped = importLidMappings();
|
|
25
27
|
const after = { chats: listChats(5000).length, msgs: countMessages() };
|
|
26
28
|
const cfg = loadConfig();
|
|
27
29
|
cfg.session.lastSyncAt = Date.now();
|
|
28
30
|
saveConfig(cfg);
|
|
31
|
+
if (mapped)
|
|
32
|
+
info(`Resolved ${mapped} contact name mappings.`);
|
|
29
33
|
ok(`Sync complete. +${after.chats - before.chats} chats, +${after.msgs - before.msgs} messages ` +
|
|
30
34
|
`(${after.chats} chats, ${after.msgs} messages total).`);
|
|
31
35
|
}
|
package/dist/db/schema.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `schema_version` in the `settings` table lets us evolve this safely later.
|
|
6
6
|
*/
|
|
7
|
-
export const SCHEMA_VERSION =
|
|
7
|
+
export const SCHEMA_VERSION = 3;
|
|
8
8
|
export const SCHEMA_SQL = /* sql */ `
|
|
9
9
|
PRAGMA journal_mode = WAL; -- concurrent read while writing (watch + reads)
|
|
10
10
|
PRAGMA synchronous = NORMAL; -- fast, safe enough for a local cache
|
|
@@ -54,4 +54,14 @@ CREATE TABLE IF NOT EXISTS settings (
|
|
|
54
54
|
key TEXT PRIMARY KEY,
|
|
55
55
|
value TEXT
|
|
56
56
|
);
|
|
57
|
+
|
|
58
|
+
-- WhatsApp's "LID" identifiers ↔ phone-number jids. Individual chats are often
|
|
59
|
+
-- stored under a @lid jid while the contact is saved under the phone jid; this
|
|
60
|
+
-- table bridges them so names resolve. Populated from the session's mapping
|
|
61
|
+
-- files (and kept fresh on sync).
|
|
62
|
+
CREATE TABLE IF NOT EXISTS lid_map (
|
|
63
|
+
lid TEXT PRIMARY KEY, -- e.g. 112735023038675@lid
|
|
64
|
+
pn TEXT -- e.g. 918179514637@s.whatsapp.net
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_lid_pn ON lid_map (pn);
|
|
57
67
|
`;
|
package/dist/db/sqlite.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { ensureDirs, PATHS } from "../config/index.js";
|
|
3
5
|
import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema.js";
|
|
4
6
|
let db = null;
|
|
@@ -108,16 +110,30 @@ export function setMediaPath(id, path) {
|
|
|
108
110
|
getDb().prepare('UPDATE messages SET mediaPath = ? WHERE id = ?').run(path, id);
|
|
109
111
|
}
|
|
110
112
|
// ---- queries ---------------------------------------------------------------
|
|
111
|
-
// Chat rows with the display name resolved
|
|
112
|
-
//
|
|
113
|
-
//
|
|
113
|
+
// Chat rows with the display name resolved. Preference order:
|
|
114
|
+
// 1. the chat's own name
|
|
115
|
+
// 2. a saved contact for the same jid (direct phone-number chats)
|
|
116
|
+
// 3. a saved contact reached via the LID→phone map (chats stored under @lid)
|
|
117
|
+
// Column order/shape matches ChatRow. `NAME_EXPR` is reused wherever we need the
|
|
118
|
+
// same resolution (e.g. searching by name).
|
|
119
|
+
// Prefer, in order: the chat's own name, the contact reached via the LID map
|
|
120
|
+
// (the real saved name for @lid chats), then the direct contact — but never a
|
|
121
|
+
// WhatsApp privacy-masked placeholder like "+91∙∙∙∙∙∙∙∙37" (those contain the
|
|
122
|
+
// U+2219 bullet), which we treat as "no name".
|
|
123
|
+
const UNMASK = (col) => `CASE WHEN ${col} LIKE '%∙%' THEN NULL ELSE ${col} END`;
|
|
124
|
+
const NAME_EXPR = `COALESCE(${UNMASK('c.name')}, ${UNMASK('ctl.name')}, ${UNMASK('ct.name')})`;
|
|
125
|
+
const CHAT_JOINS = `
|
|
126
|
+
FROM chats c
|
|
127
|
+
LEFT JOIN contacts ct ON ct.jid = c.jid
|
|
128
|
+
LEFT JOIN lid_map lm ON lm.lid = c.jid
|
|
129
|
+
LEFT JOIN contacts ctl ON ctl.jid = lm.pn`;
|
|
114
130
|
const CHAT_SELECT = `
|
|
115
131
|
SELECT c.jid,
|
|
116
|
-
|
|
132
|
+
${NAME_EXPR} AS name,
|
|
117
133
|
c.lastMessage, c.lastTimestamp, c.unreadCount,
|
|
118
|
-
c.archived, c.pinned, c.isGroup
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
c.archived, c.pinned, c.isGroup,
|
|
135
|
+
lm.pn AS pn
|
|
136
|
+
${CHAT_JOINS}`;
|
|
121
137
|
export function listChats(limit = 50, includeArchived = false) {
|
|
122
138
|
return getDb()
|
|
123
139
|
.prepare(`${CHAT_SELECT}
|
|
@@ -127,8 +143,11 @@ export function listChats(limit = 50, includeArchived = false) {
|
|
|
127
143
|
.all(limit);
|
|
128
144
|
}
|
|
129
145
|
export function listContacts(limit = 500) {
|
|
146
|
+
// Exclude WhatsApp privacy-masked placeholder names (e.g. "+91∙∙∙∙∙∙∙∙37").
|
|
130
147
|
return getDb()
|
|
131
|
-
.prepare(`SELECT * FROM contacts
|
|
148
|
+
.prepare(`SELECT * FROM contacts
|
|
149
|
+
WHERE name IS NOT NULL AND name NOT LIKE '%∙%'
|
|
150
|
+
ORDER BY name COLLATE NOCASE LIMIT ?`)
|
|
132
151
|
.all(limit);
|
|
133
152
|
}
|
|
134
153
|
export function getMessages(chatJid, limit = 30) {
|
|
@@ -165,26 +184,70 @@ export function findChat(query) {
|
|
|
165
184
|
.get(query);
|
|
166
185
|
if (exact)
|
|
167
186
|
return exact;
|
|
168
|
-
// Bare phone number → match the number portion of a direct-chat jid
|
|
187
|
+
// Bare phone number → match the number portion of a direct-chat jid, or the
|
|
188
|
+
// phone of a @lid chat via the LID map.
|
|
169
189
|
const digits = query.replace(/[^\d]/g, '');
|
|
170
190
|
if (digits.length >= 5 && digits === query.replace(/[+\s()\-]/g, '')) {
|
|
171
191
|
const byNum = dbc
|
|
172
|
-
.prepare(`${CHAT_SELECT}
|
|
173
|
-
|
|
192
|
+
.prepare(`${CHAT_SELECT}
|
|
193
|
+
WHERE c.jid LIKE ? OR lm.pn LIKE ?
|
|
194
|
+
ORDER BY c.lastTimestamp DESC LIMIT 1`)
|
|
195
|
+
.get(`${digits}@%`, `${digits}@%`);
|
|
174
196
|
if (byNum)
|
|
175
197
|
return byNum;
|
|
176
198
|
}
|
|
177
|
-
// Case-insensitive name match (chat
|
|
178
|
-
// preferring the most recently active.
|
|
199
|
+
// Case-insensitive name match (chat name, direct contact, or LID-mapped
|
|
200
|
+
// contact), preferring an exact match then the most recently active.
|
|
179
201
|
const byName = dbc
|
|
180
202
|
.prepare(`${CHAT_SELECT}
|
|
181
|
-
WHERE
|
|
182
|
-
ORDER BY (
|
|
203
|
+
WHERE ${NAME_EXPR} LIKE ? ESCAPE '\\'
|
|
204
|
+
ORDER BY (${NAME_EXPR} = ? COLLATE NOCASE) DESC,
|
|
183
205
|
c.lastTimestamp DESC
|
|
184
206
|
LIMIT 1`)
|
|
185
207
|
.get(likePattern(query), query);
|
|
186
208
|
return byName ?? null;
|
|
187
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Import the session's LID↔phone mappings into `lid_map`. Reads the
|
|
212
|
+
* `lid-mapping-<LID>_reverse.json` files (LID → phone number) that Baileys
|
|
213
|
+
* writes. Idempotent and fast (single transaction); safe to run on every sync.
|
|
214
|
+
* Returns the number of mappings imported.
|
|
215
|
+
*/
|
|
216
|
+
export function importLidMappings() {
|
|
217
|
+
let files;
|
|
218
|
+
try {
|
|
219
|
+
files = readdirSync(PATHS.sessions).filter((f) => f.startsWith('lid-mapping-') && f.endsWith('_reverse.json'));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
const dbc = getDb();
|
|
225
|
+
const stmt = dbc.prepare(`INSERT INTO lid_map (lid, pn) VALUES (?, ?)
|
|
226
|
+
ON CONFLICT(lid) DO UPDATE SET pn = excluded.pn`);
|
|
227
|
+
dbc.exec('BEGIN');
|
|
228
|
+
let n = 0;
|
|
229
|
+
try {
|
|
230
|
+
for (const f of files) {
|
|
231
|
+
const lid = f.slice('lid-mapping-'.length, -'_reverse.json'.length);
|
|
232
|
+
let pn;
|
|
233
|
+
try {
|
|
234
|
+
pn = JSON.parse(readFileSync(join(PATHS.sessions, f), 'utf8'));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!lid || !pn)
|
|
240
|
+
continue;
|
|
241
|
+
stmt.run(`${lid}@lid`, `${pn}@s.whatsapp.net`);
|
|
242
|
+
n++;
|
|
243
|
+
}
|
|
244
|
+
dbc.exec('COMMIT');
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
dbc.exec('ROLLBACK');
|
|
248
|
+
}
|
|
249
|
+
return n;
|
|
250
|
+
}
|
|
188
251
|
/** Media messages, optionally filtered by type (image/video/audio/document/sticker). */
|
|
189
252
|
export function listMedia(types, limit = 50) {
|
|
190
253
|
const dbc = getDb();
|
package/dist/utils/format.js
CHANGED
|
@@ -45,11 +45,17 @@ export function formatTime(ms, time24h = true) {
|
|
|
45
45
|
return `${date} ${time}`;
|
|
46
46
|
}
|
|
47
47
|
export function displayName(chat) {
|
|
48
|
-
|
|
48
|
+
// Real saved/known name (never a privacy-masked placeholder).
|
|
49
|
+
if (chat.name && !chat.name.includes('∙'))
|
|
49
50
|
return chat.name;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if (chat.isGroup)
|
|
52
|
+
return 'Group';
|
|
53
|
+
// Individual with no name: show a real phone number — never a @lid id.
|
|
54
|
+
// Prefer the LID→phone mapping, else the number of a phone jid.
|
|
55
|
+
const pnNum = chat.pn ? chat.pn.split('@')[0] : null;
|
|
56
|
+
const jidNum = chat.jid.endsWith('@s.whatsapp.net') ? chat.jid.split('@')[0] : null;
|
|
57
|
+
const num = pnNum ?? jidNum;
|
|
58
|
+
return num ? `+${num}` : 'Unknown contact';
|
|
53
59
|
}
|
|
54
60
|
/** Render the numbered chat list shown by `wa chats`. */
|
|
55
61
|
export function renderChatList(chats, time24h = true) {
|
|
@@ -76,13 +82,33 @@ export function renderMessages(messages, time24h = true) {
|
|
|
76
82
|
return messages
|
|
77
83
|
.map((m) => {
|
|
78
84
|
const time = c.gray(formatTime(m.timestamp, time24h));
|
|
79
|
-
const who = m.fromMe ? c.green('me') : c.cyan(m.sender
|
|
85
|
+
const who = m.fromMe ? c.green('me') : c.cyan(cleanSender(m.sender));
|
|
80
86
|
const body = m.text ?? (m.type ? c.dim(`[${m.type}]`) : c.dim('[no text]'));
|
|
81
87
|
const id = c.dim(`#${m.id}`);
|
|
82
88
|
return `${time} ${who}: ${body} ${id}`;
|
|
83
89
|
})
|
|
84
90
|
.join('\n');
|
|
85
91
|
}
|
|
92
|
+
/** A human sender label that never exposes a @lid id or a masked placeholder. */
|
|
93
|
+
export function cleanSender(sender) {
|
|
94
|
+
if (!sender)
|
|
95
|
+
return 'unknown';
|
|
96
|
+
if (sender.includes('∙'))
|
|
97
|
+
return 'unknown'; // masked placeholder
|
|
98
|
+
if (sender.includes('@')) {
|
|
99
|
+
if (sender.endsWith('@lid'))
|
|
100
|
+
return 'unknown'; // never show a lid
|
|
101
|
+
return `+${sender.split('@')[0]}`; // phone jid → number
|
|
102
|
+
}
|
|
103
|
+
return sender; // a real push name
|
|
104
|
+
}
|
|
105
|
+
/** Phone number to show under a chat header — never a @lid id. */
|
|
106
|
+
export function chatNumber(chat) {
|
|
107
|
+
const pnNum = chat.pn ? chat.pn.split('@')[0] : null;
|
|
108
|
+
const jidNum = chat.jid.endsWith('@s.whatsapp.net') ? chat.jid.split('@')[0] : null;
|
|
109
|
+
const num = pnNum ?? jidNum;
|
|
110
|
+
return num ? `+${num}` : '';
|
|
111
|
+
}
|
|
86
112
|
export function truncate(s, n) {
|
|
87
113
|
const oneLine = s.replace(/\s+/g, ' ').trim();
|
|
88
114
|
return oneLine.length > n ? oneLine.slice(0, n - 1) + '…' : oneLine;
|
package/package.json
CHANGED