cli-whatsapp 0.1.1 → 0.1.3

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 +655 -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,67 @@
1
+ import { connect, runWithConnection } from "../../whatsapp/client.js";
2
+ import { registerPersistence, extractText, isGroupJid } from "../../whatsapp/events.js";
3
+ import { countMessages, listChats, findChat } from "../../db/sqlite.js";
4
+ import { loadConfig, saveConfig } from "../../config/index.js";
5
+ import { ok, info, print } from "../../utils/ui.js";
6
+ import { c, formatTime, displayName } from "../../utils/format.js";
7
+ import { notify } from "../../utils/notify.js";
8
+ /** wa sync [--seconds N] — pull recent chats/messages into the local cache. */
9
+ export async function sync(opts) {
10
+ const seconds = Math.min(Math.max(parseInt(opts.seconds ?? '12', 10) || 12, 3), 120);
11
+ const before = { chats: listChats(5000).length, msgs: countMessages() };
12
+ info(`Syncing for ~${seconds}s… (WhatsApp streams history in the background)`);
13
+ await runWithConnection(async (conn) => {
14
+ registerPersistence(conn.sock);
15
+ // Periodic progress so long syncs don't look frozen.
16
+ const started = Date.now();
17
+ const timer = setInterval(() => {
18
+ const elapsed = Math.round((Date.now() - started) / 1000);
19
+ process.stdout.write(`\r${c.dim(` ${elapsed}s — ${countMessages()} messages cached`)} `);
20
+ }, 1000);
21
+ await new Promise((r) => setTimeout(r, seconds * 1000));
22
+ clearInterval(timer);
23
+ process.stdout.write('\r' + ' '.repeat(50) + '\r');
24
+ }, { requireSession: true, syncFullHistory: true });
25
+ const after = { chats: listChats(5000).length, msgs: countMessages() };
26
+ const cfg = loadConfig();
27
+ cfg.session.lastSyncAt = Date.now();
28
+ saveConfig(cfg);
29
+ ok(`Sync complete. +${after.chats - before.chats} chats, +${after.msgs - before.msgs} messages ` +
30
+ `(${after.chats} chats, ${after.msgs} messages total).`);
31
+ }
32
+ /** wa watch — stream incoming messages live until interrupted. */
33
+ export async function watch() {
34
+ const cfg = loadConfig();
35
+ info('Watching for incoming messages. Press Ctrl+C to stop.');
36
+ print(c.dim('─'.repeat(40)));
37
+ const conn = await connect({ requireSession: true });
38
+ registerPersistence(conn.sock, {
39
+ onMessage: (msg) => {
40
+ if (msg.key.fromMe)
41
+ return;
42
+ const jid = msg.key.remoteJid;
43
+ if (!jid || jid === 'status@broadcast')
44
+ return;
45
+ const text = extractText(msg.message) || c.dim('[media]');
46
+ const chat = findChat(jid);
47
+ const name = chat ? displayName(chat) : (msg.pushName ?? jid.split('@')[0]);
48
+ const sender = isGroupJid(jid) && msg.pushName ? `${name}/${msg.pushName}` : name;
49
+ const time = c.gray(formatTime(Date.now(), cfg.preferences.time24h));
50
+ print(`${time} ${c.cyan(sender)}: ${text}`);
51
+ if (cfg.preferences.notifications)
52
+ notify(sender, extractText(msg.message) || 'sent media');
53
+ },
54
+ });
55
+ await conn.ready;
56
+ ok('Connected. Live.');
57
+ // Keep the process alive until the user interrupts.
58
+ await new Promise((resolve) => {
59
+ const stop = () => {
60
+ conn.close();
61
+ print('\n' + c.dim('Stopped watching.'));
62
+ resolve();
63
+ };
64
+ process.on('SIGINT', stop);
65
+ process.on('SIGTERM', stop);
66
+ });
67
+ }
@@ -0,0 +1,174 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { login, logout, status } from "./commands/auth.js";
5
+ import { chats, contacts, open, history, search } from "./commands/read.js";
6
+ import { send, reply } from "./commands/send.js";
7
+ import { sync, watch } from "./commands/sync.js";
8
+ import { exportChat } from "./commands/export.js";
9
+ import { config, doctor, version } from "./commands/misc.js";
10
+ import { shell } from "./commands/shell.js";
11
+ import { media, download } from "./commands/media.js";
12
+ import { recent, pin, unpin, archive, unarchive } from "./commands/manage.js";
13
+ import { group } from "./commands/group.js";
14
+ import { closeDb } from "../db/sqlite.js";
15
+ import { whenWritesSettled } from "../whatsapp/client.js";
16
+ import { installConsoleFilter } from "../whatsapp/logger.js";
17
+ import { printError } from "../utils/ui.js";
18
+ import { c } from "../utils/format.js";
19
+ // Suppress libsignal's direct console noise (unless WA_DEBUG).
20
+ installConsoleFilter();
21
+ function pkgVersion() {
22
+ try {
23
+ const p = join(new URL('../../', import.meta.url).pathname, 'package.json');
24
+ return JSON.parse(readFileSync(p, 'utf8')).version ?? '0.0.0';
25
+ }
26
+ catch {
27
+ return '0.0.0';
28
+ }
29
+ }
30
+ /**
31
+ * Wrap an async command so failures print cleanly and the process always exits.
32
+ * We exit explicitly because commands that opened a WhatsApp socket leave
33
+ * background handles (keep-alive timers, the websocket, history-sync work) that
34
+ * would otherwise keep Node alive and hang the terminal after the work is done.
35
+ */
36
+ function run(fn) {
37
+ return async (...args) => {
38
+ try {
39
+ await fn(...args);
40
+ // Let any in-flight credential writes finish before exiting, or we risk
41
+ // truncating a session file mid-write and corrupting the login.
42
+ await whenWritesSettled();
43
+ closeDb();
44
+ process.exit(0);
45
+ }
46
+ catch (err) {
47
+ printError(err);
48
+ await whenWritesSettled();
49
+ closeDb();
50
+ process.exit(1);
51
+ }
52
+ };
53
+ }
54
+ const program = new Command();
55
+ program
56
+ .name('wa')
57
+ .description('A fast, terminal-first WhatsApp client.')
58
+ .version(pkgVersion(), '-v, --version', 'output the version number')
59
+ .configureHelp({ sortSubcommands: true })
60
+ .addHelpText('after', `\nExamples:\n` +
61
+ ` ${c.dim('$')} wa login ${c.dim('# link this device via QR')}\n` +
62
+ ` ${c.dim('$')} wa sync ${c.dim('# pull recent chats & messages')}\n` +
63
+ ` ${c.dim('$')} wa chats ${c.dim('# list conversations')}\n` +
64
+ ` ${c.dim('$')} wa open Alice ${c.dim('# read a conversation')}\n` +
65
+ ` ${c.dim('$')} wa send Alice "Hi!" ${c.dim('# send a message')}\n` +
66
+ ` ${c.dim('$')} wa search invoice ${c.dim('# search cached messages')}\n`);
67
+ // ---- Authentication --------------------------------------------------------
68
+ program
69
+ .command('login [mode]')
70
+ .description('Link this device — pairing code by default; `wa login qr` to use a QR')
71
+ .option('-p, --phone <number>', 'number for the pairing code (skips the prompt)')
72
+ .action(run(login));
73
+ program.command('logout').description('Unlink this device and wipe local session').action(run(logout));
74
+ program.command('status').description('Show login and cache status').action(run(status));
75
+ // ---- Reading (offline, from local cache) -----------------------------------
76
+ program
77
+ .command('chats')
78
+ .description('List conversations (most recent first)')
79
+ .option('-a, --all', 'include archived chats')
80
+ .option('-n, --limit <n>', 'max chats to show')
81
+ .action(run(chats));
82
+ program
83
+ .command('contacts')
84
+ .description('List known contacts')
85
+ .option('-n, --limit <n>', 'max contacts to show')
86
+ .action(run(contacts));
87
+ program
88
+ .command('open <chat>')
89
+ .description('Show the latest messages in a chat')
90
+ .option('-n, --limit <n>', 'number of messages')
91
+ .action(run(open));
92
+ program
93
+ .command('history <chat>')
94
+ .description('Show extended message history for a chat')
95
+ .option('-n, --limit <n>', 'number of messages (default 200)')
96
+ .action(run(history));
97
+ program
98
+ .command('search <text>')
99
+ .description('Search cached messages')
100
+ .option('-n, --limit <n>', 'max results')
101
+ .action(run(search));
102
+ // ---- Writing (requires connection) -----------------------------------------
103
+ program
104
+ .command('send <chat> [message]')
105
+ .description('Send a message, or media with --file (message becomes the caption)')
106
+ .option('-f, --file <path>', 'attach an image/video/audio/document to send')
107
+ .action(run(send));
108
+ program
109
+ .command('reply <id> <message>')
110
+ .description('Reply to a cached message by its #id')
111
+ .action(run(reply));
112
+ // ---- Sync & live -----------------------------------------------------------
113
+ program
114
+ .command('sync')
115
+ .description('Pull recent chats and messages into the local cache')
116
+ .option('-s, --seconds <n>', 'how long to sync (3-120)')
117
+ .action(run(sync));
118
+ program.command('watch').description('Stream incoming messages live').action(run(watch));
119
+ // ---- Media -----------------------------------------------------------------
120
+ program
121
+ .command('media [type]')
122
+ .description('List cached media (photos, videos, audio, documents, stickers, all)')
123
+ .option('-n, --limit <n>', 'max items')
124
+ .action(run(media));
125
+ program
126
+ .command('download <id>')
127
+ .description('Download & decrypt a cached media message by #id')
128
+ .option('-o, --out <file>', 'output file')
129
+ .action(run(download));
130
+ // ---- Organize (local) ------------------------------------------------------
131
+ program
132
+ .command('recent')
133
+ .description('Show most recently active chats')
134
+ .option('-n, --limit <n>', 'max chats')
135
+ .action(run(recent));
136
+ program
137
+ .command('group [action] [args...]')
138
+ .description('Manage groups: info, invite, rename, add, remove, promote, demote, leave, create')
139
+ .action(run(group));
140
+ program.command('pin <chat>').description('Pin a chat to the top (local)').action(run(pin));
141
+ program.command('unpin <chat>').description('Unpin a chat (local)').action(run(unpin));
142
+ program.command('archive <chat>').description('Archive a chat (local)').action(run(archive));
143
+ program.command('unarchive <chat>').description('Unarchive a chat (local)').action(run(unarchive));
144
+ // ---- Interactive -----------------------------------------------------------
145
+ program
146
+ .command('shell', { isDefault: false })
147
+ .aliases(['chat', 'ui'])
148
+ .description('Start interactive mode (slash commands, live chat)')
149
+ .action(run(shell));
150
+ // ---- Export ----------------------------------------------------------------
151
+ program
152
+ .command('export <chat>')
153
+ .description('Export a conversation to markdown, text, or json')
154
+ .option('-f, --format <fmt>', 'md | txt | json', 'md')
155
+ .option('-n, --limit <n>', 'max messages', '1000')
156
+ .option('-o, --out <file>', 'output file ("-" for stdout)')
157
+ .action(run(exportChat));
158
+ // ---- Utility ---------------------------------------------------------------
159
+ program
160
+ .command('config [action] [key] [value]')
161
+ .description('View or edit config (e.g. `wa config set time24h false`)')
162
+ .action(run(config));
163
+ program.command('doctor').description('Run self-diagnostics').action(run(doctor));
164
+ program.command('version').description('Print the version').action(run(version));
165
+ // Bare `wa` (no subcommand) drops into interactive mode.
166
+ if (process.argv.slice(2).length === 0) {
167
+ await run(shell)();
168
+ }
169
+ else {
170
+ await program.parseAsync(process.argv).catch((err) => {
171
+ printError(err);
172
+ process.exit(1);
173
+ });
174
+ }
@@ -0,0 +1,59 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ /**
5
+ * All persistent state lives under a single base directory so it's easy to back
6
+ * up or wipe. Defaults to ~/.cli-whatsapp in the user's home — a stable location
7
+ * that survives package updates (never inside node_modules). Override with
8
+ * WA_HOME (e.g. for tests or a portable install).
9
+ */
10
+ export const BASE_DIR = resolve(process.env.WA_HOME ?? join(homedir(), '.cli-whatsapp'));
11
+ export const PATHS = {
12
+ base: BASE_DIR,
13
+ sessions: join(BASE_DIR, 'sessions'),
14
+ data: join(BASE_DIR, 'data'),
15
+ media: join(BASE_DIR, 'media'),
16
+ db: join(BASE_DIR, 'data', 'whatsapp.db'),
17
+ config: join(BASE_DIR, 'config.json'),
18
+ };
19
+ const DEFAULT_CONFIG = {
20
+ preferences: { openLimit: 30, time24h: true, notifications: true, autoConnect: true },
21
+ ai: { enabled: false },
22
+ session: {},
23
+ };
24
+ /** Ensure every runtime directory exists (idempotent, cheap). */
25
+ export function ensureDirs() {
26
+ for (const dir of [PATHS.sessions, PATHS.data, PATHS.media]) {
27
+ if (!existsSync(dir))
28
+ mkdirSync(dir, { recursive: true });
29
+ }
30
+ }
31
+ export function loadConfig() {
32
+ if (!existsSync(PATHS.config))
33
+ return structuredClone(DEFAULT_CONFIG);
34
+ try {
35
+ const raw = JSON.parse(readFileSync(PATHS.config, 'utf8'));
36
+ // Shallow-merge so new default keys appear for old config files.
37
+ return {
38
+ ...DEFAULT_CONFIG,
39
+ ...raw,
40
+ preferences: { ...DEFAULT_CONFIG.preferences, ...raw.preferences },
41
+ ai: { ...DEFAULT_CONFIG.ai, ...raw.ai },
42
+ session: { ...DEFAULT_CONFIG.session, ...raw.session },
43
+ };
44
+ }
45
+ catch {
46
+ // Corrupt config should never crash the CLI; fall back to defaults.
47
+ return structuredClone(DEFAULT_CONFIG);
48
+ }
49
+ }
50
+ export function saveConfig(config) {
51
+ ensureDirs();
52
+ writeFileSync(PATHS.config, JSON.stringify(config, null, 2) + '\n', 'utf8');
53
+ }
54
+ /** Convenience: patch a subset of config and persist. */
55
+ export function updateConfig(patch) {
56
+ const next = { ...loadConfig(), ...patch };
57
+ saveConfig(next);
58
+ return next;
59
+ }
@@ -5,7 +5,6 @@
5
5
  * `schema_version` in the `settings` table lets us evolve this safely later.
6
6
  */
7
7
  export const SCHEMA_VERSION = 2;
8
-
9
8
  export const SCHEMA_SQL = /* sql */ `
10
9
  PRAGMA journal_mode = WAL; -- concurrent read while writing (watch + reads)
11
10
  PRAGMA synchronous = NORMAL; -- fast, safe enough for a local cache
@@ -0,0 +1,237 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { ensureDirs, PATHS } from "../config/index.js";
3
+ import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema.js";
4
+ let db = null;
5
+ /** Open (or reuse) the singleton database connection and apply the schema. */
6
+ export function getDb() {
7
+ if (db)
8
+ return db;
9
+ ensureDirs();
10
+ db = new DatabaseSync(PATHS.db);
11
+ db.exec(SCHEMA_SQL);
12
+ migrate(db);
13
+ setSetting('schema_version', String(SCHEMA_VERSION));
14
+ return db;
15
+ }
16
+ /** Additive, idempotent migrations for databases created by older versions. */
17
+ function migrate(dbc) {
18
+ ensureColumn(dbc, 'messages', 'raw', 'TEXT');
19
+ }
20
+ function ensureColumn(dbc, table, column, type) {
21
+ const cols = dbc.prepare(`PRAGMA table_info(${table})`).all();
22
+ if (!cols.some((c) => c.name === column)) {
23
+ dbc.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
24
+ }
25
+ }
26
+ export function closeDb() {
27
+ if (db) {
28
+ db.close();
29
+ db = null;
30
+ }
31
+ }
32
+ // ---- settings key/value helpers -------------------------------------------
33
+ export function getSetting(key) {
34
+ const row = getDb()
35
+ .prepare('SELECT value FROM settings WHERE key = ?')
36
+ .get(key);
37
+ return row?.value ?? null;
38
+ }
39
+ export function setSetting(key, value) {
40
+ getDb()
41
+ .prepare(`INSERT INTO settings (key, value) VALUES (?, ?)
42
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
43
+ .run(key, value);
44
+ }
45
+ // ---- upserts ---------------------------------------------------------------
46
+ export function upsertContact(c) {
47
+ getDb()
48
+ .prepare(`INSERT INTO contacts (jid, name, phone, updatedAt)
49
+ VALUES (@jid, @name, @phone, @updatedAt)
50
+ ON CONFLICT(jid) DO UPDATE SET
51
+ name = COALESCE(excluded.name, contacts.name),
52
+ phone = COALESCE(excluded.phone, contacts.phone),
53
+ updatedAt = excluded.updatedAt`)
54
+ .run({
55
+ jid: c.jid,
56
+ name: c.name ?? null,
57
+ phone: c.phone ?? null,
58
+ updatedAt: Date.now(),
59
+ });
60
+ }
61
+ export function upsertChat(c) {
62
+ getDb()
63
+ .prepare(`INSERT INTO chats (jid, name, lastMessage, lastTimestamp, unreadCount, archived, pinned, isGroup)
64
+ VALUES (@jid, @name, @lastMessage, @lastTimestamp, @unreadCount, @archived, @pinned, @isGroup)
65
+ ON CONFLICT(jid) DO UPDATE SET
66
+ name = COALESCE(excluded.name, chats.name),
67
+ lastMessage = COALESCE(excluded.lastMessage, chats.lastMessage),
68
+ lastTimestamp = MAX(excluded.lastTimestamp, chats.lastTimestamp),
69
+ unreadCount = excluded.unreadCount,
70
+ archived = excluded.archived,
71
+ pinned = excluded.pinned,
72
+ isGroup = excluded.isGroup`)
73
+ .run({
74
+ jid: c.jid,
75
+ name: c.name ?? null,
76
+ lastMessage: c.lastMessage ?? null,
77
+ lastTimestamp: c.lastTimestamp ?? 0,
78
+ unreadCount: c.unreadCount ?? 0,
79
+ archived: c.archived ?? 0,
80
+ pinned: c.pinned ?? 0,
81
+ isGroup: c.isGroup ?? 0,
82
+ });
83
+ }
84
+ export function insertMessage(m) {
85
+ getDb()
86
+ .prepare(`INSERT INTO messages (chatJid, messageId, sender, text, timestamp, status, type, fromMe, mediaPath, raw)
87
+ VALUES (@chatJid, @messageId, @sender, @text, @timestamp, @status, @type, @fromMe, @mediaPath, @raw)
88
+ ON CONFLICT(chatJid, messageId) DO UPDATE SET
89
+ text = COALESCE(excluded.text, messages.text),
90
+ status = COALESCE(excluded.status, messages.status),
91
+ mediaPath = COALESCE(excluded.mediaPath, messages.mediaPath),
92
+ raw = COALESCE(excluded.raw, messages.raw)`)
93
+ .run({
94
+ chatJid: m.chatJid,
95
+ messageId: m.messageId,
96
+ sender: m.sender ?? null,
97
+ text: m.text ?? null,
98
+ timestamp: m.timestamp,
99
+ status: m.status ?? null,
100
+ type: m.type ?? null,
101
+ fromMe: m.fromMe ? 1 : 0,
102
+ mediaPath: m.mediaPath ?? null,
103
+ raw: m.raw ?? null,
104
+ });
105
+ }
106
+ /** Update the local media file path once a media message is downloaded. */
107
+ export function setMediaPath(id, path) {
108
+ getDb().prepare('UPDATE messages SET mediaPath = ? WHERE id = ?').run(path, id);
109
+ }
110
+ // ---- 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.
114
+ const CHAT_SELECT = `
115
+ SELECT c.jid,
116
+ COALESCE(c.name, ct.name) AS name,
117
+ 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`;
121
+ export function listChats(limit = 50, includeArchived = false) {
122
+ return getDb()
123
+ .prepare(`${CHAT_SELECT}
124
+ ${includeArchived ? '' : 'WHERE c.archived = 0'}
125
+ ORDER BY c.pinned DESC, c.lastTimestamp DESC
126
+ LIMIT ?`)
127
+ .all(limit);
128
+ }
129
+ export function listContacts(limit = 500) {
130
+ return getDb()
131
+ .prepare(`SELECT * FROM contacts WHERE name IS NOT NULL ORDER BY name COLLATE NOCASE LIMIT ?`)
132
+ .all(limit);
133
+ }
134
+ export function getMessages(chatJid, limit = 30) {
135
+ // Newest N, returned oldest-first for natural chat reading order.
136
+ const rows = getDb()
137
+ .prepare(`SELECT * FROM messages WHERE chatJid = ?
138
+ ORDER BY timestamp DESC, id DESC LIMIT ?`)
139
+ .all(chatJid, limit);
140
+ return rows.reverse();
141
+ }
142
+ export function searchMessages(term, limit = 50) {
143
+ return getDb()
144
+ .prepare(`SELECT * FROM messages WHERE text LIKE ? ESCAPE '\\'
145
+ ORDER BY timestamp DESC LIMIT ?`)
146
+ .all(`%${escapeLike(term)}%`, limit);
147
+ }
148
+ function escapeLike(s) {
149
+ return s.replace(/[\\%_]/g, (m) => '\\' + m);
150
+ }
151
+ /**
152
+ * Build a forgiving LIKE pattern: escape wildcards, then let any run of
153
+ * whitespace in the query match any characters. So "Vishwas @Mobius" matches a
154
+ * stored "Vishwas @Mobius" (double space) or "Vishwas · @Mobius".
155
+ */
156
+ function likePattern(query) {
157
+ return `%${escapeLike(query).replace(/\s+/g, '%')}%`;
158
+ }
159
+ /** Resolve a user-typed chat identifier (name fragment or jid) to a chat. */
160
+ export function findChat(query) {
161
+ const dbc = getDb();
162
+ // Exact jid first.
163
+ const exact = dbc
164
+ .prepare(`${CHAT_SELECT} WHERE c.jid = ?`)
165
+ .get(query);
166
+ if (exact)
167
+ return exact;
168
+ // Bare phone number → match the number portion of a direct-chat jid.
169
+ const digits = query.replace(/[^\d]/g, '');
170
+ if (digits.length >= 5 && digits === query.replace(/[+\s()\-]/g, '')) {
171
+ const byNum = dbc
172
+ .prepare(`${CHAT_SELECT} WHERE c.jid LIKE ? ORDER BY c.lastTimestamp DESC LIMIT 1`)
173
+ .get(`${digits}@%`);
174
+ if (byNum)
175
+ return byNum;
176
+ }
177
+ // Case-insensitive name match (chat's own name OR the linked contact name),
178
+ // preferring the most recently active.
179
+ const byName = dbc
180
+ .prepare(`${CHAT_SELECT}
181
+ WHERE COALESCE(c.name, ct.name) LIKE ? ESCAPE '\\'
182
+ ORDER BY (COALESCE(c.name, ct.name) = ? COLLATE NOCASE) DESC,
183
+ c.lastTimestamp DESC
184
+ LIMIT 1`)
185
+ .get(likePattern(query), query);
186
+ return byName ?? null;
187
+ }
188
+ /** Media messages, optionally filtered by type (image/video/audio/document/sticker). */
189
+ export function listMedia(types, limit = 50) {
190
+ const dbc = getDb();
191
+ if (types && types.length) {
192
+ const placeholders = types.map(() => '?').join(',');
193
+ return dbc
194
+ .prepare(`SELECT * FROM messages WHERE type IN (${placeholders})
195
+ ORDER BY timestamp DESC LIMIT ?`)
196
+ .all(...types, limit);
197
+ }
198
+ return dbc
199
+ .prepare(`SELECT * FROM messages
200
+ WHERE type IN ('image','video','audio','document','sticker')
201
+ ORDER BY timestamp DESC LIMIT ?`)
202
+ .all(limit);
203
+ }
204
+ /** Recent chats by activity, ignoring pin priority. */
205
+ export function recentChats(limit = 15) {
206
+ return getDb()
207
+ .prepare(`${CHAT_SELECT} WHERE c.archived = 0 ORDER BY c.lastTimestamp DESC LIMIT ?`)
208
+ .all(limit);
209
+ }
210
+ export function setPinned(jid, pinned) {
211
+ getDb().prepare('UPDATE chats SET pinned = ? WHERE jid = ?').run(pinned ? 1 : 0, jid);
212
+ }
213
+ export function setArchived(jid, archived) {
214
+ getDb().prepare('UPDATE chats SET archived = ? WHERE jid = ?').run(archived ? 1 : 0, jid);
215
+ }
216
+ /** Resolve a contact by name fragment (used to start a chat with no history). */
217
+ export function findContact(query) {
218
+ const row = getDb()
219
+ .prepare(`SELECT * FROM contacts
220
+ WHERE name IS NOT NULL AND name LIKE ? ESCAPE '\\'
221
+ ORDER BY (name = ? COLLATE NOCASE) DESC, LENGTH(name) ASC
222
+ LIMIT 1`)
223
+ .get(likePattern(query), query);
224
+ return row ?? null;
225
+ }
226
+ export function getMessageById(id) {
227
+ const row = getDb()
228
+ .prepare('SELECT * FROM messages WHERE id = ?')
229
+ .get(id);
230
+ return row ?? null;
231
+ }
232
+ export function countMessages() {
233
+ const row = getDb()
234
+ .prepare('SELECT COUNT(*) AS n FROM messages')
235
+ .get();
236
+ return row.n;
237
+ }
@@ -0,0 +1,89 @@
1
+ // Minimal ANSI styling — no dependency, respects NO_COLOR.
2
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
3
+ const wrap = (code) => (s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s);
4
+ // 24-bit truecolor for brand colors (modern terminals). Falls back to plain
5
+ // text under NO_COLOR / non-TTY.
6
+ const rgb = (r, g, b) => (s) => useColor ? `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m` : String(s);
7
+ // WhatsApp brand palette.
8
+ export const BRAND = {
9
+ green: [37, 211, 102], // #25D366 signature green
10
+ teal: [18, 140, 126], // #128C7E
11
+ darkTeal: [7, 94, 84], // #075E54
12
+ };
13
+ export const c = {
14
+ bold: wrap('1'),
15
+ dim: wrap('2'),
16
+ green: rgb(...BRAND.green), // WhatsApp green (used for "me", ✓, accents)
17
+ teal: rgb(...BRAND.teal),
18
+ darkTeal: rgb(...BRAND.darkTeal),
19
+ cyan: wrap('36'),
20
+ yellow: wrap('33'),
21
+ magenta: wrap('35'),
22
+ red: wrap('31'),
23
+ gray: wrap('90'),
24
+ };
25
+ /** Human-friendly timestamp. Absolute time for today, date otherwise. */
26
+ export function formatTime(ms, time24h = true) {
27
+ if (!ms)
28
+ return '';
29
+ const d = new Date(ms);
30
+ const now = new Date();
31
+ const sameDay = d.getFullYear() === now.getFullYear() &&
32
+ d.getMonth() === now.getMonth() &&
33
+ d.getDate() === now.getDate();
34
+ const time = d.toLocaleTimeString(undefined, {
35
+ hour: '2-digit',
36
+ minute: '2-digit',
37
+ hour12: !time24h,
38
+ });
39
+ if (sameDay)
40
+ return time;
41
+ const date = d.toLocaleDateString(undefined, {
42
+ month: 'short',
43
+ day: 'numeric',
44
+ });
45
+ return `${date} ${time}`;
46
+ }
47
+ export function displayName(chat) {
48
+ if (chat.name)
49
+ 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}`;
53
+ }
54
+ /** Render the numbered chat list shown by `wa chats`. */
55
+ export function renderChatList(chats, time24h = true) {
56
+ if (chats.length === 0) {
57
+ return c.dim('No chats yet. Run `wa sync` after logging in.');
58
+ }
59
+ const width = String(chats.length).length;
60
+ return chats
61
+ .map((chat, i) => {
62
+ const idx = c.dim(String(i + 1).padStart(width) + '.');
63
+ const name = c.bold(displayName(chat));
64
+ const pin = chat.pinned ? c.yellow(' 📌') : '';
65
+ const unread = chat.unreadCount > 0 ? c.green(` (${chat.unreadCount})`) : '';
66
+ const time = c.gray(formatTime(chat.lastTimestamp, time24h));
67
+ const preview = c.dim(truncate(chat.lastMessage ?? '', 48));
68
+ return `${idx} ${name}${pin}${unread} ${time}\n ${preview}`;
69
+ })
70
+ .join('\n');
71
+ }
72
+ /** Render a conversation transcript for `wa open` / `wa history`. */
73
+ export function renderMessages(messages, time24h = true) {
74
+ if (messages.length === 0)
75
+ return c.dim('No messages.');
76
+ return messages
77
+ .map((m) => {
78
+ const time = c.gray(formatTime(m.timestamp, time24h));
79
+ const who = m.fromMe ? c.green('me') : c.cyan(m.sender ?? 'unknown');
80
+ const body = m.text ?? (m.type ? c.dim(`[${m.type}]`) : c.dim('[no text]'));
81
+ const id = c.dim(`#${m.id}`);
82
+ return `${time} ${who}: ${body} ${id}`;
83
+ })
84
+ .join('\n');
85
+ }
86
+ export function truncate(s, n) {
87
+ const oneLine = s.replace(/\s+/g, ' ').trim();
88
+ return oneLine.length > n ? oneLine.slice(0, n - 1) + '…' : oneLine;
89
+ }
@@ -0,0 +1,40 @@
1
+ import { findChat, findContact } from "../db/sqlite.js";
2
+ /** A raw phone number like +1 (415) 555-0000 → 14155550000@s.whatsapp.net */
3
+ export function phoneToJid(input) {
4
+ const digits = input.replace(/[^\d]/g, '');
5
+ if (digits.length < 7)
6
+ return null;
7
+ return `${digits}@s.whatsapp.net`;
8
+ }
9
+ /**
10
+ * Resolve a user-supplied chat target to a jid.
11
+ * Order: exact jid → known chat (name/fuzzy) → raw phone number.
12
+ * Returns null when nothing plausible matches.
13
+ */
14
+ export function resolveTarget(query) {
15
+ const q = query.trim();
16
+ if (!q)
17
+ return null;
18
+ // Already a full jid (ends with a WhatsApp domain, no spaces). Note: a
19
+ // contact name can contain "@" (e.g. "Vishwas @Mobius"), so we must match the
20
+ // domain suffix, not merely the presence of "@".
21
+ if (/^\S+@(s\.whatsapp\.net|g\.us|lid|c\.us|broadcast)$/.test(q)) {
22
+ return { jid: q, name: q, known: false };
23
+ }
24
+ const chat = findChat(q);
25
+ if (chat) {
26
+ return { jid: chat.jid, name: chat.name ?? chat.jid, known: true };
27
+ }
28
+ // Known contact with no existing chat yet (e.g. first message to someone).
29
+ const contact = findContact(q);
30
+ if (contact) {
31
+ return { jid: contact.jid, name: contact.name ?? contact.jid, known: true };
32
+ }
33
+ // Looks like a phone number.
34
+ if (/^[+\d][\d\s()\-]{5,}$/.test(q)) {
35
+ const jid = phoneToJid(q);
36
+ if (jid)
37
+ return { jid, name: q, known: false };
38
+ }
39
+ return null;
40
+ }