weflow-cli 1.7.0 → 1.8.1
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/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +2108 -2
- package/OPERATIONS.md +810 -1
- package/README.en.md +96 -10
- package/README.md +34 -9
- package/bin/weflow-cli.ts +937 -28
- package/dist/bin/weflow-cli.js +955 -28
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +25 -6
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +599 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +217 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1116 -59
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/services/wereadService.d.ts +33 -6
- package/dist/src/services/wereadService.d.ts.map +1 -1
- package/dist/src/services/wereadService.js +27 -2
- package/dist/src/services/wereadService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/BRANCHES.md +13 -4
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +210 -9
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +483 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-thinking.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +415 -0
- package/resources/panel/preload.cjs +79 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +470 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +425 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1060 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +429 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +25 -6
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +681 -74
- package/src/services/assistantTools.ts +1149 -58
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/services/wereadService.ts +43 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
package/scripts/nt_decrypt.py
CHANGED
|
@@ -1,1460 +1,1755 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
WeChat NT (4.x) Database Access Tool
|
|
4
|
-
Uses sqlcipher3 to decrypt and query NT-format databases.
|
|
5
|
-
"""
|
|
6
|
-
import sys
|
|
7
|
-
import os
|
|
8
|
-
import json
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
('
|
|
43
|
-
('
|
|
44
|
-
('
|
|
45
|
-
('
|
|
46
|
-
('
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return False
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if not
|
|
120
|
-
continue
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if name
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
os.path.join(home, '
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
"
|
|
336
|
-
"
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
contact.db
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
""
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
return
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def
|
|
580
|
-
"""
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
conn.
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
"
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
"
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
"
|
|
858
|
-
"
|
|
859
|
-
"
|
|
860
|
-
"
|
|
861
|
-
"
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
""
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
"""
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
return
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
'
|
|
1175
|
-
|
|
1176
|
-
'
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
#
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
#
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WeChat NT (4.x) Database Access Tool
|
|
4
|
+
Uses sqlcipher3 to decrypt and query NT-format databases.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
import hmac
|
|
12
|
+
import struct
|
|
13
|
+
import hashlib
|
|
14
|
+
import ctypes
|
|
15
|
+
from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sqlcipher = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_sqlcipher():
|
|
22
|
+
"""Load SQLCipher only for database operations, not path discovery."""
|
|
23
|
+
global sqlcipher
|
|
24
|
+
if sqlcipher is not None:
|
|
25
|
+
return sqlcipher
|
|
26
|
+
try:
|
|
27
|
+
from sqlcipher3 import dbapi2 as sqlcipher_module
|
|
28
|
+
except ImportError as error:
|
|
29
|
+
raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
|
|
30
|
+
sqlcipher = sqlcipher_module
|
|
31
|
+
return sqlcipher
|
|
32
|
+
|
|
33
|
+
# ========== Memory Scanner ==========
|
|
34
|
+
PROCESS_VM_READ = 0x0010
|
|
35
|
+
PROCESS_QUERY_INFORMATION = 0x0400
|
|
36
|
+
MEM_COMMIT = 0x1000
|
|
37
|
+
PAGE_NOACCESS = 0x01
|
|
38
|
+
PAGE_GUARD = 0x100
|
|
39
|
+
|
|
40
|
+
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
|
|
41
|
+
_fields_ = [
|
|
42
|
+
('BaseAddress', ctypes.c_void_p),
|
|
43
|
+
('AllocationBase', ctypes.c_void_p),
|
|
44
|
+
('AllocationProtect', wintypes.DWORD),
|
|
45
|
+
('PartitionId', wintypes.WORD),
|
|
46
|
+
('RegionSize', ctypes.c_size_t),
|
|
47
|
+
('State', wintypes.DWORD),
|
|
48
|
+
('Protect', wintypes.DWORD),
|
|
49
|
+
('Type', wintypes.DWORD),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
IS_WINDOWS = os.name == 'nt'
|
|
53
|
+
|
|
54
|
+
if IS_WINDOWS:
|
|
55
|
+
kernel32 = ctypes.windll.kernel32
|
|
56
|
+
ReadProcessMemory = kernel32.ReadProcessMemory
|
|
57
|
+
ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
|
|
58
|
+
ReadProcessMemory.restype = wintypes.BOOL
|
|
59
|
+
VirtualQueryEx = kernel32.VirtualQueryEx
|
|
60
|
+
VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
|
|
61
|
+
VirtualQueryEx.restype = ctypes.c_size_t
|
|
62
|
+
else:
|
|
63
|
+
kernel32 = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_weixin_pid():
|
|
67
|
+
"""Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
|
|
68
|
+
if not IS_WINDOWS:
|
|
69
|
+
return find_weixin_pid_linux()
|
|
70
|
+
try:
|
|
71
|
+
import pymem, pymem.process
|
|
72
|
+
for proc in pymem.process.list_processes():
|
|
73
|
+
try:
|
|
74
|
+
name = proc.szExeFile
|
|
75
|
+
if isinstance(name, bytes):
|
|
76
|
+
name = name.decode('utf-8', errors='ignore')
|
|
77
|
+
if name.lower() == 'weixin.exe':
|
|
78
|
+
return proc.th32ProcessID
|
|
79
|
+
except:
|
|
80
|
+
pass
|
|
81
|
+
except ImportError:
|
|
82
|
+
pass
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
|
|
87
|
+
_LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_linux_wechat_process(pid):
|
|
91
|
+
if pid == os.getpid():
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
with open(f'/proc/{pid}/comm') as f:
|
|
95
|
+
comm = f.read().strip().lower()
|
|
96
|
+
if comm in _LINUX_WECHAT_COMMS:
|
|
97
|
+
return True
|
|
98
|
+
try:
|
|
99
|
+
exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
|
|
100
|
+
except OSError:
|
|
101
|
+
return False
|
|
102
|
+
name = os.path.basename(exe).lower()
|
|
103
|
+
if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
|
|
104
|
+
return False
|
|
105
|
+
return 'wechat' in name or 'weixin' in name
|
|
106
|
+
except (PermissionError, FileNotFoundError, ProcessLookupError):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_weixin_pid_linux():
|
|
111
|
+
"""Find Linux WeChat main process (largest RSS among candidates)."""
|
|
112
|
+
best = None
|
|
113
|
+
best_rss = -1
|
|
114
|
+
try:
|
|
115
|
+
pids = os.listdir('/proc')
|
|
116
|
+
except OSError:
|
|
117
|
+
return None
|
|
118
|
+
for pid_str in pids:
|
|
119
|
+
if not pid_str.isdigit():
|
|
120
|
+
continue
|
|
121
|
+
pid = int(pid_str)
|
|
122
|
+
if not _is_linux_wechat_process(pid):
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
with open(f'/proc/{pid}/statm') as f:
|
|
126
|
+
rss_kb = int(f.read().split()[1]) * 4
|
|
127
|
+
except (OSError, IndexError, ValueError):
|
|
128
|
+
rss_kb = 0
|
|
129
|
+
if rss_kb > best_rss:
|
|
130
|
+
best_rss = rss_kb
|
|
131
|
+
best = pid
|
|
132
|
+
return best
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def scan_memory_keys(pid):
|
|
136
|
+
"""Scan process memory for x'<64hex_key><32hex_salt>' patterns.
|
|
137
|
+
|
|
138
|
+
Returns (keys, error): keys is a list of {"key","salt"} dicts,
|
|
139
|
+
error is None on success or 'permission' / 'gone' / 'not_windows'.
|
|
140
|
+
"""
|
|
141
|
+
if not IS_WINDOWS:
|
|
142
|
+
return scan_memory_keys_linux(pid)
|
|
143
|
+
|
|
144
|
+
hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
|
145
|
+
if not hProcess:
|
|
146
|
+
return [], None
|
|
147
|
+
|
|
148
|
+
pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
|
|
149
|
+
keys_found = []
|
|
150
|
+
address = 0x10000
|
|
151
|
+
|
|
152
|
+
while address < 0x7FFFFFFFFFFF:
|
|
153
|
+
mbi = MEMORY_BASIC_INFORMATION()
|
|
154
|
+
result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
|
|
155
|
+
if result == 0:
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
region_addr = mbi.BaseAddress or 0
|
|
159
|
+
region_size = mbi.RegionSize or 0
|
|
160
|
+
|
|
161
|
+
if (mbi.State == MEM_COMMIT and
|
|
162
|
+
region_size > 256 and region_size < 200 * 1024 * 1024 and
|
|
163
|
+
mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
|
|
164
|
+
|
|
165
|
+
pos = region_addr
|
|
166
|
+
end = region_addr + region_size
|
|
167
|
+
while pos < end:
|
|
168
|
+
chunk_size = min(65536, end - pos)
|
|
169
|
+
buf = create_string_buffer(chunk_size)
|
|
170
|
+
bytesRead = c_size_t(0)
|
|
171
|
+
ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
|
|
172
|
+
if ok and bytesRead.value > 0:
|
|
173
|
+
data = buf.raw[:bytesRead.value]
|
|
174
|
+
for m in pattern.finditer(data):
|
|
175
|
+
key_hex = m.group(1).decode()
|
|
176
|
+
salt_hex = m.group(2).decode()
|
|
177
|
+
keys_found.append((key_hex, salt_hex))
|
|
178
|
+
pos += chunk_size
|
|
179
|
+
|
|
180
|
+
address = region_addr + region_size
|
|
181
|
+
|
|
182
|
+
kernel32.CloseHandle(hProcess)
|
|
183
|
+
|
|
184
|
+
# Deduplicate
|
|
185
|
+
seen = set()
|
|
186
|
+
unique_keys = []
|
|
187
|
+
for k, s in keys_found:
|
|
188
|
+
pair = (k, s)
|
|
189
|
+
if pair not in seen:
|
|
190
|
+
seen.add(pair)
|
|
191
|
+
unique_keys.append({"key": k, "salt": s})
|
|
192
|
+
|
|
193
|
+
return unique_keys, None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
|
|
197
|
+
_LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def scan_memory_keys_linux(pid):
|
|
201
|
+
"""Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
|
|
202
|
+
|
|
203
|
+
Requires root or CAP_SYS_PTRACE (or the target being a descendant
|
|
204
|
+
of this process when yama ptrace_scope=1).
|
|
205
|
+
"""
|
|
206
|
+
regions = []
|
|
207
|
+
try:
|
|
208
|
+
with open(f'/proc/{pid}/maps') as f:
|
|
209
|
+
for line in f:
|
|
210
|
+
parts = line.split()
|
|
211
|
+
if len(parts) < 2 or 'r' not in parts[1]:
|
|
212
|
+
continue
|
|
213
|
+
if len(parts) >= 6:
|
|
214
|
+
name = parts[5]
|
|
215
|
+
if name in _LINUX_SKIP_MAPPINGS:
|
|
216
|
+
continue
|
|
217
|
+
name_lower = name.lower()
|
|
218
|
+
if name.startswith(_LINUX_SKIP_PREFIXES) and \
|
|
219
|
+
'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
start_s, end_s = parts[0].split('-')
|
|
223
|
+
start = int(start_s, 16)
|
|
224
|
+
size = int(end_s, 16) - start
|
|
225
|
+
except ValueError:
|
|
226
|
+
continue
|
|
227
|
+
if 0 < size < 500 * 1024 * 1024:
|
|
228
|
+
regions.append((start, size))
|
|
229
|
+
except PermissionError:
|
|
230
|
+
return [], 'permission'
|
|
231
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
232
|
+
return [], 'gone'
|
|
233
|
+
|
|
234
|
+
pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
|
|
235
|
+
keys_found = []
|
|
236
|
+
try:
|
|
237
|
+
with open(f'/proc/{pid}/mem', 'rb') as mem:
|
|
238
|
+
for base, size in regions:
|
|
239
|
+
try:
|
|
240
|
+
mem.seek(base)
|
|
241
|
+
data = mem.read(size)
|
|
242
|
+
except (OSError, ValueError):
|
|
243
|
+
continue
|
|
244
|
+
for m in pattern.finditer(data):
|
|
245
|
+
keys_found.append((m.group(1).decode(), m.group(2).decode()))
|
|
246
|
+
except PermissionError:
|
|
247
|
+
return [], 'permission'
|
|
248
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
249
|
+
return [], 'gone'
|
|
250
|
+
|
|
251
|
+
seen = set()
|
|
252
|
+
unique_keys = []
|
|
253
|
+
for k, s in keys_found:
|
|
254
|
+
pair = (k, s)
|
|
255
|
+
if pair not in seen:
|
|
256
|
+
seen.add(pair)
|
|
257
|
+
unique_keys.append({"key": k, "salt": s})
|
|
258
|
+
|
|
259
|
+
return unique_keys, None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ========== NT Database Discovery ==========
|
|
263
|
+
|
|
264
|
+
def _is_nt_account_dir(path):
|
|
265
|
+
return os.path.isdir(os.path.join(path, 'db_storage')) or \
|
|
266
|
+
os.path.isdir(os.path.join(path, 'Msg'))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _normalize_nt_root(root):
|
|
270
|
+
if not root:
|
|
271
|
+
return None
|
|
272
|
+
path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
|
|
273
|
+
if os.path.isfile(path):
|
|
274
|
+
path = os.path.dirname(path)
|
|
275
|
+
if not os.path.isdir(path):
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
for entry in os.listdir(path):
|
|
280
|
+
candidate = os.path.join(path, entry)
|
|
281
|
+
if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
|
|
282
|
+
return path
|
|
283
|
+
except OSError:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
for _ in range(6):
|
|
287
|
+
if _is_nt_account_dir(path):
|
|
288
|
+
return os.path.dirname(path)
|
|
289
|
+
parent = os.path.dirname(path)
|
|
290
|
+
if parent == path:
|
|
291
|
+
break
|
|
292
|
+
path = parent
|
|
293
|
+
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def find_nt_databases(root=None):
|
|
298
|
+
"""Find all NT-format databases under xwechat_files (message + contact)."""
|
|
299
|
+
if root:
|
|
300
|
+
normalized_root = _normalize_nt_root(root)
|
|
301
|
+
candidates = [normalized_root] if normalized_root else []
|
|
302
|
+
elif IS_WINDOWS:
|
|
303
|
+
candidates = [
|
|
304
|
+
os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
|
|
305
|
+
os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
|
|
306
|
+
]
|
|
307
|
+
else:
|
|
308
|
+
home = os.path.expanduser('~')
|
|
309
|
+
candidates = [
|
|
310
|
+
os.path.join(home, '.local', 'share', 'xwechat_files'),
|
|
311
|
+
os.path.join(home, 'xwechat_files'),
|
|
312
|
+
os.path.join(home, 'Documents', 'xwechat_files'),
|
|
313
|
+
os.path.join(home, '文档', 'xwechat_files'),
|
|
314
|
+
]
|
|
315
|
+
xwechat = None
|
|
316
|
+
for c in candidates:
|
|
317
|
+
if os.path.isdir(c):
|
|
318
|
+
xwechat = c
|
|
319
|
+
break
|
|
320
|
+
if not xwechat:
|
|
321
|
+
return []
|
|
322
|
+
|
|
323
|
+
databases = []
|
|
324
|
+
for wxid_dir in os.listdir(xwechat):
|
|
325
|
+
# Scan message databases
|
|
326
|
+
msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
|
|
327
|
+
if os.path.isdir(msg_storage):
|
|
328
|
+
for f in os.listdir(msg_storage):
|
|
329
|
+
if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
|
|
330
|
+
full_path = os.path.join(msg_storage, f)
|
|
331
|
+
try:
|
|
332
|
+
with open(full_path, 'rb') as fh:
|
|
333
|
+
salt = fh.read(16)
|
|
334
|
+
databases.append({
|
|
335
|
+
"path": full_path,
|
|
336
|
+
"name": f"message/{f}",
|
|
337
|
+
"salt": salt.hex(),
|
|
338
|
+
"size": os.path.getsize(full_path),
|
|
339
|
+
"wxid": wxid_dir,
|
|
340
|
+
})
|
|
341
|
+
except:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
# Scan contact database
|
|
345
|
+
contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
|
|
346
|
+
if os.path.isfile(contact_db):
|
|
347
|
+
try:
|
|
348
|
+
with open(contact_db, 'rb') as fh:
|
|
349
|
+
salt = fh.read(16)
|
|
350
|
+
databases.append({
|
|
351
|
+
"path": contact_db,
|
|
352
|
+
"name": "contact/contact.db",
|
|
353
|
+
"salt": salt.hex(),
|
|
354
|
+
"size": os.path.getsize(contact_db),
|
|
355
|
+
"wxid": wxid_dir,
|
|
356
|
+
})
|
|
357
|
+
except:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
# Scan SNS (朋友圈) database
|
|
361
|
+
sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
|
|
362
|
+
if os.path.isfile(sns_db):
|
|
363
|
+
try:
|
|
364
|
+
with open(sns_db, 'rb') as fh:
|
|
365
|
+
salt = fh.read(16)
|
|
366
|
+
databases.append({
|
|
367
|
+
"path": sns_db,
|
|
368
|
+
"name": "sns/sns.db",
|
|
369
|
+
"salt": salt.hex(),
|
|
370
|
+
"size": os.path.getsize(sns_db),
|
|
371
|
+
"wxid": wxid_dir,
|
|
372
|
+
})
|
|
373
|
+
except:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
# Scan favorites (收藏) database
|
|
377
|
+
fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
|
|
378
|
+
if os.path.isfile(fav_db):
|
|
379
|
+
try:
|
|
380
|
+
with open(fav_db, 'rb') as fh:
|
|
381
|
+
salt = fh.read(16)
|
|
382
|
+
databases.append({
|
|
383
|
+
"path": fav_db,
|
|
384
|
+
"name": "favorite/favorite.db",
|
|
385
|
+
"salt": salt.hex(),
|
|
386
|
+
"size": os.path.getsize(fav_db),
|
|
387
|
+
"wxid": wxid_dir,
|
|
388
|
+
})
|
|
389
|
+
except:
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
return databases
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def find_contact_db_path(message_db_path):
|
|
396
|
+
"""Derive contact.db path from message_0.db path.
|
|
397
|
+
|
|
398
|
+
message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
|
|
399
|
+
contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
|
|
400
|
+
"""
|
|
401
|
+
msg_dir = os.path.dirname(message_db_path)
|
|
402
|
+
wxid_dir = os.path.dirname(msg_dir) # .../db_storage
|
|
403
|
+
xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
|
|
404
|
+
contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
|
|
405
|
+
if os.path.isfile(contact_db):
|
|
406
|
+
return contact_db
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
|
|
411
|
+
"""Load wxid -> {remark, nick_name} map from contact.db.
|
|
412
|
+
|
|
413
|
+
Returns dict: {wxid: display_name}
|
|
414
|
+
display_name priority: remark > nick_name > alias > wxid
|
|
415
|
+
|
|
416
|
+
取不到就返回空表(名字是装饰性的,调用方会退回 wxid)。**连接必须在 finally
|
|
417
|
+
里关**:原先 `conn.close()` 写在 try 末尾,异常路径上泄漏——而在 Windows 上
|
|
418
|
+
一个没关的连接会把文件锁住(同一个坑早先在这个文件的分片读取里踩过一次)。
|
|
419
|
+
读了一半也保留已经拿到的名字,而不是整份丢掉。
|
|
420
|
+
"""
|
|
421
|
+
if not contact_db_path or not contact_key_hex or not contact_salt_hex:
|
|
422
|
+
return {}
|
|
423
|
+
if not os.path.isfile(contact_db_path):
|
|
424
|
+
return {}
|
|
425
|
+
|
|
426
|
+
name_map = {}
|
|
427
|
+
conn = None
|
|
428
|
+
try:
|
|
429
|
+
raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
|
|
430
|
+
conn = require_sqlcipher().connect(contact_db_path)
|
|
431
|
+
c = conn.cursor()
|
|
432
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
433
|
+
|
|
434
|
+
# contact.db schema: username, alias, remark, nick_name, ...
|
|
435
|
+
c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
|
|
436
|
+
for username, display in c.fetchall():
|
|
437
|
+
if username:
|
|
438
|
+
name_map[username] = display
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
finally:
|
|
442
|
+
if conn is not None:
|
|
443
|
+
try:
|
|
444
|
+
conn.close()
|
|
445
|
+
except Exception:
|
|
446
|
+
pass
|
|
447
|
+
return name_map
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def filter_contacts(contacts, keyword, limit=0):
|
|
451
|
+
"""按关键字过滤联系人,**然后**才截断到 `limit`。
|
|
452
|
+
|
|
453
|
+
顺序是这里的全部要点:先截断会让第 `limit` 个之后的人搜不到(实测 500 人的通讯录里,
|
|
454
|
+
按备注名找 10 个只命中 3 个)。关键字比对 `username + displayName + remark + nickname`,
|
|
455
|
+
大小写不敏感。
|
|
456
|
+
"""
|
|
457
|
+
if not keyword:
|
|
458
|
+
return contacts[:limit] if limit else contacts
|
|
459
|
+
needle = keyword.lower()
|
|
460
|
+
hits = [
|
|
461
|
+
c for c in contacts
|
|
462
|
+
if needle in (c.get('username', '') + c.get('displayName', '')
|
|
463
|
+
+ c.get('remark', '') + c.get('nickname', '')).lower()
|
|
464
|
+
]
|
|
465
|
+
return hits[:limit] if limit else hits
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def apply_contact_names(sessions, name_map):
|
|
469
|
+
"""Apply contact names to session list, replacing bare wxid displayNames."""
|
|
470
|
+
if not name_map:
|
|
471
|
+
return sessions
|
|
472
|
+
for s in sessions:
|
|
473
|
+
username = s.get('username', '')
|
|
474
|
+
if username in name_map:
|
|
475
|
+
s['displayName'] = name_map[username]
|
|
476
|
+
return sessions
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def match_keys_to_databases(keys, databases):
|
|
480
|
+
"""Match memory keys to databases by comparing salts."""
|
|
481
|
+
salt_to_key = {}
|
|
482
|
+
for k in keys:
|
|
483
|
+
salt_to_key[k["salt"]] = k["key"]
|
|
484
|
+
|
|
485
|
+
matched = []
|
|
486
|
+
for db in databases:
|
|
487
|
+
if db["salt"] in salt_to_key:
|
|
488
|
+
db["key"] = salt_to_key[db["salt"]]
|
|
489
|
+
matched.append(db)
|
|
490
|
+
|
|
491
|
+
return matched
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ========== Database Operations ==========
|
|
495
|
+
|
|
496
|
+
def connect_nt_db(db_path, key_hex, salt_hex):
|
|
497
|
+
"""Connect to an NT database using sqlcipher3."""
|
|
498
|
+
raw_key = f"x'{key_hex}{salt_hex}'"
|
|
499
|
+
conn = require_sqlcipher().connect(db_path)
|
|
500
|
+
c = conn.cursor()
|
|
501
|
+
c.execute(f'PRAGMA key = "{raw_key}";')
|
|
502
|
+
return conn, c
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# WeChat rolls a conversation into a new shard over time, and every shard is
|
|
506
|
+
# encrypted with its own key. Reading only the configured database therefore
|
|
507
|
+
# shows a transcript that stops wherever the first shard's last write left off
|
|
508
|
+
# - which is silent, because the query still succeeds. Only the HTML exporter
|
|
509
|
+
# used to merge shards, so the same chat exported two different histories
|
|
510
|
+
# depending on the format asked for.
|
|
511
|
+
|
|
512
|
+
# 分片发现、密钥派生、列探测都在 nt_common 里——**只此一份**。
|
|
513
|
+
# 原先 `nt_decrypt` 与 `export_chat_html` 各有一份,而契约并不相同。
|
|
514
|
+
#
|
|
515
|
+
# 这一行不能省:本文件被 `spec_from_file_location` 加载时(测试就是这么加的),
|
|
516
|
+
# 解释器**不会**把脚本目录放进 sys.path——只有直接运行才会。少了它,单独跑
|
|
517
|
+
# `nt_decrypt_shards_test.py` 会以 ModuleNotFoundError 失败,而全套一起跑却能过
|
|
518
|
+
# (别的测试文件先插了路径)。那种"碰巧能过"正是要避免的。
|
|
519
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
520
|
+
from nt_common import (discover_message_shards, derive_database_key,
|
|
521
|
+
table_columns, MESSAGE_ANCHOR_COLUMNS)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def verify_passphrase_native(passphrase_hex, db_path, internal_key_hex=''):
|
|
525
|
+
"""Check a passphrase against a database without sqlcipher3.
|
|
526
|
+
|
|
527
|
+
Verifies the SQLCipher page-1 HMAC directly with stdlib primitives:
|
|
528
|
+
|
|
529
|
+
mac_salt = header_salt XOR 0x3a
|
|
530
|
+
key = PBKDF2-HMAC-SHA512(passphrase, header_salt, 256000, 32)
|
|
531
|
+
mac_key = PBKDF2-HMAC-SHA512(key, mac_salt, 2, 32)
|
|
532
|
+
compare HMAC-SHA512(mac_key, page1_body || page_number=1) with the
|
|
533
|
+
digest stored at the end of page 1
|
|
534
|
+
|
|
535
|
+
Why this exists: the normal way to check a key is to open the database,
|
|
536
|
+
which needs the native library. When sqlcipher3 is missing or misbehaving,
|
|
537
|
+
that route says nothing about whether the key itself is right - and the
|
|
538
|
+
two failures look identical. This separates them.
|
|
539
|
+
|
|
540
|
+
Returns True (correct), False (wrong), or None (cannot tell - bad input,
|
|
541
|
+
file too small, or a passphrase that is not hex).
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
raw_passphrase = bytes.fromhex(passphrase_hex)
|
|
545
|
+
except (TypeError, ValueError):
|
|
546
|
+
return None
|
|
547
|
+
if internal_key_hex:
|
|
548
|
+
# Some WeChat builds XOR the passphrase with a key embedded in
|
|
549
|
+
# Weixin.dll before deriving. Optional: the installs seen so far do
|
|
550
|
+
# not need it, so an empty value must stay the default.
|
|
551
|
+
try:
|
|
552
|
+
internal = bytes.fromhex(internal_key_hex)
|
|
553
|
+
except (TypeError, ValueError):
|
|
554
|
+
return None
|
|
555
|
+
if len(internal) == len(raw_passphrase):
|
|
556
|
+
raw_passphrase = bytes(a ^ b for a, b in zip(raw_passphrase, internal))
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
with open(db_path, 'rb') as handle:
|
|
560
|
+
page = handle.read(4096)
|
|
561
|
+
except OSError:
|
|
562
|
+
return None
|
|
563
|
+
if len(page) < 4096 or page[:16] == b'\x00' * 16:
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
salt = page[:16]
|
|
567
|
+
mac_salt = bytes(byte ^ 0x3a for byte in salt)
|
|
568
|
+
key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32)
|
|
569
|
+
mac_key = hashlib.pbkdf2_hmac('sha512', key, mac_salt, 2, 32)
|
|
570
|
+
|
|
571
|
+
reserve = 16 + 64 # IV + HMAC-SHA512
|
|
572
|
+
reserve = ((reserve + 15) // 16) * 16 # rounded up to the AES block size
|
|
573
|
+
body_end = 4096 - reserve + 16
|
|
574
|
+
mac = hmac.new(mac_key, page[16:body_end], hashlib.sha512)
|
|
575
|
+
mac.update(struct.pack('<I', 1)) # page number
|
|
576
|
+
return hmac.compare_digest(mac.digest(), page[body_end:body_end + 64])
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
|
|
580
|
+
"""Open every shard and report what happened to each one.
|
|
581
|
+
|
|
582
|
+
Returns (pairs, failures):
|
|
583
|
+
pairs [(shard_path, conn)] for the shards that opened
|
|
584
|
+
failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
|
|
585
|
+
|
|
586
|
+
A shard that fails is still skipped rather than fatal - a partially
|
|
587
|
+
readable transcript beats a command that refuses to run at all - but the
|
|
588
|
+
failure stops being invisible. `shardsFailed` is the difference between
|
|
589
|
+
"read everything" and "read what happened to be reachable", which is the
|
|
590
|
+
whole point of the coverage report.
|
|
591
|
+
|
|
592
|
+
Only the basename is reported: the caller stores this in a state file, and
|
|
593
|
+
absolute paths must not end up there.
|
|
594
|
+
"""
|
|
595
|
+
pairs = []
|
|
596
|
+
failures = []
|
|
597
|
+
for shard in discover_message_shards(db_path):
|
|
598
|
+
derived_key, derived_salt = derive_database_key(
|
|
599
|
+
shard, key_hex, salt_hex, passphrase)
|
|
600
|
+
# Try the derived key first; fall back to the configured pair so
|
|
601
|
+
# installs without a passphrase keep working exactly as before.
|
|
602
|
+
candidates = [(derived_key, derived_salt)]
|
|
603
|
+
if (derived_key, derived_salt) != (key_hex, salt_hex):
|
|
604
|
+
candidates.append((key_hex, salt_hex))
|
|
605
|
+
failure_reason = 'OPEN_FAILED'
|
|
606
|
+
for candidate_key, candidate_salt in candidates:
|
|
607
|
+
conn = None
|
|
608
|
+
try:
|
|
609
|
+
conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
|
|
610
|
+
# PRAGMA key alone never fails; only a read surfaces a bad key.
|
|
611
|
+
conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
|
|
612
|
+
except Exception:
|
|
613
|
+
# Reached the database but could not decrypt it: the key is
|
|
614
|
+
# wrong, not the file. Close before retrying, or the handle
|
|
615
|
+
# leaks and on Windows the file stays locked.
|
|
616
|
+
if conn is not None:
|
|
617
|
+
try:
|
|
618
|
+
conn.close()
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
failure_reason = 'KEY_REJECTED'
|
|
622
|
+
continue
|
|
623
|
+
pairs.append((shard, conn))
|
|
624
|
+
break
|
|
625
|
+
else:
|
|
626
|
+
failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
|
|
627
|
+
return pairs, failures
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
|
|
631
|
+
"""Open the configured database plus every sibling shard.
|
|
632
|
+
|
|
633
|
+
Returns the connections that actually opened. A shard that fails is
|
|
634
|
+
skipped rather than fatal: a partially readable transcript beats a
|
|
635
|
+
command that refuses to run at all.
|
|
636
|
+
|
|
637
|
+
Behaviour-preserving delegate: callers that do not ask for the failure
|
|
638
|
+
detail get exactly what they got before.
|
|
639
|
+
"""
|
|
640
|
+
pairs, _failures = connect_message_shards_detailed(
|
|
641
|
+
db_path, key_hex, salt_hex, passphrase)
|
|
642
|
+
return [conn for _path, conn in pairs]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def count_talker_rows(conn, msg_table):
|
|
646
|
+
"""Rows this conversation has in one shard, or None if it has no table there.
|
|
647
|
+
|
|
648
|
+
Distinguishes "this shard holds nothing for the conversation" from "this
|
|
649
|
+
shard could not be read" - without it both look like zero.
|
|
650
|
+
"""
|
|
651
|
+
try:
|
|
652
|
+
cur = conn.execute(
|
|
653
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
|
|
654
|
+
if cur.fetchone()[0] == 0:
|
|
655
|
+
return None
|
|
656
|
+
return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
|
|
657
|
+
except Exception:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def msg_tables(conn):
|
|
662
|
+
"""Names of the per-conversation Msg_ tables in one shard."""
|
|
663
|
+
try:
|
|
664
|
+
rows = conn.execute(
|
|
665
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
|
|
666
|
+
).fetchall()
|
|
667
|
+
except Exception:
|
|
668
|
+
return set()
|
|
669
|
+
return {row[0] for row in rows}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def get_fav_schema(db_path, key_hex):
|
|
673
|
+
"""Dump favorite.db schema + sample rows (key verification / exploration)."""
|
|
674
|
+
try:
|
|
675
|
+
with open(db_path, 'rb') as fh:
|
|
676
|
+
salt = fh.read(16).hex()
|
|
677
|
+
conn = require_sqlcipher().connect(db_path)
|
|
678
|
+
conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
|
|
679
|
+
tables = conn.execute(
|
|
680
|
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
681
|
+
).fetchall()
|
|
682
|
+
result = {"success": True, "tables": [t[0] for t in tables]}
|
|
683
|
+
if not tables:
|
|
684
|
+
result["error"] = "数据库为空或密钥错误"
|
|
685
|
+
result["success"] = False
|
|
686
|
+
conn.close()
|
|
687
|
+
return result
|
|
688
|
+
except Exception as e:
|
|
689
|
+
return {"success": False, "error": str(e).split('\n')[0][:200]}
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
FAV_TYPE_NAMES = {
|
|
693
|
+
1: 'text', # 文字
|
|
694
|
+
2: 'image', # 图片
|
|
695
|
+
4: 'video', # 视频
|
|
696
|
+
5: 'article', # 公众号文章/网页链接
|
|
697
|
+
14: 'chatrecord', # 聊天记录
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def parse_fav_content(content):
|
|
702
|
+
"""Extract display fields (title/link/desc/source/cover) from favitem XML."""
|
|
703
|
+
out = {}
|
|
704
|
+
if not content:
|
|
705
|
+
return out
|
|
706
|
+
import xml.etree.ElementTree as ET
|
|
707
|
+
try:
|
|
708
|
+
root = ET.fromstring(content)
|
|
709
|
+
except ET.ParseError:
|
|
710
|
+
return out
|
|
711
|
+
|
|
712
|
+
def text(path):
|
|
713
|
+
el = root.find(path)
|
|
714
|
+
if el is not None and el.text and el.text.strip():
|
|
715
|
+
return el.text.strip()
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
title = (text('weburlitem/pagetitle')
|
|
719
|
+
or text('datalist/dataitem/datatitle')
|
|
720
|
+
or text('title')
|
|
721
|
+
or text('desc'))
|
|
722
|
+
if title:
|
|
723
|
+
out['title'] = title
|
|
724
|
+
link = (text('weburlitem/clean_url')
|
|
725
|
+
or text('source/link')
|
|
726
|
+
or text('datalist/dataitem/stream_weburl'))
|
|
727
|
+
if link:
|
|
728
|
+
out['link'] = link
|
|
729
|
+
desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
|
|
730
|
+
if desc and desc != out.get('title'):
|
|
731
|
+
out['desc'] = desc
|
|
732
|
+
src = text('weburlitem/appmsgshareitem/srcdisplayname')
|
|
733
|
+
if src:
|
|
734
|
+
out['source_name'] = src
|
|
735
|
+
cover = (text('weburlitem/pagethumb_url')
|
|
736
|
+
or text('datalist/dataitem/dataext')
|
|
737
|
+
or text('datalist/dataitem/cdn_thumburl'))
|
|
738
|
+
if cover:
|
|
739
|
+
out['cover'] = cover
|
|
740
|
+
fmt = text('datalist/dataitem/datafmt')
|
|
741
|
+
if fmt:
|
|
742
|
+
out['format'] = fmt
|
|
743
|
+
return out
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
|
|
747
|
+
"""List favorite items from favorite.db with parsed content."""
|
|
748
|
+
try:
|
|
749
|
+
with open(db_path, 'rb') as fh:
|
|
750
|
+
salt = fh.read(16).hex()
|
|
751
|
+
conn = require_sqlcipher().connect(db_path)
|
|
752
|
+
conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
|
|
753
|
+
c = conn.cursor()
|
|
754
|
+
|
|
755
|
+
tables = [t[0] for t in c.execute(
|
|
756
|
+
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
|
|
757
|
+
if 'fav_db_item' not in tables:
|
|
758
|
+
conn.close()
|
|
759
|
+
return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
|
|
760
|
+
|
|
761
|
+
total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
|
|
762
|
+
|
|
763
|
+
sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
|
|
764
|
+
'FROM fav_db_item WHERE 1=1')
|
|
765
|
+
params = []
|
|
766
|
+
if fav_type is not None:
|
|
767
|
+
sql += ' AND type = ?'
|
|
768
|
+
params.append(fav_type)
|
|
769
|
+
if keyword:
|
|
770
|
+
sql += ' AND content LIKE ?'
|
|
771
|
+
params.append(f'%{keyword}%')
|
|
772
|
+
sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
|
|
773
|
+
params.extend([limit, offset])
|
|
774
|
+
rows = c.execute(sql, params).fetchall()
|
|
775
|
+
conn.close()
|
|
776
|
+
|
|
777
|
+
items = []
|
|
778
|
+
for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
|
|
779
|
+
ct = content if isinstance(content, str) else (
|
|
780
|
+
content.decode('utf-8', errors='replace') if content else '')
|
|
781
|
+
item = {
|
|
782
|
+
'local_id': local_id,
|
|
783
|
+
'server_id': server_id,
|
|
784
|
+
'type': ftype,
|
|
785
|
+
'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
|
|
786
|
+
'update_time': update_time,
|
|
787
|
+
'from_user': fromusr,
|
|
788
|
+
'chat_name': realchatname or None,
|
|
789
|
+
}
|
|
790
|
+
item.update(parse_fav_content(ct))
|
|
791
|
+
items.append(item)
|
|
792
|
+
return {"favorites": items, "total": total, "count": len(items),
|
|
793
|
+
"limit": limit, "offset": offset}
|
|
794
|
+
except Exception as e:
|
|
795
|
+
return {"error": str(e).split('\n')[0][:200]}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _summarise(row):
|
|
799
|
+
"""(last_time, summary) from the newest message row of a conversation."""
|
|
800
|
+
last_time = row[0] or 0
|
|
801
|
+
msg_type = row[3] or 0
|
|
802
|
+
source_text = row[1]
|
|
803
|
+
content_text = row[2]
|
|
804
|
+
|
|
805
|
+
if msg_type == 1:
|
|
806
|
+
# Text message: use message_content
|
|
807
|
+
if isinstance(content_text, str) and content_text:
|
|
808
|
+
return last_time, content_text[:50]
|
|
809
|
+
if isinstance(content_text, bytes):
|
|
810
|
+
return last_time, content_text.decode('utf-8', errors='ignore')[:50]
|
|
811
|
+
return last_time, ""
|
|
812
|
+
|
|
813
|
+
if isinstance(source_text, str) and source_text:
|
|
814
|
+
# Non-text: try to extract from source, stripping XML tags
|
|
815
|
+
clean = re.sub(r'<[^>]+>', '', source_text)
|
|
816
|
+
lines = clean.split('\n')
|
|
817
|
+
if len(lines) > 1 and lines[1].strip():
|
|
818
|
+
return last_time, lines[1].strip()[:50]
|
|
819
|
+
if clean.strip():
|
|
820
|
+
return last_time, clean.strip()[:50]
|
|
821
|
+
return last_time, ""
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def get_sessions(conns):
|
|
825
|
+
"""Get chat sessions, newest message per conversation across every shard."""
|
|
826
|
+
sessions = {}
|
|
827
|
+
|
|
828
|
+
# NT format: each chat has its own Msg_<MD5> table
|
|
829
|
+
# The Name2Id table maps usernames to IDs (user_name, is_session)
|
|
830
|
+
for conn in conns:
|
|
831
|
+
c = conn.cursor()
|
|
832
|
+
try:
|
|
833
|
+
c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
|
|
834
|
+
usernames = [row[0] for row in c.fetchall() if row[0]]
|
|
835
|
+
except Exception:
|
|
836
|
+
continue
|
|
837
|
+
|
|
838
|
+
tables = msg_tables(conn)
|
|
839
|
+
for username in usernames:
|
|
840
|
+
entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
|
|
841
|
+
msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
|
|
842
|
+
if msg_table not in tables:
|
|
843
|
+
continue
|
|
844
|
+
try:
|
|
845
|
+
c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
|
|
846
|
+
row = c.fetchone()
|
|
847
|
+
if row:
|
|
848
|
+
last_time, summary = _summarise(row)
|
|
849
|
+
# A conversation spans shards; only the newest speaks for it.
|
|
850
|
+
if last_time >= entry["last_time"]:
|
|
851
|
+
entry["last_time"] = last_time
|
|
852
|
+
entry["summary"] = summary
|
|
853
|
+
except Exception:
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
result = [{
|
|
857
|
+
"username": username,
|
|
858
|
+
"type": 1 if "@chatroom" in username else 0,
|
|
859
|
+
"unreadCount": 0,
|
|
860
|
+
"summary": entry["summary"],
|
|
861
|
+
"sortTimestamp": entry["last_time"],
|
|
862
|
+
"lastTimestamp": entry["last_time"],
|
|
863
|
+
"displayName": username,
|
|
864
|
+
} for username, entry in sessions.items()]
|
|
865
|
+
|
|
866
|
+
# Sort by timestamp descending
|
|
867
|
+
result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
|
|
868
|
+
return {"sessions": result}
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _strip_group_speaker(content, known_ids):
|
|
872
|
+
"""Drop the `wxid_...:` prefix group rows carry in their content.
|
|
873
|
+
|
|
874
|
+
Only an id the shard's Name2Id actually knows is accepted, so a message
|
|
875
|
+
that merely starts with `note: ...` is left alone.
|
|
876
|
+
"""
|
|
877
|
+
match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
|
|
878
|
+
if not match or match.group(1) not in known_ids:
|
|
879
|
+
return content
|
|
880
|
+
return content[match.end():]
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# The columns the reader actually consumes. The SELECT is built from these
|
|
884
|
+
# only: a WeChat version that renames or drops any *other* column of the
|
|
885
|
+
# message table cannot break a read of a column nobody looks at.
|
|
886
|
+
MESSAGE_COLUMNS = ('local_id', 'server_id', 'local_type', 'real_sender_id',
|
|
887
|
+
'create_time', 'message_content')
|
|
888
|
+
|
|
889
|
+
# Any one of these is enough to give rows an identity and an order. With none
|
|
890
|
+
# of them a row cannot become a message at all, and that is a schema mismatch
|
|
891
|
+
# rather than an empty conversation.
|
|
892
|
+
# 定义搬去了 `nt_common`:`ORDER BY` 那处(同一个文件下面)过去自己又写了一份
|
|
893
|
+
# 字面量,导出器再写一份——顺序在这里就是行为(先按 create_time 排),
|
|
894
|
+
# 三份各自为政等于谁改一处谁静默换一种排法。
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# ---- 非文本消息的显示形态 ---------------------------------------------------
|
|
898
|
+
#
|
|
899
|
+
# 这条路径此前**把非文本消息丢掉了**:`parsedContent` 只在 local_type == 1 时才有值,
|
|
900
|
+
# 而 BLOB 内容不是 str,会在下面被置成空串。于是图片/表情/文件/引用/撤回/红包在下游
|
|
901
|
+
# 一律表现为"空内容"——实测那 44 条里"空"的三条,分别是**一条撤回提示**和两个表情。
|
|
902
|
+
#
|
|
903
|
+
# 类型编码有结构:`local_type = apptype * 2**32 + 49`,49 表示 appmsg 家族。所以这里
|
|
904
|
+
# 是**推导**而不是一张长表;只有少数几个固定类型需要单独列。
|
|
905
|
+
APPMSG_SUBTYPE = 49
|
|
906
|
+
NON_TEXT_LABELS = {
|
|
907
|
+
3: '图片', 34: '语音', 42: '名片', 43: '视频', 47: '表情', 48: '位置',
|
|
908
|
+
}
|
|
909
|
+
APPMSG_LABELS = {
|
|
910
|
+
4: '链接', 5: '链接', 6: '文件', 19: '聊天记录', 33: '小程序', 57: '引用', 63: '直播',
|
|
911
|
+
2000: '转账', 2001: '红包',
|
|
912
|
+
}
|
|
913
|
+
# 不认识的 apptype 回 `[应用消息]` 而不是猜成"链接":标签是给下游读的,不能支撑不起也写。
|
|
914
|
+
APPMSG_UNKNOWN_LABEL = '应用消息'
|
|
915
|
+
|
|
916
|
+
# 引用消息(apptype 57)的正文与被引原文之间的分隔。用词而不是符号,因为下游会把它读成
|
|
917
|
+
# 一句话;`|` 在别处会被当表格分隔。
|
|
918
|
+
QUOTE_SEP = ' | 引:'
|
|
919
|
+
# 被引原文的截断长度。实测 37 条真实引用消息:中位 36 字、17 条超过 60 字、最长的
|
|
920
|
+
# 12733 字(引用了一整篇公众号文章)。60 会切掉近一半,120 能留住大多数完整句子,
|
|
921
|
+
# 又把那个极端值压住。
|
|
922
|
+
QUOTE_CLIP = 120
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
ZSTD_MAGIC = bytes([0x28, 0xB5, 0x2F, 0xFD])
|
|
926
|
+
def _decode_content(raw):
|
|
927
|
+
"""消息内容 → 文本。BLOB 先按 zstd 解压(公众号消息同一套机制)。解不出回空串。"""
|
|
928
|
+
if isinstance(raw, str):
|
|
929
|
+
return raw
|
|
930
|
+
if not raw:
|
|
931
|
+
return ''
|
|
932
|
+
data = bytes(raw)
|
|
933
|
+
if data[:4] == ZSTD_MAGIC: # zstd frame header
|
|
934
|
+
try:
|
|
935
|
+
import zstandard
|
|
936
|
+
return zstandard.ZstdDecompressor().decompress(data).decode('utf-8', 'ignore')
|
|
937
|
+
except Exception:
|
|
938
|
+
return ''
|
|
939
|
+
return data.decode('utf-8', 'ignore')
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _xml_block(xml, tag):
|
|
943
|
+
"""取一个元素的起始标签到它自己的结束标签之间的整段(含标签)。
|
|
944
|
+
|
|
945
|
+
只在区块内找 `title`/`des`:整份 payload 里还有 `<emotionpageshared><title>` 这类
|
|
946
|
+
同名标签,在全文里搜会匹配到那个去(自验时真的把一段 XML 当文本吐出来过)。
|
|
947
|
+
"""
|
|
948
|
+
text = xml or ''
|
|
949
|
+
match = re.search(r'<%s(?:\s[^>]*)?>.*?</%s>' % (tag, tag), text, re.S)
|
|
950
|
+
return match.group(0) if match else ''
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _xml_text(xml, tag):
|
|
954
|
+
"""取一个标签的文本(含 CDATA)。取不到回空串——`<title />` 这种自闭合就是取不到。
|
|
955
|
+
|
|
956
|
+
起始标签里**不许出现 `/`**:否则 `<title />` 会被当成开标签,一路吃到后面某个
|
|
957
|
+
`</title>`,把中间整段 XML 当成文本返回(自验时真的吐出来过)。
|
|
958
|
+
|
|
959
|
+
实体在这**解掉**:显示形态是给人(和模型)读的,`a&b` 该显示成 `a&b`。
|
|
960
|
+
TS 侧的 `appMsgFormat.tagText` 同样解,两边一致——否则同一条消息 3.x 与 4.x 读起来
|
|
961
|
+
不一样。
|
|
962
|
+
"""
|
|
963
|
+
match = re.search(
|
|
964
|
+
r'<%s(?:\s[^>/]*)?>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?</%s>' % (tag, tag), xml or '', re.S)
|
|
965
|
+
return html.unescape(match.group(1).strip()) if match else ''
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _clip(text, limit):
|
|
969
|
+
"""超长截断并**留下省略号**。
|
|
970
|
+
|
|
971
|
+
不标记的截断读起来像一句说完的话——`title[:60]` 此前就是这样,被引原文最长的
|
|
972
|
+
12733 字,切完看着像"这就是全部"。
|
|
973
|
+
"""
|
|
974
|
+
if len(text) <= limit:
|
|
975
|
+
return text
|
|
976
|
+
return text[:limit] + '…'
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def non_text_display(local_type, raw):
|
|
980
|
+
"""非文本消息 → 给下游看的文本。**绝不返回空串**——这是这次修复的全部要点。
|
|
981
|
+
|
|
982
|
+
宁可写 `[未识别的消息类型 81604378673]`,也不要让下游看到空字符串:空串在下游与
|
|
983
|
+
"这条消息不存在"无法区分,而它的真实后果是助手回答"我没解析出内容"。
|
|
984
|
+
"""
|
|
985
|
+
lt = int(local_type or 0)
|
|
986
|
+
text = _decode_content(raw)
|
|
987
|
+
|
|
988
|
+
if lt & 0xFFFFFFFF == APPMSG_SUBTYPE:
|
|
989
|
+
apptype = lt >> 32
|
|
990
|
+
label = APPMSG_LABELS.get(apptype, APPMSG_UNKNOWN_LABEL)
|
|
991
|
+
block = _xml_block(text, 'appmsg')
|
|
992
|
+
title = _xml_text(block, 'title') or _xml_text(block, 'des')
|
|
993
|
+
# 实测 payload 里 `<emotionpageshared>` 这类**嵌套容器**也带 `<title>`,取到的是
|
|
994
|
+
# 它们自己的占位值(`null`)。这种不是标题,按"没有标题"处理。
|
|
995
|
+
if title.lower() in ('null', 'undefined', '0'):
|
|
996
|
+
title = ''
|
|
997
|
+
# 截断:解析万一走偏,也不许把一段 XML 当成标题交给下游。
|
|
998
|
+
title = _clip(title, 60)
|
|
999
|
+
# 引用消息(apptype 57):`title` 是**回复正文**,被引用的原文在 `refermsg/content`。
|
|
1000
|
+
# 实测 37 条真实引用消息,被引原文中位 36 字。此前只留回复,模型看到的是一句
|
|
1001
|
+
# "是呀,够得意个"却不知道在回什么——引用不带原文等于没引用。
|
|
1002
|
+
if apptype == 57:
|
|
1003
|
+
quoted = _xml_text(_xml_block(text, 'refermsg'), 'content')
|
|
1004
|
+
if quoted.lower() in ('null', 'undefined', '0'):
|
|
1005
|
+
quoted = ''
|
|
1006
|
+
quoted = _clip(quoted, QUOTE_CLIP)
|
|
1007
|
+
if quoted:
|
|
1008
|
+
body = (title + QUOTE_SEP + quoted) if title else quoted
|
|
1009
|
+
return '[%s] %s' % (label, body)
|
|
1010
|
+
return '[%s] %s' % (label, title) if title else '[%s]' % label
|
|
1011
|
+
|
|
1012
|
+
label = NON_TEXT_LABELS.get(lt)
|
|
1013
|
+
if label:
|
|
1014
|
+
return '[%s]' % label
|
|
1015
|
+
if lt == 10000: # 系统消息:撤回、拍一拍、入群提示
|
|
1016
|
+
return _xml_text(text, 'content') or '[系统消息]'
|
|
1017
|
+
return '[未识别的消息类型 %d]' % lt
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
|
|
1021
|
+
"""One message row -> the CLI's message shape.
|
|
1022
|
+
|
|
1023
|
+
`row` is a column-name keyed dict, not a tuple. Positional access assumes
|
|
1024
|
+
every column exists; when a WeChat version drops one, the whole shard read
|
|
1025
|
+
fails and the conversation looks empty. Reading by name means a missing
|
|
1026
|
+
column costs that one field instead.
|
|
1027
|
+
"""
|
|
1028
|
+
local_type = row.get('local_type') or 0
|
|
1029
|
+
create_time = row.get('create_time') or 0
|
|
1030
|
+
real_sender_id = row.get('real_sender_id') or 0
|
|
1031
|
+
|
|
1032
|
+
# Resolve sender: real_sender_id -> Name2Id -> user_name
|
|
1033
|
+
sender_username = sender_id_map.get(real_sender_id, "")
|
|
1034
|
+
|
|
1035
|
+
# Determine if message is from self
|
|
1036
|
+
# own_wxid may have _xxxx suffix (from xwechat_files dir), try both
|
|
1037
|
+
is_self = bool(own_wxid and (
|
|
1038
|
+
sender_username == own_wxid or
|
|
1039
|
+
(own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
|
|
1040
|
+
))
|
|
1041
|
+
if not is_self and own_wxid:
|
|
1042
|
+
# Strip _xxxx suffix and retry
|
|
1043
|
+
parts = own_wxid.rsplit('_', 1)
|
|
1044
|
+
if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
|
|
1045
|
+
is_self = (sender_username == parts[0])
|
|
1046
|
+
|
|
1047
|
+
# Resolve sender display name from contact map
|
|
1048
|
+
if is_self:
|
|
1049
|
+
sender_display = "" # Let the CLI show "我"
|
|
1050
|
+
else:
|
|
1051
|
+
sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
|
|
1052
|
+
|
|
1053
|
+
# Parse message_content - TEXT column
|
|
1054
|
+
raw_content = row.get('message_content')
|
|
1055
|
+
content = raw_content if isinstance(raw_content, str) else ""
|
|
1056
|
+
|
|
1057
|
+
# `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
|
|
1058
|
+
# field every consumer reads first - gets the display-ready form.
|
|
1059
|
+
#
|
|
1060
|
+
# 非文本消息(图片/表情/文件/引用/撤回/红包…)在过去得到的是**空串**:它们的内容是
|
|
1061
|
+
# JSON 里不是 str 的 BLOB,上面那行会把它置空。现在它们走 `non_text_display`,
|
|
1062
|
+
# 至少拿到 `[图片]`,多数还能带上文件名、被引用的原文或"谁撤回了一条消息"。
|
|
1063
|
+
if content:
|
|
1064
|
+
display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
|
|
1065
|
+
else:
|
|
1066
|
+
display = non_text_display(local_type, raw_content)
|
|
1067
|
+
|
|
1068
|
+
return {
|
|
1069
|
+
"localId": row.get('local_id') or 0,
|
|
1070
|
+
"serverId": str(row.get('server_id') or ''),
|
|
1071
|
+
"localType": local_type,
|
|
1072
|
+
"createTime": create_time,
|
|
1073
|
+
"isSend": 1 if is_self else 0, # 1 = I sent this
|
|
1074
|
+
"senderUsername": sender_username,
|
|
1075
|
+
"senderDisplay": sender_display,
|
|
1076
|
+
"content": content,
|
|
1077
|
+
"rawContent": content,
|
|
1078
|
+
"parsedContent": display[:200] if local_type == 1 else display[:200],
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
|
|
1083
|
+
shard_names=None, shard_report=None, from_time=None, to_time=None):
|
|
1084
|
+
"""Get messages for a specific talker, merged across every shard.
|
|
1085
|
+
|
|
1086
|
+
Args:
|
|
1087
|
+
name_map: optional {wxid: display_name} dict for resolving sender names
|
|
1088
|
+
own_wxid: account owner wxid for self-message detection
|
|
1089
|
+
shard_names: optional basename per connection, parallel to `conns`
|
|
1090
|
+
shard_report: optional list to append one outcome dict per shard to.
|
|
1091
|
+
Purely additive: the returned messages are identical with or
|
|
1092
|
+
without it, which is asserted by test/nt_decrypt_shards_test.py.
|
|
1093
|
+
from_time: optional inclusive lower bound on `create_time` (unix
|
|
1094
|
+
seconds), pushed into SQL so a bounded read does not have to fetch
|
|
1095
|
+
and discard the whole conversation.
|
|
1096
|
+
to_time: optional inclusive upper bound, same units.
|
|
1097
|
+
|
|
1098
|
+
A requested window is only ever honoured or refused, never approximated: a
|
|
1099
|
+
shard whose table has no `create_time` is skipped and reported as
|
|
1100
|
+
WINDOW_UNAVAILABLE rather than returning out-of-range rows that a caller
|
|
1101
|
+
tracking coverage would count as covered. Every in-tree caller that passes
|
|
1102
|
+
a window also passes `shard_report`, so that refusal is always visible.
|
|
1103
|
+
"""
|
|
1104
|
+
if name_map is None:
|
|
1105
|
+
name_map = {}
|
|
1106
|
+
|
|
1107
|
+
window_requested = from_time is not None or to_time is not None
|
|
1108
|
+
|
|
1109
|
+
msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
|
|
1110
|
+
is_group = '@chatroom' in talker
|
|
1111
|
+
|
|
1112
|
+
def record(index, opened, has_table, rows_for_talker, reason, missing=None):
|
|
1113
|
+
if shard_report is None:
|
|
1114
|
+
return
|
|
1115
|
+
name = shard_names[index] if shard_names and index < len(shard_names) else ''
|
|
1116
|
+
shard_report.append({
|
|
1117
|
+
'name': name,
|
|
1118
|
+
'opened': opened,
|
|
1119
|
+
'hasTalkerTable': has_table,
|
|
1120
|
+
'rowsForTalker': rows_for_talker,
|
|
1121
|
+
'reason': reason,
|
|
1122
|
+
# Columns this shard does not have. A non-empty list together with
|
|
1123
|
+
# a null `reason` means the read succeeded but lost fields, which is
|
|
1124
|
+
# a different thing from a failed read and has to stay visible.
|
|
1125
|
+
'missingColumns': list(missing or ()),
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
# Each shard only needs to yield its newest window: once every shard's rows
|
|
1129
|
+
# are merged and re-sorted, nothing older than that can reach this page.
|
|
1130
|
+
window = 0 if limit <= 0 else limit + offset
|
|
1131
|
+
collected = []
|
|
1132
|
+
found = False
|
|
1133
|
+
|
|
1134
|
+
for index, conn in enumerate(conns):
|
|
1135
|
+
c = conn.cursor()
|
|
1136
|
+
try:
|
|
1137
|
+
c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
|
|
1138
|
+
if c.fetchone()[0] == 0:
|
|
1139
|
+
# The shard is readable and simply holds nothing for this
|
|
1140
|
+
# conversation - distinct from not being readable at all.
|
|
1141
|
+
record(index, True, False, None, None)
|
|
1142
|
+
continue
|
|
1143
|
+
found = True
|
|
1144
|
+
rows_for_talker = c.execute(
|
|
1145
|
+
'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
|
|
1146
|
+
|
|
1147
|
+
available = table_columns(c, msg_table)
|
|
1148
|
+
selected = [col for col in MESSAGE_COLUMNS if col in available]
|
|
1149
|
+
missing = [col for col in MESSAGE_COLUMNS if col not in available]
|
|
1150
|
+
if not [col for col in MESSAGE_ANCHOR_COLUMNS if col in available]:
|
|
1151
|
+
# The row shape no longer matches anything the reader can name.
|
|
1152
|
+
record(index, True, True, rows_for_talker, 'SCHEMA_MISMATCH',
|
|
1153
|
+
missing=missing)
|
|
1154
|
+
continue
|
|
1155
|
+
if window_requested and 'create_time' not in available:
|
|
1156
|
+
record(index, True, True, rows_for_talker, 'WINDOW_UNAVAILABLE',
|
|
1157
|
+
missing=missing)
|
|
1158
|
+
continue
|
|
1159
|
+
|
|
1160
|
+
order = [col for col in MESSAGE_ANCHOR_COLUMNS
|
|
1161
|
+
if col in available]
|
|
1162
|
+
sql = 'SELECT %s FROM "%s"' % (
|
|
1163
|
+
', '.join('"%s"' % col for col in selected), msg_table)
|
|
1164
|
+
params = []
|
|
1165
|
+
clauses = []
|
|
1166
|
+
if from_time is not None:
|
|
1167
|
+
clauses.append('"create_time" >= ?')
|
|
1168
|
+
params.append(from_time)
|
|
1169
|
+
if to_time is not None:
|
|
1170
|
+
clauses.append('"create_time" <= ?')
|
|
1171
|
+
params.append(to_time)
|
|
1172
|
+
if clauses:
|
|
1173
|
+
sql += ' WHERE ' + ' AND '.join(clauses)
|
|
1174
|
+
sql += ' ORDER BY ' + ', '.join('"%s" DESC' % col for col in order)
|
|
1175
|
+
if window:
|
|
1176
|
+
sql += ' LIMIT ?'
|
|
1177
|
+
params.append(window)
|
|
1178
|
+
c.execute(sql, params)
|
|
1179
|
+
# Dicts rather than driver rows: the SELECT list varies per shard,
|
|
1180
|
+
# so position no longer identifies a column. Built here rather than
|
|
1181
|
+
# via row_factory, which every other reader sharing these
|
|
1182
|
+
# connections would inherit.
|
|
1183
|
+
rows = [dict(zip(selected, values)) for values in c.fetchall()]
|
|
1184
|
+
|
|
1185
|
+
# Sender ids are rowids, so the map has to come from the same
|
|
1186
|
+
# shard. Absent, it costs sender names and nothing else, so it is
|
|
1187
|
+
# probed rather than left to raise and lose the whole shard.
|
|
1188
|
+
has_name_table = c.execute(
|
|
1189
|
+
"SELECT COUNT(*) FROM sqlite_master WHERE name='Name2Id'").fetchone()[0] > 0
|
|
1190
|
+
sender_id_map = {}
|
|
1191
|
+
if has_name_table:
|
|
1192
|
+
c.execute("SELECT rowid, user_name FROM Name2Id")
|
|
1193
|
+
sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
|
|
1194
|
+
except Exception:
|
|
1195
|
+
# This shard had the conversation's table but could not be read.
|
|
1196
|
+
# Previously this was indistinguishable from "no rows here".
|
|
1197
|
+
record(index, True, None, None, 'READ_FAILED')
|
|
1198
|
+
continue
|
|
1199
|
+
record(index, True, True, rows_for_talker, None, missing=missing)
|
|
1200
|
+
|
|
1201
|
+
for row in rows:
|
|
1202
|
+
collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
|
|
1203
|
+
|
|
1204
|
+
if not found:
|
|
1205
|
+
return {"error": f"未找到会话: {talker}"}
|
|
1206
|
+
|
|
1207
|
+
collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
|
|
1208
|
+
if limit > 0:
|
|
1209
|
+
collected = collected[offset:offset + limit]
|
|
1210
|
+
return {"messages": collected}
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def get_contacts(conns, limit=200, keyword=None):
|
|
1214
|
+
"""Get contacts from NT database, merged across every shard.
|
|
1215
|
+
|
|
1216
|
+
**给了 `keyword` 就不加 `LIMIT`**,由调用方在名字解析之后过滤、再按 `limit` 截断。
|
|
1217
|
+
原来无条件 `LIMIT`,于是过滤发生在**截断之后**:关键字只能在前 `limit` 行里找,
|
|
1218
|
+
后面的人搜不到(实测 500 人的通讯录、按备注名找 10 个只命中 3 个)。名字解析要用
|
|
1219
|
+
contact.db 的备注/昵称,那是调用方才有的东西,所以截断必须挪到过滤之后。
|
|
1220
|
+
"""
|
|
1221
|
+
seen = set()
|
|
1222
|
+
contacts = []
|
|
1223
|
+
for conn in conns:
|
|
1224
|
+
try:
|
|
1225
|
+
sql = "SELECT user_name FROM Name2Id" if keyword else "SELECT user_name FROM Name2Id LIMIT ?"
|
|
1226
|
+
rows = conn.execute(sql, () if keyword else (limit,)).fetchall()
|
|
1227
|
+
except Exception:
|
|
1228
|
+
continue
|
|
1229
|
+
for (username,) in rows:
|
|
1230
|
+
# Name2Id carries a placeholder row with no user_name; rendering it
|
|
1231
|
+
# produced a blank line at the top of every contact list.
|
|
1232
|
+
if not username or username in seen:
|
|
1233
|
+
continue
|
|
1234
|
+
seen.add(username)
|
|
1235
|
+
contacts.append({"username": username, "displayName": username})
|
|
1236
|
+
return {"contacts": contacts}
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
# ========== SNS (朋友圈) Queries ==========
|
|
1240
|
+
|
|
1241
|
+
def parse_sns_content(content_str):
|
|
1242
|
+
"""Parse SNS content XML/Protobuf text to extract title, description, media etc."""
|
|
1243
|
+
result = {
|
|
1244
|
+
'content': '',
|
|
1245
|
+
'create_time': 0,
|
|
1246
|
+
'username': '',
|
|
1247
|
+
'object_id': '',
|
|
1248
|
+
'media_count': 0,
|
|
1249
|
+
}
|
|
1250
|
+
if not content_str:
|
|
1251
|
+
return result
|
|
1252
|
+
|
|
1253
|
+
import re
|
|
1254
|
+
|
|
1255
|
+
# Extract createTime
|
|
1256
|
+
m = re.search(r'<createTime>(\d+)</createTime>', content_str)
|
|
1257
|
+
if m:
|
|
1258
|
+
result['create_time'] = int(m.group(1))
|
|
1259
|
+
|
|
1260
|
+
# Extract username
|
|
1261
|
+
m = re.search(r'<username>([^<]+)</username>', content_str)
|
|
1262
|
+
if m:
|
|
1263
|
+
result['username'] = m.group(1)
|
|
1264
|
+
|
|
1265
|
+
# Extract id
|
|
1266
|
+
m = re.search(r'<id>(\d+)</id>', content_str)
|
|
1267
|
+
if m:
|
|
1268
|
+
result['object_id'] = m.group(1)
|
|
1269
|
+
|
|
1270
|
+
# Extract contentDesc (main text)
|
|
1271
|
+
m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
|
|
1272
|
+
if m:
|
|
1273
|
+
result['content'] = m.group(1)
|
|
1274
|
+
|
|
1275
|
+
# Extract contentDesc CDATA
|
|
1276
|
+
m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
|
|
1277
|
+
if m:
|
|
1278
|
+
result['content'] = m.group(1).strip()
|
|
1279
|
+
|
|
1280
|
+
# Extract title if present
|
|
1281
|
+
m = re.search(r'<title>([^<]*)</title>', content_str)
|
|
1282
|
+
if m:
|
|
1283
|
+
title = m.group(1)
|
|
1284
|
+
if title and not result['content']:
|
|
1285
|
+
result['content'] = title
|
|
1286
|
+
elif title:
|
|
1287
|
+
result['content'] = title + '\n' + result['content']
|
|
1288
|
+
|
|
1289
|
+
# Count media (ContentObject tags)
|
|
1290
|
+
result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
|
|
1291
|
+
|
|
1292
|
+
return result
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
|
|
1296
|
+
start_time=None, end_time=None):
|
|
1297
|
+
"""Query SNS timeline posts from SnsTimeLine table."""
|
|
1298
|
+
# Check if SnsTimeLine exists, fall back to SnsTopItem_1
|
|
1299
|
+
tables = [r[0] for r in cursor.execute(
|
|
1300
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
1301
|
+
|
|
1302
|
+
table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
|
|
1303
|
+
if not table:
|
|
1304
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
|
|
1305
|
+
if not table:
|
|
1306
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
1307
|
+
|
|
1308
|
+
conditions = []
|
|
1309
|
+
params = []
|
|
1310
|
+
|
|
1311
|
+
if table == 'SnsTimeLine':
|
|
1312
|
+
if usernames:
|
|
1313
|
+
placeholders = ','.join(['?' for _ in usernames])
|
|
1314
|
+
conditions.append(f'user_name IN ({placeholders})')
|
|
1315
|
+
params.extend(usernames)
|
|
1316
|
+
if keyword:
|
|
1317
|
+
conditions.append('content LIKE ?')
|
|
1318
|
+
params.append(f'%{keyword}%')
|
|
1319
|
+
if start_time:
|
|
1320
|
+
conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
|
|
1321
|
+
params.append(start_time)
|
|
1322
|
+
if end_time:
|
|
1323
|
+
conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
|
|
1324
|
+
params.append(end_time)
|
|
1325
|
+
elif table == 'SnsTopItem_1':
|
|
1326
|
+
uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
|
|
1327
|
+
if usernames:
|
|
1328
|
+
placeholders = ','.join(['?' for _ in usernames])
|
|
1329
|
+
conditions.append(f'{uname_col} IN ({placeholders})')
|
|
1330
|
+
params.extend(usernames)
|
|
1331
|
+
if keyword:
|
|
1332
|
+
conditions.append('summary LIKE ?')
|
|
1333
|
+
params.append(f'%{keyword}%')
|
|
1334
|
+
if start_time:
|
|
1335
|
+
conditions.append('create_time >= ?')
|
|
1336
|
+
params.append(start_time)
|
|
1337
|
+
if end_time:
|
|
1338
|
+
conditions.append('create_time <= ?')
|
|
1339
|
+
params.append(end_time)
|
|
1340
|
+
|
|
1341
|
+
where = ' AND '.join(conditions) if conditions else '1=1'
|
|
1342
|
+
order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
|
|
1343
|
+
|
|
1344
|
+
try:
|
|
1345
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
1346
|
+
# Only select known text columns to avoid binary decode errors
|
|
1347
|
+
text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
|
|
1348
|
+
'create_time', 'last_read_time', 'is_read',
|
|
1349
|
+
'from_username', 'from_nickname', 'to_username',
|
|
1350
|
+
'to_nickname', 'comment_id', 'feed_id',
|
|
1351
|
+
'createTime', 'userName')]
|
|
1352
|
+
if not text_cols:
|
|
1353
|
+
text_cols = ['*']
|
|
1354
|
+
select_str = ', '.join(text_cols)
|
|
1355
|
+
rows = cursor.execute(
|
|
1356
|
+
f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
|
|
1357
|
+
params + [limit, offset]
|
|
1358
|
+
).fetchall()
|
|
1359
|
+
|
|
1360
|
+
timeline = []
|
|
1361
|
+
|
|
1362
|
+
for row in rows:
|
|
1363
|
+
item = dict(zip(text_cols, row))
|
|
1364
|
+
|
|
1365
|
+
if table == 'SnsTimeLine':
|
|
1366
|
+
# Parse XML content
|
|
1367
|
+
content_str = item.get('content', '') or ''
|
|
1368
|
+
parsed = parse_sns_content(content_str)
|
|
1369
|
+
create_time = parsed['create_time'] or 0
|
|
1370
|
+
username = parsed['username'] or item.get('user_name', '')
|
|
1371
|
+
text = parsed['content'] or ''
|
|
1372
|
+
media_count = parsed['media_count']
|
|
1373
|
+
else:
|
|
1374
|
+
create_time = item.get('create_time', 0) or 0
|
|
1375
|
+
username = item.get('username', '') or item.get('user_name', '')
|
|
1376
|
+
text = item.get('summary', '') or ''
|
|
1377
|
+
media_count = 0
|
|
1378
|
+
|
|
1379
|
+
timeline.append({
|
|
1380
|
+
'create_time': create_time,
|
|
1381
|
+
'username': username,
|
|
1382
|
+
'content': text,
|
|
1383
|
+
'media_count': media_count,
|
|
1384
|
+
'table': table,
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
return {'success': True, 'timeline': timeline, 'table': table}
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
return {'success': False, 'error': str(e)}
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def get_sns_usernames(cursor):
|
|
1393
|
+
"""Get unique usernames from SnsTopItem_1."""
|
|
1394
|
+
tables = [r[0] for r in cursor.execute(
|
|
1395
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
1396
|
+
|
|
1397
|
+
# Prefer SnsTopItem_1 for user listing (larger)
|
|
1398
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
|
|
1399
|
+
if not table:
|
|
1400
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
1401
|
+
|
|
1402
|
+
# Discover username column
|
|
1403
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
1404
|
+
uname_col = 'username' if 'username' in cols else 'user_name'
|
|
1405
|
+
|
|
1406
|
+
try:
|
|
1407
|
+
rows = cursor.execute(
|
|
1408
|
+
f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
|
|
1409
|
+
).fetchall()
|
|
1410
|
+
usernames = [r[0] for r in rows if r[0]]
|
|
1411
|
+
counts = {r[0]: r[1] for r in rows if r[0]}
|
|
1412
|
+
return {'success': True, 'usernames': usernames, 'counts': counts}
|
|
1413
|
+
except Exception as e:
|
|
1414
|
+
return {'success': False, 'error': str(e)}
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def get_sns_stats(cursor, my_wxid=None):
|
|
1418
|
+
"""Get SNS statistics from SnsTopItem_1."""
|
|
1419
|
+
tables = [r[0] for r in cursor.execute(
|
|
1420
|
+
"SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
|
|
1421
|
+
|
|
1422
|
+
table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
|
|
1423
|
+
if not table:
|
|
1424
|
+
return {'success': False, 'error': 'No SNS table found'}
|
|
1425
|
+
|
|
1426
|
+
try:
|
|
1427
|
+
total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
|
|
1428
|
+
|
|
1429
|
+
cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
|
|
1430
|
+
uname_col = 'username' if 'username' in cols else 'user_name'
|
|
1431
|
+
|
|
1432
|
+
total_friends = 0
|
|
1433
|
+
if uname_col:
|
|
1434
|
+
total_friends = cursor.execute(
|
|
1435
|
+
f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
|
|
1436
|
+
).fetchone()[0]
|
|
1437
|
+
|
|
1438
|
+
my_posts = None
|
|
1439
|
+
if my_wxid and uname_col:
|
|
1440
|
+
my_posts = cursor.execute(
|
|
1441
|
+
f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
|
|
1442
|
+
(my_wxid,)).fetchone()[0]
|
|
1443
|
+
|
|
1444
|
+
return {
|
|
1445
|
+
'success': True,
|
|
1446
|
+
'data': {
|
|
1447
|
+
'totalPosts': total,
|
|
1448
|
+
'totalFriends': total_friends,
|
|
1449
|
+
'myPosts': my_posts,
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
except Exception as e:
|
|
1453
|
+
return {'success': False, 'error': str(e)}
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
# ========== Main CLI ==========
|
|
1457
|
+
|
|
1458
|
+
def main():
|
|
1459
|
+
import argparse
|
|
1460
|
+
parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
|
|
1461
|
+
sub = parser.add_subparsers(dest='command')
|
|
1462
|
+
|
|
1463
|
+
# scan command
|
|
1464
|
+
scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
|
|
1465
|
+
scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
|
|
1466
|
+
scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
|
|
1467
|
+
|
|
1468
|
+
# sessions command
|
|
1469
|
+
sessions_parser = sub.add_parser('sessions', help='List chat sessions')
|
|
1470
|
+
sessions_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1471
|
+
sessions_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1472
|
+
sessions_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1473
|
+
sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1474
|
+
sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
|
|
1475
|
+
sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
|
|
1476
|
+
sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1477
|
+
sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1478
|
+
|
|
1479
|
+
# messages command
|
|
1480
|
+
msg_parser = sub.add_parser('messages', help='Get messages')
|
|
1481
|
+
msg_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1482
|
+
msg_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1483
|
+
msg_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1484
|
+
msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1485
|
+
msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
|
|
1486
|
+
msg_parser.add_argument('--limit', type=int, default=100)
|
|
1487
|
+
msg_parser.add_argument('--offset', type=int, default=0)
|
|
1488
|
+
# Unix seconds, inclusive at both ends. Pushed into SQL, so a bounded read
|
|
1489
|
+
# no longer has to fetch the whole conversation to discard most of it.
|
|
1490
|
+
msg_parser.add_argument('--from', dest='from_time', type=int,
|
|
1491
|
+
default=os.environ.get('WEFLOW_FROM_TIME'),
|
|
1492
|
+
help='Only messages at or after this unix time')
|
|
1493
|
+
msg_parser.add_argument('--to', dest='to_time', type=int,
|
|
1494
|
+
default=os.environ.get('WEFLOW_TO_TIME'),
|
|
1495
|
+
help='Only messages at or before this unix time')
|
|
1496
|
+
msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
|
|
1497
|
+
msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1498
|
+
msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1499
|
+
msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
|
|
1500
|
+
# Opt-in: without it the JSON is byte-identical to before this flag existed.
|
|
1501
|
+
msg_parser.add_argument('--report-shards', action='store_true',
|
|
1502
|
+
help='Add a per-shard read report to the result (additive)')
|
|
1503
|
+
|
|
1504
|
+
# contacts command
|
|
1505
|
+
contacts_parser = sub.add_parser('contacts', help='List contacts')
|
|
1506
|
+
contacts_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1507
|
+
contacts_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1508
|
+
contacts_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1509
|
+
contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
|
|
1510
|
+
contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
|
|
1511
|
+
contacts_parser.add_argument('--limit', type=int, default=200)
|
|
1512
|
+
contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
|
|
1513
|
+
contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
|
|
1514
|
+
contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
|
|
1515
|
+
|
|
1516
|
+
# sns-timeline command
|
|
1517
|
+
sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
|
|
1518
|
+
sns_tl_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1519
|
+
sns_tl_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1520
|
+
sns_tl_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1521
|
+
sns_tl_parser.add_argument('--limit', type=int, default=20)
|
|
1522
|
+
sns_tl_parser.add_argument('--offset', type=int, default=0)
|
|
1523
|
+
sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
|
|
1524
|
+
sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
|
|
1525
|
+
sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
|
|
1526
|
+
sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
|
|
1527
|
+
|
|
1528
|
+
# sns-usernames command
|
|
1529
|
+
sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
|
|
1530
|
+
sns_un_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1531
|
+
sns_un_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1532
|
+
sns_un_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1533
|
+
|
|
1534
|
+
# sns-stats command
|
|
1535
|
+
sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
|
|
1536
|
+
sns_stats_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to sns.db')
|
|
1537
|
+
sns_stats_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1538
|
+
sns_stats_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1539
|
+
sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
|
|
1540
|
+
|
|
1541
|
+
# fav-schema command
|
|
1542
|
+
fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
|
|
1543
|
+
fav_schema_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
|
|
1544
|
+
fav_schema_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1545
|
+
|
|
1546
|
+
verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
|
|
1547
|
+
verify_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to NT database')
|
|
1548
|
+
verify_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1549
|
+
verify_parser.add_argument('--salt', default=os.environ.get('WEFLOW_NT_SALT'), required=not os.environ.get('WEFLOW_NT_SALT'), help='Salt hex (32 chars)')
|
|
1550
|
+
|
|
1551
|
+
# Same question as `verify`, answered without the native library - so a
|
|
1552
|
+
# missing or broken sqlcipher3 does not hide whether the key is correct.
|
|
1553
|
+
native_parser = sub.add_parser('verify-native',
|
|
1554
|
+
help='Verify a passphrase from the file header alone (no sqlcipher3)')
|
|
1555
|
+
native_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to any database in the set')
|
|
1556
|
+
native_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), required=not os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Passphrase hex (64 chars)')
|
|
1557
|
+
native_parser.add_argument('--internal-key', default=os.environ.get('WEFLOW_INTERNAL_DB_KEY', ''), help='Optional 64-char hex XOR key (some builds)')
|
|
1558
|
+
|
|
1559
|
+
# fav-list command
|
|
1560
|
+
fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
|
|
1561
|
+
fav_list_parser.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'), required=not os.environ.get('WEFLOW_DB_PATH'), help='Path to favorite.db')
|
|
1562
|
+
fav_list_parser.add_argument('--key', default=os.environ.get('WEFLOW_NT_KEY'), required=not os.environ.get('WEFLOW_NT_KEY'), help='Key hex (64 chars)')
|
|
1563
|
+
fav_list_parser.add_argument('--limit', type=int, default=100)
|
|
1564
|
+
fav_list_parser.add_argument('--offset', type=int, default=0)
|
|
1565
|
+
fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
|
|
1566
|
+
fav_list_parser.add_argument('--type', type=int, dest='fav_type',
|
|
1567
|
+
help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
|
|
1568
|
+
|
|
1569
|
+
args = parser.parse_args()
|
|
1570
|
+
|
|
1571
|
+
if args.command == 'scan':
|
|
1572
|
+
# Discover the database files before touching the process. Walking the
|
|
1573
|
+
# filesystem does not need WeChat running, but key matching does - and
|
|
1574
|
+
# the caller derives keys from the passphrase when the memory scan
|
|
1575
|
+
# finds nothing, which only needs the file list. Returning early on
|
|
1576
|
+
# "process not running" without the databases threw that list away and
|
|
1577
|
+
# left the caller with nothing to derive from.
|
|
1578
|
+
databases = find_nt_databases(getattr(args, 'root', None))
|
|
1579
|
+
|
|
1580
|
+
pid = find_weixin_pid()
|
|
1581
|
+
if not pid:
|
|
1582
|
+
if IS_WINDOWS:
|
|
1583
|
+
print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
|
|
1584
|
+
else:
|
|
1585
|
+
print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
|
|
1586
|
+
"databases": databases}))
|
|
1587
|
+
return
|
|
1588
|
+
|
|
1589
|
+
if not args.json:
|
|
1590
|
+
print(f"扫描进程 PID {pid}...")
|
|
1591
|
+
|
|
1592
|
+
keys, scan_err = scan_memory_keys(pid)
|
|
1593
|
+
if scan_err == 'permission':
|
|
1594
|
+
print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
|
|
1595
|
+
"databases": databases}))
|
|
1596
|
+
return
|
|
1597
|
+
if scan_err == 'gone':
|
|
1598
|
+
print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
|
|
1599
|
+
return
|
|
1600
|
+
if not args.json:
|
|
1601
|
+
print(f"找到 {len(keys)} 个密钥")
|
|
1602
|
+
|
|
1603
|
+
if not args.json:
|
|
1604
|
+
print(f"找到 {len(databases)} 个 NT 数据库")
|
|
1605
|
+
|
|
1606
|
+
matched = match_keys_to_databases(keys, databases)
|
|
1607
|
+
if not args.json:
|
|
1608
|
+
print(f"匹配 {len(matched)} 个数据库")
|
|
1609
|
+
for db in matched:
|
|
1610
|
+
print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
|
|
1611
|
+
else:
|
|
1612
|
+
print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
|
|
1613
|
+
return
|
|
1614
|
+
|
|
1615
|
+
if args.command == 'verify':
|
|
1616
|
+
# sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
|
|
1617
|
+
try:
|
|
1618
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1619
|
+
n = conn.execute(
|
|
1620
|
+
"SELECT count(*) FROM sqlite_master WHERE type='table'"
|
|
1621
|
+
).fetchone()[0]
|
|
1622
|
+
conn.close()
|
|
1623
|
+
print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
|
|
1624
|
+
except Exception as e:
|
|
1625
|
+
print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
|
|
1626
|
+
return
|
|
1627
|
+
|
|
1628
|
+
if args.command == 'verify-native':
|
|
1629
|
+
verdict = verify_passphrase_native(args.passphrase, args.db,
|
|
1630
|
+
getattr(args, 'internal_key', '') or '')
|
|
1631
|
+
if verdict is None:
|
|
1632
|
+
print(json.dumps({"success": False, "verified": None,
|
|
1633
|
+
"error": "无法判定(文件不可读、过短,或 passphrase 不是 64 位 hex)"},
|
|
1634
|
+
ensure_ascii=True))
|
|
1635
|
+
return
|
|
1636
|
+
print(json.dumps({"success": True, "verified": verdict}, ensure_ascii=True))
|
|
1637
|
+
return
|
|
1638
|
+
|
|
1639
|
+
# Build contact name map once if contact db provided
|
|
1640
|
+
contact_name_map = {}
|
|
1641
|
+
contact_db = getattr(args, 'contact_db', None)
|
|
1642
|
+
contact_key = getattr(args, 'contact_key', None)
|
|
1643
|
+
contact_salt = getattr(args, 'contact_salt', None)
|
|
1644
|
+
if contact_db and contact_key and contact_salt:
|
|
1645
|
+
contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
|
|
1646
|
+
|
|
1647
|
+
if args.command == 'sessions':
|
|
1648
|
+
conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
|
|
1649
|
+
if not conns:
|
|
1650
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1651
|
+
return
|
|
1652
|
+
result = get_sessions(conns)
|
|
1653
|
+
if 'sessions' in result:
|
|
1654
|
+
result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
|
|
1655
|
+
if args.keyword:
|
|
1656
|
+
kw = args.keyword.lower()
|
|
1657
|
+
result['sessions'] = [
|
|
1658
|
+
s for s in result['sessions']
|
|
1659
|
+
if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
|
|
1660
|
+
]
|
|
1661
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1662
|
+
for conn in conns:
|
|
1663
|
+
conn.close()
|
|
1664
|
+
|
|
1665
|
+
elif args.command == 'messages':
|
|
1666
|
+
passphrase = getattr(args, 'passphrase', '') or ''
|
|
1667
|
+
own_wxid = getattr(args, 'own_wxid', None)
|
|
1668
|
+
|
|
1669
|
+
if getattr(args, 'report_shards', False):
|
|
1670
|
+
pairs, failures = connect_message_shards_detailed(
|
|
1671
|
+
args.db, args.key, args.salt, passphrase)
|
|
1672
|
+
if not pairs:
|
|
1673
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1674
|
+
return
|
|
1675
|
+
conns = [conn for _path, conn in pairs]
|
|
1676
|
+
names = [os.path.basename(path) for path, _conn in pairs]
|
|
1677
|
+
shard_items = []
|
|
1678
|
+
result = get_messages(conns, args.talker, args.limit, args.offset,
|
|
1679
|
+
contact_name_map, own_wxid,
|
|
1680
|
+
shard_names=names, shard_report=shard_items,
|
|
1681
|
+
from_time=args.from_time, to_time=args.to_time)
|
|
1682
|
+
# Failure entries carry no opened connection, so they are not in
|
|
1683
|
+
# `conns` and could not be recorded by get_messages itself.
|
|
1684
|
+
result['shards'] = {
|
|
1685
|
+
'scanned': len(pairs) + len(failures),
|
|
1686
|
+
'opened': len(pairs),
|
|
1687
|
+
'failed': len(failures),
|
|
1688
|
+
'items': failures + shard_items,
|
|
1689
|
+
}
|
|
1690
|
+
else:
|
|
1691
|
+
conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
|
|
1692
|
+
if not conns:
|
|
1693
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1694
|
+
return
|
|
1695
|
+
result = get_messages(conns, args.talker, args.limit, args.offset,
|
|
1696
|
+
contact_name_map, own_wxid,
|
|
1697
|
+
from_time=args.from_time, to_time=args.to_time)
|
|
1698
|
+
|
|
1699
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1700
|
+
for conn in conns:
|
|
1701
|
+
conn.close()
|
|
1702
|
+
|
|
1703
|
+
elif args.command == 'contacts':
|
|
1704
|
+
conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
|
|
1705
|
+
if not conns:
|
|
1706
|
+
print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
|
|
1707
|
+
return
|
|
1708
|
+
result = get_contacts(conns, args.limit, keyword=args.keyword)
|
|
1709
|
+
if 'contacts' in result:
|
|
1710
|
+
result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
|
|
1711
|
+
result['contacts'] = filter_contacts(result['contacts'], args.keyword, args.limit)
|
|
1712
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1713
|
+
for conn in conns:
|
|
1714
|
+
conn.close()
|
|
1715
|
+
|
|
1716
|
+
elif args.command == 'sns-timeline':
|
|
1717
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1718
|
+
usernames = None
|
|
1719
|
+
if args.usernames:
|
|
1720
|
+
try:
|
|
1721
|
+
usernames = json.loads(args.usernames)
|
|
1722
|
+
except: pass
|
|
1723
|
+
result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
|
|
1724
|
+
usernames, args.keyword,
|
|
1725
|
+
args.start_time, args.end_time)
|
|
1726
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1727
|
+
conn.close()
|
|
1728
|
+
|
|
1729
|
+
elif args.command == 'sns-usernames':
|
|
1730
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1731
|
+
result = get_sns_usernames(conn.cursor())
|
|
1732
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1733
|
+
conn.close()
|
|
1734
|
+
|
|
1735
|
+
elif args.command == 'sns-stats':
|
|
1736
|
+
conn, _ = connect_nt_db(args.db, args.key, args.salt)
|
|
1737
|
+
result = get_sns_stats(conn.cursor(), args.my_wxid)
|
|
1738
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1739
|
+
conn.close()
|
|
1740
|
+
|
|
1741
|
+
elif args.command == 'fav-schema':
|
|
1742
|
+
result = get_fav_schema(args.db, args.key)
|
|
1743
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
1744
|
+
|
|
1745
|
+
elif args.command == 'fav-list':
|
|
1746
|
+
result = get_favorites(args.db, args.key, args.limit, args.offset,
|
|
1747
|
+
args.keyword, getattr(args, 'fav_type', None))
|
|
1748
|
+
print(json.dumps(result, ensure_ascii=True, default=str))
|
|
1749
|
+
|
|
1750
|
+
else:
|
|
1751
|
+
parser.print_help()
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
if __name__ == '__main__':
|
|
1755
|
+
main()
|