cli-whatsapp 0.1.2 → 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.
@@ -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 --------------------------------------------------------
@@ -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
- const chat = findChat(target);
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
- print(c.bold(displayName(chat)) + c.dim(` ${chat.jid}`));
261
- print(renderMessages(getMessages(chat.jid, 15), loadConfig().preferences.time24h));
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 --------------------------------------------------------------
@@ -544,6 +559,39 @@ export async function shell() {
544
559
  // listener, so rl.line already includes the just-typed character.
545
560
  readline.emitKeypressEvents(process.stdin);
546
561
  let pickerOpen = false;
562
+ // Commands whose <chat> argument gets the live chat picker. "final" = chat is
563
+ // the last arg (run on pick); "then" = chat then more input (send/sendfile).
564
+ const CHAT_FINAL = new Set(['open', 'history', 'pin', 'unpin', 'archive', 'unarchive']);
565
+ const CHAT_THEN = new Set(['send', 'sendfile']);
566
+ const clearLine = () => rl.write(null, { ctrl: true, name: 'u' });
567
+ // Open the chat picker for a command, then insert/run with the chosen chat.
568
+ const chatPickInto = async (cmd) => {
569
+ // Existing chats first (names resolved via contacts + the LID map)…
570
+ const items = listChats(5000, true).map((ch) => ({
571
+ value: displayName(ch),
572
+ desc: truncate((ch.lastMessage ?? '').replace(/\s+/g, ' '), 30),
573
+ }));
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, '');
584
+ clearLine();
585
+ if (!chat)
586
+ return; // cancelled → empty line
587
+ const q = /\s/.test(chat) ? `"${chat}"` : chat; // quote names with spaces
588
+ if (CHAT_THEN.has(cmd))
589
+ rl.write(`/${cmd} ${q} `); // wait for message/file
590
+ else {
591
+ queue.push(`/${cmd} ${q}`); // run right away
592
+ void pump();
593
+ }
594
+ };
547
595
  process.stdin.on('keypress', (str, _key) => {
548
596
  void _key;
549
597
  if (!process.stdin.isTTY || pickerOpen || busy)
@@ -551,26 +599,48 @@ export async function shell() {
551
599
  // `/` menu — only when it's the first character on the line.
552
600
  if (str === '/' && rl.line === '/') {
553
601
  pickerOpen = true;
554
- pickFromList('Slash Commands', SLASH_ITEMS, '')
555
- .then((picked) => {
556
- rl.write(null, { ctrl: true, name: 'u' }); // clear the '/'
557
- if (!picked)
558
- return; // cancelled (esc / backspace) — leave the line empty
559
- const item = SLASH_ITEMS.find((i) => i.value === picked);
560
- if (item && !item.args) {
561
- // No arguments needed → run it right away (no second Enter).
562
- queue.push(picked);
563
- void pump();
602
+ void (async () => {
603
+ try {
604
+ const picked = await pickFromList('Slash Commands', SLASH_ITEMS, '');
605
+ clearLine();
606
+ if (!picked)
607
+ return; // cancelled leave the line empty
608
+ const cmd = picked.slice(1);
609
+ if (CHAT_FINAL.has(cmd) || CHAT_THEN.has(cmd)) {
610
+ await chatPickInto(cmd); // chain straight into the chat picker
611
+ return;
612
+ }
613
+ const item = SLASH_ITEMS.find((i) => i.value === picked);
614
+ if (item && !item.args) {
615
+ queue.push(picked); // no args → run now
616
+ void pump();
617
+ }
618
+ else {
619
+ rl.write(picked + ' '); // needs (non-chat) args → wait
620
+ }
564
621
  }
565
- else {
566
- rl.write(picked + ' '); // needs args → wait for input
622
+ finally {
623
+ pickerOpen = false;
567
624
  }
568
- })
569
- .finally(() => {
570
- pickerOpen = false;
571
- });
625
+ })();
572
626
  return;
573
627
  }
628
+ // Space right after a chat command (typed manually) → open the chat picker.
629
+ if (str === ' ') {
630
+ const m = rl.line.match(/^\/(open|history|pin|unpin|archive|unarchive|send|sendfile) $/);
631
+ if (m) {
632
+ pickerOpen = true;
633
+ void (async () => {
634
+ try {
635
+ await chatPickInto(m[1]);
636
+ }
637
+ finally {
638
+ pickerOpen = false;
639
+ }
640
+ })();
641
+ return;
642
+ }
643
+ }
574
644
  // `@` file picker — only with a chat open and at a fresh token boundary.
575
645
  if (str === '@' && state.active) {
576
646
  const before = rl.line.slice(0, Math.max(0, rl.cursor - 1)).replace(/@$/, '');
@@ -579,7 +649,6 @@ export async function shell() {
579
649
  pickerOpen = true;
580
650
  pickFile(process.cwd())
581
651
  .then((picked) => {
582
- // Quote paths with spaces so the whole path is treated as one token.
583
652
  if (picked)
584
653
  rl.write(picked.includes(' ') ? `"${picked}"` : picked);
585
654
  else
@@ -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.2",
3
+ "version": "0.1.4",
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": {