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.
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 +611 -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
package/src/cli/index.ts DELETED
@@ -1,198 +0,0 @@
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.ts';
5
- import { chats, contacts, open, history, search } from './commands/read.ts';
6
- import { send, reply } from './commands/send.ts';
7
- import { sync, watch } from './commands/sync.ts';
8
- import { exportChat } from './commands/export.ts';
9
- import { config, doctor, version } from './commands/misc.ts';
10
- import { shell } from './commands/shell.ts';
11
- import { media, download } from './commands/media.ts';
12
- import { recent, pin, unpin, archive, unarchive } from './commands/manage.ts';
13
- import { group } from './commands/group.ts';
14
- import { closeDb } from '../db/sqlite.ts';
15
- import { whenWritesSettled } from '../whatsapp/client.ts';
16
- import { installConsoleFilter } from '../whatsapp/logger.ts';
17
- import { printError } from '../utils/ui.ts';
18
- import { c } from '../utils/format.ts';
19
-
20
- // Suppress libsignal's direct console noise (unless WA_DEBUG).
21
- installConsoleFilter();
22
-
23
- function pkgVersion(): string {
24
- try {
25
- const p = join(new URL('../../', import.meta.url).pathname, 'package.json');
26
- return JSON.parse(readFileSync(p, 'utf8')).version ?? '0.0.0';
27
- } catch {
28
- return '0.0.0';
29
- }
30
- }
31
-
32
- /**
33
- * Wrap an async command so failures print cleanly and the process always exits.
34
- * We exit explicitly because commands that opened a WhatsApp socket leave
35
- * background handles (keep-alive timers, the websocket, history-sync work) that
36
- * would otherwise keep Node alive and hang the terminal after the work is done.
37
- */
38
- function run(fn: (...args: any[]) => unknown | Promise<unknown>) {
39
- return async (...args: any[]) => {
40
- try {
41
- await fn(...args);
42
- // Let any in-flight credential writes finish before exiting, or we risk
43
- // truncating a session file mid-write and corrupting the login.
44
- await whenWritesSettled();
45
- closeDb();
46
- process.exit(0);
47
- } catch (err) {
48
- printError(err);
49
- await whenWritesSettled();
50
- closeDb();
51
- process.exit(1);
52
- }
53
- };
54
- }
55
-
56
- const program = new Command();
57
-
58
- program
59
- .name('wa')
60
- .description('A fast, terminal-first WhatsApp client.')
61
- .version(pkgVersion(), '-v, --version', 'output the version number')
62
- .configureHelp({ sortSubcommands: true })
63
- .addHelpText(
64
- 'after',
65
- `\nExamples:\n` +
66
- ` ${c.dim('$')} wa login ${c.dim('# link this device via QR')}\n` +
67
- ` ${c.dim('$')} wa sync ${c.dim('# pull recent chats & messages')}\n` +
68
- ` ${c.dim('$')} wa chats ${c.dim('# list conversations')}\n` +
69
- ` ${c.dim('$')} wa open Alice ${c.dim('# read a conversation')}\n` +
70
- ` ${c.dim('$')} wa send Alice "Hi!" ${c.dim('# send a message')}\n` +
71
- ` ${c.dim('$')} wa search invoice ${c.dim('# search cached messages')}\n`,
72
- );
73
-
74
- // ---- Authentication --------------------------------------------------------
75
- program
76
- .command('login [mode]')
77
- .description('Link this device — pairing code by default; `wa login qr` to use a QR')
78
- .option('-p, --phone <number>', 'number for the pairing code (skips the prompt)')
79
- .action(run(login));
80
- program.command('logout').description('Unlink this device and wipe local session').action(run(logout));
81
- program.command('status').description('Show login and cache status').action(run(status));
82
-
83
- // ---- Reading (offline, from local cache) -----------------------------------
84
- program
85
- .command('chats')
86
- .description('List conversations (most recent first)')
87
- .option('-a, --all', 'include archived chats')
88
- .option('-n, --limit <n>', 'max chats to show')
89
- .action(run(chats));
90
-
91
- program
92
- .command('contacts')
93
- .description('List known contacts')
94
- .option('-n, --limit <n>', 'max contacts to show')
95
- .action(run(contacts));
96
-
97
- program
98
- .command('open <chat>')
99
- .description('Show the latest messages in a chat')
100
- .option('-n, --limit <n>', 'number of messages')
101
- .action(run(open));
102
-
103
- program
104
- .command('history <chat>')
105
- .description('Show extended message history for a chat')
106
- .option('-n, --limit <n>', 'number of messages (default 200)')
107
- .action(run(history));
108
-
109
- program
110
- .command('search <text>')
111
- .description('Search cached messages')
112
- .option('-n, --limit <n>', 'max results')
113
- .action(run(search));
114
-
115
- // ---- Writing (requires connection) -----------------------------------------
116
- program
117
- .command('send <chat> [message]')
118
- .description('Send a message, or media with --file (message becomes the caption)')
119
- .option('-f, --file <path>', 'attach an image/video/audio/document to send')
120
- .action(run(send));
121
-
122
- program
123
- .command('reply <id> <message>')
124
- .description('Reply to a cached message by its #id')
125
- .action(run(reply));
126
-
127
- // ---- Sync & live -----------------------------------------------------------
128
- program
129
- .command('sync')
130
- .description('Pull recent chats and messages into the local cache')
131
- .option('-s, --seconds <n>', 'how long to sync (3-120)')
132
- .action(run(sync));
133
-
134
- program.command('watch').description('Stream incoming messages live').action(run(watch));
135
-
136
- // ---- Media -----------------------------------------------------------------
137
- program
138
- .command('media [type]')
139
- .description('List cached media (photos, videos, audio, documents, stickers, all)')
140
- .option('-n, --limit <n>', 'max items')
141
- .action(run(media));
142
-
143
- program
144
- .command('download <id>')
145
- .description('Download & decrypt a cached media message by #id')
146
- .option('-o, --out <file>', 'output file')
147
- .action(run(download));
148
-
149
- // ---- Organize (local) ------------------------------------------------------
150
- program
151
- .command('recent')
152
- .description('Show most recently active chats')
153
- .option('-n, --limit <n>', 'max chats')
154
- .action(run(recent));
155
- program
156
- .command('group [action] [args...]')
157
- .description('Manage groups: info, invite, rename, add, remove, promote, demote, leave, create')
158
- .action(run(group));
159
-
160
- program.command('pin <chat>').description('Pin a chat to the top (local)').action(run(pin));
161
- program.command('unpin <chat>').description('Unpin a chat (local)').action(run(unpin));
162
- program.command('archive <chat>').description('Archive a chat (local)').action(run(archive));
163
- program.command('unarchive <chat>').description('Unarchive a chat (local)').action(run(unarchive));
164
-
165
- // ---- Interactive -----------------------------------------------------------
166
- program
167
- .command('shell', { isDefault: false })
168
- .aliases(['chat', 'ui'])
169
- .description('Start interactive mode (slash commands, live chat)')
170
- .action(run(shell));
171
-
172
- // ---- Export ----------------------------------------------------------------
173
- program
174
- .command('export <chat>')
175
- .description('Export a conversation to markdown, text, or json')
176
- .option('-f, --format <fmt>', 'md | txt | json', 'md')
177
- .option('-n, --limit <n>', 'max messages', '1000')
178
- .option('-o, --out <file>', 'output file ("-" for stdout)')
179
- .action(run(exportChat));
180
-
181
- // ---- Utility ---------------------------------------------------------------
182
- program
183
- .command('config [action] [key] [value]')
184
- .description('View or edit config (e.g. `wa config set time24h false`)')
185
- .action(run(config));
186
-
187
- program.command('doctor').description('Run self-diagnostics').action(run(doctor));
188
- program.command('version').description('Print the version').action(run(version));
189
-
190
- // Bare `wa` (no subcommand) drops into interactive mode.
191
- if (process.argv.slice(2).length === 0) {
192
- await run(shell)();
193
- } else {
194
- await program.parseAsync(process.argv).catch((err) => {
195
- printError(err);
196
- process.exit(1);
197
- });
198
- }
@@ -1,92 +0,0 @@
1
- import { homedir } from 'node:os';
2
- import { join, resolve } from 'node:path';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
-
5
- /**
6
- * All persistent state lives under a single base directory so it's easy to back
7
- * up or wipe. Defaults to ~/.cli-whatsapp in the user's home — a stable location
8
- * that survives package updates (never inside node_modules). Override with
9
- * WA_HOME (e.g. for tests or a portable install).
10
- */
11
- export const BASE_DIR = resolve(
12
- process.env.WA_HOME ?? join(homedir(), '.cli-whatsapp'),
13
- );
14
-
15
- export const PATHS = {
16
- base: BASE_DIR,
17
- sessions: join(BASE_DIR, 'sessions'),
18
- data: join(BASE_DIR, 'data'),
19
- media: join(BASE_DIR, 'media'),
20
- db: join(BASE_DIR, 'data', 'whatsapp.db'),
21
- config: join(BASE_DIR, 'config.json'),
22
- } as const;
23
-
24
- export interface AppConfig {
25
- /** Display name shown for the logged-in account (best effort). */
26
- self?: string;
27
- /** Preferences. */
28
- preferences: {
29
- /** Default number of messages shown by `wa open`. */
30
- openLimit: number;
31
- /** 12h/24h clock. */
32
- time24h: boolean;
33
- /** Show native desktop notifications for incoming messages (watch/live). */
34
- notifications: boolean;
35
- /** Go live automatically when the interactive app starts. */
36
- autoConnect: boolean;
37
- };
38
- /** AI plugin settings (optional, never required by core). */
39
- ai: {
40
- enabled: boolean;
41
- provider?: string;
42
- model?: string;
43
- };
44
- /** Session bookkeeping. */
45
- session: {
46
- lastSyncAt?: number;
47
- loggedIn?: boolean;
48
- };
49
- }
50
-
51
- const DEFAULT_CONFIG: AppConfig = {
52
- preferences: { openLimit: 30, time24h: true, notifications: true, autoConnect: true },
53
- ai: { enabled: false },
54
- session: {},
55
- };
56
-
57
- /** Ensure every runtime directory exists (idempotent, cheap). */
58
- export function ensureDirs(): void {
59
- for (const dir of [PATHS.sessions, PATHS.data, PATHS.media]) {
60
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
61
- }
62
- }
63
-
64
- export function loadConfig(): AppConfig {
65
- if (!existsSync(PATHS.config)) return structuredClone(DEFAULT_CONFIG);
66
- try {
67
- const raw = JSON.parse(readFileSync(PATHS.config, 'utf8'));
68
- // Shallow-merge so new default keys appear for old config files.
69
- return {
70
- ...DEFAULT_CONFIG,
71
- ...raw,
72
- preferences: { ...DEFAULT_CONFIG.preferences, ...raw.preferences },
73
- ai: { ...DEFAULT_CONFIG.ai, ...raw.ai },
74
- session: { ...DEFAULT_CONFIG.session, ...raw.session },
75
- };
76
- } catch {
77
- // Corrupt config should never crash the CLI; fall back to defaults.
78
- return structuredClone(DEFAULT_CONFIG);
79
- }
80
- }
81
-
82
- export function saveConfig(config: AppConfig): void {
83
- ensureDirs();
84
- writeFileSync(PATHS.config, JSON.stringify(config, null, 2) + '\n', 'utf8');
85
- }
86
-
87
- /** Convenience: patch a subset of config and persist. */
88
- export function updateConfig(patch: Partial<AppConfig>): AppConfig {
89
- const next = { ...loadConfig(), ...patch };
90
- saveConfig(next);
91
- return next;
92
- }
package/src/db/sqlite.ts DELETED
@@ -1,354 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { ensureDirs, PATHS } from '../config/index.ts';
3
- import { SCHEMA_SQL, SCHEMA_VERSION } from './schema.ts';
4
-
5
- export interface ChatRow {
6
- jid: string;
7
- name: string | null;
8
- lastMessage: string | null;
9
- lastTimestamp: number;
10
- unreadCount: number;
11
- archived: number;
12
- pinned: number;
13
- isGroup: number;
14
- }
15
-
16
- export interface MessageRow {
17
- id: number;
18
- chatJid: string;
19
- messageId: string;
20
- sender: string | null;
21
- text: string | null;
22
- timestamp: number;
23
- status: string | null;
24
- type: string | null;
25
- fromMe: number;
26
- mediaPath: string | null;
27
- raw: string | null;
28
- }
29
-
30
- export interface ContactRow {
31
- jid: string;
32
- name: string | null;
33
- phone: string | null;
34
- updatedAt: number;
35
- }
36
-
37
- let db: DatabaseSync | null = null;
38
-
39
- /** Open (or reuse) the singleton database connection and apply the schema. */
40
- export function getDb(): DatabaseSync {
41
- if (db) return db;
42
- ensureDirs();
43
- db = new DatabaseSync(PATHS.db);
44
- db.exec(SCHEMA_SQL);
45
- migrate(db);
46
- setSetting('schema_version', String(SCHEMA_VERSION));
47
- return db;
48
- }
49
-
50
- /** Additive, idempotent migrations for databases created by older versions. */
51
- function migrate(dbc: DatabaseSync): void {
52
- ensureColumn(dbc, 'messages', 'raw', 'TEXT');
53
- }
54
-
55
- function ensureColumn(
56
- dbc: DatabaseSync,
57
- table: string,
58
- column: string,
59
- type: string,
60
- ): void {
61
- const cols = dbc.prepare(`PRAGMA table_info(${table})`).all() as {
62
- name: string;
63
- }[];
64
- if (!cols.some((c) => c.name === column)) {
65
- dbc.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
66
- }
67
- }
68
-
69
- export function closeDb(): void {
70
- if (db) {
71
- db.close();
72
- db = null;
73
- }
74
- }
75
-
76
- // ---- settings key/value helpers -------------------------------------------
77
-
78
- export function getSetting(key: string): string | null {
79
- const row = getDb()
80
- .prepare('SELECT value FROM settings WHERE key = ?')
81
- .get(key) as { value: string } | undefined;
82
- return row?.value ?? null;
83
- }
84
-
85
- export function setSetting(key: string, value: string): void {
86
- getDb()
87
- .prepare(
88
- `INSERT INTO settings (key, value) VALUES (?, ?)
89
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
90
- )
91
- .run(key, value);
92
- }
93
-
94
- // ---- upserts ---------------------------------------------------------------
95
-
96
- export function upsertContact(c: {
97
- jid: string;
98
- name?: string | null;
99
- phone?: string | null;
100
- }): void {
101
- getDb()
102
- .prepare(
103
- `INSERT INTO contacts (jid, name, phone, updatedAt)
104
- VALUES (@jid, @name, @phone, @updatedAt)
105
- ON CONFLICT(jid) DO UPDATE SET
106
- name = COALESCE(excluded.name, contacts.name),
107
- phone = COALESCE(excluded.phone, contacts.phone),
108
- updatedAt = excluded.updatedAt`,
109
- )
110
- .run({
111
- jid: c.jid,
112
- name: c.name ?? null,
113
- phone: c.phone ?? null,
114
- updatedAt: Date.now(),
115
- });
116
- }
117
-
118
- export function upsertChat(c: {
119
- jid: string;
120
- name?: string | null;
121
- lastMessage?: string | null;
122
- lastTimestamp?: number;
123
- unreadCount?: number;
124
- archived?: number;
125
- pinned?: number;
126
- isGroup?: number;
127
- }): void {
128
- getDb()
129
- .prepare(
130
- `INSERT INTO chats (jid, name, lastMessage, lastTimestamp, unreadCount, archived, pinned, isGroup)
131
- VALUES (@jid, @name, @lastMessage, @lastTimestamp, @unreadCount, @archived, @pinned, @isGroup)
132
- ON CONFLICT(jid) DO UPDATE SET
133
- name = COALESCE(excluded.name, chats.name),
134
- lastMessage = COALESCE(excluded.lastMessage, chats.lastMessage),
135
- lastTimestamp = MAX(excluded.lastTimestamp, chats.lastTimestamp),
136
- unreadCount = excluded.unreadCount,
137
- archived = excluded.archived,
138
- pinned = excluded.pinned,
139
- isGroup = excluded.isGroup`,
140
- )
141
- .run({
142
- jid: c.jid,
143
- name: c.name ?? null,
144
- lastMessage: c.lastMessage ?? null,
145
- lastTimestamp: c.lastTimestamp ?? 0,
146
- unreadCount: c.unreadCount ?? 0,
147
- archived: c.archived ?? 0,
148
- pinned: c.pinned ?? 0,
149
- isGroup: c.isGroup ?? 0,
150
- });
151
- }
152
-
153
- export function insertMessage(m: {
154
- chatJid: string;
155
- messageId: string;
156
- sender?: string | null;
157
- text?: string | null;
158
- timestamp: number;
159
- status?: string | null;
160
- type?: string | null;
161
- fromMe: boolean;
162
- mediaPath?: string | null;
163
- raw?: string | null;
164
- }): void {
165
- getDb()
166
- .prepare(
167
- `INSERT INTO messages (chatJid, messageId, sender, text, timestamp, status, type, fromMe, mediaPath, raw)
168
- VALUES (@chatJid, @messageId, @sender, @text, @timestamp, @status, @type, @fromMe, @mediaPath, @raw)
169
- ON CONFLICT(chatJid, messageId) DO UPDATE SET
170
- text = COALESCE(excluded.text, messages.text),
171
- status = COALESCE(excluded.status, messages.status),
172
- mediaPath = COALESCE(excluded.mediaPath, messages.mediaPath),
173
- raw = COALESCE(excluded.raw, messages.raw)`,
174
- )
175
- .run({
176
- chatJid: m.chatJid,
177
- messageId: m.messageId,
178
- sender: m.sender ?? null,
179
- text: m.text ?? null,
180
- timestamp: m.timestamp,
181
- status: m.status ?? null,
182
- type: m.type ?? null,
183
- fromMe: m.fromMe ? 1 : 0,
184
- mediaPath: m.mediaPath ?? null,
185
- raw: m.raw ?? null,
186
- });
187
- }
188
-
189
- /** Update the local media file path once a media message is downloaded. */
190
- export function setMediaPath(id: number, path: string): void {
191
- getDb().prepare('UPDATE messages SET mediaPath = ? WHERE id = ?').run(path, id);
192
- }
193
-
194
- // ---- queries ---------------------------------------------------------------
195
-
196
- // Chat rows with the display name resolved: prefer the chat's own name, then
197
- // fall back to a known contact name for the same jid (direct chats often have
198
- // no name of their own). Column order/shape matches ChatRow.
199
- const CHAT_SELECT = `
200
- SELECT c.jid,
201
- COALESCE(c.name, ct.name) AS name,
202
- c.lastMessage, c.lastTimestamp, c.unreadCount,
203
- c.archived, c.pinned, c.isGroup
204
- FROM chats c
205
- LEFT JOIN contacts ct ON ct.jid = c.jid`;
206
-
207
- export function listChats(limit = 50, includeArchived = false): ChatRow[] {
208
- return getDb()
209
- .prepare(
210
- `${CHAT_SELECT}
211
- ${includeArchived ? '' : 'WHERE c.archived = 0'}
212
- ORDER BY c.pinned DESC, c.lastTimestamp DESC
213
- LIMIT ?`,
214
- )
215
- .all(limit) as unknown as ChatRow[];
216
- }
217
-
218
- export function listContacts(limit = 500): ContactRow[] {
219
- return getDb()
220
- .prepare(
221
- `SELECT * FROM contacts WHERE name IS NOT NULL ORDER BY name COLLATE NOCASE LIMIT ?`,
222
- )
223
- .all(limit) as unknown as ContactRow[];
224
- }
225
-
226
- export function getMessages(chatJid: string, limit = 30): MessageRow[] {
227
- // Newest N, returned oldest-first for natural chat reading order.
228
- const rows = getDb()
229
- .prepare(
230
- `SELECT * FROM messages WHERE chatJid = ?
231
- ORDER BY timestamp DESC, id DESC LIMIT ?`,
232
- )
233
- .all(chatJid, limit) as unknown as MessageRow[];
234
- return rows.reverse();
235
- }
236
-
237
- export function searchMessages(term: string, limit = 50): MessageRow[] {
238
- return getDb()
239
- .prepare(
240
- `SELECT * FROM messages WHERE text LIKE ? ESCAPE '\\'
241
- ORDER BY timestamp DESC LIMIT ?`,
242
- )
243
- .all(`%${escapeLike(term)}%`, limit) as unknown as MessageRow[];
244
- }
245
-
246
- function escapeLike(s: string): string {
247
- return s.replace(/[\\%_]/g, (m) => '\\' + m);
248
- }
249
-
250
- /**
251
- * Build a forgiving LIKE pattern: escape wildcards, then let any run of
252
- * whitespace in the query match any characters. So "Vishwas @Mobius" matches a
253
- * stored "Vishwas @Mobius" (double space) or "Vishwas · @Mobius".
254
- */
255
- function likePattern(query: string): string {
256
- return `%${escapeLike(query).replace(/\s+/g, '%')}%`;
257
- }
258
-
259
- /** Resolve a user-typed chat identifier (name fragment or jid) to a chat. */
260
- export function findChat(query: string): ChatRow | null {
261
- const dbc = getDb();
262
- // Exact jid first.
263
- const exact = dbc
264
- .prepare(`${CHAT_SELECT} WHERE c.jid = ?`)
265
- .get(query) as ChatRow | undefined;
266
- if (exact) return exact;
267
- // Bare phone number → match the number portion of a direct-chat jid.
268
- const digits = query.replace(/[^\d]/g, '');
269
- if (digits.length >= 5 && digits === query.replace(/[+\s()\-]/g, '')) {
270
- const byNum = dbc
271
- .prepare(
272
- `${CHAT_SELECT} WHERE c.jid LIKE ? ORDER BY c.lastTimestamp DESC LIMIT 1`,
273
- )
274
- .get(`${digits}@%`) as ChatRow | undefined;
275
- if (byNum) return byNum;
276
- }
277
- // Case-insensitive name match (chat's own name OR the linked contact name),
278
- // preferring the most recently active.
279
- const byName = dbc
280
- .prepare(
281
- `${CHAT_SELECT}
282
- WHERE COALESCE(c.name, ct.name) LIKE ? ESCAPE '\\'
283
- ORDER BY (COALESCE(c.name, ct.name) = ? COLLATE NOCASE) DESC,
284
- c.lastTimestamp DESC
285
- LIMIT 1`,
286
- )
287
- .get(likePattern(query), query) as ChatRow | undefined;
288
- return byName ?? null;
289
- }
290
-
291
- /** Media messages, optionally filtered by type (image/video/audio/document/sticker). */
292
- export function listMedia(types: string[] | null, limit = 50): MessageRow[] {
293
- const dbc = getDb();
294
- if (types && types.length) {
295
- const placeholders = types.map(() => '?').join(',');
296
- return dbc
297
- .prepare(
298
- `SELECT * FROM messages WHERE type IN (${placeholders})
299
- ORDER BY timestamp DESC LIMIT ?`,
300
- )
301
- .all(...types, limit) as unknown as MessageRow[];
302
- }
303
- return dbc
304
- .prepare(
305
- `SELECT * FROM messages
306
- WHERE type IN ('image','video','audio','document','sticker')
307
- ORDER BY timestamp DESC LIMIT ?`,
308
- )
309
- .all(limit) as unknown as MessageRow[];
310
- }
311
-
312
- /** Recent chats by activity, ignoring pin priority. */
313
- export function recentChats(limit = 15): ChatRow[] {
314
- return getDb()
315
- .prepare(
316
- `${CHAT_SELECT} WHERE c.archived = 0 ORDER BY c.lastTimestamp DESC LIMIT ?`,
317
- )
318
- .all(limit) as unknown as ChatRow[];
319
- }
320
-
321
- export function setPinned(jid: string, pinned: boolean): void {
322
- getDb().prepare('UPDATE chats SET pinned = ? WHERE jid = ?').run(pinned ? 1 : 0, jid);
323
- }
324
-
325
- export function setArchived(jid: string, archived: boolean): void {
326
- getDb().prepare('UPDATE chats SET archived = ? WHERE jid = ?').run(archived ? 1 : 0, jid);
327
- }
328
-
329
- /** Resolve a contact by name fragment (used to start a chat with no history). */
330
- export function findContact(query: string): ContactRow | null {
331
- const row = getDb()
332
- .prepare(
333
- `SELECT * FROM contacts
334
- WHERE name IS NOT NULL AND name LIKE ? ESCAPE '\\'
335
- ORDER BY (name = ? COLLATE NOCASE) DESC, LENGTH(name) ASC
336
- LIMIT 1`,
337
- )
338
- .get(likePattern(query), query) as ContactRow | undefined;
339
- return row ?? null;
340
- }
341
-
342
- export function getMessageById(id: number): MessageRow | null {
343
- const row = getDb()
344
- .prepare('SELECT * FROM messages WHERE id = ?')
345
- .get(id) as MessageRow | undefined;
346
- return row ?? null;
347
- }
348
-
349
- export function countMessages(): number {
350
- const row = getDb()
351
- .prepare('SELECT COUNT(*) AS n FROM messages')
352
- .get() as { n: number };
353
- return row.n;
354
- }