@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/src/keyscan.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Offline key (device passphrase) capture for NTQQ.
3
+ *
4
+ * The passphrase is never stored on disk by QQ: it is derived in-process and
5
+ * handed to SQLCipher. It is, however, a short ASCII string living in the QQ
6
+ * process, and it is the same for every database on the device. This module
7
+ * scrapes candidate tokens from process memory and accepts the first one that
8
+ * actually decrypts a known artefact (a `.material` DDL blob or page 1 of a
9
+ * database), so a candidate is only reported when it is verified.
10
+ */
11
+ import { execFile } from "node:child_process";
12
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
13
+ import { availableParallelism } from "node:os";
14
+ import { dirname } from "node:path";
15
+ import { Worker } from "node:worker_threads";
16
+ import { decryptMaterial } from "./schema.js";
17
+ import { verifyPassphrase } from "./sqlcipher.js";
18
+ import { findQQDataDirs, KEY_FILE, listDataSources, SCANNER_SCRIPT, type DataSource } from "./offline.js";
19
+
20
+ const PYTHON = process.env.QQ_MCP_PYTHON ?? "python";
21
+
22
+ /** A cheap, verifiable fact that a candidate passphrase must reproduce. */
23
+ export type KeyOracle =
24
+ | { kind: "material"; materialPath: string }
25
+ | { kind: "database"; dbPath: string };
26
+
27
+ /** Tests one candidate against the oracle. Wrong candidates fail structurally. */
28
+ export function verifyKeyCandidate(oracle: KeyOracle, candidate: string): boolean {
29
+ try {
30
+ if (oracle.kind === "material") {
31
+ const { plaintext } = decryptMaterial(oracle.materialPath, candidate);
32
+ return plaintext.includes(Buffer.from("CREATE TABLE", "utf8"));
33
+ }
34
+ return verifyPassphrase(oracle.dbPath, candidate);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** PIDs of running `QQ.exe` processes (Windows). */
41
+ export function findQQPids(): Promise<number[]> {
42
+ const { promise, resolve, reject } = Promise.withResolvers<number[]>();
43
+ execFile(
44
+ "powershell",
45
+ ["-NoProfile", "-Command", "(Get-Process -Name 'QQ' -ErrorAction SilentlyContinue).Id"],
46
+ { windowsHide: true },
47
+ (error, stdout) => {
48
+ if (error) {
49
+ reject(error);
50
+ return;
51
+ }
52
+ const pids = stdout
53
+ .split(/\s+/)
54
+ .map((s) => Number.parseInt(s, 10))
55
+ .filter((n) => Number.isInteger(n) && n > 0);
56
+ resolve(pids);
57
+ }
58
+ );
59
+ return promise;
60
+ }
61
+
62
+ /** Picks a verification oracle from the locally installed QQ data. */
63
+ export function pickOracle(opts: { materialPath?: string; dbPath?: string; sources?: DataSource[] } = {}): KeyOracle | undefined {
64
+ if (opts.materialPath) return { kind: "material", materialPath: opts.materialPath };
65
+ if (opts.dbPath) return { kind: "database", dbPath: opts.dbPath };
66
+ const sources = opts.sources ?? listDataSources();
67
+ const withMaterial = sources.find((s) => s.materialPath && s.name.startsWith("nt_msg"));
68
+ if (withMaterial?.materialPath) return { kind: "material", materialPath: withMaterial.materialPath };
69
+ const anyMaterial = sources.find((s) => s.materialPath);
70
+ if (anyMaterial?.materialPath) return { kind: "material", materialPath: anyMaterial.materialPath };
71
+ const anyUsable = sources.find((s) => s.pages > 0);
72
+ if (anyUsable) return { kind: "database", dbPath: anyUsable.dbPath };
73
+ return undefined;
74
+ }
75
+
76
+ export interface CaptureOptions {
77
+ pids?: number[];
78
+ materialPath?: string;
79
+ dbPath?: string;
80
+ maxCandidates?: number;
81
+ /** Override the memory scanner path (used by tests). */
82
+ scannerPath?: string;
83
+ /** Persist the recovered passphrase to the `qqkey` file in {@link KEY_FILE}. */
84
+ save?: boolean;
85
+ onProgress?: (message: string) => void;
86
+ }
87
+
88
+ export interface CaptureResult {
89
+ passphrase?: string;
90
+ oracle?: KeyOracle;
91
+ pidsScanned: number[];
92
+ candidates: number;
93
+ testedInParallel: number;
94
+ elapsedMs: number;
95
+ savedTo?: string;
96
+ }
97
+
98
+ /** Scrapes candidate tokens from QQ's memory and verifies them against a key oracle. */
99
+ export async function captureOfflineKey(opts: CaptureOptions = {}): Promise<CaptureResult> {
100
+ const started = Date.now();
101
+ const sources = listDataSources();
102
+ const oracle = pickOracle({ materialPath: opts.materialPath, dbPath: opts.dbPath, sources });
103
+ if (!oracle) {
104
+ throw new Error("no verification oracle: pass materialPath or dbPath (no NTQQ databases found locally)");
105
+ }
106
+ const pids = opts.pids?.length ? opts.pids : await findQQPids();
107
+ if (pids.length === 0) throw new Error("QQ is not running — log in first, then retry");
108
+ const scanner = opts.scannerPath ?? SCANNER_SCRIPT;
109
+ if (!existsSync(scanner)) throw new Error(`missing memory scanner: ${scanner}`);
110
+
111
+ opts.onProgress?.(`scanning ${pids.length} process(es) for candidates`);
112
+ const candidates = await runScanner(scanner, pids, opts.maxCandidates ?? 200_000);
113
+ opts.onProgress?.(`testing ${candidates.length} candidates against ${oracle.kind}`);
114
+ const found = await verifyCandidates(oracle, candidates, opts.onProgress);
115
+
116
+ const result: CaptureResult = {
117
+ passphrase: found.passphrase,
118
+ oracle,
119
+ pidsScanned: pids,
120
+ candidates: candidates.length,
121
+ testedInParallel: found.tested,
122
+ elapsedMs: Date.now() - started,
123
+ };
124
+ if (found.passphrase && opts.save) {
125
+ mkdirSync(dirname(KEY_FILE), { recursive: true });
126
+ writeFileSync(KEY_FILE, `${found.passphrase}\n`, { encoding: "utf8", mode: 0o600 });
127
+ result.savedTo = KEY_FILE;
128
+ }
129
+ return result;
130
+ }
131
+
132
+ function runScanner(scanner: string, pids: number[], maxCandidates: number): Promise<string[]> {
133
+ const { promise, resolve, reject } = Promise.withResolvers<string[]>();
134
+ execFile(
135
+ PYTHON,
136
+ [scanner, ...pids.map(String), "--max", String(maxCandidates)],
137
+ { windowsHide: true, timeout: 900_000, maxBuffer: 64 * 1024 * 1024 },
138
+ (error, stdout, stderr) => {
139
+ if (error && !stdout) {
140
+ reject(new Error(`memory scan failed: ${stderr || error.message}`));
141
+ return;
142
+ }
143
+ const seen = new Set<string>();
144
+ for (const line of stdout.split(/\r?\n/)) {
145
+ const token = line.trim();
146
+ if (token.length >= 12 && token.length <= 32) seen.add(token);
147
+ }
148
+ resolve([...seen]);
149
+ }
150
+ );
151
+ return promise;
152
+ }
153
+
154
+ interface VerifyOutcome {
155
+ passphrase?: string;
156
+ tested: number;
157
+ }
158
+
159
+ /** Fans candidates across workers; returns as soon as one worker verifies a key. */
160
+ async function verifyCandidates(
161
+ oracle: KeyOracle,
162
+ candidates: string[],
163
+ onProgress?: (message: string) => void
164
+ ): Promise<VerifyOutcome> {
165
+ if (candidates.length === 0) return { tested: 0 };
166
+ const workers = Math.max(1, Math.min(availableParallelism(), 16, candidates.length));
167
+ const chunkSize = Math.ceil(candidates.length / workers);
168
+ const chunks: string[][] = [];
169
+ for (let i = 0; i < candidates.length; i += chunkSize) chunks.push(candidates.slice(i, i + chunkSize));
170
+ const workerUrl = new URL("./verify-worker.js", import.meta.url);
171
+ const running = chunks.map(
172
+ (chunk) =>
173
+ new Promise<VerifyOutcome>((resolve, reject) => {
174
+ const worker = new Worker(workerUrl, { workerData: { oracle, candidates: chunk } });
175
+ worker.once("message", (message: { passphrase?: string; tested: number }) => {
176
+ resolve({ passphrase: message.passphrase, tested: message.tested });
177
+ });
178
+ worker.once("error", reject);
179
+ })
180
+ );
181
+ const outcomes = running.map(async (promise, index) => {
182
+ const outcome = await promise;
183
+ onProgress?.(`worker ${index + 1}/${running.length} tested ${outcome.tested}`);
184
+ return outcome;
185
+ });
186
+ const settled = await Promise.allSettled(outcomes);
187
+ let tested = 0;
188
+ let passphrase: string | undefined;
189
+ for (const entry of settled) {
190
+ if (entry.status !== "fulfilled") continue;
191
+ tested += entry.value.tested;
192
+ if (!passphrase && entry.value.passphrase) passphrase = entry.value.passphrase;
193
+ }
194
+ return { passphrase, tested };
195
+ }
196
+
197
+ /** Convenience: verifies a known passphrase against every locally found database. */
198
+ export function verifyPassphraseEverywhere(passphrase: string): Array<{ name: string; ok: boolean }> {
199
+ return listDataSources().map((source) => ({ name: source.name, ok: verifyPassphrase(source.dbPath, passphrase) }));
200
+ }
201
+
202
+ export { findQQDataDirs };
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Protobuf decoding of NTQQ message bodies.
3
+ *
4
+ * `c2c_msg_table.[40800]` / `group_msg_table.[40800]` hold a protobuf
5
+ * `MsgBody { repeated MsgContent content = 40800 }`. Field ids below were
6
+ * verified against this account's local database and cross-checked with the
7
+ * public field research (QQBackup/nt_msg_db_util, `msgdb/proto/c2c_40800.proto`).
8
+ */
9
+ export interface WireField {
10
+ field: number;
11
+ wire: number;
12
+ /** varint/fixed value for numeric fields. */
13
+ value?: bigint;
14
+ /** payload for length-delimited fields. */
15
+ bytes?: Buffer;
16
+ }
17
+
18
+ export const MSG_BODY_CONTENT_FIELD = 40800;
19
+
20
+ /** Decodes one protobuf message level into its wire fields. */
21
+ export function decodeWireFields(buf: Buffer, maxFields = 512): WireField[] {
22
+ const out: WireField[] = [];
23
+ let off = 0;
24
+ while (off < buf.length && out.length < maxFields) {
25
+ const [tag, afterTag] = readVarint(buf, off);
26
+ off = afterTag;
27
+ const field = Number(tag >> 3n);
28
+ const wire = Number(tag & 7n);
29
+ if (wire === 0) {
30
+ const [value, next] = readVarint(buf, off);
31
+ off = next;
32
+ out.push({ field, wire, value });
33
+ } else if (wire === 2) {
34
+ const [len, next] = readVarint(buf, off);
35
+ off = next;
36
+ const end = off + Number(len);
37
+ if (end > buf.length) throw new Error(`field ${field}: length ${len} exceeds buffer`);
38
+ out.push({ field, wire, bytes: buf.subarray(off, end) });
39
+ off = end;
40
+ } else if (wire === 5) {
41
+ out.push({ field, wire, value: BigInt(buf.readUInt32LE(off)) });
42
+ off += 4;
43
+ } else if (wire === 1) {
44
+ out.push({ field, wire, value: buf.readBigUInt64LE(off) });
45
+ off += 8;
46
+ } else {
47
+ throw new Error(`field ${field}: unsupported wire type ${wire}`);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function readVarint(buf: Buffer, off: number): [bigint, number] {
54
+ let value = 0n;
55
+ let shift = 0n;
56
+ let i = off;
57
+ while (i < buf.length) {
58
+ const byte = buf[i++];
59
+ value |= BigInt(byte & 0x7f) << shift;
60
+ if ((byte & 0x80) === 0) return [value, i];
61
+ shift += 7n;
62
+ if (shift > 63n) break;
63
+ }
64
+ throw new Error(`varint overflow at ${off}`);
65
+ }
66
+
67
+ export type SegmentType =
68
+ | "text"
69
+ | "image"
70
+ | "video"
71
+ | "file"
72
+ | "sticker"
73
+ | "contact"
74
+ | "ark"
75
+ | "nudge"
76
+ | "call"
77
+ | "reply"
78
+ | "forward"
79
+ | "legacy_forward"
80
+ | "system"
81
+ | "unknown";
82
+
83
+ export interface MessageSegment {
84
+ type: SegmentType;
85
+ /** Plain text for `text` segments, or the summary of media segments. */
86
+ text?: string;
87
+ filename?: string;
88
+ fileExt?: string;
89
+ fileSize?: number;
90
+ fileUuid?: string;
91
+ /** Lowercase hex of the raw md5 field when present. */
92
+ md5?: string;
93
+ width?: number;
94
+ height?: number;
95
+ /** CDN download URLs (fields 45802-45804). */
96
+ urls?: string[];
97
+ cdnHost?: string;
98
+ msgId?: string;
99
+ contentType?: number;
100
+ replyMsgId?: string;
101
+ replySummary?: string;
102
+ callType?: number;
103
+ callDuration?: number;
104
+ callDesc?: string;
105
+ systemContent?: string;
106
+ /** Contact-card payload (fields 47702-47715): shared nickname/uid/remark. */
107
+ contact?: { relation?: number; uid?: string; nickname?: string; remark?: string };
108
+ /** Interactive card (`ark`) payload (fields 48401-48421) with its opaque ids. */
109
+ card?: { appId?: string; token?: string; template?: string };
110
+ /** Nudge ("戳一戳") payload, carried by message type 5 rows. */
111
+ nudge?: { action?: string; suffix?: string; image?: string; parties?: Array<{ uid?: string; nickname?: string }> };
112
+ /** Field ids seen in the blob but not modelled, so callers can spot gaps. */
113
+ unmodelledFields?: number[];
114
+ }
115
+
116
+ export interface DecodedMessageContent {
117
+ segments: MessageSegment[];
118
+ /** Concatenated text of every segment, for search and preview. */
119
+ text: string;
120
+ /** Single segment type, `mixed` for several kinds, `unknown` when empty. */
121
+ kind: SegmentType | "mixed";
122
+ /** Unix seconds from the inner protobuf timestamp (field 49155) when present. */
123
+ innerTimestamp?: number;
124
+ protoVersion?: string;
125
+ }
126
+
127
+ const IMAGE_EXT: Record<string, true> = {
128
+ jpg: true, jpeg: true, png: true, gif: true, webp: true, bmp: true, heic: true, avif: true,
129
+ };
130
+ const VIDEO_EXT: Record<string, true> = {
131
+ mp4: true, mov: true, mkv: true, avi: true, webm: true, m4v: true, wmv: true,
132
+ };
133
+ /** MsgContent fields this module models; everything else is reported as unmodelled. */
134
+ const MODELLED_FIELDS: Record<number, true> = {
135
+ 5: true, 40010: true, 40020: true, 40021: true, 45001: true, 45002: true, 45003: true, 45004: true,
136
+ 45101: true, 45102: true, 45103: true, 45104: true, 45105: true, 45106: true, 45108: true, 45109: true,
137
+ 45110: true, 45111: true, 45112: true, 45402: true, 45403: true, 45404: true, 45405: true, 45406: true,
138
+ 45407: true, 45408: true, 45409: true, 45410: true, 45411: true, 45412: true, 45413: true, 45414: true,
139
+ 45415: true, 45416: true, 45418: true, 45419: true, 45421: true, 45422: true, 45423: true, 45424: true,
140
+ 45501: true, 45503: true, 45504: true, 45505: true, 45507: true, 45509: true, 45510: true, 45511: true,
141
+ 45512: true, 45513: true, 45514: true, 45515: true, 45516: true, 45517: true, 45518: true, 45519: true,
142
+ 45526: true, 45550: true, 45600: true, 45801: true, 45802: true, 45803: true, 45804: true, 45805: true,
143
+ 45806: true, 45807: true, 45812: true, 45814: true, 45815: true, 45816: true, 45817: true, 45818: true,
144
+ 45819: true, 45820: true, 45821: true, 45822: true, 45823: true, 45824: true, 45825: true, 45826: true,
145
+ 45827: true, 45828: true, 45829: true, 45851: true, 45852: true, 45853: true, 45854: true, 45855: true,
146
+ 45856: true, 45857: true, 45858: true, 45859: true, 45860: true, 45861: true, 45862: true, 45863: true,
147
+ 45906: true, 45907: true, 45909: true, 45911: true, 45922: true, 45923: true, 45924: true, 45925: true,
148
+ 45926: true, 45954: true, 45974: true, 47401: true, 47402: true, 47403: true, 47404: true, 47413: true,
149
+ 47421: true, 47422: true, 47423: true, 47501: true, 47502: true, 47601: true, 47602: true, 47603: true,
150
+ 47604: true, 47605: true, 47606: true, 47607: true, 47608: true, 47609: true, 47610: true, 47611: true,
151
+ 47612: true, 47613: true, 47614: true, 47615: true, 47616: true, 47617: true, 47618: true, 47619: true,
152
+ 47620: true, 47622: true, 47702: true, 47703: true, 47704: true, 47705: true, 47706: true, 47710: true,
153
+ 47711: true, 47713: true, 47714: true, 47715: true, 47901: true, 47902: true, 47904: true, 48151: true,
154
+ 48152: true, 48153: true, 48154: true, 48155: true, 48156: true, 48157: true, 48210: true, 48211: true,
155
+ 48212: true, 48213: true, 48214: true, 48215: true, 48216: true, 48217: true, 48218: true, 48271: true,
156
+ 48272: true, 48273: true, 48275: true, 48401: true, 48402: true, 48403: true, 48404: true, 48405: true,
157
+ 48406: true, 48407: true, 48408: true, 48409: true, 48410: true, 48411: true, 48412: true, 48417: true,
158
+ 48418: true, 48419: true, 48421: true, 48601: true, 48602: true, 48603: true, 49154: true, 49155: true,
159
+ 80810: true, 80824: true, 80900: true, 80901: true, 80902: true, 80903: true, 80905: true, 80908: true,
160
+ 80909: true, 80910: true, 80935: true, 80941: true, 80942: true, 80970: true, 80975: true, 80980: true,
161
+ 80981: true, 80983: true, 80995: true, 95654: true, 48542: true,
162
+ };
163
+ const STICKER_SUMMARY = "[动画表情]";
164
+
165
+ /** Decodes the `[40800]` blob of a message row into typed segments. */
166
+ export function decodeMessageContent(blob: Buffer | Uint8Array, maxFields = 512): DecodedMessageContent {
167
+ const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
168
+ const fields = decodeWireFields(buf, maxFields);
169
+ const segments: MessageSegment[] = [];
170
+ let innerTimestamp: number | undefined;
171
+ let protoVersion: string | undefined;
172
+ for (const field of fields) {
173
+ if (field.field === MSG_BODY_CONTENT_FIELD && field.bytes) {
174
+ segments.push(decodeSegment(field.bytes));
175
+ continue;
176
+ }
177
+ if (field.field === 49155 && field.value !== undefined) innerTimestamp = Number(field.value);
178
+ if (field.field === 49154 && field.bytes) protoVersion = asText(field.bytes);
179
+ }
180
+ const text = segments
181
+ .map((s) => s.text ?? "")
182
+ .filter((t) => t.length > 0)
183
+ .join("\n");
184
+ const kinds = new Set(segments.map((s) => s.type));
185
+ const kind: DecodedMessageContent["kind"] =
186
+ kinds.size === 1 ? segments[0]?.type ?? "unknown" : kinds.size > 1 ? "mixed" : "unknown";
187
+ return { segments, text, kind, innerTimestamp, protoVersion };
188
+ }
189
+
190
+ function decodeSegment(buf: Buffer): MessageSegment {
191
+ const fields = decodeWireFields(buf);
192
+ const byId = new Map<number, WireField>();
193
+ for (const f of fields) if (!byId.has(f.field)) byId.set(f.field, f);
194
+ const all = (id: number): WireField[] => fields.filter((f) => f.field === id);
195
+ const textFallbacks = all(45815)
196
+ .map((f) => asText(f.bytes ?? Buffer.alloc(0)))
197
+ .filter(Boolean);
198
+
199
+ const num = (id: number): number | undefined => {
200
+ const v = byId.get(id)?.value;
201
+ return v === undefined ? undefined : Number(v);
202
+ };
203
+ const str = (id: number): string | undefined => {
204
+ const b = byId.get(id)?.bytes;
205
+ return b ? asText(b) : undefined;
206
+ };
207
+ const raw = (id: number): Buffer | undefined => byId.get(id)?.bytes;
208
+
209
+ const filename = str(45402);
210
+ const fileExt = (str(45419)?.replace(/^\./, "") ?? filename?.split(".").pop() ?? "").toLowerCase() || undefined;
211
+ const md5Raw = raw(45406);
212
+ const urls = [45802, 45803, 45804].map(str).filter((u): u is string => Boolean(u));
213
+ const summary = textFallbacks[0] ?? str(45422) ?? str(45812) ?? str(80900);
214
+ const unmodelledFields = unmappedFields(byId);
215
+ const msgIdValue = byId.get(45001)?.value;
216
+
217
+ const segment: MessageSegment = {
218
+ type: "unknown",
219
+ msgId: msgIdValue === undefined ? undefined : String(msgIdValue),
220
+ contentType: num(45002),
221
+ filename,
222
+ fileExt,
223
+ fileSize: num(45405),
224
+ fileUuid: str(45503),
225
+ md5: md5Raw && md5Raw.length >= 16 ? md5Raw.subarray(0, 16).toString("hex") : undefined,
226
+ width: num(45411),
227
+ height: num(45412),
228
+ urls,
229
+ cdnHost: str(45816),
230
+ unmodelledFields,
231
+ };
232
+
233
+ const callType = num(48151);
234
+ if (callType !== undefined || str(48153) !== undefined) {
235
+ return { ...segment, type: "call", callType, callDuration: num(48152), callDesc: str(48153), text: str(48153) ?? summary };
236
+ }
237
+ const systemContent = str(80900);
238
+ if (systemContent !== undefined) {
239
+ return { ...segment, type: "system", systemContent, text: systemContent };
240
+ }
241
+ const contactNickname = str(47705) ?? str(47714);
242
+ if (contactNickname !== undefined || str(47703) !== undefined) {
243
+ const contact = {
244
+ relation: num(47702),
245
+ uid: str(47703) ?? str(47704),
246
+ nickname: contactNickname,
247
+ remark: str(47706) ?? str(47715),
248
+ };
249
+ return { ...segment, type: "contact", contact, text: summary ?? contact.nickname ?? contact.remark ?? "[名片]" };
250
+ }
251
+ const cardAppId = str(48409);
252
+ if (cardAppId !== undefined || raw(48403) !== undefined) {
253
+ const card = { appId: cardAppId, token: str(48410) ?? str(48418), template: str(48402) };
254
+ return { ...segment, type: "ark", card, text: summary ?? "[卡片消息]" };
255
+ }
256
+ const tipXml = str(48214) ?? str(48271);
257
+ if (tipXml !== undefined) {
258
+ const pairs = fieldPairs(all(48217));
259
+ const parties = fieldPairs(all(48210)).map(([uid, nickname]) => ({ uid, nickname: nickname || undefined }));
260
+ const nudge = {
261
+ action: pairs.find(([key]) => key === "action_str")?.[1],
262
+ suffix: pairs.find(([key]) => key === "suffix_str")?.[1],
263
+ image: pairs.find(([key]) => key === "action_img_url")?.[1],
264
+ parties: parties.length ? parties : undefined,
265
+ };
266
+ return { ...segment, type: "nudge", nudge, text: summary ?? tipText(tipXml) ?? "[互动消息]" };
267
+ }
268
+ const isImageExt = fileExt !== undefined && IMAGE_EXT[fileExt] === true;
269
+ const isVideoExt = fileExt !== undefined && VIDEO_EXT[fileExt] === true;
270
+ if (num(47601)) {
271
+ return { ...segment, type: "video", text: summary ?? (filename ? `[视频] ${filename}` : "[视频]") };
272
+ }
273
+ if (raw(45600) || summary === STICKER_SUMMARY) {
274
+ return { ...segment, type: "sticker", text: summary ?? "[表情]" };
275
+ }
276
+ if (isVideoExt) {
277
+ return { ...segment, type: "video", text: summary ?? `[视频] ${filename}` };
278
+ }
279
+ if (isImageExt || (fileExt === undefined && filename !== undefined && (num(45411) ?? 0) > 0 && (num(45412) ?? 0) > 0)) {
280
+ return { ...segment, type: "image", text: summary ?? (filename ? `[图片] ${filename}` : "[图片]") };
281
+ }
282
+ const legacyXml = str(48602);
283
+ if (legacyXml !== undefined) {
284
+ return { ...segment, type: "legacy_forward", text: str(47901) ?? legacyXml.slice(0, 200) };
285
+ }
286
+ if (str(47901) !== undefined || str(47904) !== undefined) {
287
+ return { ...segment, type: "forward", text: str(47901) ?? summary ?? "[合并转发]" };
288
+ }
289
+ const replyMsgIdValue = byId.get(47401)?.value;
290
+ if (replyMsgIdValue !== undefined) {
291
+ const replySummary = str(47413);
292
+ return {
293
+ ...segment,
294
+ type: "reply",
295
+ replyMsgId: String(replyMsgIdValue),
296
+ replySummary,
297
+ text: summary ?? replySummary ?? "[引用]",
298
+ };
299
+ }
300
+ if (filename !== undefined || num(45405) !== undefined) {
301
+ return { ...segment, type: "file", text: summary ?? (filename ? `[文件] ${filename}` : "[文件]") };
302
+ }
303
+ const text = str(45101);
304
+ if (text !== undefined) return { ...segment, type: "text", text };
305
+ return { ...segment, type: "unknown", text: summary };
306
+ }
307
+
308
+ /** Reads repeated sub-messages that encode `{key, value}` string pairs. */
309
+ function fieldPairs(fields: WireField[]): Array<[string, string]> {
310
+ const out: Array<[string, string]> = [];
311
+ for (const field of fields) {
312
+ if (!field.bytes) continue;
313
+ const parts = decodeWireFields(field.bytes)
314
+ .filter((inner) => inner.bytes)
315
+ .map((inner) => asText(inner.bytes!));
316
+ if (parts.length >= 2) out.push([parts[0], parts[1]]);
317
+ else if (parts.length === 1) out.push([parts[0], ""]);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /** Renders a nudge tip payload: JSON items when parseable, else its XML text. */
323
+ function tipText(raw: string): string | undefined {
324
+ if (raw.startsWith("{")) {
325
+ try {
326
+ const parsed = JSON.parse(raw) as { items?: Array<{ txt?: string; nm?: string }> };
327
+ const parts = (parsed.items ?? []).map((item) => item.txt ?? item.nm ?? "").filter(Boolean);
328
+ if (parts.length) return parts.join("");
329
+ } catch {
330
+ // Fall through to the XML path.
331
+ }
332
+ }
333
+ const parts = [...raw.matchAll(/(?:txt|nm)="([^"]*)"/g)].map((match) => match[1]).filter(Boolean);
334
+ return parts.length ? parts.join("") : undefined;
335
+ }
336
+
337
+ function unmappedFields(byId: Map<number, WireField>): number[] | undefined {
338
+ const ids: number[] = [];
339
+ for (const id of byId.keys()) if (MODELLED_FIELDS[id] !== true) ids.push(id);
340
+ return ids.length ? ids.sort((a, b) => a - b) : undefined;
341
+ }
342
+
343
+ function asText(buf: Buffer): string {
344
+ return buf.toString("utf8").replace(/\0+$/, "");
345
+ }
346
+
347
+ export interface MessageRecord {
348
+ msgId: string;
349
+ timestamp: number;
350
+ iso: string;
351
+ direction: number;
352
+ chatType: "c2c" | "group";
353
+ senderQq?: number;
354
+ senderUid?: string;
355
+ senderName?: string;
356
+ peerQq?: number;
357
+ groupQq?: number;
358
+ msgType?: number;
359
+ kind: string;
360
+ text: string;
361
+ segments: MessageSegment[];
362
+ }
363
+
364
+ /** Row shape expected by {@link toMessageRecord}, decoupled from the SQL layer. */
365
+ export interface RawMessageRow {
366
+ msgId: unknown;
367
+ msgType: unknown;
368
+ direction: unknown;
369
+ senderUid: unknown;
370
+ peerUid: unknown;
371
+ senderQq: unknown;
372
+ peerQq: unknown;
373
+ /** `[40027]`: the group number for group chats, a constant for c2c rows. */
374
+ conversationId: unknown;
375
+ timestamp: unknown;
376
+ senderName: unknown;
377
+ body: unknown;
378
+ }
379
+
380
+ export function toMessageRecord(row: RawMessageRow, chatType: "c2c" | "group"): MessageRecord {
381
+ const decoded = row.body ? decodeMessageContent(row.body as Buffer) : { segments: [], text: "", kind: "unknown" };
382
+ const timestamp = Number(row.timestamp ?? 0);
383
+ // Group rows sometimes carry 0 in the peer column (system messages); the
384
+ // conversation column holds the group number in every row.
385
+ const groupQq = asNumber(row.conversationId) ?? asNumber(row.peerQq);
386
+ return {
387
+ msgId: String(row.msgId ?? ""),
388
+ timestamp,
389
+ iso: timestamp ? new Date(timestamp * 1000).toISOString() : "",
390
+ direction: Number(row.direction ?? 0),
391
+ chatType,
392
+ senderQq: asNumber(row.senderQq),
393
+ senderUid: asOptionalString(row.senderUid),
394
+ senderName: asOptionalString(row.senderName),
395
+ peerQq: asNumber(row.peerQq),
396
+ groupQq: chatType === "group" ? groupQq : undefined,
397
+ msgType: asNumber(row.msgType),
398
+ kind: decoded.kind,
399
+ text: decoded.text,
400
+ segments: decoded.segments,
401
+ };
402
+ }
403
+
404
+ function asNumber(value: unknown): number | undefined {
405
+ if (value === null || value === undefined) return undefined;
406
+ const n = Number(value);
407
+ return Number.isFinite(n) ? n : undefined;
408
+ }
409
+
410
+ function asOptionalString(value: unknown): string | undefined {
411
+ if (value === null || value === undefined) return undefined;
412
+ const s = String(value);
413
+ return s.length ? s : undefined;
414
+ }