weflow-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1949 -1
  3. package/OPERATIONS.md +774 -1
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +934 -27
  7. package/dist/bin/weflow-cli.js +952 -27
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/utils/atomicWrite.d.ts +4 -0
  100. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  101. package/dist/src/utils/atomicWrite.js +60 -0
  102. package/dist/src/utils/atomicWrite.js.map +1 -0
  103. package/dist/src/utils/dateRange.d.ts +15 -0
  104. package/dist/src/utils/dateRange.d.ts.map +1 -1
  105. package/dist/src/utils/dateRange.js +47 -0
  106. package/dist/src/utils/dateRange.js.map +1 -1
  107. package/dist/src/utils/text.d.ts +18 -0
  108. package/dist/src/utils/text.d.ts.map +1 -0
  109. package/dist/src/utils/text.js +20 -0
  110. package/dist/src/utils/text.js.map +1 -0
  111. package/docs/AI_INTERFACE.md +2 -0
  112. package/docs/DECISIONS.md +1424 -0
  113. package/docs/EXTENDING.md +187 -0
  114. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  115. package/docs/MCP.md +23 -4
  116. package/docs/PROJECT_STATE.md +299 -98
  117. package/docs/SETUP.md +22 -0
  118. package/docs/SYNC_CONTRACT.md +31 -3
  119. package/mcp-server/index.ts +681 -657
  120. package/package.json +84 -83
  121. package/resources/panel/ball-position.cjs +153 -0
  122. package/resources/panel/index.html +46 -0
  123. package/resources/panel/main.cjs +447 -0
  124. package/resources/panel/mascot-focus.png +0 -0
  125. package/resources/panel/mascot-happy.png +0 -0
  126. package/resources/panel/mascot-sorry.png +0 -0
  127. package/resources/panel/mascot-tired.png +0 -0
  128. package/resources/panel/mascot.png +0 -0
  129. package/resources/panel/package.json +7 -0
  130. package/resources/panel/panel.css +407 -0
  131. package/resources/panel/preload.cjs +72 -0
  132. package/resources/panel/quick-menu.cjs +197 -0
  133. package/resources/panel/renderer.js +441 -0
  134. package/resources/panel/tray-menu.cjs +40 -0
  135. package/resources/panel/tray.png +0 -0
  136. package/scripts/_utils.py +403 -54
  137. package/scripts/annual_report.py +4 -2
  138. package/scripts/article_notes.py +423 -0
  139. package/scripts/backfill_articles.py +438 -0
  140. package/scripts/biz_daily.py +590 -105
  141. package/scripts/chat_notes.py +470 -0
  142. package/scripts/classify_daily.py +10 -18
  143. package/scripts/compile_wiki.py +1059 -46
  144. package/scripts/create_reading_notes.py +199 -14
  145. package/scripts/decide.py +253 -0
  146. package/scripts/draft_reply.py +671 -0
  147. package/scripts/export_chat_html.py +35 -33
  148. package/scripts/extract_todos.py +31 -5
  149. package/scripts/fav_notes.py +318 -0
  150. package/scripts/fix_topics.py +2 -1
  151. package/scripts/generate_ai_report.py +214 -9
  152. package/scripts/generate_html.py +18 -3
  153. package/scripts/generate_review.py +2 -2
  154. package/scripts/jev_client.py +294 -0
  155. package/scripts/jev_probe.py +440 -0
  156. package/scripts/nt_common.py +82 -0
  157. package/scripts/nt_decrypt.py +1755 -1460
  158. package/scripts/pipeline.py +27 -2
  159. package/scripts/promote_ideas.py +19 -4
  160. package/scripts/quality_eval.py +707 -0
  161. package/scripts/rag_chat.py +4 -4
  162. package/scripts/read_image.py +187 -0
  163. package/scripts/reply_debt.py +488 -0
  164. package/scripts/route_cards.py +453 -0
  165. package/scripts/semantic_search.py +208 -49
  166. package/scripts/user_notes.py +315 -0
  167. package/scripts/vault_rag.py +13 -7
  168. package/scripts/vault_search.py +21 -4
  169. package/scripts/wiki_lint.py +397 -0
  170. package/src/core/appMsgFormat.ts +117 -0
  171. package/src/core/ntCore.ts +27 -2
  172. package/src/core/sqlcipherCore.ts +5 -61
  173. package/src/core/wcdbCore.ts +6 -2
  174. package/src/panel/client.ts +49 -0
  175. package/src/panel/endpoint.ts +144 -0
  176. package/src/panel/launch.ts +101 -0
  177. package/src/panel/server.ts +424 -0
  178. package/src/panel/userId.ts +72 -0
  179. package/src/services/assistantDaemon.ts +57 -9
  180. package/src/services/assistantEval.ts +611 -0
  181. package/src/services/assistantMemory.ts +413 -57
  182. package/src/services/assistantPrivacy.ts +18 -4
  183. package/src/services/assistantRouter.ts +191 -0
  184. package/src/services/assistantService.ts +671 -74
  185. package/src/services/assistantTools.ts +1118 -57
  186. package/src/services/assistantTrace.ts +175 -0
  187. package/src/services/chatService.ts +6 -2
  188. package/src/services/configService.ts +403 -385
  189. package/src/services/pythonBridge.ts +139 -0
  190. package/src/services/syncService.ts +37 -9
  191. package/src/services/wechatMessageService.ts +73 -7
  192. package/src/utils/atomicWrite.ts +56 -0
  193. package/src/utils/dateRange.ts +46 -0
  194. package/src/utils/text.ts +20 -0
@@ -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 re
10
- import hashlib
11
- import ctypes
12
- from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
13
- from pathlib import Path
14
-
15
- sqlcipher = None
16
-
17
-
18
- def require_sqlcipher():
19
- """Load SQLCipher only for database operations, not path discovery."""
20
- global sqlcipher
21
- if sqlcipher is not None:
22
- return sqlcipher
23
- try:
24
- from sqlcipher3 import dbapi2 as sqlcipher_module
25
- except ImportError as error:
26
- raise RuntimeError("需要 sqlcipher3: pip install sqlcipher3") from error
27
- sqlcipher = sqlcipher_module
28
- return sqlcipher
29
-
30
- # ========== Memory Scanner ==========
31
- PROCESS_VM_READ = 0x0010
32
- PROCESS_QUERY_INFORMATION = 0x0400
33
- MEM_COMMIT = 0x1000
34
- PAGE_NOACCESS = 0x01
35
- PAGE_GUARD = 0x100
36
-
37
- class MEMORY_BASIC_INFORMATION(ctypes.Structure):
38
- _fields_ = [
39
- ('BaseAddress', ctypes.c_void_p),
40
- ('AllocationBase', ctypes.c_void_p),
41
- ('AllocationProtect', wintypes.DWORD),
42
- ('PartitionId', wintypes.WORD),
43
- ('RegionSize', ctypes.c_size_t),
44
- ('State', wintypes.DWORD),
45
- ('Protect', wintypes.DWORD),
46
- ('Type', wintypes.DWORD),
47
- ]
48
-
49
- IS_WINDOWS = os.name == 'nt'
50
-
51
- if IS_WINDOWS:
52
- kernel32 = ctypes.windll.kernel32
53
- ReadProcessMemory = kernel32.ReadProcessMemory
54
- ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(c_size_t)]
55
- ReadProcessMemory.restype = wintypes.BOOL
56
- VirtualQueryEx = kernel32.VirtualQueryEx
57
- VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
58
- VirtualQueryEx.restype = ctypes.c_size_t
59
- else:
60
- kernel32 = None
61
-
62
-
63
- def find_weixin_pid():
64
- """Find WeChat process ID (Windows: pymem; Linux: /proc scan)."""
65
- if not IS_WINDOWS:
66
- return find_weixin_pid_linux()
67
- try:
68
- import pymem, pymem.process
69
- for proc in pymem.process.list_processes():
70
- try:
71
- name = proc.szExeFile
72
- if isinstance(name, bytes):
73
- name = name.decode('utf-8', errors='ignore')
74
- if name.lower() == 'weixin.exe':
75
- return proc.th32ProcessID
76
- except:
77
- pass
78
- except ImportError:
79
- pass
80
- return None
81
-
82
-
83
- _LINUX_WECHAT_COMMS = {'wechat', 'wechatappex', 'weixin'}
84
- _LINUX_EXE_PREFIX_DENY = ('python', 'bash', 'sh', 'zsh', 'node', 'perl', 'ruby', 'electron')
85
-
86
-
87
- def _is_linux_wechat_process(pid):
88
- if pid == os.getpid():
89
- return False
90
- try:
91
- with open(f'/proc/{pid}/comm') as f:
92
- comm = f.read().strip().lower()
93
- if comm in _LINUX_WECHAT_COMMS:
94
- return True
95
- try:
96
- exe = os.path.realpath(os.readlink(f'/proc/{pid}/exe'))
97
- except OSError:
98
- return False
99
- name = os.path.basename(exe).lower()
100
- if any(name.startswith(p) for p in _LINUX_EXE_PREFIX_DENY):
101
- return False
102
- return 'wechat' in name or 'weixin' in name
103
- except (PermissionError, FileNotFoundError, ProcessLookupError):
104
- return False
105
-
106
-
107
- def find_weixin_pid_linux():
108
- """Find Linux WeChat main process (largest RSS among candidates)."""
109
- best = None
110
- best_rss = -1
111
- try:
112
- pids = os.listdir('/proc')
113
- except OSError:
114
- return None
115
- for pid_str in pids:
116
- if not pid_str.isdigit():
117
- continue
118
- pid = int(pid_str)
119
- if not _is_linux_wechat_process(pid):
120
- continue
121
- try:
122
- with open(f'/proc/{pid}/statm') as f:
123
- rss_kb = int(f.read().split()[1]) * 4
124
- except (OSError, IndexError, ValueError):
125
- rss_kb = 0
126
- if rss_kb > best_rss:
127
- best_rss = rss_kb
128
- best = pid
129
- return best
130
-
131
-
132
- def scan_memory_keys(pid):
133
- """Scan process memory for x'<64hex_key><32hex_salt>' patterns.
134
-
135
- Returns (keys, error): keys is a list of {"key","salt"} dicts,
136
- error is None on success or 'permission' / 'gone' / 'not_windows'.
137
- """
138
- if not IS_WINDOWS:
139
- return scan_memory_keys_linux(pid)
140
-
141
- hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
142
- if not hProcess:
143
- return [], None
144
-
145
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
146
- keys_found = []
147
- address = 0x10000
148
-
149
- while address < 0x7FFFFFFFFFFF:
150
- mbi = MEMORY_BASIC_INFORMATION()
151
- result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
152
- if result == 0:
153
- break
154
-
155
- region_addr = mbi.BaseAddress or 0
156
- region_size = mbi.RegionSize or 0
157
-
158
- if (mbi.State == MEM_COMMIT and
159
- region_size > 256 and region_size < 200 * 1024 * 1024 and
160
- mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
161
-
162
- pos = region_addr
163
- end = region_addr + region_size
164
- while pos < end:
165
- chunk_size = min(65536, end - pos)
166
- buf = create_string_buffer(chunk_size)
167
- bytesRead = c_size_t(0)
168
- ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
169
- if ok and bytesRead.value > 0:
170
- data = buf.raw[:bytesRead.value]
171
- for m in pattern.finditer(data):
172
- key_hex = m.group(1).decode()
173
- salt_hex = m.group(2).decode()
174
- keys_found.append((key_hex, salt_hex))
175
- pos += chunk_size
176
-
177
- address = region_addr + region_size
178
-
179
- kernel32.CloseHandle(hProcess)
180
-
181
- # Deduplicate
182
- seen = set()
183
- unique_keys = []
184
- for k, s in keys_found:
185
- pair = (k, s)
186
- if pair not in seen:
187
- seen.add(pair)
188
- unique_keys.append({"key": k, "salt": s})
189
-
190
- return unique_keys, None
191
-
192
-
193
- _LINUX_SKIP_MAPPINGS = {'[vdso]', '[vsyscall]', '[vvar]'}
194
- _LINUX_SKIP_PREFIXES = ('/usr/lib/', '/lib/', '/usr/share/')
195
-
196
-
197
- def scan_memory_keys_linux(pid):
198
- """Scan /proc/<pid>/maps + /proc/<pid>/mem for the key pattern.
199
-
200
- Requires root or CAP_SYS_PTRACE (or the target being a descendant
201
- of this process when yama ptrace_scope=1).
202
- """
203
- regions = []
204
- try:
205
- with open(f'/proc/{pid}/maps') as f:
206
- for line in f:
207
- parts = line.split()
208
- if len(parts) < 2 or 'r' not in parts[1]:
209
- continue
210
- if len(parts) >= 6:
211
- name = parts[5]
212
- if name in _LINUX_SKIP_MAPPINGS:
213
- continue
214
- name_lower = name.lower()
215
- if name.startswith(_LINUX_SKIP_PREFIXES) and \
216
- 'wcdb' not in name_lower and 'wechat' not in name_lower and 'weixin' not in name_lower:
217
- continue
218
- try:
219
- start_s, end_s = parts[0].split('-')
220
- start = int(start_s, 16)
221
- size = int(end_s, 16) - start
222
- except ValueError:
223
- continue
224
- if 0 < size < 500 * 1024 * 1024:
225
- regions.append((start, size))
226
- except PermissionError:
227
- return [], 'permission'
228
- except (FileNotFoundError, ProcessLookupError):
229
- return [], 'gone'
230
-
231
- pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
232
- keys_found = []
233
- try:
234
- with open(f'/proc/{pid}/mem', 'rb') as mem:
235
- for base, size in regions:
236
- try:
237
- mem.seek(base)
238
- data = mem.read(size)
239
- except (OSError, ValueError):
240
- continue
241
- for m in pattern.finditer(data):
242
- keys_found.append((m.group(1).decode(), m.group(2).decode()))
243
- except PermissionError:
244
- return [], 'permission'
245
- except (FileNotFoundError, ProcessLookupError):
246
- return [], 'gone'
247
-
248
- seen = set()
249
- unique_keys = []
250
- for k, s in keys_found:
251
- pair = (k, s)
252
- if pair not in seen:
253
- seen.add(pair)
254
- unique_keys.append({"key": k, "salt": s})
255
-
256
- return unique_keys, None
257
-
258
-
259
- # ========== NT Database Discovery ==========
260
-
261
- def _is_nt_account_dir(path):
262
- return os.path.isdir(os.path.join(path, 'db_storage')) or \
263
- os.path.isdir(os.path.join(path, 'Msg'))
264
-
265
-
266
- def _normalize_nt_root(root):
267
- if not root:
268
- return None
269
- path = os.path.abspath(os.path.expandvars(os.path.expanduser(root)))
270
- if os.path.isfile(path):
271
- path = os.path.dirname(path)
272
- if not os.path.isdir(path):
273
- return None
274
-
275
- try:
276
- for entry in os.listdir(path):
277
- candidate = os.path.join(path, entry)
278
- if os.path.isdir(candidate) and _is_nt_account_dir(candidate):
279
- return path
280
- except OSError:
281
- return None
282
-
283
- for _ in range(6):
284
- if _is_nt_account_dir(path):
285
- return os.path.dirname(path)
286
- parent = os.path.dirname(path)
287
- if parent == path:
288
- break
289
- path = parent
290
-
291
- return None
292
-
293
-
294
- def find_nt_databases(root=None):
295
- """Find all NT-format databases under xwechat_files (message + contact)."""
296
- if root:
297
- normalized_root = _normalize_nt_root(root)
298
- candidates = [normalized_root] if normalized_root else []
299
- elif IS_WINDOWS:
300
- candidates = [
301
- os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
302
- os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
303
- ]
304
- else:
305
- home = os.path.expanduser('~')
306
- candidates = [
307
- os.path.join(home, '.local', 'share', 'xwechat_files'),
308
- os.path.join(home, 'xwechat_files'),
309
- os.path.join(home, 'Documents', 'xwechat_files'),
310
- os.path.join(home, '文档', 'xwechat_files'),
311
- ]
312
- xwechat = None
313
- for c in candidates:
314
- if os.path.isdir(c):
315
- xwechat = c
316
- break
317
- if not xwechat:
318
- return []
319
-
320
- databases = []
321
- for wxid_dir in os.listdir(xwechat):
322
- # Scan message databases
323
- msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
324
- if os.path.isdir(msg_storage):
325
- for f in os.listdir(msg_storage):
326
- if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
327
- full_path = os.path.join(msg_storage, f)
328
- try:
329
- with open(full_path, 'rb') as fh:
330
- salt = fh.read(16)
331
- databases.append({
332
- "path": full_path,
333
- "name": f"message/{f}",
334
- "salt": salt.hex(),
335
- "size": os.path.getsize(full_path),
336
- "wxid": wxid_dir,
337
- })
338
- except:
339
- pass
340
-
341
- # Scan contact database
342
- contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
343
- if os.path.isfile(contact_db):
344
- try:
345
- with open(contact_db, 'rb') as fh:
346
- salt = fh.read(16)
347
- databases.append({
348
- "path": contact_db,
349
- "name": "contact/contact.db",
350
- "salt": salt.hex(),
351
- "size": os.path.getsize(contact_db),
352
- "wxid": wxid_dir,
353
- })
354
- except:
355
- pass
356
-
357
- # Scan SNS (朋友圈) database
358
- sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
359
- if os.path.isfile(sns_db):
360
- try:
361
- with open(sns_db, 'rb') as fh:
362
- salt = fh.read(16)
363
- databases.append({
364
- "path": sns_db,
365
- "name": "sns/sns.db",
366
- "salt": salt.hex(),
367
- "size": os.path.getsize(sns_db),
368
- "wxid": wxid_dir,
369
- })
370
- except:
371
- pass
372
-
373
- # Scan favorites (收藏) database
374
- fav_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'favorite', 'favorite.db')
375
- if os.path.isfile(fav_db):
376
- try:
377
- with open(fav_db, 'rb') as fh:
378
- salt = fh.read(16)
379
- databases.append({
380
- "path": fav_db,
381
- "name": "favorite/favorite.db",
382
- "salt": salt.hex(),
383
- "size": os.path.getsize(fav_db),
384
- "wxid": wxid_dir,
385
- })
386
- except:
387
- pass
388
-
389
- return databases
390
-
391
-
392
- def find_contact_db_path(message_db_path):
393
- """Derive contact.db path from message_0.db path.
394
-
395
- message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
396
- contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
397
- """
398
- msg_dir = os.path.dirname(message_db_path)
399
- wxid_dir = os.path.dirname(msg_dir) # .../db_storage
400
- xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
401
- contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
402
- if os.path.isfile(contact_db):
403
- return contact_db
404
- return None
405
-
406
-
407
- def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
408
- """Load wxid -> {remark, nick_name} map from contact.db.
409
-
410
- Returns dict: {wxid: display_name}
411
- display_name priority: remark > nick_name > alias > wxid
412
- """
413
- if not contact_db_path or not contact_key_hex or not contact_salt_hex:
414
- return {}
415
-
416
- try:
417
- raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
418
- conn = require_sqlcipher().connect(contact_db_path)
419
- c = conn.cursor()
420
- c.execute(f'PRAGMA key = "{raw_key}";')
421
-
422
- # contact.db schema: username, alias, remark, nick_name, ...
423
- c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
424
- name_map = {}
425
- for username, display in c.fetchall():
426
- if username:
427
- name_map[username] = display
428
-
429
- conn.close()
430
- return name_map
431
- except Exception as e:
432
- return {}
433
-
434
-
435
- def apply_contact_names(sessions, name_map):
436
- """Apply contact names to session list, replacing bare wxid displayNames."""
437
- if not name_map:
438
- return sessions
439
- for s in sessions:
440
- username = s.get('username', '')
441
- if username in name_map:
442
- s['displayName'] = name_map[username]
443
- return sessions
444
-
445
-
446
- def match_keys_to_databases(keys, databases):
447
- """Match memory keys to databases by comparing salts."""
448
- salt_to_key = {}
449
- for k in keys:
450
- salt_to_key[k["salt"]] = k["key"]
451
-
452
- matched = []
453
- for db in databases:
454
- if db["salt"] in salt_to_key:
455
- db["key"] = salt_to_key[db["salt"]]
456
- matched.append(db)
457
-
458
- return matched
459
-
460
-
461
- # ========== Database Operations ==========
462
-
463
- def connect_nt_db(db_path, key_hex, salt_hex):
464
- """Connect to an NT database using sqlcipher3."""
465
- raw_key = f"x'{key_hex}{salt_hex}'"
466
- conn = require_sqlcipher().connect(db_path)
467
- c = conn.cursor()
468
- c.execute(f'PRAGMA key = "{raw_key}";')
469
- return conn, c
470
-
471
-
472
- # WeChat rolls a conversation into a new shard over time, and every shard is
473
- # encrypted with its own key. Reading only the configured database therefore
474
- # shows a transcript that stops wherever the first shard's last write left off
475
- # - which is silent, because the query still succeeds. Only the HTML exporter
476
- # used to merge shards, so the same chat exported two different histories
477
- # depending on the format asked for.
478
-
479
- SHARD_EXCLUDED = {'message_fts.db', 'message_resource.db'}
480
-
481
-
482
- def discover_message_shards(db_path):
483
- """Every NT message shard sitting beside the configured database."""
484
- path = Path(db_path)
485
- if not path.parent.is_dir():
486
- return [str(path)]
487
- shards = sorted(str(p) for p in path.parent.glob('message_*.db')
488
- if p.name.lower() not in SHARD_EXCLUDED)
489
- return shards or [str(path)]
490
-
491
-
492
- def derive_database_key(path, fallback_key, fallback_salt, passphrase=''):
493
- """Per-shard SQLCipher key (WeChat 4.1.12.26+).
494
-
495
- The shared passphrase is PBKDF2-HMAC-SHA512'd against each shard's own
496
- 16-byte header salt. Without a passphrase the configured pair is used
497
- unchanged, which is what shard 0 opens with on older installs.
498
- """
499
- if not passphrase:
500
- return fallback_key, fallback_salt
501
- try:
502
- with open(path, 'rb') as fh:
503
- salt = fh.read(16)
504
- if len(salt) != 16:
505
- return fallback_key, fallback_salt
506
- raw_passphrase = bytes.fromhex(passphrase)
507
- key = hashlib.pbkdf2_hmac('sha512', raw_passphrase, salt, 256000, 32).hex()
508
- return key, salt.hex()
509
- except (OSError, ValueError):
510
- return fallback_key, fallback_salt
511
-
512
-
513
- def connect_message_shards_detailed(db_path, key_hex, salt_hex, passphrase=''):
514
- """Open every shard and report what happened to each one.
515
-
516
- Returns (pairs, failures):
517
- pairs [(shard_path, conn)] for the shards that opened
518
- failures [{"name": basename, "reason": "KEY_REJECTED" | "OPEN_FAILED"}]
519
-
520
- A shard that fails is still skipped rather than fatal - a partially
521
- readable transcript beats a command that refuses to run at all - but the
522
- failure stops being invisible. `shardsFailed` is the difference between
523
- "read everything" and "read what happened to be reachable", which is the
524
- whole point of the coverage report.
525
-
526
- Only the basename is reported: the caller stores this in a state file, and
527
- absolute paths must not end up there.
528
- """
529
- pairs = []
530
- failures = []
531
- for shard in discover_message_shards(db_path):
532
- derived_key, derived_salt = derive_database_key(
533
- shard, key_hex, salt_hex, passphrase)
534
- # Try the derived key first; fall back to the configured pair so
535
- # installs without a passphrase keep working exactly as before.
536
- candidates = [(derived_key, derived_salt)]
537
- if (derived_key, derived_salt) != (key_hex, salt_hex):
538
- candidates.append((key_hex, salt_hex))
539
- failure_reason = 'OPEN_FAILED'
540
- for candidate_key, candidate_salt in candidates:
541
- conn = None
542
- try:
543
- conn, _ = connect_nt_db(shard, candidate_key, candidate_salt)
544
- # PRAGMA key alone never fails; only a read surfaces a bad key.
545
- conn.execute('SELECT count(*) FROM sqlite_master').fetchone()
546
- except Exception:
547
- # Reached the database but could not decrypt it: the key is
548
- # wrong, not the file. Close before retrying, or the handle
549
- # leaks and on Windows the file stays locked.
550
- if conn is not None:
551
- try:
552
- conn.close()
553
- except Exception:
554
- pass
555
- failure_reason = 'KEY_REJECTED'
556
- continue
557
- pairs.append((shard, conn))
558
- break
559
- else:
560
- failures.append({'name': os.path.basename(shard), 'reason': failure_reason})
561
- return pairs, failures
562
-
563
-
564
- def connect_message_shards(db_path, key_hex, salt_hex, passphrase=''):
565
- """Open the configured database plus every sibling shard.
566
-
567
- Returns the connections that actually opened. A shard that fails is
568
- skipped rather than fatal: a partially readable transcript beats a
569
- command that refuses to run at all.
570
-
571
- Behaviour-preserving delegate: callers that do not ask for the failure
572
- detail get exactly what they got before.
573
- """
574
- pairs, _failures = connect_message_shards_detailed(
575
- db_path, key_hex, salt_hex, passphrase)
576
- return [conn for _path, conn in pairs]
577
-
578
-
579
- def count_talker_rows(conn, msg_table):
580
- """Rows this conversation has in one shard, or None if it has no table there.
581
-
582
- Distinguishes "this shard holds nothing for the conversation" from "this
583
- shard could not be read" - without it both look like zero.
584
- """
585
- try:
586
- cur = conn.execute(
587
- "SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
588
- if cur.fetchone()[0] == 0:
589
- return None
590
- return conn.execute('SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
591
- except Exception:
592
- return None
593
-
594
-
595
- def msg_tables(conn):
596
- """Names of the per-conversation Msg_ tables in one shard."""
597
- try:
598
- rows = conn.execute(
599
- "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg\\_%' ESCAPE '\\'"
600
- ).fetchall()
601
- except Exception:
602
- return set()
603
- return {row[0] for row in rows}
604
-
605
-
606
- def get_fav_schema(db_path, key_hex):
607
- """Dump favorite.db schema + sample rows (key verification / exploration)."""
608
- try:
609
- with open(db_path, 'rb') as fh:
610
- salt = fh.read(16).hex()
611
- conn = require_sqlcipher().connect(db_path)
612
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
613
- tables = conn.execute(
614
- "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
615
- ).fetchall()
616
- result = {"success": True, "tables": [t[0] for t in tables]}
617
- if not tables:
618
- result["error"] = "数据库为空或密钥错误"
619
- result["success"] = False
620
- conn.close()
621
- return result
622
- except Exception as e:
623
- return {"success": False, "error": str(e).split('\n')[0][:200]}
624
-
625
-
626
- FAV_TYPE_NAMES = {
627
- 1: 'text', # 文字
628
- 2: 'image', # 图片
629
- 4: 'video', # 视频
630
- 5: 'article', # 公众号文章/网页链接
631
- 14: 'chatrecord', # 聊天记录
632
- }
633
-
634
-
635
- def parse_fav_content(content):
636
- """Extract display fields (title/link/desc/source/cover) from favitem XML."""
637
- out = {}
638
- if not content:
639
- return out
640
- import xml.etree.ElementTree as ET
641
- try:
642
- root = ET.fromstring(content)
643
- except ET.ParseError:
644
- return out
645
-
646
- def text(path):
647
- el = root.find(path)
648
- if el is not None and el.text and el.text.strip():
649
- return el.text.strip()
650
- return None
651
-
652
- title = (text('weburlitem/pagetitle')
653
- or text('datalist/dataitem/datatitle')
654
- or text('title')
655
- or text('desc'))
656
- if title:
657
- out['title'] = title
658
- link = (text('weburlitem/clean_url')
659
- or text('source/link')
660
- or text('datalist/dataitem/stream_weburl'))
661
- if link:
662
- out['link'] = link
663
- desc = text('weburlitem/pagedesc') or text('datalist/dataitem/datadesc')
664
- if desc and desc != out.get('title'):
665
- out['desc'] = desc
666
- src = text('weburlitem/appmsgshareitem/srcdisplayname')
667
- if src:
668
- out['source_name'] = src
669
- cover = (text('weburlitem/pagethumb_url')
670
- or text('datalist/dataitem/dataext')
671
- or text('datalist/dataitem/cdn_thumburl'))
672
- if cover:
673
- out['cover'] = cover
674
- fmt = text('datalist/dataitem/datafmt')
675
- if fmt:
676
- out['format'] = fmt
677
- return out
678
-
679
-
680
- def get_favorites(db_path, key_hex, limit=100, offset=0, keyword=None, fav_type=None):
681
- """List favorite items from favorite.db with parsed content."""
682
- try:
683
- with open(db_path, 'rb') as fh:
684
- salt = fh.read(16).hex()
685
- conn = require_sqlcipher().connect(db_path)
686
- conn.execute(f'PRAGMA key = "x\'{key_hex}{salt}\'";')
687
- c = conn.cursor()
688
-
689
- tables = [t[0] for t in c.execute(
690
- "SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
691
- if 'fav_db_item' not in tables:
692
- conn.close()
693
- return {"error": f"未找到 fav_db_item 表,现有表: {tables[:20]}"}
694
-
695
- total = c.execute('SELECT count(*) FROM fav_db_item').fetchone()[0]
696
-
697
- sql = ('SELECT local_id, server_id, type, update_time, fromusr, realchatname, content '
698
- 'FROM fav_db_item WHERE 1=1')
699
- params = []
700
- if fav_type is not None:
701
- sql += ' AND type = ?'
702
- params.append(fav_type)
703
- if keyword:
704
- sql += ' AND content LIKE ?'
705
- params.append(f'%{keyword}%')
706
- sql += ' ORDER BY update_time DESC LIMIT ? OFFSET ?'
707
- params.extend([limit, offset])
708
- rows = c.execute(sql, params).fetchall()
709
- conn.close()
710
-
711
- items = []
712
- for local_id, server_id, ftype, update_time, fromusr, realchatname, content in rows:
713
- ct = content if isinstance(content, str) else (
714
- content.decode('utf-8', errors='replace') if content else '')
715
- item = {
716
- 'local_id': local_id,
717
- 'server_id': server_id,
718
- 'type': ftype,
719
- 'type_name': FAV_TYPE_NAMES.get(ftype, 'type_%s' % ftype),
720
- 'update_time': update_time,
721
- 'from_user': fromusr,
722
- 'chat_name': realchatname or None,
723
- }
724
- item.update(parse_fav_content(ct))
725
- items.append(item)
726
- return {"favorites": items, "total": total, "count": len(items),
727
- "limit": limit, "offset": offset}
728
- except Exception as e:
729
- return {"error": str(e).split('\n')[0][:200]}
730
-
731
-
732
- def _summarise(row):
733
- """(last_time, summary) from the newest message row of a conversation."""
734
- last_time = row[0] or 0
735
- msg_type = row[3] or 0
736
- source_text = row[1]
737
- content_text = row[2]
738
-
739
- if msg_type == 1:
740
- # Text message: use message_content
741
- if isinstance(content_text, str) and content_text:
742
- return last_time, content_text[:50]
743
- if isinstance(content_text, bytes):
744
- return last_time, content_text.decode('utf-8', errors='ignore')[:50]
745
- return last_time, ""
746
-
747
- if isinstance(source_text, str) and source_text:
748
- # Non-text: try to extract from source, stripping XML tags
749
- clean = re.sub(r'<[^>]+>', '', source_text)
750
- lines = clean.split('\n')
751
- if len(lines) > 1 and lines[1].strip():
752
- return last_time, lines[1].strip()[:50]
753
- if clean.strip():
754
- return last_time, clean.strip()[:50]
755
- return last_time, ""
756
-
757
-
758
- def get_sessions(conns):
759
- """Get chat sessions, newest message per conversation across every shard."""
760
- sessions = {}
761
-
762
- # NT format: each chat has its own Msg_<MD5> table
763
- # The Name2Id table maps usernames to IDs (user_name, is_session)
764
- for conn in conns:
765
- c = conn.cursor()
766
- try:
767
- c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
768
- usernames = [row[0] for row in c.fetchall() if row[0]]
769
- except Exception:
770
- continue
771
-
772
- tables = msg_tables(conn)
773
- for username in usernames:
774
- entry = sessions.setdefault(username, {"summary": "", "last_time": 0})
775
- msg_table = f"Msg_{hashlib.md5(username.encode()).hexdigest()}"
776
- if msg_table not in tables:
777
- continue
778
- try:
779
- c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
780
- row = c.fetchone()
781
- if row:
782
- last_time, summary = _summarise(row)
783
- # A conversation spans shards; only the newest speaks for it.
784
- if last_time >= entry["last_time"]:
785
- entry["last_time"] = last_time
786
- entry["summary"] = summary
787
- except Exception:
788
- continue
789
-
790
- result = [{
791
- "username": username,
792
- "type": 1 if "@chatroom" in username else 0,
793
- "unreadCount": 0,
794
- "summary": entry["summary"],
795
- "sortTimestamp": entry["last_time"],
796
- "lastTimestamp": entry["last_time"],
797
- "displayName": username,
798
- } for username, entry in sessions.items()]
799
-
800
- # Sort by timestamp descending
801
- result.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
802
- return {"sessions": result}
803
-
804
-
805
- def _strip_group_speaker(content, known_ids):
806
- """Drop the `wxid_...:` prefix group rows carry in their content.
807
-
808
- Only an id the shard's Name2Id actually knows is accepted, so a message
809
- that merely starts with `note: ...` is left alone.
810
- """
811
- match = re.match(r'^([A-Za-z0-9_@.-]{5,64})\s*[::]\s', str(content or ''))
812
- if not match or match.group(1) not in known_ids:
813
- return content
814
- return content[match.end():]
815
-
816
-
817
- def _message_dict(row, sender_id_map, name_map, own_wxid, is_group=False):
818
- """One message row -> the CLI's message shape."""
819
- local_type = row[2] or 0
820
- create_time = row[5] or 0
821
- real_sender_id = row[4] or 0
822
-
823
- # Resolve sender: real_sender_id -> Name2Id -> user_name
824
- sender_username = sender_id_map.get(real_sender_id, "")
825
-
826
- # Determine if message is from self
827
- # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
828
- is_self = bool(own_wxid and (
829
- sender_username == own_wxid or
830
- (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
831
- ))
832
- if not is_self and own_wxid:
833
- # Strip _xxxx suffix and retry
834
- parts = own_wxid.rsplit('_', 1)
835
- if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
836
- is_self = (sender_username == parts[0])
837
-
838
- # Resolve sender display name from contact map
839
- if is_self:
840
- sender_display = "" # Let the CLI show "我"
841
- else:
842
- sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
843
-
844
- # Parse message_content - TEXT column
845
- content = row[12] if isinstance(row[12], str) else ""
846
-
847
- # `content`/`rawContent` stay exactly as stored; only `parsedContent` - the
848
- # field every consumer reads first - gets the display-ready form.
849
- display = _strip_group_speaker(content, set(sender_id_map.values())) if is_group else content
850
-
851
- return {
852
- "localId": row[0] or 0,
853
- "serverId": str(row[1] or ''),
854
- "localType": local_type,
855
- "createTime": create_time,
856
- "isSend": 1 if is_self else 0, # 1 = I sent this
857
- "senderUsername": sender_username,
858
- "senderDisplay": sender_display,
859
- "content": content,
860
- "rawContent": content,
861
- "parsedContent": display[:200] if local_type == 1 else "",
862
- }
863
-
864
-
865
- def get_messages(conns, talker, limit=100, offset=0, name_map=None, own_wxid=None,
866
- shard_names=None, shard_report=None):
867
- """Get messages for a specific talker, merged across every shard.
868
-
869
- Args:
870
- name_map: optional {wxid: display_name} dict for resolving sender names
871
- own_wxid: account owner wxid for self-message detection
872
- shard_names: optional basename per connection, parallel to `conns`
873
- shard_report: optional list to append one outcome dict per shard to.
874
- Purely additive: the returned messages are identical with or
875
- without it, which is asserted by test/nt_decrypt_shards_test.py.
876
- """
877
- if name_map is None:
878
- name_map = {}
879
-
880
- msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
881
- is_group = '@chatroom' in talker
882
-
883
- def record(index, opened, has_table, rows_for_talker, reason):
884
- if shard_report is None:
885
- return
886
- name = shard_names[index] if shard_names and index < len(shard_names) else ''
887
- shard_report.append({
888
- 'name': name,
889
- 'opened': opened,
890
- 'hasTalkerTable': has_table,
891
- 'rowsForTalker': rows_for_talker,
892
- 'reason': reason,
893
- })
894
-
895
- # Each shard only needs to yield its newest window: once every shard's rows
896
- # are merged and re-sorted, nothing older than that can reach this page.
897
- window = 0 if limit <= 0 else limit + offset
898
- collected = []
899
- found = False
900
-
901
- for index, conn in enumerate(conns):
902
- c = conn.cursor()
903
- try:
904
- c.execute("SELECT COUNT(*) FROM sqlite_master WHERE name=?", (msg_table,))
905
- if c.fetchone()[0] == 0:
906
- # The shard is readable and simply holds nothing for this
907
- # conversation - distinct from not being readable at all.
908
- record(index, True, False, None, None)
909
- continue
910
- found = True
911
- rows_for_talker = c.execute(
912
- 'SELECT COUNT(*) FROM "%s"' % msg_table).fetchone()[0]
913
-
914
- sql = f'''
915
- SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
916
- create_time, status, upload_status, download_status,
917
- server_seq, origin_source, source, message_content, compress_content
918
- FROM "{msg_table}"
919
- ORDER BY create_time DESC, local_id DESC
920
- '''
921
- if window:
922
- c.execute(sql + ' LIMIT ?', (window,))
923
- else:
924
- c.execute(sql)
925
- rows = c.fetchall()
926
-
927
- # Sender ids are rowids, so the map has to come from the same shard.
928
- c.execute("SELECT rowid, user_name FROM Name2Id")
929
- sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
930
- except Exception:
931
- # This shard had the conversation's table but could not be read.
932
- # Previously this was indistinguishable from "no rows here".
933
- record(index, True, None, None, 'READ_FAILED')
934
- continue
935
- record(index, True, True, rows_for_talker, None)
936
-
937
- for row in rows:
938
- collected.append(_message_dict(row, sender_id_map, name_map, own_wxid, is_group))
939
-
940
- if not found:
941
- return {"error": f"未找到会话: {talker}"}
942
-
943
- collected.sort(key=lambda m: (m["createTime"], m["localId"]), reverse=True)
944
- if limit > 0:
945
- collected = collected[offset:offset + limit]
946
- return {"messages": collected}
947
-
948
-
949
- def get_contacts(conns, limit=200):
950
- """Get contacts from NT database, merged across every shard."""
951
- seen = set()
952
- contacts = []
953
- for conn in conns:
954
- try:
955
- rows = conn.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,)).fetchall()
956
- except Exception:
957
- continue
958
- for (username,) in rows:
959
- # Name2Id carries a placeholder row with no user_name; rendering it
960
- # produced a blank line at the top of every contact list.
961
- if not username or username in seen:
962
- continue
963
- seen.add(username)
964
- contacts.append({"username": username, "displayName": username})
965
- return {"contacts": contacts}
966
-
967
-
968
- # ========== SNS (朋友圈) Queries ==========
969
-
970
- def parse_sns_content(content_str):
971
- """Parse SNS content XML/Protobuf text to extract title, description, media etc."""
972
- result = {
973
- 'content': '',
974
- 'create_time': 0,
975
- 'username': '',
976
- 'object_id': '',
977
- 'media_count': 0,
978
- }
979
- if not content_str:
980
- return result
981
-
982
- import re
983
-
984
- # Extract createTime
985
- m = re.search(r'<createTime>(\d+)</createTime>', content_str)
986
- if m:
987
- result['create_time'] = int(m.group(1))
988
-
989
- # Extract username
990
- m = re.search(r'<username>([^<]+)</username>', content_str)
991
- if m:
992
- result['username'] = m.group(1)
993
-
994
- # Extract id
995
- m = re.search(r'<id>(\d+)</id>', content_str)
996
- if m:
997
- result['object_id'] = m.group(1)
998
-
999
- # Extract contentDesc (main text)
1000
- m = re.search(r'<contentDesc>([^<]*)</contentDesc>', content_str)
1001
- if m:
1002
- result['content'] = m.group(1)
1003
-
1004
- # Extract contentDesc CDATA
1005
- m = re.search(r'<contentDesc>\s*<!\[CDATA\[(.*?)\]\]>\s*</contentDesc>', content_str, re.DOTALL)
1006
- if m:
1007
- result['content'] = m.group(1).strip()
1008
-
1009
- # Extract title if present
1010
- m = re.search(r'<title>([^<]*)</title>', content_str)
1011
- if m:
1012
- title = m.group(1)
1013
- if title and not result['content']:
1014
- result['content'] = title
1015
- elif title:
1016
- result['content'] = title + '\n' + result['content']
1017
-
1018
- # Count media (ContentObject tags)
1019
- result['media_count'] = len(re.findall(r'<ContentObject[ >]', content_str))
1020
-
1021
- return result
1022
-
1023
-
1024
- def get_sns_timeline(cursor, limit=20, offset=0, usernames=None, keyword=None,
1025
- start_time=None, end_time=None):
1026
- """Query SNS timeline posts from SnsTimeLine table."""
1027
- # Check if SnsTimeLine exists, fall back to SnsTopItem_1
1028
- tables = [r[0] for r in cursor.execute(
1029
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1030
-
1031
- table = 'SnsTimeLine' if 'SnsTimeLine' in tables else None
1032
- if not table:
1033
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else None
1034
- if not table:
1035
- return {'success': False, 'error': 'No SNS table found'}
1036
-
1037
- conditions = []
1038
- params = []
1039
-
1040
- if table == 'SnsTimeLine':
1041
- if usernames:
1042
- placeholders = ','.join(['?' for _ in usernames])
1043
- conditions.append(f'user_name IN ({placeholders})')
1044
- params.extend(usernames)
1045
- if keyword:
1046
- conditions.append('content LIKE ?')
1047
- params.append(f'%{keyword}%')
1048
- if start_time:
1049
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) >= ?")
1050
- params.append(start_time)
1051
- if end_time:
1052
- conditions.append("CAST(substr(content, instr(content, '<createTime>') + 12, 10) AS INTEGER) <= ?")
1053
- params.append(end_time)
1054
- elif table == 'SnsTopItem_1':
1055
- uname_col = 'username' if 'username' in [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()] else 'user_name'
1056
- if usernames:
1057
- placeholders = ','.join(['?' for _ in usernames])
1058
- conditions.append(f'{uname_col} IN ({placeholders})')
1059
- params.extend(usernames)
1060
- if keyword:
1061
- conditions.append('summary LIKE ?')
1062
- params.append(f'%{keyword}%')
1063
- if start_time:
1064
- conditions.append('create_time >= ?')
1065
- params.append(start_time)
1066
- if end_time:
1067
- conditions.append('create_time <= ?')
1068
- params.append(end_time)
1069
-
1070
- where = ' AND '.join(conditions) if conditions else '1=1'
1071
- order = 'tid DESC' if table == 'SnsTimeLine' else 'create_time DESC'
1072
-
1073
- try:
1074
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1075
- # Only select known text columns to avoid binary decode errors
1076
- text_cols = [c for c in cols if c in ('tid', 'user_name', 'content', 'username', 'summary',
1077
- 'create_time', 'last_read_time', 'is_read',
1078
- 'from_username', 'from_nickname', 'to_username',
1079
- 'to_nickname', 'comment_id', 'feed_id',
1080
- 'createTime', 'userName')]
1081
- if not text_cols:
1082
- text_cols = ['*']
1083
- select_str = ', '.join(text_cols)
1084
- rows = cursor.execute(
1085
- f'SELECT {select_str} FROM [{table}] WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?',
1086
- params + [limit, offset]
1087
- ).fetchall()
1088
-
1089
- timeline = []
1090
-
1091
- for row in rows:
1092
- item = dict(zip(text_cols, row))
1093
-
1094
- if table == 'SnsTimeLine':
1095
- # Parse XML content
1096
- content_str = item.get('content', '') or ''
1097
- parsed = parse_sns_content(content_str)
1098
- create_time = parsed['create_time'] or 0
1099
- username = parsed['username'] or item.get('user_name', '')
1100
- text = parsed['content'] or ''
1101
- media_count = parsed['media_count']
1102
- else:
1103
- create_time = item.get('create_time', 0) or 0
1104
- username = item.get('username', '') or item.get('user_name', '')
1105
- text = item.get('summary', '') or ''
1106
- media_count = 0
1107
-
1108
- timeline.append({
1109
- 'create_time': create_time,
1110
- 'username': username,
1111
- 'content': text,
1112
- 'media_count': media_count,
1113
- 'table': table,
1114
- })
1115
-
1116
- return {'success': True, 'timeline': timeline, 'table': table}
1117
- except Exception as e:
1118
- return {'success': False, 'error': str(e)}
1119
-
1120
-
1121
- def get_sns_usernames(cursor):
1122
- """Get unique usernames from SnsTopItem_1."""
1123
- tables = [r[0] for r in cursor.execute(
1124
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1125
-
1126
- # Prefer SnsTopItem_1 for user listing (larger)
1127
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1128
- if not table:
1129
- return {'success': False, 'error': 'No SNS table found'}
1130
-
1131
- # Discover username column
1132
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1133
- uname_col = 'username' if 'username' in cols else 'user_name'
1134
-
1135
- try:
1136
- rows = cursor.execute(
1137
- f'SELECT [{uname_col}], COUNT(*) as cnt FROM [{table}] GROUP BY [{uname_col}] ORDER BY cnt DESC'
1138
- ).fetchall()
1139
- usernames = [r[0] for r in rows if r[0]]
1140
- counts = {r[0]: r[1] for r in rows if r[0]}
1141
- return {'success': True, 'usernames': usernames, 'counts': counts}
1142
- except Exception as e:
1143
- return {'success': False, 'error': str(e)}
1144
-
1145
-
1146
- def get_sns_stats(cursor, my_wxid=None):
1147
- """Get SNS statistics from SnsTopItem_1."""
1148
- tables = [r[0] for r in cursor.execute(
1149
- "SELECT name FROM sqlite_master WHERE type='table';").fetchall()]
1150
-
1151
- table = 'SnsTopItem_1' if 'SnsTopItem_1' in tables else ('SnsTimeLine' if 'SnsTimeLine' in tables else None)
1152
- if not table:
1153
- return {'success': False, 'error': 'No SNS table found'}
1154
-
1155
- try:
1156
- total = cursor.execute(f'SELECT COUNT(*) FROM [{table}]').fetchone()[0]
1157
-
1158
- cols = [r[1] for r in cursor.execute(f'PRAGMA table_info([{table}]);').fetchall()]
1159
- uname_col = 'username' if 'username' in cols else 'user_name'
1160
-
1161
- total_friends = 0
1162
- if uname_col:
1163
- total_friends = cursor.execute(
1164
- f'SELECT COUNT(DISTINCT [{uname_col}]) FROM [{table}] WHERE [{uname_col}] IS NOT NULL'
1165
- ).fetchone()[0]
1166
-
1167
- my_posts = None
1168
- if my_wxid and uname_col:
1169
- my_posts = cursor.execute(
1170
- f'SELECT COUNT(*) FROM [{table}] WHERE [{uname_col}] = ?',
1171
- (my_wxid,)).fetchone()[0]
1172
-
1173
- return {
1174
- 'success': True,
1175
- 'data': {
1176
- 'totalPosts': total,
1177
- 'totalFriends': total_friends,
1178
- 'myPosts': my_posts,
1179
- }
1180
- }
1181
- except Exception as e:
1182
- return {'success': False, 'error': str(e)}
1183
-
1184
-
1185
- # ========== Main CLI ==========
1186
-
1187
- def main():
1188
- import argparse
1189
- parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
1190
- sub = parser.add_subparsers(dest='command')
1191
-
1192
- # scan command
1193
- scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
1194
- scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
1195
- scan_parser.add_argument('--root', default=os.environ.get('WEFLOW_SCAN_ROOT'), help='xwechat_files root directory override')
1196
-
1197
- # sessions command
1198
- sessions_parser = sub.add_parser('sessions', help='List chat sessions')
1199
- 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')
1200
- 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)')
1201
- 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)')
1202
- sessions_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1203
- sessions_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1204
- sessions_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1205
- sessions_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1206
- sessions_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1207
-
1208
- # messages command
1209
- msg_parser = sub.add_parser('messages', help='Get messages')
1210
- 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')
1211
- 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)')
1212
- 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)')
1213
- msg_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1214
- msg_parser.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'), required=not os.environ.get('WEFLOW_TALKER'), help='Talker username')
1215
- msg_parser.add_argument('--limit', type=int, default=100)
1216
- msg_parser.add_argument('--offset', type=int, default=0)
1217
- msg_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for sender names')
1218
- msg_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1219
- msg_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1220
- msg_parser.add_argument('--own-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid (for self-message detection)')
1221
- # Opt-in: without it the JSON is byte-identical to before this flag existed.
1222
- msg_parser.add_argument('--report-shards', action='store_true',
1223
- help='Add a per-shard read report to the result (additive)')
1224
-
1225
- # contacts command
1226
- contacts_parser = sub.add_parser('contacts', help='List contacts')
1227
- 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')
1228
- 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)')
1229
- 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)')
1230
- contacts_parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE'), help='Shared passphrase for deriving per-shard keys')
1231
- contacts_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Filter by keyword')
1232
- contacts_parser.add_argument('--limit', type=int, default=200)
1233
- contacts_parser.add_argument('--contact-db', default=os.environ.get('WEFLOW_CONTACT_DB'), help='Path to contact.db for display names')
1234
- contacts_parser.add_argument('--contact-key', default=os.environ.get('WEFLOW_CONTACT_KEY'), help='Contact DB key hex (64 chars)')
1235
- contacts_parser.add_argument('--contact-salt', default=os.environ.get('WEFLOW_CONTACT_SALT'), help='Contact DB salt hex (32 chars)')
1236
-
1237
- # sns-timeline command
1238
- sns_tl_parser = sub.add_parser('sns-timeline', help='Get SNS/Moments timeline')
1239
- 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')
1240
- 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)')
1241
- 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)')
1242
- sns_tl_parser.add_argument('--limit', type=int, default=20)
1243
- sns_tl_parser.add_argument('--offset', type=int, default=0)
1244
- sns_tl_parser.add_argument('--usernames', default=os.environ.get('WEFLOW_QUERY_USERNAMES'), help='JSON array of usernames to filter')
1245
- sns_tl_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1246
- sns_tl_parser.add_argument('--start-time', type=int, help='Start timestamp')
1247
- sns_tl_parser.add_argument('--end-time', type=int, help='End timestamp')
1248
-
1249
- # sns-usernames command
1250
- sns_un_parser = sub.add_parser('sns-usernames', help='List usernames with SNS posts')
1251
- 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')
1252
- 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)')
1253
- 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)')
1254
-
1255
- # sns-stats command
1256
- sns_stats_parser = sub.add_parser('sns-stats', help='SNS statistics')
1257
- 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')
1258
- 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)')
1259
- 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)')
1260
- sns_stats_parser.add_argument('--my-wxid', default=os.environ.get('WEFLOW_OWN_WXID'), help='Account owner wxid for my-posts count')
1261
-
1262
- # fav-schema command
1263
- fav_schema_parser = sub.add_parser('fav-schema', help='Dump favorite.db schema (key verification)')
1264
- 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')
1265
- 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)')
1266
-
1267
- verify_parser = sub.add_parser('verify', help='Verify a key+salt can open a database')
1268
- 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')
1269
- 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)')
1270
- 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)')
1271
-
1272
- # fav-list command
1273
- fav_list_parser = sub.add_parser('fav-list', help='List favorite items')
1274
- 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')
1275
- 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)')
1276
- fav_list_parser.add_argument('--limit', type=int, default=100)
1277
- fav_list_parser.add_argument('--offset', type=int, default=0)
1278
- fav_list_parser.add_argument('--keyword', default=os.environ.get('WEFLOW_QUERY_KEYWORD'), help='Search keyword')
1279
- fav_list_parser.add_argument('--type', type=int, dest='fav_type',
1280
- help='Filter by type: 1=text 2=image 4=video 5=article 14=chatrecord')
1281
-
1282
- args = parser.parse_args()
1283
-
1284
- if args.command == 'scan':
1285
- # Discover the database files before touching the process. Walking the
1286
- # filesystem does not need WeChat running, but key matching does - and
1287
- # the caller derives keys from the passphrase when the memory scan
1288
- # finds nothing, which only needs the file list. Returning early on
1289
- # "process not running" without the databases threw that list away and
1290
- # left the caller with nothing to derive from.
1291
- databases = find_nt_databases(getattr(args, 'root', None))
1292
-
1293
- pid = find_weixin_pid()
1294
- if not pid:
1295
- if IS_WINDOWS:
1296
- print(json.dumps({"error": "Weixin.exe 未运行", "databases": databases}))
1297
- else:
1298
- print(json.dumps({"error": "未检测到 Linux 微信进程,请确认微信已启动并登录",
1299
- "databases": databases}))
1300
- return
1301
-
1302
- if not args.json:
1303
- print(f"扫描进程 PID {pid}...")
1304
-
1305
- keys, scan_err = scan_memory_keys(pid)
1306
- if scan_err == 'permission':
1307
- print(json.dumps({"error": "PERMISSION_DENIED: 读取微信进程内存需要 root 或 CAP_SYS_PTRACE 权限。可使用 sudo 运行,或执行: sudo setcap cap_sys_ptrace=ep $(which python3)",
1308
- "databases": databases}))
1309
- return
1310
- if scan_err == 'gone':
1311
- print(json.dumps({"error": "微信进程已退出,请重试", "databases": databases}))
1312
- return
1313
- if not args.json:
1314
- print(f"找到 {len(keys)} 个密钥")
1315
-
1316
- if not args.json:
1317
- print(f"找到 {len(databases)} 个 NT 数据库")
1318
-
1319
- matched = match_keys_to_databases(keys, databases)
1320
- if not args.json:
1321
- print(f"匹配 {len(matched)} 个数据库")
1322
- for db in matched:
1323
- print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
1324
- else:
1325
- print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
1326
- return
1327
-
1328
- if args.command == 'verify':
1329
- # sqlcipher 打开+读 sqlite_master 才触发解密, 错误密钥在此报 "file is not a database"
1330
- try:
1331
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1332
- n = conn.execute(
1333
- "SELECT count(*) FROM sqlite_master WHERE type='table'"
1334
- ).fetchone()[0]
1335
- conn.close()
1336
- print(json.dumps({"success": n > 0, "tables": n}, ensure_ascii=True))
1337
- except Exception as e:
1338
- print(json.dumps({"success": False, "error": str(e).split('\n')[0][:200]}, ensure_ascii=True))
1339
- return
1340
-
1341
- # Build contact name map once if contact db provided
1342
- contact_name_map = {}
1343
- contact_db = getattr(args, 'contact_db', None)
1344
- contact_key = getattr(args, 'contact_key', None)
1345
- contact_salt = getattr(args, 'contact_salt', None)
1346
- if contact_db and contact_key and contact_salt:
1347
- contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
1348
-
1349
- if args.command == 'sessions':
1350
- conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1351
- if not conns:
1352
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1353
- return
1354
- result = get_sessions(conns)
1355
- if 'sessions' in result:
1356
- result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
1357
- if args.keyword:
1358
- kw = args.keyword.lower()
1359
- result['sessions'] = [
1360
- s for s in result['sessions']
1361
- if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
1362
- ]
1363
- print(json.dumps(result, ensure_ascii=True))
1364
- for conn in conns:
1365
- conn.close()
1366
-
1367
- elif args.command == 'messages':
1368
- passphrase = getattr(args, 'passphrase', '') or ''
1369
- own_wxid = getattr(args, 'own_wxid', None)
1370
-
1371
- if getattr(args, 'report_shards', False):
1372
- pairs, failures = connect_message_shards_detailed(
1373
- args.db, args.key, args.salt, passphrase)
1374
- if not pairs:
1375
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1376
- return
1377
- conns = [conn for _path, conn in pairs]
1378
- names = [os.path.basename(path) for path, _conn in pairs]
1379
- shard_items = []
1380
- result = get_messages(conns, args.talker, args.limit, args.offset,
1381
- contact_name_map, own_wxid,
1382
- shard_names=names, shard_report=shard_items)
1383
- # Failure entries carry no opened connection, so they are not in
1384
- # `conns` and could not be recorded by get_messages itself.
1385
- result['shards'] = {
1386
- 'scanned': len(pairs) + len(failures),
1387
- 'opened': len(pairs),
1388
- 'failed': len(failures),
1389
- 'items': failures + shard_items,
1390
- }
1391
- else:
1392
- conns = connect_message_shards(args.db, args.key, args.salt, passphrase)
1393
- if not conns:
1394
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1395
- return
1396
- result = get_messages(conns, args.talker, args.limit, args.offset,
1397
- contact_name_map, own_wxid)
1398
-
1399
- print(json.dumps(result, ensure_ascii=True))
1400
- for conn in conns:
1401
- conn.close()
1402
-
1403
- elif args.command == 'contacts':
1404
- conns = connect_message_shards(args.db, args.key, args.salt, getattr(args, 'passphrase', '') or '')
1405
- if not conns:
1406
- print(json.dumps({"error": "无法打开消息数据库,请检查密钥"}, ensure_ascii=True))
1407
- return
1408
- result = get_contacts(conns, args.limit)
1409
- if 'contacts' in result:
1410
- result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
1411
- if args.keyword:
1412
- kw = args.keyword.lower()
1413
- result['contacts'] = [
1414
- c for c in result['contacts']
1415
- if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
1416
- ]
1417
- print(json.dumps(result, ensure_ascii=True))
1418
- for conn in conns:
1419
- conn.close()
1420
-
1421
- elif args.command == 'sns-timeline':
1422
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1423
- usernames = None
1424
- if args.usernames:
1425
- try:
1426
- usernames = json.loads(args.usernames)
1427
- except: pass
1428
- result = get_sns_timeline(conn.cursor(), args.limit, args.offset,
1429
- usernames, args.keyword,
1430
- args.start_time, args.end_time)
1431
- print(json.dumps(result, ensure_ascii=True, default=str))
1432
- conn.close()
1433
-
1434
- elif args.command == 'sns-usernames':
1435
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1436
- result = get_sns_usernames(conn.cursor())
1437
- print(json.dumps(result, ensure_ascii=True, default=str))
1438
- conn.close()
1439
-
1440
- elif args.command == 'sns-stats':
1441
- conn, _ = connect_nt_db(args.db, args.key, args.salt)
1442
- result = get_sns_stats(conn.cursor(), args.my_wxid)
1443
- print(json.dumps(result, ensure_ascii=True, default=str))
1444
- conn.close()
1445
-
1446
- elif args.command == 'fav-schema':
1447
- result = get_fav_schema(args.db, args.key)
1448
- print(json.dumps(result, ensure_ascii=True))
1449
-
1450
- elif args.command == 'fav-list':
1451
- result = get_favorites(args.db, args.key, args.limit, args.offset,
1452
- args.keyword, getattr(args, 'fav_type', None))
1453
- print(json.dumps(result, ensure_ascii=True, default=str))
1454
-
1455
- else:
1456
- parser.print_help()
1457
-
1458
-
1459
- if __name__ == '__main__':
1460
- main()
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&amp;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()