@powercess/qq-mcp 0.2.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.
Potentially problematic release.
This version of @powercess/qq-mcp might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/index.js +287 -0
- package/dist/index.js.map +1 -0
- package/dist/keyscan.js +156 -0
- package/dist/keyscan.js.map +1 -0
- package/dist/messages.js +309 -0
- package/dist/messages.js.map +1 -0
- package/dist/offline.js +316 -0
- package/dist/offline.js.map +1 -0
- package/dist/schema.js +86 -0
- package/dist/schema.js.map +1 -0
- package/dist/sqlcipher.js +273 -0
- package/dist/sqlcipher.js.map +1 -0
- package/dist/verify-worker.js +18 -0
- package/dist/verify-worker.js.map +1 -0
- package/docs/reverse-engineering.md +188 -0
- package/package.json +62 -0
- package/scripts/scan_memory.py +155 -0
- package/scripts/smoke-client.mjs +30 -0
- package/src/index.ts +341 -0
- package/src/keyscan.ts +202 -0
- package/src/messages.ts +414 -0
- package/src/offline.ts +422 -0
- package/src/schema.ts +101 -0
- package/src/sqlcipher.ts +326 -0
- package/src/verify-worker.ts +18 -0
package/src/offline.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline access to NTQQ (Windows QQ NT) databases.
|
|
3
|
+
*
|
|
4
|
+
* Finds the local `nt_qq/nt_db` directories, resolves the device passphrase,
|
|
5
|
+
* decrypts a database on demand into a cached plaintext SQLite file, and turns
|
|
6
|
+
* it into chat/contact queries. This replaces the earlier in-memory scrape:
|
|
7
|
+
* the on-disk databases yield exact, schema-accurate records.
|
|
8
|
+
*/
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { parseNtqqHeader, pageCount, readSalt, reconstructDatabase, verifyPassphrase } from "./sqlcipher.js";
|
|
15
|
+
import { toMessageRecord, type MessageRecord, type RawMessageRow } from "./messages.js";
|
|
16
|
+
|
|
17
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-user directory holding the device key file and the plaintext cache.
|
|
21
|
+
* Kept out of the package directory so an installed copy does not write
|
|
22
|
+
* gigabytes (or a device key) into `node_modules`.
|
|
23
|
+
*/
|
|
24
|
+
export const QQ_MCP_HOME = process.env.QQ_MCP_HOME ?? join(homedir(), ".qq-mcp");
|
|
25
|
+
/** Where decrypted plaintext databases are cached. */
|
|
26
|
+
export const DEFAULT_CACHE_DIR = process.env.QQ_MCP_CACHE_DIR ?? join(QQ_MCP_HOME, "plain");
|
|
27
|
+
/** Device key file written by `capture_offline_key` when asked to persist. */
|
|
28
|
+
export const KEY_FILE = join(QQ_MCP_HOME, "qqkey");
|
|
29
|
+
/** Bundled memory scanner, shipped next to the compiled server. */
|
|
30
|
+
export const SCANNER_SCRIPT = join(ROOT, "scripts", "scan_memory.py");
|
|
31
|
+
|
|
32
|
+
export interface DataSource {
|
|
33
|
+
name: string;
|
|
34
|
+
dbPath: string;
|
|
35
|
+
bytes: number;
|
|
36
|
+
pages: number;
|
|
37
|
+
saltHex: string;
|
|
38
|
+
/** Schema/format version advertised by the NTQQ header, e.g. `1.1.0.1`. */
|
|
39
|
+
formatVersion: string;
|
|
40
|
+
/** `<db>-first.material` companion, when present (independent offline key oracle). */
|
|
41
|
+
materialPath?: string;
|
|
42
|
+
mtimeMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Candidate `nt_db` directories for the current machine. */
|
|
46
|
+
export function findQQDataDirs(): string[] {
|
|
47
|
+
const dirs: string[] = [];
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const push = (dir: string): void => {
|
|
50
|
+
if (!dir || seen.has(dir) || !existsSync(dir)) return;
|
|
51
|
+
seen.add(dir);
|
|
52
|
+
dirs.push(dir);
|
|
53
|
+
};
|
|
54
|
+
const override = process.env.QQ_MCP_DATA_DIR;
|
|
55
|
+
if (override) for (const part of override.split(";")) push(part.trim());
|
|
56
|
+
const docs = join(process.env.USERPROFILE ?? "", "Documents", "Tencent Files");
|
|
57
|
+
if (existsSync(docs)) {
|
|
58
|
+
for (const entry of safeReaddir(docs)) {
|
|
59
|
+
push(join(docs, entry, "nt_qq", "nt_db"));
|
|
60
|
+
}
|
|
61
|
+
push(join(docs, "nt_qq", "global", "nt_db"));
|
|
62
|
+
}
|
|
63
|
+
return dirs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeReaddir(dir: string): string[] {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Lists NTQQ databases found in the given directories (defaults to {@link findQQDataDirs}). */
|
|
75
|
+
export function listDataSources(dirs: string[] = findQQDataDirs()): DataSource[] {
|
|
76
|
+
const out: DataSource[] = [];
|
|
77
|
+
for (const dir of dirs) {
|
|
78
|
+
for (const entry of safeReaddir(dir)) {
|
|
79
|
+
if (!entry.endsWith(".db")) continue;
|
|
80
|
+
const dbPath = join(dir, entry);
|
|
81
|
+
try {
|
|
82
|
+
const stats = statSync(dbPath);
|
|
83
|
+
const header = parseNtqqHeader(dbPath);
|
|
84
|
+
const materialPath = `${dbPath}-first.material`;
|
|
85
|
+
out.push({
|
|
86
|
+
name: entry,
|
|
87
|
+
dbPath,
|
|
88
|
+
bytes: stats.size,
|
|
89
|
+
pages: pageCount(dbPath),
|
|
90
|
+
saltHex: readSalt(dbPath).toString("hex"),
|
|
91
|
+
formatVersion: header.formatVersion,
|
|
92
|
+
materialPath: existsSync(materialPath) ? materialPath : undefined,
|
|
93
|
+
mtimeMs: stats.mtimeMs,
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
// Not an NTQQ database (stub files, unrelated sqlite) — skip.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Passphrase from an explicit argument, `QQ_MCP_PASSPHRASE`, or the `qqkey` file in {@link QQ_MCP_HOME}. */
|
|
104
|
+
export function resolvePassphrase(explicit?: string): string | undefined {
|
|
105
|
+
if (explicit) return explicit;
|
|
106
|
+
const env = process.env.QQ_MCP_PASSPHRASE;
|
|
107
|
+
if (env) return env;
|
|
108
|
+
try {
|
|
109
|
+
const file = readFileSync(KEY_FILE, "utf8").trim();
|
|
110
|
+
if (file.length) return file;
|
|
111
|
+
} catch {
|
|
112
|
+
// No cached key file.
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function requirePassphrase(explicit?: string): string {
|
|
118
|
+
const passphrase = resolvePassphrase(explicit);
|
|
119
|
+
if (!passphrase) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`no device passphrase: pass \`passphrase\`, set QQ_MCP_PASSPHRASE, write ${KEY_FILE}, ` +
|
|
122
|
+
"or run capture_offline_key while QQ is logged in"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return passphrase;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Cache path for the decrypted form of `dbPath`; keyed by size+mtime so QQ writes invalidate it. */
|
|
129
|
+
export function plaintextCachePath(dbPath: string, cacheDir = DEFAULT_CACHE_DIR): string {
|
|
130
|
+
const stats = statSync(dbPath);
|
|
131
|
+
const name = dbPath.split(/[\\/]/).pop() ?? "db";
|
|
132
|
+
return join(cacheDir, `${name}.${stats.size}.${Math.floor(stats.mtimeMs)}.plain.db`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface EnsureResult {
|
|
136
|
+
path: string;
|
|
137
|
+
/** True when an existing cache entry was reused instead of re-decrypting. */
|
|
138
|
+
reused: boolean;
|
|
139
|
+
durationMs: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Decrypts `dbPath` into the cache (or reuses a fresh cache entry). */
|
|
143
|
+
export function ensurePlaintext(
|
|
144
|
+
dbPath: string,
|
|
145
|
+
passphrase: string,
|
|
146
|
+
opts: { cacheDir?: string; force?: boolean; onProgress?: (done: number, total: number) => void } = {}
|
|
147
|
+
): EnsureResult {
|
|
148
|
+
if (!verifyPassphrase(dbPath, passphrase)) {
|
|
149
|
+
throw new Error(`passphrase does not decrypt ${dbPath} (wrong key or not an NTQQ database)`);
|
|
150
|
+
}
|
|
151
|
+
const cacheDir = opts.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
152
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
153
|
+
const target = plaintextCachePath(dbPath, cacheDir);
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
if (!opts.force && existsSync(target)) {
|
|
156
|
+
return { path: target, reused: true, durationMs: 0 };
|
|
157
|
+
}
|
|
158
|
+
const result = reconstructDatabase({ dbPath, passphrase, outPath: target, onProgress: opts.onProgress });
|
|
159
|
+
pruneCache(cacheDir, dbPath);
|
|
160
|
+
return { path: result.outPath, reused: false, durationMs: Date.now() - started };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Keeps at most two plaintext copies per source database, newest first. */
|
|
164
|
+
function pruneCache(cacheDir: string, dbPath: string): void {
|
|
165
|
+
const name = dbPath.split(/[\\/]/).pop() ?? "db";
|
|
166
|
+
try {
|
|
167
|
+
const entries = safeReaddir(cacheDir)
|
|
168
|
+
.filter((e) => e.startsWith(`${name}.`) && e.endsWith(".plain.db"))
|
|
169
|
+
.map((e) => ({ e, mtimeMs: statSync(join(cacheDir, e)).mtimeMs }))
|
|
170
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
171
|
+
for (const stale of entries.slice(2)) rmSync(join(cacheDir, stale.e), { force: true });
|
|
172
|
+
} catch {
|
|
173
|
+
// Cache pruning is best-effort.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface OfflineOptions {
|
|
178
|
+
passphrase?: string;
|
|
179
|
+
cacheDir?: string;
|
|
180
|
+
/** Skip decryption entirely and use this plaintext file. */
|
|
181
|
+
plaintextPath?: string;
|
|
182
|
+
force?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface OfflineDatabase {
|
|
186
|
+
db: DatabaseSync;
|
|
187
|
+
plaintextPath: string;
|
|
188
|
+
sourcePath: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Opens a decrypted NTQQ database read-only. */
|
|
192
|
+
export function openOffline(dbPath: string, opts: OfflineOptions = {}): OfflineDatabase {
|
|
193
|
+
const plaintextPath = opts.plaintextPath ?? ensurePlaintext(dbPath, requirePassphrase(opts.passphrase), opts).path;
|
|
194
|
+
return { db: new DatabaseSync(plaintextPath, { readOnly: true, readBigInts: true }), plaintextPath, sourcePath: dbPath };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const MSG_SELECT = `[40001] AS msgId, [40011] AS msgType, [40013] AS direction, [40020] AS senderUid,
|
|
198
|
+
[40021] AS peerUid, [40033] AS senderQq, [40030] AS peerQq, [40027] AS conversationId,
|
|
199
|
+
[40050] AS timestamp, [40093] AS senderName, [40800] AS body`;
|
|
200
|
+
|
|
201
|
+
export interface MessageQuery {
|
|
202
|
+
/** `c2c`, `group`, or both. */
|
|
203
|
+
chat?: "c2c" | "group" | "all";
|
|
204
|
+
/** For c2c: the other party's QQ number. */
|
|
205
|
+
peerQq?: number;
|
|
206
|
+
/** For group: the group number. */
|
|
207
|
+
groupQq?: number;
|
|
208
|
+
since?: number;
|
|
209
|
+
until?: number;
|
|
210
|
+
/** Substring match on decoded text (case-sensitive). */
|
|
211
|
+
keyword?: string;
|
|
212
|
+
limit?: number;
|
|
213
|
+
/** Newest first by default. */
|
|
214
|
+
order?: "asc" | "desc";
|
|
215
|
+
/** Stop after this many rows scanned without keyword matches; guards full scans. */
|
|
216
|
+
maxScan?: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface MessageQueryResult {
|
|
220
|
+
messages: MessageRecord[];
|
|
221
|
+
scanned: number;
|
|
222
|
+
truncated: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Queries chat messages. Text lives inside protobuf, so keyword filtering
|
|
227
|
+
* happens after decoding; rows are paged by primary key (keyset) rather than by
|
|
228
|
+
* an index, because some QQ databases ship damaged index pages while the table
|
|
229
|
+
* b-trees stay readable. `maxScan` bounds how deep a keyword search may go.
|
|
230
|
+
*/
|
|
231
|
+
export function queryMessages(offline: OfflineDatabase, query: MessageQuery = {}): MessageQueryResult {
|
|
232
|
+
const { db } = offline;
|
|
233
|
+
const limit = Math.min(Math.max(query.limit ?? 100, 1), 5000);
|
|
234
|
+
const order = query.order === "asc" ? "ASC" : "DESC";
|
|
235
|
+
const comparator = order === "ASC" ? "<" : ">";
|
|
236
|
+
const maxScan = query.maxScan ?? 200_000;
|
|
237
|
+
const pageSize = 1000;
|
|
238
|
+
const chats: Array<"c2c" | "group"> =
|
|
239
|
+
query.chat === "all" || query.chat === undefined ? ["c2c", "group"] : [query.chat];
|
|
240
|
+
|
|
241
|
+
const messages: MessageRecord[] = [];
|
|
242
|
+
let scanned = 0;
|
|
243
|
+
for (const chatType of chats) {
|
|
244
|
+
if (messages.length >= limit || scanned >= maxScan) break;
|
|
245
|
+
const table = chatType === "c2c" ? "c2c_msg_table" : "group_msg_table";
|
|
246
|
+
const filters: string[] = [];
|
|
247
|
+
if (chatType === "c2c" && query.peerQq !== undefined) {
|
|
248
|
+
filters.push(`([40030] = ${Number(query.peerQq)} OR [40033] = ${Number(query.peerQq)})`);
|
|
249
|
+
}
|
|
250
|
+
if (chatType === "group" && query.groupQq !== undefined) {
|
|
251
|
+
filters.push(`[40027] = ${Number(query.groupQq)}`);
|
|
252
|
+
}
|
|
253
|
+
if (query.since !== undefined) filters.push(`[40050] >= ${Number(query.since)}`);
|
|
254
|
+
if (query.until !== undefined) filters.push(`[40050] <= ${Number(query.until)}`);
|
|
255
|
+
let cursor: string | undefined;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const where = [...filters];
|
|
258
|
+
if (cursor) where.push(`[40001] ${comparator} '${cursor}'`);
|
|
259
|
+
const sql = `SELECT ${MSG_SELECT} FROM ${table} ${
|
|
260
|
+
where.length ? `WHERE ${where.join(" AND ")}` : ""
|
|
261
|
+
} ORDER BY [40001] ${order} LIMIT ${pageSize}`;
|
|
262
|
+
let rows: RawMessageRow[];
|
|
263
|
+
try {
|
|
264
|
+
rows = db.prepare(sql).all() as unknown as RawMessageRow[];
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw new Error(`query failed on ${table}: ${(error as Error).message}`);
|
|
267
|
+
}
|
|
268
|
+
if (rows.length === 0) break;
|
|
269
|
+
for (const row of rows) {
|
|
270
|
+
scanned++;
|
|
271
|
+
cursor = String(row.msgId);
|
|
272
|
+
const record = toMessageRecord(row, chatType);
|
|
273
|
+
if (query.keyword && !record.text.includes(query.keyword)) continue;
|
|
274
|
+
if (messages.length >= limit) {
|
|
275
|
+
return finish(messages, scanned, order);
|
|
276
|
+
}
|
|
277
|
+
messages.push(record);
|
|
278
|
+
}
|
|
279
|
+
if (rows.length < pageSize || scanned >= maxScan) break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const truncated = scanned >= maxScan && messages.length < limit;
|
|
283
|
+
return finish(messages, scanned, order, truncated);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function finish(messages: MessageRecord[], scanned: number, order: "ASC" | "DESC", truncated = false): MessageQueryResult {
|
|
287
|
+
messages.sort((a, b) => (order === "DESC" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp));
|
|
288
|
+
return { messages, scanned, truncated };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ContactRecord {
|
|
292
|
+
kind: "friend" | "group";
|
|
293
|
+
qq?: number;
|
|
294
|
+
uid?: string;
|
|
295
|
+
name?: string;
|
|
296
|
+
remark?: string;
|
|
297
|
+
lastActive?: number;
|
|
298
|
+
source: "recent_contact" | "group_info" | "uid_mapping";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface ContactsResult {
|
|
302
|
+
friends: ContactRecord[];
|
|
303
|
+
groups: ContactRecord[];
|
|
304
|
+
uidMappings: Array<{ qq: number; uid: string }>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Contact book: friends from `recent_contact_v3_table`, groups from
|
|
309
|
+
* `group_info.db.group_list` when available, plus the QQ↔NT-UID mapping table.
|
|
310
|
+
*/
|
|
311
|
+
export function listContacts(offline: OfflineDatabase, opts: { groupInfoPath?: string; passphrase?: string; limit?: number } = {}): ContactsResult {
|
|
312
|
+
const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
|
|
313
|
+
const friends = readFriends(offline.db, limit);
|
|
314
|
+
const uidMappings = readUidMappings(offline.db, limit);
|
|
315
|
+
const groups = opts.groupInfoPath ? readGroups(opts.groupInfoPath, opts.passphrase, limit) : [];
|
|
316
|
+
return { friends, groups, uidMappings };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readFriends(db: DatabaseSync, limit: number): ContactRecord[] {
|
|
320
|
+
const rows = db
|
|
321
|
+
.prepare(
|
|
322
|
+
`SELECT [40020] AS uid, [40033] AS qq, [40090] AS name1, [40093] AS name2, [40050] AS lastActive
|
|
323
|
+
FROM recent_contact_v3_table
|
|
324
|
+
WHERE cast([40020] AS TEXT) GLOB 'u_*'
|
|
325
|
+
ORDER BY [40050] DESC LIMIT ${limit}`
|
|
326
|
+
)
|
|
327
|
+
.all() as Array<Record<string, unknown>>;
|
|
328
|
+
return rows.map((row) => ({
|
|
329
|
+
kind: "friend" as const,
|
|
330
|
+
qq: Number(row.qq) || undefined,
|
|
331
|
+
uid: String(row.uid ?? "") || undefined,
|
|
332
|
+
name: String(row.name1 ?? "") || String(row.name2 ?? "") || undefined,
|
|
333
|
+
lastActive: Number(row.lastActive) || undefined,
|
|
334
|
+
source: "recent_contact" as const,
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function readUidMappings(db: DatabaseSync, limit: number): Array<{ qq: number; uid: string }> {
|
|
339
|
+
const rows = db
|
|
340
|
+
.prepare(`SELECT [1002] AS qq, [48902] AS uid FROM nt_uid_mapping_table LIMIT ${limit}`)
|
|
341
|
+
.all() as Array<Record<string, unknown>>;
|
|
342
|
+
return rows
|
|
343
|
+
.map((row) => ({ qq: Number(row.qq), uid: String(row.uid ?? "") }))
|
|
344
|
+
.filter((r) => Number.isFinite(r.qq) && r.uid.length > 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function readGroups(groupInfoPath: string, passphrase: string | undefined, limit: number): ContactRecord[] {
|
|
348
|
+
const { db } = openOffline(groupInfoPath, { passphrase });
|
|
349
|
+
const rows = db
|
|
350
|
+
.prepare(
|
|
351
|
+
`SELECT [60001] AS qq, [60007] AS name, [60006] AS members, [60004] AS owner, [60008] AS notice
|
|
352
|
+
FROM group_list WHERE cast([60001] AS TEXT) GLOB '[0-9]*' ORDER BY [60006] DESC LIMIT ${limit}`
|
|
353
|
+
)
|
|
354
|
+
.all() as Array<Record<string, unknown>>;
|
|
355
|
+
return rows.map((row) => ({
|
|
356
|
+
kind: "group" as const,
|
|
357
|
+
qq: Number(row.qq) || undefined,
|
|
358
|
+
name: String(row.name ?? "") || undefined,
|
|
359
|
+
remark: String(row.notice ?? "") || undefined,
|
|
360
|
+
source: "group_info" as const,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface GroupMemberRecord {
|
|
365
|
+
groupQq: number;
|
|
366
|
+
qq?: number;
|
|
367
|
+
uid?: string;
|
|
368
|
+
nickname?: string;
|
|
369
|
+
card?: string;
|
|
370
|
+
lastSpeakTs?: number;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Group member roster from `group_info.db.group_member3`. */
|
|
374
|
+
export function listGroupMembers(groupInfoPath: string, opts: { passphrase?: string; groupQq?: number; limit?: number } = {}): GroupMemberRecord[] {
|
|
375
|
+
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 5000);
|
|
376
|
+
const { db } = openOffline(groupInfoPath, { passphrase: opts.passphrase });
|
|
377
|
+
const where = opts.groupQq !== undefined ? `WHERE [60001] = ${Number(opts.groupQq)}` : "";
|
|
378
|
+
const rows = db
|
|
379
|
+
.prepare(
|
|
380
|
+
`SELECT [60001] AS groupQq, [1002] AS qq, [1000] AS uid, [20002] AS nickname, [64003] AS card,
|
|
381
|
+
[64008] AS lastSpeakTs FROM group_member3 ${where} ORDER BY [64008] DESC LIMIT ${limit}`
|
|
382
|
+
)
|
|
383
|
+
.all() as Array<Record<string, unknown>>;
|
|
384
|
+
return rows.map((row) => ({
|
|
385
|
+
groupQq: Number(row.groupQq),
|
|
386
|
+
qq: Number(row.qq) || undefined,
|
|
387
|
+
uid: String(row.uid ?? "") || undefined,
|
|
388
|
+
nickname: String(row.nickname ?? "") || undefined,
|
|
389
|
+
card: String(row.card ?? "") || undefined,
|
|
390
|
+
lastSpeakTs: Number(row.lastSpeakTs) || undefined,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface ChatStats {
|
|
395
|
+
c2cMessages: number;
|
|
396
|
+
groupMessages: number;
|
|
397
|
+
contacts: number;
|
|
398
|
+
groups: number;
|
|
399
|
+
firstTimestamp?: number;
|
|
400
|
+
lastTimestamp?: number;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Row counts and time span of a decrypted `nt_msg.db`. */
|
|
404
|
+
export function chatStats(offline: OfflineDatabase): ChatStats {
|
|
405
|
+
const { db } = offline;
|
|
406
|
+
const one = (sql: string): number => {
|
|
407
|
+
try {
|
|
408
|
+
const row = db.prepare(sql).get() as { v?: unknown } | undefined;
|
|
409
|
+
return Number(row?.v ?? 0);
|
|
410
|
+
} catch {
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
return {
|
|
415
|
+
c2cMessages: one("SELECT count(*) AS v FROM c2c_msg_table"),
|
|
416
|
+
groupMessages: one("SELECT count(*) AS v FROM group_msg_table"),
|
|
417
|
+
contacts: one("SELECT count(*) AS v FROM recent_contact_v3_table"),
|
|
418
|
+
groups: one("SELECT count(DISTINCT [40027]) AS v FROM group_msg_table"),
|
|
419
|
+
firstTimestamp: one("SELECT min([40050]) AS v FROM c2c_msg_table") || undefined,
|
|
420
|
+
lastTimestamp: one("SELECT max([40050]) AS v FROM c2c_msg_table") || undefined,
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQNT database schema extraction from the `<db>.material` key-material files.
|
|
3
|
+
*
|
|
4
|
+
* Reverse-engineered on the local Windows NTQQ 9.9.35 install:
|
|
5
|
+
* - The material file layout is: [16B salt][AES-256-CBC ciphertext]
|
|
6
|
+
* - enc_key = PBKDF2-HMAC-SHA512(passphrase, salt, 4000, 32), IV = zero block
|
|
7
|
+
* - Decrypted payload is a plaintext stream of SQL DDL (CREATE TABLE / CREATE INDEX)
|
|
8
|
+
* describing the full schema of the corresponding SQLCipher database.
|
|
9
|
+
*
|
|
10
|
+
* Pure Node.js (node:crypto), works fully offline once passphrase + material path
|
|
11
|
+
* are known (passphrase can be captured from a running QQ via the debugger helper
|
|
12
|
+
* in scripts/ — see README).
|
|
13
|
+
*/
|
|
14
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_KDF_ITER = 4000;
|
|
18
|
+
|
|
19
|
+
export interface DecryptedMaterial {
|
|
20
|
+
/** 16-byte KDF salt (matches the first 16 bytes of the .material file and the SQLCipher salt at offset 1024). */
|
|
21
|
+
salt: Buffer;
|
|
22
|
+
/** Full decrypted payload (DDL stream + embedded table names). */
|
|
23
|
+
plaintext: Buffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decrypts a QQNT `.material` file into its plaintext payload.
|
|
28
|
+
*/
|
|
29
|
+
export function decryptMaterial(path: string, passphrase: string | Buffer, kdfIter = DEFAULT_KDF_ITER): DecryptedMaterial {
|
|
30
|
+
const data = readFileSync(path);
|
|
31
|
+
if (data.length < 32) throw new Error(`material file too small: ${data.length} bytes`);
|
|
32
|
+
const salt = data.subarray(0, 16);
|
|
33
|
+
const body = data.subarray(16);
|
|
34
|
+
if (body.length % 16 !== 0) throw new Error(`material ciphertext not block aligned: ${body.length}`);
|
|
35
|
+
|
|
36
|
+
const key = pbkdf2Sync(passphrase, salt, kdfIter, 32, "sha512");
|
|
37
|
+
const decipher = createDecipheriv("aes-256-cbc", key, Buffer.alloc(16, 0));
|
|
38
|
+
decipher.setAutoPadding(false);
|
|
39
|
+
const plain = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
40
|
+
return { salt, plaintext: plain };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SchemaTable {
|
|
44
|
+
/** Extracted name (e.g. `c2c_msg_table`). */
|
|
45
|
+
name: string;
|
|
46
|
+
/** The CREATE TABLE statement if found. */
|
|
47
|
+
statement?: string;
|
|
48
|
+
/** Column definitions extracted from the statement. */
|
|
49
|
+
columns?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CREATE_RE = /CREATE(?: UNIQUE)? (TABLE|INDEX)\s+([A-Za-z0-9_]+)\s*\(([^)]*)\)/g;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extracts table/DDL definitions from the decrypted material payload.
|
|
56
|
+
*/
|
|
57
|
+
export function extractSchema(plain: Buffer, maxStatements = 2000): SchemaTable[] {
|
|
58
|
+
const text = plain.toString("utf8");
|
|
59
|
+
const out: SchemaTable[] = [];
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
|
|
62
|
+
const re = /CREATE(?: UNIQUE)? (TABLE|INDEX)\s+([A-Za-z0-9_]+)/g;
|
|
63
|
+
let m: RegExpExecArray | null;
|
|
64
|
+
while ((m = re.exec(text)) !== null) {
|
|
65
|
+
const kind = m[1] as "TABLE" | "INDEX";
|
|
66
|
+
const name = m[2];
|
|
67
|
+
if (seen.has(name)) continue;
|
|
68
|
+
seen.add(name);
|
|
69
|
+
|
|
70
|
+
// Try to capture the full statement up to the closing paren.
|
|
71
|
+
let statement: string | undefined;
|
|
72
|
+
const start = m.index;
|
|
73
|
+
const close = findStatementEnd(text, start);
|
|
74
|
+
if (close > start) statement = text.substring(start, close);
|
|
75
|
+
else statement = text.substring(start, start + Math.min(200, text.length - start));
|
|
76
|
+
|
|
77
|
+
const entry: SchemaTable = { name };
|
|
78
|
+
if (kind === "TABLE" && statement) {
|
|
79
|
+
entry.statement = statement;
|
|
80
|
+
const cols = statement.match(/[\[\]]([0-9]{3,5})[\[\]]\s+(INTEGER|TEXT|BLOB|REAL|NUMERIC)/g);
|
|
81
|
+
if (cols) entry.columns = [...new Set(cols)];
|
|
82
|
+
}
|
|
83
|
+
out.push(entry);
|
|
84
|
+
if (out.length >= maxStatements) break;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Finds the index of the balanced closing parenthesis after `start`, or -1. */
|
|
90
|
+
function findStatementEnd(text: string, start: number): number {
|
|
91
|
+
let depth = 0;
|
|
92
|
+
for (let i = start; i < text.length; i++) {
|
|
93
|
+
const c = text[i];
|
|
94
|
+
if (c === "(") depth++;
|
|
95
|
+
else if (c === ")") {
|
|
96
|
+
depth--;
|
|
97
|
+
if (depth === 0) return i + 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return -1;
|
|
101
|
+
}
|