cli-whatsapp 0.1.3 → 0.1.5

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.
@@ -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.jid}`));
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 --------------------------------------------------------
@@ -162,6 +162,19 @@ function printAbove(state, text) {
162
162
  process.stdout.write(text + '\n');
163
163
  state.rl.prompt(true);
164
164
  }
165
+ /**
166
+ * Replace the line the user just submitted (their echoed input) with `text`, so
167
+ * an outgoing message shows once as a bubble instead of appearing twice. Called
168
+ * synchronously while processing the line, when the cursor sits just below the
169
+ * submitted input.
170
+ */
171
+ function replaceSubmitted(state, text) {
172
+ readline.moveCursor(process.stdout, 0, -1); // up onto the submitted-input line
173
+ readline.cursorTo(process.stdout, 0);
174
+ readline.clearLine(process.stdout, 0);
175
+ process.stdout.write(text + '\n');
176
+ state.rl.prompt(true);
177
+ }
165
178
  function bubble(who, text, incoming) {
166
179
  const time = c.gray(formatTime(Date.now(), loadConfig().preferences.time24h));
167
180
  const name = incoming ? c.cyan(who) : c.green('me');
@@ -233,14 +246,20 @@ async function ensureConnected(state) {
233
246
  }
234
247
  }
235
248
  // ---- sending ---------------------------------------------------------------
236
- async function sendTo(state, jid, name, content, meta) {
249
+ async function sendTo(state, jid, name, content, meta, replaceInput = false) {
250
+ const wasLive = state.status === 'live';
237
251
  const conn = await ensureConnected(state);
238
252
  if (!conn)
239
253
  return;
240
254
  try {
241
255
  await sendContent(conn.sock, jid, content, meta);
242
256
  const echo = meta.type === 'text' ? meta.previewText : `[${meta.type}] ${meta.previewText}`;
243
- printAbove(state, bubble(name, echo, false));
257
+ // Replace the just-typed input with the bubble so it shows once — but only
258
+ // when we were already live (no "connecting…" lines were printed in between).
259
+ if (replaceInput && wasLive)
260
+ replaceSubmitted(state, bubble(name, echo, false));
261
+ else
262
+ printAbove(state, bubble(name, echo, false));
244
263
  refreshNames();
245
264
  }
246
265
  catch (err) {
@@ -249,16 +268,31 @@ async function sendTo(state, jid, name, content, meta) {
249
268
  }
250
269
  // ---- chat context ----------------------------------------------------------
251
270
  function openChat(state, target) {
252
- const chat = findChat(target);
271
+ // Resolve against chats, contacts, or a raw number/jid.
272
+ const resolved = resolveTarget(target);
273
+ let chat = resolved ? findChat(resolved.jid) : findChat(target);
274
+ if (!chat && resolved) {
275
+ // Known contact / number with no conversation yet — start one.
276
+ upsertChat({
277
+ jid: resolved.jid,
278
+ name: resolved.name,
279
+ isGroup: resolved.jid.endsWith('@g.us') ? 1 : 0,
280
+ });
281
+ chat = findChat(resolved.jid);
282
+ }
253
283
  if (!chat) {
254
- warn(`No chat matching "${target}". Try /chats or /search.`);
284
+ warn(`No chat or contact matching "${target}". Try /chats or /search.`);
255
285
  return;
256
286
  }
257
287
  state.active = chat;
258
288
  setPrompt(state);
259
289
  print(c.dim('─'.repeat(40)));
260
- print(c.bold(displayName(chat)) + c.dim(` ${chat.jid}`));
261
- print(renderMessages(getMessages(chat.jid, 15), loadConfig().preferences.time24h));
290
+ const sub = chatNumber(chat);
291
+ print(c.bold(displayName(chat)) + (sub ? c.dim(` ${sub}`) : ''));
292
+ const msgs = getMessages(chat.jid, 15);
293
+ print(msgs.length
294
+ ? renderMessages(msgs, loadConfig().preferences.time24h)
295
+ : c.dim('No messages yet — say hi 👋'));
262
296
  print(c.dim(`─── in this chat: type to send · @ opens a file picker to attach · /close ───`));
263
297
  }
264
298
  // ---- settings --------------------------------------------------------------
@@ -334,11 +368,7 @@ async function handle(state, raw) {
334
368
  if (at) {
335
369
  try {
336
370
  const built = buildMediaContent(at.file, at.caption || undefined);
337
- await sendTo(state, jid, name, built.content, {
338
- previewText: at.caption || `[${built.kind}]`,
339
- type: built.kind,
340
- mediaPath: built.path,
341
- });
371
+ await sendTo(state, jid, name, built.content, { previewText: at.caption || `[${built.kind}]`, type: built.kind, mediaPath: built.path }, true);
342
372
  }
343
373
  catch (err) {
344
374
  printError(err);
@@ -346,7 +376,7 @@ async function handle(state, raw) {
346
376
  return;
347
377
  }
348
378
  // Otherwise plain text.
349
- await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' });
379
+ await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' }, true);
350
380
  return;
351
381
  }
352
382
  const [cmdRaw, ...args] = tokenize(line);
@@ -551,11 +581,21 @@ export async function shell() {
551
581
  const clearLine = () => rl.write(null, { ctrl: true, name: 'u' });
552
582
  // Open the chat picker for a command, then insert/run with the chosen chat.
553
583
  const chatPickInto = async (cmd) => {
554
- const items = listChats(3000, true).map((ch) => ({
584
+ // Existing chats first (names resolved via contacts + the LID map)
585
+ const items = listChats(5000, true).map((ch) => ({
555
586
  value: displayName(ch),
556
587
  desc: truncate((ch.lastMessage ?? '').replace(/\s+/g, ' '), 30),
557
588
  }));
558
- const chat = await pickFromList('Select a chat', items, '');
589
+ // …then every other contact, so nobody is missing. Dedupe by name so a
590
+ // contact already shown as a chat doesn't appear twice.
591
+ const seen = new Set(items.map((it) => it.value.toLowerCase()));
592
+ for (const ct of listContacts(50000)) {
593
+ if (ct.name && !seen.has(ct.name.toLowerCase())) {
594
+ items.push({ value: ct.name, desc: 'contact' });
595
+ seen.add(ct.name.toLowerCase());
596
+ }
597
+ }
598
+ const chat = await pickFromList('Select a chat or contact', items, '');
559
599
  clearLine();
560
600
  if (!chat)
561
601
  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 = 2;
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: prefer the chat's own name, then
112
- // fall back to a known contact name for the same jid (direct chats often have
113
- // no name of their own). Column order/shape matches ChatRow.
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
- COALESCE(c.name, ct.name) AS name,
132
+ ${NAME_EXPR} AS name,
117
133
  c.lastMessage, c.lastTimestamp, c.unreadCount,
118
- c.archived, c.pinned, c.isGroup
119
- FROM chats c
120
- LEFT JOIN contacts ct ON ct.jid = c.jid`;
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 WHERE name IS NOT NULL ORDER BY name COLLATE NOCASE LIMIT ?`)
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} WHERE c.jid LIKE ? ORDER BY c.lastTimestamp DESC LIMIT 1`)
173
- .get(`${digits}@%`);
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's own name OR the linked contact name),
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 COALESCE(c.name, ct.name) LIKE ? ESCAPE '\\'
182
- ORDER BY (COALESCE(c.name, ct.name) = ? COLLATE NOCASE) DESC,
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();
@@ -45,11 +45,17 @@ export function formatTime(ms, time24h = true) {
45
45
  return `${date} ${time}`;
46
46
  }
47
47
  export function displayName(chat) {
48
- if (chat.name)
48
+ // Real saved/known name (never a privacy-masked placeholder).
49
+ if (chat.name && !chat.name.includes('∙'))
49
50
  return chat.name;
50
- // Fall back to the phone number portion of a direct jid.
51
- const num = chat.jid.split('@')[0];
52
- return chat.isGroup ? `Group ${num}` : `+${num}`;
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 ?? 'unknown');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-whatsapp",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {