weflow-cli 1.5.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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * SQLCipher Core - 3.x 数据库访问层
3
+ *
4
+ * WeChat 3.x 使用自定义 SQLCipher 加密格式:
5
+ * - 页大小: 4096 字节
6
+ * - KDF: PBKDF2-HMAC-SHA1, 64000 次迭代
7
+ * - 加密: AES-256-CBC
8
+ * - HMAC: HMAC-SHA1 (用于验证, 解密时不需要)
9
+ *
10
+ * 每页结构 (文件字节布局):
11
+ * - 文件头 16 字节: Salt (仅首页之前)
12
+ * - 数据区: 4048 字节加密数据
13
+ * - 保留区 48 字节: [IV(16) | HMAC-SHA1(20) | 保留(12)]
14
+ */
15
+ import crypto from 'crypto'
16
+ import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync } from 'fs'
17
+ import { join, basename, dirname } from 'path'
18
+ import os from 'os'
19
+ import { DatabaseSync } from 'node:sqlite'
20
+ import lz4 from 'lz4'
21
+ import type { ChatSession, Message, Contact } from '../types.js'
22
+
23
+ const PAGE_SIZE = 4096
24
+ const KEY_SIZE = 32
25
+ const DEFAULT_ITER = 64000
26
+ const RESERVED_LEN = 48 // IV(16) + HMAC(20) + 保留(12)
27
+ const SQLITE_HEADER = 'SQLite format 3\x00'
28
+
29
+ export interface SqlcipherResult {
30
+ success: boolean
31
+ error?: string
32
+ }
33
+
34
+ export interface SessionsResult extends SqlcipherResult {
35
+ sessions?: ChatSession[]
36
+ }
37
+
38
+ export interface MessagesResult extends SqlcipherResult {
39
+ messages?: Message[]
40
+ }
41
+
42
+ export interface ContactsResult extends SqlcipherResult {
43
+ contacts?: Contact[]
44
+ }
45
+
46
+ export class SqlcipherCore {
47
+ private db: DatabaseSync | null = null
48
+ private tempDir: string = ''
49
+ private tempFiles: Map<string, string> = new Map()
50
+ private pageKey: Buffer | null = null
51
+ private opened = false
52
+ private wxid: string = ''
53
+ private wxDirRoot: string = ''
54
+
55
+ /**
56
+ * 解密数据库到临时文件
57
+ */
58
+ private decryptToTemp(dbPath: string, keyHex: string): string {
59
+ const cached = this.tempFiles.get(dbPath)
60
+ if (cached && existsSync(cached)) return cached
61
+
62
+ const password = Buffer.from(keyHex, 'hex')
63
+ const blist = readFileSync(dbPath)
64
+ const salt = blist.subarray(0, 16)
65
+
66
+ // 派生页面加密密钥 (AES-256 key)
67
+ this.pageKey = crypto.pbkdf2Sync(password, salt, DEFAULT_ITER, KEY_SIZE, 'sha1')
68
+
69
+ // 创建临时目录
70
+ if (!this.tempDir) {
71
+ this.tempDir = join(os.tmpdir(), 'weflow_3x_decrypt')
72
+ if (!existsSync(this.tempDir)) {
73
+ mkdirSync(this.tempDir, { recursive: true })
74
+ }
75
+ }
76
+
77
+ const tempPath = join(this.tempDir, basename(dbPath) + '.decrypted.db')
78
+ const output: Buffer[] = []
79
+
80
+ // 写入 SQLite 文件头 "SQLite format 3\0"
81
+ output.push(Buffer.from(SQLITE_HEADER))
82
+
83
+ // 处理每一页
84
+ for (let i = 0; i < blist.length; i += PAGE_SIZE) {
85
+ if (i === 0) {
86
+ // 第一页: 跳过 salt (16字节), 取 blist[16:4096] = 4080 字节
87
+ const pageData = blist.subarray(16, i + PAGE_SIZE)
88
+ this.decryptPage(pageData, output)
89
+ } else {
90
+ const pageData = blist.subarray(i, Math.min(i + PAGE_SIZE, blist.length))
91
+ this.decryptPage(pageData, output)
92
+ }
93
+ }
94
+
95
+ writeFileSync(tempPath, Buffer.concat(output))
96
+ this.tempFiles.set(dbPath, tempPath)
97
+ return tempPath
98
+ }
99
+
100
+ /**
101
+ * 解密单个页面
102
+ * pageData: 第一页 4080 字节, 其他页 4096 字节
103
+ * 结构: [加密数据] + [IV(16) | HMAC(20) | 保留(12)]
104
+ */
105
+ private decryptPage(pageData: Buffer, output: Buffer[]): void {
106
+ if (pageData.length === 0) return
107
+
108
+ if (pageData.length <= RESERVED_LEN) {
109
+ output.push(pageData)
110
+ return
111
+ }
112
+
113
+ const encryptedLen = pageData.length - RESERVED_LEN
114
+ const encryptedData = pageData.subarray(0, encryptedLen)
115
+ const reserved = pageData.subarray(encryptedLen)
116
+ const iv = reserved.subarray(0, 16)
117
+
118
+ try {
119
+ const decipher = crypto.createDecipheriv('aes-256-cbc', this.pageKey!, iv)
120
+ decipher.setAutoPadding(false)
121
+ const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()])
122
+ output.push(decrypted)
123
+ output.push(reserved)
124
+ } catch {
125
+ // 解密失败时保持原始数据
126
+ output.push(pageData)
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 直接打开已解密的 SQLite 数据库 (跳过解密步骤)
132
+ * 用于 4.x 已解密的 MSG0_decrypted.db 或测试数据库
133
+ */
134
+ async openRaw(dbPath: string, wxid: string): Promise<SqlcipherResult> {
135
+ if (this.opened) this.close()
136
+
137
+ if (!existsSync(dbPath)) {
138
+ return { success: false, error: `数据库不存在: ${dbPath}` }
139
+ }
140
+
141
+ this.wxid = wxid || ''
142
+
143
+ try {
144
+ this.db = new DatabaseSync(dbPath)
145
+ this.opened = true
146
+ return { success: true }
147
+ } catch (e) {
148
+ const msg = e instanceof Error ? e.message : String(e)
149
+ return { success: false, error: `打开 4.x 数据库失败: ${msg}` }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * 尝试直接解密 4.x MSG0.db
155
+ *
156
+ * 4.x 使用 WCDB/SQLCipher 4 加密:
157
+ * - AES-256-CBC
158
+ * - PBKDF2-HMAC-SHA512, 256000 次迭代
159
+ * - 页大小: 4096 字节
160
+ * - 保留区: 80 字节 (IV 16 + HMAC 64) 或 48 字节 (兼容旧版)
161
+ *
162
+ * 注意: 密钥必须是微信登录时通过 wx_key.dll Hook 提取的正确密钥。
163
+ * 已登录后提取的密钥可能不正确。
164
+ */
165
+ async open4x(dbPath: string, keyHex: string, wxid: string): Promise<SqlcipherResult> {
166
+ if (this.opened) this.close()
167
+
168
+ if (!existsSync(dbPath)) {
169
+ return { success: false, error: `数据库不存在: ${dbPath}` }
170
+ }
171
+
172
+ if (!keyHex || keyHex.length !== 64) {
173
+ return { success: false, error: '4.x 密钥格式错误,需要 64 位十六进制字符串' }
174
+ }
175
+
176
+ this.wxid = wxid || ''
177
+ try {
178
+ this.wxDirRoot = dirname(dirname(dirname(dirname(dbPath))))
179
+ } catch {
180
+ this.wxDirRoot = ''
181
+ }
182
+
183
+ const password = Buffer.from(keyHex, 'hex')
184
+ const blist = readFileSync(dbPath)
185
+ const salt = blist.subarray(0, 16)
186
+
187
+ // 4.x 参数组合 (按优先级尝试)
188
+ const configs: Array<{ sha: string; iters: number; reserve: number }> = [
189
+ { sha: 'sha512', iters: 256000, reserve: 80 }, // WCDB/SQLCipher 4 标准
190
+ { sha: 'sha512', iters: 256000, reserve: 48 }, // SQLCipher 4 + 48 字节保留区
191
+ { sha: 'sha1', iters: 64000, reserve: 48 }, // SQLCipher 3 兼容
192
+ { sha: 'sha512', iters: 64000, reserve: 80 },
193
+ { sha: 'sha256', iters: 256000, reserve: 80 },
194
+ ]
195
+
196
+ // 创建临时目录
197
+ if (!this.tempDir) {
198
+ this.tempDir = join(os.tmpdir(), 'weflow_4x_decrypt')
199
+ if (!existsSync(this.tempDir)) {
200
+ mkdirSync(this.tempDir, { recursive: true })
201
+ }
202
+ }
203
+
204
+ for (const cfg of configs) {
205
+ try {
206
+ const pageKey = crypto.pbkdf2Sync(
207
+ password, salt, cfg.iters, KEY_SIZE, cfg.sha
208
+ )
209
+ const tempPath = join(this.tempDir, basename(dbPath) + '.4x_decrypted.db')
210
+ this.decryptPages(blist, pageKey, salt, cfg.reserve, tempPath)
211
+
212
+ // 验证解密结果
213
+ const db = new DatabaseSync(tempPath)
214
+ this.db = db
215
+ this.opened = true
216
+ this.tempFiles.set(dbPath, tempPath)
217
+ return { success: true }
218
+ } catch {
219
+ // 尝试下一组参数
220
+ }
221
+ }
222
+
223
+ // 如果 PBKDF2 派生失败,尝试原始密钥直接使用
224
+ for (const reserve of [80, 48]) {
225
+ try {
226
+ const tempPath = join(this.tempDir, basename(dbPath) + '.4x_raw.decrypted.db')
227
+ this.decryptPages(blist, password, salt, reserve, tempPath)
228
+
229
+ const db = new DatabaseSync(tempPath)
230
+ this.db = db
231
+ this.opened = true
232
+ this.tempFiles.set(dbPath, tempPath)
233
+ return { success: true }
234
+ } catch {
235
+ // 尝试下一组参数
236
+ }
237
+ }
238
+
239
+ return {
240
+ success: false,
241
+ error: [
242
+ '4.x 数据库直接解密失败,可能原因:',
243
+ ' 1. 密钥不是微信登录时提取的 (wx_key.dll 仅在登录时捕获密钥)',
244
+ ' 2. 密钥与数据库版本不匹配',
245
+ ' 3. 数据库使用了非标准加密参数',
246
+ '',
247
+ '解决方案:',
248
+ ' - 使用 python scripts/scan_decrypt_4x.py 自动扫描解密',
249
+ ' - 或重启微信并在登录时运行: weflow-cli dbkey --timeout 120000',
250
+ ].join('\n'),
251
+ }
252
+ }
253
+
254
+ /**
255
+ * 按页面解密 4.x 数据库
256
+ */
257
+ private decryptPages(
258
+ blist: Buffer,
259
+ pageKey: Buffer,
260
+ salt: Buffer,
261
+ reserveLen: number,
262
+ outputPath: string
263
+ ): void {
264
+ const output: Buffer[] = []
265
+
266
+ // 写入 SQLite 文件头
267
+ output.push(Buffer.from(SQLITE_HEADER))
268
+
269
+ for (let i = 0; i < blist.length; i += PAGE_SIZE) {
270
+ const pageData = blist.subarray(i, Math.min(i + PAGE_SIZE, blist.length))
271
+ if (pageData.length === 0) continue
272
+
273
+ if (i === 0) {
274
+ // 第一页: 跳过 salt(16), 解密数据区
275
+ const encryptedLen = pageData.length - 16 - reserveLen
276
+ if (encryptedLen <= 0) {
277
+ output.push(pageData.subarray(16))
278
+ continue
279
+ }
280
+ const encryptedData = pageData.subarray(16, 16 + encryptedLen)
281
+ const reserved = pageData.subarray(16 + encryptedLen)
282
+ const iv = reserved.subarray(0, 16)
283
+
284
+ try {
285
+ const decipher = crypto.createDecipheriv('aes-256-cbc', pageKey, iv)
286
+ decipher.setAutoPadding(false)
287
+ const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()])
288
+ output.push(decrypted)
289
+ output.push(reserved)
290
+ } catch {
291
+ throw new Error('解密失败')
292
+ }
293
+ } else {
294
+ // 后续页: 全页加密数据区 + 保留区
295
+ const encryptedLen = pageData.length - reserveLen
296
+ if (encryptedLen <= 0) {
297
+ output.push(pageData)
298
+ continue
299
+ }
300
+ const encryptedData = pageData.subarray(0, encryptedLen)
301
+ const reserved = pageData.subarray(encryptedLen)
302
+ const iv = reserved.subarray(0, 16)
303
+
304
+ try {
305
+ const decipher = crypto.createDecipheriv('aes-256-cbc', pageKey, iv)
306
+ decipher.setAutoPadding(false)
307
+ const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()])
308
+ output.push(decrypted)
309
+ output.push(reserved)
310
+ } catch {
311
+ throw new Error('解密失败')
312
+ }
313
+ }
314
+ }
315
+
316
+ writeFileSync(outputPath, Buffer.concat(output))
317
+ }
318
+
319
+ /**
320
+ * 打开 3.x 数据库
321
+ */
322
+ async open(dbPath: string, keyHex: string, wxid: string): Promise<SqlcipherResult> {
323
+ if (this.opened) this.close()
324
+
325
+ if (!existsSync(dbPath)) {
326
+ return { success: false, error: `数据库不存在: ${dbPath}` }
327
+ }
328
+
329
+ if (!keyHex || keyHex.length !== 64) {
330
+ return { success: false, error: '3.x 密钥格式错误,需要 64 位十六进制字符串' }
331
+ }
332
+
333
+ this.wxid = wxid || ''
334
+ // 从 dbPath 推导 WeChat Files 根目录
335
+ // dbPath: {wxDirRoot}/{wxid}/Msg/Multi/MSG0.db
336
+ // wxDirRoot = dirname(dirname(dirname(dbPath)))
337
+ try {
338
+ this.wxDirRoot = dirname(dirname(dirname(dirname(dbPath))))
339
+ } catch {
340
+ this.wxDirRoot = ''
341
+ }
342
+
343
+ try {
344
+ const tempPath = this.decryptToTemp(dbPath, keyHex)
345
+ this.db = new DatabaseSync(tempPath)
346
+ this.opened = true
347
+ return { success: true }
348
+ } catch (e) {
349
+ const msg = e instanceof Error ? e.message : String(e)
350
+ return { success: false, error: `打开 3.x 数据库失败: ${msg}` }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * 获取会话列表 (从 MSG 表中聚合 StrTalker)
356
+ */
357
+ async getSessions(): Promise<SessionsResult> {
358
+ if (!this.db) return { success: false, error: '数据库未打开' }
359
+
360
+ try {
361
+ const stmt = this.db.prepare(`
362
+ SELECT
363
+ m.StrTalker as username,
364
+ MAX(m.CreateTime) as lastTimestamp,
365
+ (SELECT m2.StrContent FROM MSG m2
366
+ WHERE m2.StrTalker = m.StrTalker
367
+ ORDER BY m2.CreateTime DESC LIMIT 1) as summary
368
+ FROM MSG m
369
+ WHERE m.StrTalker != ''
370
+ GROUP BY m.StrTalker
371
+ ORDER BY lastTimestamp DESC
372
+ LIMIT 500
373
+ `)
374
+ const rows = stmt.all() as any[]
375
+
376
+ const sessions: ChatSession[] = rows.map((row: any) => ({
377
+ username: row.username || '',
378
+ type: (row.username || '').includes('@chatroom') ? 1 : 0,
379
+ unreadCount: 0,
380
+ summary: row.summary || '',
381
+ sortTimestamp: row.lastTimestamp || 0,
382
+ lastTimestamp: row.lastTimestamp || 0,
383
+ displayName: row.username || '',
384
+ }))
385
+
386
+ return { success: true, sessions }
387
+ } catch (e) {
388
+ const msg = e instanceof Error ? e.message : String(e)
389
+ return { success: false, error: `获取会话失败: ${msg}` }
390
+ }
391
+ }
392
+
393
+ /**
394
+ * 获取消息列表
395
+ */
396
+ async getMessages(
397
+ talker: string,
398
+ limit = 0,
399
+ offset = 0
400
+ ): Promise<MessagesResult> {
401
+ if (!this.db) return { success: false, error: '数据库未打开' }
402
+
403
+ try {
404
+ let rows: any[]
405
+ if (limit > 0) {
406
+ const stmt = this.db.prepare(`
407
+ SELECT
408
+ localId, CAST(MsgSvrID AS TEXT) as MsgSvrID, Type, SubType, IsSender,
409
+ CreateTime, StrTalker, StrContent, CompressContent, BytesExtra
410
+ FROM MSG
411
+ WHERE StrTalker = ?
412
+ ORDER BY CreateTime DESC
413
+ LIMIT ? OFFSET ?
414
+ `)
415
+ rows = stmt.all(talker, limit, offset) as any[]
416
+ } else {
417
+ const stmt = this.db.prepare(`
418
+ SELECT
419
+ localId, CAST(MsgSvrID AS TEXT) as MsgSvrID, Type, SubType, IsSender,
420
+ CreateTime, StrTalker, StrContent, CompressContent, BytesExtra
421
+ FROM MSG
422
+ WHERE StrTalker = ?
423
+ ORDER BY CreateTime DESC
424
+ `)
425
+ rows = stmt.all(talker) as any[]
426
+ }
427
+
428
+ const messages: Message[] = rows.map((row: any) =>
429
+ this.processMessage(row)
430
+ )
431
+
432
+ return { success: true, messages }
433
+ } catch (e) {
434
+ const msg = e instanceof Error ? e.message : String(e)
435
+ return { success: false, error: `获取消息失败: ${msg}` }
436
+ }
437
+ }
438
+
439
+ /**
440
+ * 处理单条消息,根据类型解析内容
441
+ */
442
+ private processMessage(row: any): Message {
443
+ const localType = Number(row.Type) || 0
444
+ const isSend = Number(row.IsSender) || 0
445
+ const strContent = String(row.StrContent ?? '')
446
+ const compressContent = row.CompressContent as any
447
+ const bytesExtra = row.BytesExtra as any
448
+
449
+ let parsedContent = ''
450
+ let imageBase64: string | undefined
451
+ let imageFileName: string | undefined
452
+ let appTitle: string | undefined
453
+ let appDescription: string | undefined
454
+ let appUrl: string | undefined
455
+
456
+ switch (localType) {
457
+ case 1: // 文本
458
+ parsedContent = strContent
459
+ break
460
+
461
+ case 3: { // 图片
462
+ const imgInfo = this.parseImageXml(strContent)
463
+ // 从 BytesExtra 提取图片文件名
464
+ imageFileName = this.parseBytesExtraImageFileName(bytesExtra)
465
+ if (imageFileName) {
466
+ parsedContent = `[图片: ${imageFileName}]`
467
+ } else {
468
+ parsedContent = '[图片]'
469
+ }
470
+ // 尝试查找缩略图
471
+ if (imageFileName) {
472
+ imageBase64 = this.findThumbnail(imageFileName, row.CreateTime)
473
+ }
474
+ break
475
+ }
476
+
477
+ case 34: // 语音
478
+ parsedContent = '[语音]'
479
+ break
480
+
481
+ case 43: // 视频
482
+ parsedContent = '[视频]'
483
+ break
484
+
485
+ case 47: // 表情
486
+ parsedContent = strContent ? `[表情: ${strContent}]` : '[表情]'
487
+ break
488
+
489
+ case 49: { // AppMsg (链接/文件/引用)
490
+ const xml = this.decompressCompressContent(compressContent)
491
+ if (xml) {
492
+ const appInfo = this.parseAppMsgXml(xml)
493
+ appTitle = appInfo.title
494
+ appDescription = appInfo.description
495
+ appUrl = appInfo.url
496
+ parsedContent = this.formatAppMsg(appInfo)
497
+ } else {
498
+ // 部分 Type=49 消息内容在 StrContent 中
499
+ parsedContent = strContent || '[链接/文件]'
500
+ }
501
+ break
502
+ }
503
+
504
+ case 50: // 语音通话
505
+ parsedContent = '[语音通话]'
506
+ break
507
+
508
+ case 10000: // 系统消息
509
+ parsedContent = strContent.replace(/\n/g, ' ')
510
+ break
511
+
512
+ default:
513
+ parsedContent = strContent || ''
514
+ }
515
+
516
+ return {
517
+ localId: Number(row.localId) || 0,
518
+ serverId: String(row.MsgSvrID ?? ''),
519
+ localType,
520
+ createTime: Number(row.CreateTime) || 0,
521
+ isSend,
522
+ senderUsername: isSend ? '' : (row.StrTalker || ''),
523
+ content: strContent,
524
+ rawContent: strContent,
525
+ parsedContent,
526
+ imageBase64,
527
+ imageFileName,
528
+ appTitle,
529
+ appDescription,
530
+ appUrl,
531
+ }
532
+ }
533
+
534
+ /**
535
+ * 将 node:sqlite 返回的 BLOB (可能是 Uint8Array 或 Buffer) 转换为 Buffer
536
+ */
537
+ private toBuffer(data: any): Buffer | null {
538
+ if (!data || data.length === 0) return null
539
+ if (Buffer.isBuffer(data)) return data
540
+ // Uint8Array from node:sqlite
541
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
542
+ }
543
+
544
+ /**
545
+ * LZ4 解压 CompressContent
546
+ */
547
+ private decompressCompressContent(data: any): string | null {
548
+ const buf = this.toBuffer(data)
549
+ if (!buf) return null
550
+
551
+ try {
552
+ const maxSize = buf.length * 256
553
+ const outBuf = Buffer.alloc(maxSize)
554
+ const actualSize = lz4.decodeBlock(buf, outBuf)
555
+ return outBuf.subarray(0, actualSize).toString('utf8')
556
+ } catch {
557
+ return null
558
+ }
559
+ }
560
+
561
+ /**
562
+ * 解析 Type=3 图片 XML
563
+ */
564
+ private parseImageXml(xml: string): { aeskey?: string; cdnthumburl?: string } {
565
+ try {
566
+ const aesMatch = xml.match(/aeskey="([^"]+)"/)
567
+ const urlMatch = xml.match(/cdnthumburl="([^"]+)"/)
568
+ return {
569
+ aeskey: aesMatch ? aesMatch[1] : undefined,
570
+ cdnthumburl: urlMatch ? urlMatch[1] : undefined,
571
+ }
572
+ } catch {
573
+ return {}
574
+ }
575
+ }
576
+
577
+ /**
578
+ * 从 BytesExtra (protobuf) 中提取图片文件名
579
+ * BytesExtra 包含 <msgsource><img_file_name>uuid.png</img_file_name></msgsource> 之类的 XML
580
+ */
581
+ private parseBytesExtraImageFileName(data: any): string | undefined {
582
+ const buf = this.toBuffer(data)
583
+ if (!buf) return undefined
584
+ try {
585
+ const str = buf.toString('utf8')
586
+ const match = str.match(/<img_file_name>([^<]+)<\/img_file_name>/)
587
+ return match ? match[1] : undefined
588
+ } catch {
589
+ return undefined
590
+ }
591
+ }
592
+
593
+ /**
594
+ * 查找缩略图并返回 base64
595
+ * 缩略图在 FileStorage/Cache/YYYY-MM/ 目录下,以 _t.jpg 结尾
596
+ * 文件名基于 MD5 哈希,我们需要遍历目录查找匹配图片文件名的缩略图
597
+ */
598
+ private findThumbnail(imageFileName: string, createTime: number): string | undefined {
599
+ if (!this.wxDirRoot || !this.wxid) return undefined
600
+
601
+ try {
602
+ // 从时间戳推断月份目录
603
+ const date = new Date(createTime * 1000)
604
+ const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
605
+
606
+ const cacheDir = join(this.wxDirRoot, this.wxid, 'FileStorage', 'Cache', yearMonth)
607
+ if (!existsSync(cacheDir)) return undefined
608
+
609
+ // 读取目录中的所有文件
610
+ const files: string[] = readdirSync(cacheDir)
611
+
612
+ // 图片文件名格式: {uuid}.png → 对应的缩略图可能以 _t.jpg 结尾
613
+ // 缩略图命名是基于 MD5,而不是 UUID,所以无法直接匹配
614
+ // 返回 undefined,让调用方只显示图片文件名
615
+ return undefined
616
+ } catch {
617
+ return undefined
618
+ }
619
+ }
620
+
621
+ /**
622
+ * 解析 Type=49 AppMsg XML
623
+ */
624
+ private parseAppMsgXml(xml: string): { title?: string; description?: string; url?: string; type?: string } {
625
+ try {
626
+ const titleMatch = xml.match(/<title>([^<]*)<\/title>/)
627
+ const desMatch = xml.match(/<des>([^<]*)<\/des>/)
628
+ const urlMatch = xml.match(/<url>([^<]*)<\/url>/)
629
+ const typeMatch = xml.match(/<type>(\d+)<\/type>/)
630
+
631
+ return {
632
+ title: titleMatch ? this.decodeXmlEntities(titleMatch[1]) : undefined,
633
+ description: desMatch ? this.decodeXmlEntities(desMatch[1]) : undefined,
634
+ url: urlMatch ? this.decodeXmlEntities(urlMatch[1]) : undefined,
635
+ type: typeMatch ? typeMatch[1] : undefined,
636
+ }
637
+ } catch {
638
+ return {}
639
+ }
640
+ }
641
+
642
+ /**
643
+ * 解码 XML 实体字符
644
+ */
645
+ private decodeXmlEntities(str: string): string {
646
+ return str
647
+ .replace(/&amp;/g, '&')
648
+ .replace(/&lt;/g, '<')
649
+ .replace(/&gt;/g, '>')
650
+ .replace(/&quot;/g, '"')
651
+ .replace(/&apos;/g, "'")
652
+ }
653
+
654
+ /**
655
+ * 格式化 AppMsg 为可读文本
656
+ */
657
+ private formatAppMsg(info: { title?: string; description?: string; url?: string; type?: string }): string {
658
+ const parts: string[] = []
659
+
660
+ switch (info.type) {
661
+ case '5': // 链接分享
662
+ if (info.title) parts.push(`[分享] ${info.title}`)
663
+ if (info.description) parts.push(info.description)
664
+ if (info.url) parts.push(info.url)
665
+ break
666
+ case '6': // 文件分享
667
+ if (info.title) parts.push(`[文件] ${info.title}`)
668
+ break
669
+ case '57': // 引用回复
670
+ if (info.title) parts.push(`[引用] ${info.title}`)
671
+ break
672
+ default:
673
+ if (info.title) parts.push(`[AppMsg] ${info.title}`)
674
+ if (info.description) parts.push(info.description)
675
+ break
676
+ }
677
+
678
+ return parts.join('\n') || '[链接/文件]'
679
+ }
680
+
681
+ /**
682
+ * 获取联系人列表
683
+ */
684
+ async getContacts(): Promise<ContactsResult> {
685
+ if (!this.db) return { success: false, error: '数据库未打开' }
686
+
687
+ try {
688
+ const stmt = this.db.prepare(`
689
+ SELECT UsrName as username
690
+ FROM Name2ID
691
+ ORDER BY UsrName
692
+ LIMIT 500
693
+ `)
694
+ const rows = stmt.all() as any[]
695
+
696
+ const contacts: Contact[] = rows.map((row: any) => ({
697
+ username: row.username || '',
698
+ displayName: row.username || '',
699
+ }))
700
+
701
+ return { success: true, contacts }
702
+ } catch (e) {
703
+ const msg = e instanceof Error ? e.message : String(e)
704
+ return { success: false, error: `获取联系人失败: ${msg}` }
705
+ }
706
+ }
707
+
708
+ /**
709
+ * 验证密钥是否正确 (使用第一页 HMAC)
710
+ */
711
+ static verifyKey(keyHex: string, dbPath: string): boolean {
712
+ try {
713
+ const password = Buffer.from(keyHex, 'hex')
714
+ const blist = readFileSync(dbPath)
715
+
716
+ if (blist.length < PAGE_SIZE) return false
717
+
718
+ const salt = blist.subarray(0, 16)
719
+ // first = blist[16:4096] = 4080 bytes (第一页跳过salt)
720
+ const first = blist.subarray(16, PAGE_SIZE)
721
+
722
+ // 派生页面密钥
723
+ const byteHmac = crypto.pbkdf2Sync(password, salt, DEFAULT_ITER, KEY_SIZE, 'sha1')
724
+
725
+ // 派生 HMAC 密钥: mac_salt = salt ^ 0x3A
726
+ const macSalt = Buffer.alloc(16)
727
+ for (let i = 0; i < 16; i++) {
728
+ macSalt[i] = salt[i] ^ 58
729
+ }
730
+ const macKey = crypto.pbkdf2Sync(byteHmac, macSalt, 2, KEY_SIZE, 'sha1')
731
+
732
+ // 计算 HMAC: hmac(macKey, data[16:4064] + page_number)
733
+ // data[16:4064] = 4048 bytes (加密数据区)
734
+ const hmac = crypto.createHmac('sha1', macKey)
735
+ hmac.update(blist.subarray(16, PAGE_SIZE - 32)) // bytes 16-4063 = 4048 bytes
736
+ hmac.update(Buffer.from([1, 0, 0, 0])) // page 1, little-endian
737
+
738
+ // first[-32:-12] = HMAC at position 4048-4067 within first = file bytes 4064-4083
739
+ const expected = first.subarray(first.length - 32, first.length - 12)
740
+ return hmac.digest().equals(expected)
741
+ } catch {
742
+ return false
743
+ }
744
+ }
745
+
746
+ /**
747
+ * 关闭数据库并清理临时文件
748
+ */
749
+ close(): void {
750
+ if (this.db) {
751
+ try { this.db.close() } catch { }
752
+ this.db = null
753
+ }
754
+
755
+ for (const tempPath of this.tempFiles.values()) {
756
+ try {
757
+ if (existsSync(tempPath)) unlinkSync(tempPath)
758
+ } catch { }
759
+ }
760
+ this.tempFiles.clear()
761
+ this.pageKey = null
762
+ this.opened = false
763
+ }
764
+ }