signal-db-cli 0.1.0
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/README.md +248 -0
- package/docs/MANUAL.md +187 -0
- package/lib/signal-db.js +432 -0
- package/package.json +47 -0
- package/signal-db-cli.js +625 -0
- package/signal-db-mcp.js +206 -0
package/lib/signal-db.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only data access layer for the local Signal Desktop database.
|
|
3
|
+
*
|
|
4
|
+
* This module owns:
|
|
5
|
+
* - OS-specific path resolution for Signal's encrypted SQLite file
|
|
6
|
+
* - opening SQLCipher/better-sqlite3 in read-only mode
|
|
7
|
+
* - query helpers used by the CLI commands
|
|
8
|
+
* - small formatting helpers shared by interactive and non-interactive output
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import SQL from '@signalapp/better-sqlite3';
|
|
14
|
+
|
|
15
|
+
/** Resolve the default Signal application directory for the current OS. */
|
|
16
|
+
function getFolderPath() {
|
|
17
|
+
const platform = process.platform;
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Signal');
|
|
20
|
+
}
|
|
21
|
+
if (platform === 'linux') {
|
|
22
|
+
return path.join(os.homedir(), '.config', 'Signal');
|
|
23
|
+
}
|
|
24
|
+
return path.join(os.homedir(), 'Library/Application Support/Signal');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve the default encrypted SQLite path used by Signal Desktop. */
|
|
28
|
+
function getDBPath() {
|
|
29
|
+
return path.join(getFolderPath(), 'sql/db.sqlite');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the SQLCipher key exactly as provided by env configuration. */
|
|
33
|
+
function getKey() {
|
|
34
|
+
return process.env.SIGNAL_DECRYPTION_KEY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Open the Signal database in read-only mode and apply the SQLCipher key. */
|
|
38
|
+
function openDB() {
|
|
39
|
+
const key = getKey();
|
|
40
|
+
if (!key || !/^[0-9a-fA-F]+$/.test(key)) {
|
|
41
|
+
throw new Error('Invalid SIGNAL_DECRYPTION_KEY: must be a non-empty hex string');
|
|
42
|
+
}
|
|
43
|
+
const db = SQL(getDBPath(), { readonly: true });
|
|
44
|
+
db.pragma(`key = "x'${key}'"`);
|
|
45
|
+
return db;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Format timestamps for the CLI output. */
|
|
49
|
+
function formatDate(ts) {
|
|
50
|
+
if (!ts) return '-';
|
|
51
|
+
return new Date(ts).toLocaleString('en-US');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize message rows for compact CLI rendering.
|
|
56
|
+
*
|
|
57
|
+
* The formatter intentionally hides schema details and returns only the pieces
|
|
58
|
+
* that the CLI needs to build timeline lines.
|
|
59
|
+
*/
|
|
60
|
+
function formatMessage(msg, options = {}) {
|
|
61
|
+
const maxLen = options.bodyMaxLen ?? 80;
|
|
62
|
+
const body = (msg.body || '(no text)').replace(/\n/g, ' ').slice(0, maxLen);
|
|
63
|
+
const suffix = (msg.body || '').length > maxLen ? '...' : '';
|
|
64
|
+
const conv = msg.conversationName || msg.conversationPhone || msg.conversationId || '?';
|
|
65
|
+
const dir = msg.type === 'incoming' ? '▶' : '◀';
|
|
66
|
+
return { body: `${body}${suffix}`, conv, dir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Format a call history record for display. */
|
|
70
|
+
function formatCall(msg) {
|
|
71
|
+
const dir = (msg.callDirection || '').toLowerCase() === 'incoming' ? '📞↓' : '📞↑';
|
|
72
|
+
const status = msg.callStatus || '?';
|
|
73
|
+
const mode = msg.callMode || msg.callType || '';
|
|
74
|
+
const parts = [dir, status];
|
|
75
|
+
if (mode && mode !== 'Group') parts.push(mode);
|
|
76
|
+
return parts.join(' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unified message query with composable filters.
|
|
81
|
+
*
|
|
82
|
+
* options:
|
|
83
|
+
* - conv: conversation name or ID
|
|
84
|
+
* - unread: only unread incoming messages
|
|
85
|
+
* - unanswered: incoming without outgoing reply (implies incoming)
|
|
86
|
+
* - olderThan: hours threshold for unanswered (default 24)
|
|
87
|
+
* - search: FTS5 full-text search query
|
|
88
|
+
* - from / to: date range (ISO string or timestamp)
|
|
89
|
+
* - incoming: only incoming
|
|
90
|
+
* - outgoing: only outgoing
|
|
91
|
+
* - limit: max results (default 20)
|
|
92
|
+
*
|
|
93
|
+
* Returns { messages, total, conversationName? }
|
|
94
|
+
*/
|
|
95
|
+
function getMessages(db, options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
conv, unread, unanswered, olderThan = 24,
|
|
98
|
+
search, from, to,
|
|
99
|
+
incoming, outgoing,
|
|
100
|
+
limit = 20,
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
// Validate conflicting direction options
|
|
104
|
+
if (incoming && outgoing) {
|
|
105
|
+
throw new Error('Cannot combine --incoming and --outgoing');
|
|
106
|
+
}
|
|
107
|
+
if (unread && outgoing) {
|
|
108
|
+
throw new Error('Cannot combine --unread and --outgoing (unread messages are always incoming)');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Resolve conversation filter
|
|
112
|
+
// Supports: UUID (direct ID), "=exact name" (exact match), or fuzzy LIKE search.
|
|
113
|
+
// Fuzzy search with multiple matches queries across all matching conversations.
|
|
114
|
+
let conversationIds = null;
|
|
115
|
+
let conversationName = null;
|
|
116
|
+
if (conv) {
|
|
117
|
+
if (/^[0-9a-f]+(-[0-9a-f]+){3,}$/i.test(conv)) {
|
|
118
|
+
conversationIds = [conv];
|
|
119
|
+
} else if (conv.startsWith('=')) {
|
|
120
|
+
const exact = conv.slice(1);
|
|
121
|
+
const convs = findConversations(db, exact, { exact: true });
|
|
122
|
+
if (convs.length === 0) {
|
|
123
|
+
throw new Error(`Conversation not found: "${exact}"`);
|
|
124
|
+
}
|
|
125
|
+
conversationIds = [convs[0].id];
|
|
126
|
+
conversationName = convs[0].name || convs[0].e164;
|
|
127
|
+
} else {
|
|
128
|
+
const convs = findConversations(db, conv);
|
|
129
|
+
if (convs.length === 0) {
|
|
130
|
+
throw new Error(`Conversation not found: "${conv}"`);
|
|
131
|
+
}
|
|
132
|
+
conversationIds = convs.map((c) => c.id);
|
|
133
|
+
conversationName = convs.length === 1
|
|
134
|
+
? (convs[0].name || convs[0].e164)
|
|
135
|
+
: `${conv} (${convs.length} conversations)`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Build SELECT columns
|
|
140
|
+
const selectCols = [
|
|
141
|
+
'm.id', 'm.body', 'm.sent_at', 'm.type', 'm.conversationId',
|
|
142
|
+
'COALESCE(c.name, c.profileFullName, c.profileName) AS conversationName', 'c.e164 AS conversationPhone',
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// Include call-history fields when viewing a single specific conversation
|
|
146
|
+
const includeCallHistory = conversationIds && conversationIds.length === 1;
|
|
147
|
+
if (includeCallHistory) {
|
|
148
|
+
selectCols.push('m.callId');
|
|
149
|
+
selectCols.push('ch.direction AS callDirection', 'ch.status AS callStatus', 'ch.mode AS callMode', 'ch.type AS callType');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// has_call_after subselect for unread mode
|
|
153
|
+
if (unread) {
|
|
154
|
+
selectCols.push(`(SELECT 1 FROM messages m2
|
|
155
|
+
WHERE m2.conversationId = m.conversationId
|
|
156
|
+
AND m2.type = 'call-history'
|
|
157
|
+
AND m2.sent_at > m.sent_at
|
|
158
|
+
LIMIT 1) AS has_call_after`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build FROM / JOIN
|
|
162
|
+
let fromClause = 'FROM messages m\n LEFT JOIN conversations c ON m.conversationId = c.id';
|
|
163
|
+
if (includeCallHistory) {
|
|
164
|
+
fromClause += '\n LEFT JOIN callsHistory ch ON m.callId = ch.callId';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Unanswered: JOIN on precomputed last outgoing per conversation (avoids correlated NOT EXISTS)
|
|
168
|
+
if (unanswered) {
|
|
169
|
+
fromClause += `\n LEFT JOIN (
|
|
170
|
+
SELECT conversationId, MAX(sent_at) AS last_out
|
|
171
|
+
FROM messages WHERE type = 'outgoing'
|
|
172
|
+
GROUP BY conversationId
|
|
173
|
+
) lo ON lo.conversationId = m.conversationId`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// FTS join
|
|
177
|
+
const ftsQuery = search ? toFTS5Query(search) : null;
|
|
178
|
+
if (ftsQuery) {
|
|
179
|
+
fromClause = `FROM messages_fts fts\n JOIN messages m ON m.rowid = fts.rowid\n LEFT JOIN conversations c ON m.conversationId = c.id`;
|
|
180
|
+
if (includeCallHistory) {
|
|
181
|
+
fromClause += '\n LEFT JOIN callsHistory ch ON m.callId = ch.callId';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Build WHERE conditions
|
|
186
|
+
const conditions = [];
|
|
187
|
+
const params = [];
|
|
188
|
+
|
|
189
|
+
if (ftsQuery) {
|
|
190
|
+
conditions.push('messages_fts MATCH ?');
|
|
191
|
+
params.push(ftsQuery);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (includeCallHistory) {
|
|
195
|
+
conditions.push("(m.type = 'call-history' OR (m.body IS NOT NULL AND m.body != ''))");
|
|
196
|
+
} else {
|
|
197
|
+
conditions.push("m.body IS NOT NULL AND m.body != ''");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Type filters
|
|
201
|
+
if (includeCallHistory) {
|
|
202
|
+
if (incoming) {
|
|
203
|
+
conditions.push("(m.type = 'incoming' OR m.type = 'call-history')");
|
|
204
|
+
} else if (outgoing) {
|
|
205
|
+
conditions.push("(m.type = 'outgoing' OR m.type = 'call-history')");
|
|
206
|
+
} else {
|
|
207
|
+
conditions.push("(m.type IN ('incoming', 'outgoing') OR m.type = 'call-history')");
|
|
208
|
+
}
|
|
209
|
+
} else if (unread || incoming) {
|
|
210
|
+
conditions.push("m.type = 'incoming'");
|
|
211
|
+
} else if (outgoing) {
|
|
212
|
+
conditions.push("m.type = 'outgoing'");
|
|
213
|
+
} else if (!unanswered) {
|
|
214
|
+
conditions.push("m.type IN ('incoming', 'outgoing')");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (unread) {
|
|
218
|
+
conditions.push('m.readStatus = 1');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (unanswered) {
|
|
222
|
+
const cutoff = Date.now() - olderThan * 60 * 60 * 1000;
|
|
223
|
+
conditions.push("m.type = 'incoming'");
|
|
224
|
+
conditions.push('m.sent_at < ?');
|
|
225
|
+
params.push(cutoff);
|
|
226
|
+
// No outgoing reply after this message (using precomputed last outgoing)
|
|
227
|
+
conditions.push('(lo.last_out IS NULL OR lo.last_out < m.sent_at)');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (conversationIds) {
|
|
231
|
+
if (conversationIds.length === 1) {
|
|
232
|
+
conditions.push('m.conversationId = ?');
|
|
233
|
+
params.push(conversationIds[0]);
|
|
234
|
+
} else {
|
|
235
|
+
conditions.push(`m.conversationId IN (${conversationIds.map(() => '?').join(', ')})`);
|
|
236
|
+
params.push(...conversationIds);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const fromTs = parseDateToTs(from);
|
|
241
|
+
const toTs = parseDateToTs(to, true);
|
|
242
|
+
if (fromTs != null) {
|
|
243
|
+
conditions.push('m.sent_at >= ?');
|
|
244
|
+
params.push(fromTs);
|
|
245
|
+
}
|
|
246
|
+
if (toTs != null) {
|
|
247
|
+
conditions.push('m.sent_at <= ?');
|
|
248
|
+
params.push(toTs);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join('\n AND ') : '';
|
|
252
|
+
|
|
253
|
+
// Unanswered mode: deduplicate per conversation (latest message + count)
|
|
254
|
+
if (unanswered) {
|
|
255
|
+
const innerSql = `SELECT ${selectCols.join(', ')},
|
|
256
|
+
ROW_NUMBER() OVER (PARTITION BY m.conversationId ORDER BY m.sent_at DESC) AS rn,
|
|
257
|
+
COUNT(*) OVER (PARTITION BY m.conversationId) AS rottingCount
|
|
258
|
+
${fromClause}\n ${whereClause}`;
|
|
259
|
+
const countSql = `SELECT COUNT(DISTINCT m.conversationId) as total ${fromClause}\n ${whereClause}`;
|
|
260
|
+
const { total } = db.prepare(countSql).get(...params);
|
|
261
|
+
const dataSql = `SELECT * FROM (${innerSql}) WHERE rn = 1 ORDER BY sent_at DESC LIMIT ?`;
|
|
262
|
+
const messages = db.prepare(dataSql).all(...params, limit);
|
|
263
|
+
return { messages, total, conversationName };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Count query
|
|
267
|
+
const countSql = `SELECT COUNT(*) as total ${fromClause}\n ${whereClause}`;
|
|
268
|
+
const { total } = db.prepare(countSql).get(...params);
|
|
269
|
+
|
|
270
|
+
// Data query
|
|
271
|
+
const dataSql = `SELECT ${selectCols.join(', ')}\n ${fromClause}\n ${whereClause}\n ORDER BY m.sent_at DESC\n LIMIT ?`;
|
|
272
|
+
const messages = db.prepare(dataSql).all(...params, limit);
|
|
273
|
+
|
|
274
|
+
return { messages, total, conversationName };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Find conversations matching a query (name or ID).
|
|
279
|
+
*/
|
|
280
|
+
function findConversations(db, query, options = {}) {
|
|
281
|
+
const { type = null, limit = 20, exact = false } = options;
|
|
282
|
+
let sql;
|
|
283
|
+
let params;
|
|
284
|
+
if (exact) {
|
|
285
|
+
sql = `
|
|
286
|
+
SELECT id, COALESCE(name, profileFullName, profileName) AS name, e164, type
|
|
287
|
+
FROM conversations
|
|
288
|
+
WHERE (name = ? OR profileFullName = ? OR profileName = ? OR e164 = ?)
|
|
289
|
+
`;
|
|
290
|
+
params = [query, query, query, query];
|
|
291
|
+
} else {
|
|
292
|
+
const escaped = query.replace(/[%_\\]/g, '\\$&');
|
|
293
|
+
const q = `%${escaped}%`;
|
|
294
|
+
sql = `
|
|
295
|
+
SELECT id, COALESCE(name, profileFullName, profileName) AS name, e164, type
|
|
296
|
+
FROM conversations
|
|
297
|
+
WHERE (name LIKE ? ESCAPE '\\' OR profileFullName LIKE ? ESCAPE '\\' OR profileName LIKE ? ESCAPE '\\' OR id = ? OR e164 LIKE ? ESCAPE '\\')
|
|
298
|
+
`;
|
|
299
|
+
params = [q, q, q, query, q];
|
|
300
|
+
}
|
|
301
|
+
if (type === 'private' || type === 'group') {
|
|
302
|
+
sql += ` AND type = ?`;
|
|
303
|
+
params.push(type);
|
|
304
|
+
}
|
|
305
|
+
sql += ` ORDER BY active_at DESC LIMIT ?`;
|
|
306
|
+
params.push(limit);
|
|
307
|
+
return db.prepare(sql).all(...params);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get a message by ID.
|
|
312
|
+
* Primarily used after interactive selection from FTS search results.
|
|
313
|
+
*/
|
|
314
|
+
function getMessageById(db, id) {
|
|
315
|
+
const stm = db.prepare(`
|
|
316
|
+
SELECT m.id, m.body, m.sent_at, m.type, m.conversationId,
|
|
317
|
+
COALESCE(c.name, c.profileFullName, c.profileName) AS conversationName, c.e164 AS conversationPhone
|
|
318
|
+
FROM messages m
|
|
319
|
+
LEFT JOIN conversations c ON m.conversationId = c.id
|
|
320
|
+
WHERE m.id = ?
|
|
321
|
+
`);
|
|
322
|
+
return stm.get(id);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Convert user query to FTS5 syntax:
|
|
327
|
+
* - space = OR (hello deadline → hello OR deadline)
|
|
328
|
+
* - comma = AND (hello, deadline → hello AND deadline)
|
|
329
|
+
* - each term gets a * suffix for prefix matching (hel → hel* matches hello, helpful...)
|
|
330
|
+
*
|
|
331
|
+
* This lets the CLI offer simple syntax without requiring raw FTS5 queries.
|
|
332
|
+
*/
|
|
333
|
+
function toFTS5Query(userQuery) {
|
|
334
|
+
const q = (userQuery || '').trim();
|
|
335
|
+
if (!q) return '';
|
|
336
|
+
const andParts = q.split(',').map((p) => p.trim()).filter(Boolean);
|
|
337
|
+
const ftsParts = andParts.map((part) => {
|
|
338
|
+
const orTerms = part.split(/\s+/).filter(Boolean).map((t) => t + '*');
|
|
339
|
+
if (orTerms.length === 0) return '';
|
|
340
|
+
if (orTerms.length === 1) return orTerms[0];
|
|
341
|
+
return '(' + orTerms.join(' OR ') + ')';
|
|
342
|
+
});
|
|
343
|
+
return ftsParts.filter(Boolean).join(' AND ');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Parse a date string to a timestamp (ms).
|
|
348
|
+
* `str` can be an ISO date (2025-01-15) or a number.
|
|
349
|
+
* `endOfDay=true` shifts to end of day for inclusive `--to` filter.
|
|
350
|
+
*/
|
|
351
|
+
function parseDateToTs(str, endOfDay = false) {
|
|
352
|
+
if (str == null || str === '') return null;
|
|
353
|
+
if (typeof str === 'number') return str;
|
|
354
|
+
|
|
355
|
+
// Relative offset: 10m, 5h, 8d
|
|
356
|
+
const offsetMatch = String(str).match(/^(\d+)([mhd])$/);
|
|
357
|
+
if (offsetMatch) {
|
|
358
|
+
const val = parseInt(offsetMatch[1], 10);
|
|
359
|
+
const unit = offsetMatch[2];
|
|
360
|
+
const multipliers = { m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000 };
|
|
361
|
+
return Date.now() - val * multipliers[unit];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ISO date string
|
|
365
|
+
const d = new Date(str);
|
|
366
|
+
if (isNaN(d.getTime())) {
|
|
367
|
+
throw new Error(`Invalid date format: "${str}" (allowed: ISO date, relative offset like 10m/5h/8d)`);
|
|
368
|
+
}
|
|
369
|
+
if (endOfDay) {
|
|
370
|
+
d.setHours(23, 59, 59, 999);
|
|
371
|
+
} else {
|
|
372
|
+
d.setHours(0, 0, 0, 0);
|
|
373
|
+
}
|
|
374
|
+
return d.getTime();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* List conversations with activity.
|
|
379
|
+
* Used for both text output and interactive mode menus.
|
|
380
|
+
*/
|
|
381
|
+
function getConversations(db, options = {}) {
|
|
382
|
+
const { type = null, limit = 50 } = options;
|
|
383
|
+
let sql = `
|
|
384
|
+
SELECT id, COALESCE(name, profileFullName, profileName) AS name, e164, type, active_at
|
|
385
|
+
FROM conversations
|
|
386
|
+
WHERE active_at IS NOT NULL
|
|
387
|
+
`;
|
|
388
|
+
const params = [];
|
|
389
|
+
if (type === 'private' || type === 'group') {
|
|
390
|
+
sql += ` AND type = ?`;
|
|
391
|
+
params.push(type);
|
|
392
|
+
}
|
|
393
|
+
sql += ` ORDER BY active_at DESC LIMIT ?`;
|
|
394
|
+
params.push(limit);
|
|
395
|
+
const stm = db.prepare(sql);
|
|
396
|
+
return stm.all(...params);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Last N calls across conversations.
|
|
401
|
+
* Joins via `messages.callId` to add conversation names to callsHistory records.
|
|
402
|
+
*/
|
|
403
|
+
function getCalls(db, limit = 20) {
|
|
404
|
+
const stm = db.prepare(`
|
|
405
|
+
SELECT ch.callId, ch.direction, ch.status, ch.mode, ch.type AS callType,
|
|
406
|
+
ch.timestamp, COALESCE(c.name, c.profileFullName, c.profileName) AS conversationName, c.e164 AS conversationPhone
|
|
407
|
+
FROM callsHistory ch
|
|
408
|
+
LEFT JOIN messages m ON m.callId = ch.callId
|
|
409
|
+
LEFT JOIN conversations c ON m.conversationId = c.id
|
|
410
|
+
WHERE ch.timestamp IS NOT NULL
|
|
411
|
+
GROUP BY ch.callId
|
|
412
|
+
ORDER BY ch.timestamp DESC
|
|
413
|
+
LIMIT ?
|
|
414
|
+
`);
|
|
415
|
+
return stm.all(limit);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export {
|
|
419
|
+
getFolderPath,
|
|
420
|
+
getDBPath,
|
|
421
|
+
openDB,
|
|
422
|
+
formatDate,
|
|
423
|
+
formatMessage,
|
|
424
|
+
formatCall,
|
|
425
|
+
getMessages,
|
|
426
|
+
findConversations,
|
|
427
|
+
getMessageById,
|
|
428
|
+
toFTS5Query,
|
|
429
|
+
parseDateToTs,
|
|
430
|
+
getConversations,
|
|
431
|
+
getCalls,
|
|
432
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signal-db-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI for browsing a local encrypted Signal Desktop database",
|
|
6
|
+
"bin": {
|
|
7
|
+
"signal-db-cli": "./signal-db-cli.js",
|
|
8
|
+
"signal-db-mcp": "./signal-db-mcp.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"signal-db-cli.js",
|
|
12
|
+
"signal-db-mcp.js",
|
|
13
|
+
"lib/",
|
|
14
|
+
"docs/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"signal",
|
|
18
|
+
"signal-desktop",
|
|
19
|
+
"cli",
|
|
20
|
+
"sqlite",
|
|
21
|
+
"encrypted"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"lint": "eslint .",
|
|
29
|
+
"lint:fix": "eslint . --fix",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"eslint": "^9.0.0",
|
|
35
|
+
"globals": "^15.0.0",
|
|
36
|
+
"vitest": "^3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@inquirer/prompts": "^8.2.1",
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
41
|
+
"@signalapp/better-sqlite3": "^9.0.13",
|
|
42
|
+
"commander": "^14.0.3",
|
|
43
|
+
"dotenv": "^17.3.1",
|
|
44
|
+
"update-notifier": "^7.3.1",
|
|
45
|
+
"zod": "^3.23.0"
|
|
46
|
+
}
|
|
47
|
+
}
|