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.
- package/bin/wa.js +4 -3
- package/dist/cli/commands/auth.js +220 -0
- package/dist/cli/commands/export.js +51 -0
- package/dist/cli/commands/filepicker.js +232 -0
- package/dist/cli/commands/group.js +139 -0
- package/dist/cli/commands/manage.js +41 -0
- package/dist/cli/commands/media.js +105 -0
- package/dist/cli/commands/misc.js +112 -0
- package/dist/cli/commands/read.js +79 -0
- package/dist/cli/commands/send.js +137 -0
- package/dist/cli/commands/shell.js +611 -0
- package/dist/cli/commands/sync.js +67 -0
- package/dist/cli/index.js +174 -0
- package/dist/config/index.js +59 -0
- package/{src/db/schema.ts → dist/db/schema.js} +0 -1
- package/dist/db/sqlite.js +237 -0
- package/dist/utils/format.js +89 -0
- package/dist/utils/jid.js +40 -0
- package/dist/utils/notify.js +29 -0
- package/dist/utils/ui.js +29 -0
- package/dist/whatsapp/client.js +157 -0
- package/dist/whatsapp/events.js +161 -0
- package/dist/whatsapp/logger.js +52 -0
- package/package.json +5 -3
- package/src/cli/commands/auth.ts +0 -236
- package/src/cli/commands/export.ts +0 -60
- package/src/cli/commands/filepicker.ts +0 -247
- package/src/cli/commands/group.ts +0 -139
- package/src/cli/commands/manage.ts +0 -46
- package/src/cli/commands/media.ts +0 -125
- package/src/cli/commands/misc.ts +0 -113
- package/src/cli/commands/read.ts +0 -99
- package/src/cli/commands/send.ts +0 -201
- package/src/cli/commands/shell.ts +0 -596
- package/src/cli/commands/sync.ts +0 -80
- package/src/cli/index.ts +0 -198
- package/src/config/index.ts +0 -92
- package/src/db/sqlite.ts +0 -354
- package/src/utils/format.ts +0 -102
- package/src/utils/jid.ts +0 -51
- package/src/utils/notify.ts +0 -29
- package/src/utils/ui.ts +0 -35
- package/src/whatsapp/client.ts +0 -207
- package/src/whatsapp/events.ts +0 -166
- 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
|
-
}
|
package/src/config/index.ts
DELETED
|
@@ -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
|
-
}
|