weflow-cli 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,562 @@
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 ctypes
11
+ from ctypes import wintypes, c_void_p, c_size_t, create_string_buffer, byref, sizeof
12
+ from pathlib import Path
13
+
14
+ try:
15
+ from sqlcipher3 import dbapi2 as sqlcipher
16
+ except ImportError:
17
+ print(json.dumps({"error": "需要 sqlcipher3: pip install sqlcipher3"}))
18
+ sys.exit(1)
19
+
20
+ # ========== Memory Scanner ==========
21
+ PROCESS_VM_READ = 0x0010
22
+ PROCESS_QUERY_INFORMATION = 0x0400
23
+ MEM_COMMIT = 0x1000
24
+ PAGE_NOACCESS = 0x01
25
+ PAGE_GUARD = 0x100
26
+
27
+ class MEMORY_BASIC_INFORMATION(ctypes.Structure):
28
+ _fields_ = [
29
+ ('BaseAddress', ctypes.c_void_p),
30
+ ('AllocationBase', ctypes.c_void_p),
31
+ ('AllocationProtect', wintypes.DWORD),
32
+ ('PartitionId', wintypes.WORD),
33
+ ('RegionSize', ctypes.c_size_t),
34
+ ('State', wintypes.DWORD),
35
+ ('Protect', wintypes.DWORD),
36
+ ('Type', wintypes.DWORD),
37
+ ]
38
+
39
+ kernel32 = ctypes.windll.kernel32
40
+ ReadProcessMemory = kernel32.ReadProcessMemory
41
+ ReadProcessMemory.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
42
+ ReadProcessMemory.restype = wintypes.BOOL
43
+ VirtualQueryEx = kernel32.VirtualQueryEx
44
+ VirtualQueryEx.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
45
+ VirtualQueryEx.restype = ctypes.c_size_t
46
+
47
+
48
+ def find_weixin_pid():
49
+ """Find Weixin.exe process ID."""
50
+ try:
51
+ import pymem, pymem.process
52
+ for proc in pymem.process.list_processes():
53
+ try:
54
+ name = proc.szExeFile
55
+ if isinstance(name, bytes):
56
+ name = name.decode('utf-8', errors='ignore')
57
+ if name.lower() == 'weixin.exe':
58
+ return proc.th32ProcessID
59
+ except:
60
+ pass
61
+ except ImportError:
62
+ pass
63
+ return None
64
+
65
+
66
+ def scan_memory_keys(pid):
67
+ """Scan process memory for x'<64hex_key><32hex_salt>' patterns."""
68
+ hProcess = kernel32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
69
+ if not hProcess:
70
+ return []
71
+
72
+ pattern = re.compile(rb"x'([0-9a-fA-F]{64})([0-9a-fA-F]{32})'")
73
+ keys_found = []
74
+ address = 0x10000
75
+
76
+ while address < 0x7FFFFFFFFFFF:
77
+ mbi = MEMORY_BASIC_INFORMATION()
78
+ result = VirtualQueryEx(hProcess, ctypes.c_void_p(address), ctypes.byref(mbi), sizeof(mbi))
79
+ if result == 0:
80
+ break
81
+
82
+ region_addr = mbi.BaseAddress or 0
83
+ region_size = mbi.RegionSize or 0
84
+
85
+ if (mbi.State == MEM_COMMIT and
86
+ region_size > 256 and region_size < 200 * 1024 * 1024 and
87
+ mbi.Protect not in (0, PAGE_NOACCESS, PAGE_GUARD)):
88
+
89
+ pos = region_addr
90
+ end = region_addr + region_size
91
+ while pos < end:
92
+ chunk_size = min(65536, end - pos)
93
+ buf = create_string_buffer(chunk_size)
94
+ bytesRead = c_size_t(0)
95
+ ok = ReadProcessMemory(hProcess, ctypes.c_void_p(pos), buf, chunk_size, byref(bytesRead))
96
+ if ok and bytesRead.value > 0:
97
+ data = buf.raw[:bytesRead.value]
98
+ for m in pattern.finditer(data):
99
+ key_hex = m.group(1).decode()
100
+ salt_hex = m.group(2).decode()
101
+ keys_found.append((key_hex, salt_hex))
102
+ pos += chunk_size
103
+
104
+ address = region_addr + region_size
105
+
106
+ kernel32.CloseHandle(hProcess)
107
+
108
+ # Deduplicate
109
+ seen = set()
110
+ unique_keys = []
111
+ for k, s in keys_found:
112
+ pair = (k, s)
113
+ if pair not in seen:
114
+ seen.add(pair)
115
+ unique_keys.append({"key": k, "salt": s})
116
+
117
+ return unique_keys
118
+
119
+
120
+ # ========== NT Database Discovery ==========
121
+
122
+ def find_nt_databases():
123
+ """Find all NT-format databases under xwechat_files (message + contact)."""
124
+ candidates = [
125
+ os.path.expandvars(r'%USERPROFILE%\xwechat_files'),
126
+ os.path.expandvars(r'%USERPROFILE%\Documents\xwechat_files'),
127
+ ]
128
+ xwechat = None
129
+ for c in candidates:
130
+ if os.path.isdir(c):
131
+ xwechat = c
132
+ break
133
+ if not xwechat:
134
+ return []
135
+
136
+ databases = []
137
+ for wxid_dir in os.listdir(xwechat):
138
+ # Scan message databases
139
+ msg_storage = os.path.join(xwechat, wxid_dir, 'db_storage', 'message')
140
+ if os.path.isdir(msg_storage):
141
+ for f in os.listdir(msg_storage):
142
+ if f.endswith('.db') and not any(x in f for x in ['-shm', '-wal']):
143
+ full_path = os.path.join(msg_storage, f)
144
+ try:
145
+ with open(full_path, 'rb') as fh:
146
+ salt = fh.read(16)
147
+ databases.append({
148
+ "path": full_path,
149
+ "name": f"message/{f}",
150
+ "salt": salt.hex(),
151
+ "size": os.path.getsize(full_path),
152
+ "wxid": wxid_dir,
153
+ })
154
+ except:
155
+ pass
156
+
157
+ # Scan contact database
158
+ contact_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'contact', 'contact.db')
159
+ if os.path.isfile(contact_db):
160
+ try:
161
+ with open(contact_db, 'rb') as fh:
162
+ salt = fh.read(16)
163
+ databases.append({
164
+ "path": contact_db,
165
+ "name": "contact/contact.db",
166
+ "salt": salt.hex(),
167
+ "size": os.path.getsize(contact_db),
168
+ "wxid": wxid_dir,
169
+ })
170
+ except:
171
+ pass
172
+
173
+ # Scan SNS (朋友圈) database
174
+ sns_db = os.path.join(xwechat, wxid_dir, 'db_storage', 'sns', 'sns.db')
175
+ if os.path.isfile(sns_db):
176
+ try:
177
+ with open(sns_db, 'rb') as fh:
178
+ salt = fh.read(16)
179
+ databases.append({
180
+ "path": sns_db,
181
+ "name": "sns/sns.db",
182
+ "salt": salt.hex(),
183
+ "size": os.path.getsize(sns_db),
184
+ "wxid": wxid_dir,
185
+ })
186
+ except:
187
+ pass
188
+
189
+ return databases
190
+
191
+
192
+ def find_contact_db_path(message_db_path):
193
+ """Derive contact.db path from message_0.db path.
194
+
195
+ message_0.db: <xwechat_files>/<wxid>/db_storage/message/message_0.db
196
+ contact.db: <xwechat_files>/<wxid>/db_storage/contact/contact.db
197
+ """
198
+ msg_dir = os.path.dirname(message_db_path)
199
+ wxid_dir = os.path.dirname(msg_dir) # .../db_storage
200
+ xwechat_dir = os.path.dirname(wxid_dir) # .../<wxid>
201
+ contact_db = os.path.join(xwechat_dir, 'db_storage', 'contact', 'contact.db')
202
+ if os.path.isfile(contact_db):
203
+ return contact_db
204
+ return None
205
+
206
+
207
+ def load_contact_names(contact_db_path, contact_key_hex, contact_salt_hex):
208
+ """Load wxid -> {remark, nick_name} map from contact.db.
209
+
210
+ Returns dict: {wxid: display_name}
211
+ display_name priority: remark > nick_name > alias > wxid
212
+ """
213
+ if not contact_db_path or not contact_key_hex or not contact_salt_hex:
214
+ return {}
215
+
216
+ try:
217
+ raw_key = f"x'{contact_key_hex}{contact_salt_hex}'"
218
+ conn = sqlcipher.connect(contact_db_path)
219
+ c = conn.cursor()
220
+ c.execute(f'PRAGMA key = "{raw_key}";')
221
+
222
+ # contact.db schema: username, alias, remark, nick_name, ...
223
+ c.execute("SELECT username, COALESCE(NULLIF(remark,''), NULLIF(nick_name,''), NULLIF(alias,''), username) FROM contact")
224
+ name_map = {}
225
+ for username, display in c.fetchall():
226
+ if username:
227
+ name_map[username] = display
228
+
229
+ conn.close()
230
+ return name_map
231
+ except Exception as e:
232
+ return {}
233
+
234
+
235
+ def apply_contact_names(sessions, name_map):
236
+ """Apply contact names to session list, replacing bare wxid displayNames."""
237
+ if not name_map:
238
+ return sessions
239
+ for s in sessions:
240
+ username = s.get('username', '')
241
+ if username in name_map:
242
+ s['displayName'] = name_map[username]
243
+ return sessions
244
+
245
+
246
+ def match_keys_to_databases(keys, databases):
247
+ """Match memory keys to databases by comparing salts."""
248
+ salt_to_key = {}
249
+ for k in keys:
250
+ salt_to_key[k["salt"]] = k["key"]
251
+
252
+ matched = []
253
+ for db in databases:
254
+ if db["salt"] in salt_to_key:
255
+ db["key"] = salt_to_key[db["salt"]]
256
+ matched.append(db)
257
+
258
+ return matched
259
+
260
+
261
+ # ========== Database Operations ==========
262
+
263
+ def connect_nt_db(db_path, key_hex, salt_hex):
264
+ """Connect to an NT database using sqlcipher3."""
265
+ raw_key = f"x'{key_hex}{salt_hex}'"
266
+ conn = sqlcipher.connect(db_path)
267
+ c = conn.cursor()
268
+ c.execute(f'PRAGMA key = "{raw_key}";')
269
+ return conn, c
270
+
271
+
272
+ def get_sessions(conn):
273
+ """Get chat sessions from NT database (Name2Id table)."""
274
+ c = conn.cursor()
275
+ sessions = []
276
+
277
+ # NT format: each chat has its own Msg_<MD5> table
278
+ # The Name2Id table maps usernames to IDs (user_name, is_session)
279
+ try:
280
+ c.execute("SELECT user_name FROM Name2Id WHERE is_session = 1 LIMIT 500")
281
+ rows = c.fetchall()
282
+
283
+ import hashlib as hl
284
+
285
+ for (username,) in rows:
286
+ summary = ""
287
+ last_time = 0
288
+
289
+ # Try to get last message summary
290
+ try:
291
+ tbl_hash = hl.md5(username.encode()).hexdigest()
292
+ msg_table = f"Msg_{tbl_hash}"
293
+
294
+ c.execute(f'SELECT create_time, source, message_content, local_type FROM "{msg_table}" ORDER BY create_time DESC LIMIT 1')
295
+ row = c.fetchone()
296
+ if row:
297
+ last_time = row[0] or 0
298
+ msg_type = row[3] or 0
299
+ source_text = row[1]
300
+ content_text = row[2]
301
+
302
+ if msg_type == 1:
303
+ # Text message: use message_content
304
+ if isinstance(content_text, str) and content_text:
305
+ summary = content_text[:50]
306
+ elif isinstance(content_text, bytes):
307
+ summary = content_text.decode('utf-8', errors='ignore')[:50]
308
+ elif isinstance(source_text, str) and source_text:
309
+ # Non-text: try to extract from source
310
+ # Strip XML tags for summary
311
+ import re as _re
312
+ clean = _re.sub(r'<[^>]+>', '', source_text)
313
+ lines = clean.split('\n')
314
+ if len(lines) > 1 and lines[1].strip():
315
+ summary = lines[1].strip()[:50]
316
+ elif clean.strip():
317
+ summary = clean.strip()[:50]
318
+ except:
319
+ pass
320
+
321
+ sessions.append({
322
+ "username": username,
323
+ "type": 1 if "@chatroom" in username else 0,
324
+ "unreadCount": 0,
325
+ "summary": summary,
326
+ "sortTimestamp": last_time,
327
+ "lastTimestamp": last_time,
328
+ "displayName": username,
329
+ })
330
+ except Exception as e:
331
+ return {"error": str(e)}
332
+
333
+ # Sort by timestamp descending
334
+ sessions.sort(key=lambda s: s.get("sortTimestamp", 0), reverse=True)
335
+ return {"sessions": sessions}
336
+
337
+
338
+ def get_messages(conn, talker, limit=100, offset=0, name_map=None, own_wxid=None):
339
+ """Get messages for a specific talker from NT database.
340
+
341
+ Args:
342
+ name_map: optional {wxid: display_name} dict for resolving sender names
343
+ own_wxid: account owner wxid for self-message detection
344
+ """
345
+ import hashlib
346
+ if name_map is None:
347
+ name_map = {}
348
+ c = conn.cursor()
349
+
350
+ msg_table = f"Msg_{hashlib.md5(talker.encode()).hexdigest()}"
351
+
352
+ try:
353
+ # Check if table exists
354
+ c.execute(f"SELECT COUNT(*) FROM sqlite_master WHERE name='{msg_table}'")
355
+ if c.fetchone()[0] == 0:
356
+ return {"error": f"未找到会话: {talker}"}
357
+
358
+ if limit > 0:
359
+ c.execute(f'''
360
+ SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
361
+ create_time, status, upload_status, download_status,
362
+ server_seq, origin_source, source, message_content, compress_content
363
+ FROM "{msg_table}"
364
+ ORDER BY create_time DESC
365
+ LIMIT ? OFFSET ?
366
+ ''', (limit, offset))
367
+ else:
368
+ c.execute(f'''
369
+ SELECT local_id, server_id, local_type, sort_seq, real_sender_id,
370
+ create_time, status, upload_status, download_status,
371
+ server_seq, origin_source, source, message_content, compress_content
372
+ FROM "{msg_table}"
373
+ ORDER BY create_time DESC
374
+ ''')
375
+
376
+ rows = c.fetchall()
377
+ messages = []
378
+
379
+ # Build sender_id -> username map from Name2Id (one query for all messages)
380
+ c.execute("SELECT rowid, user_name FROM Name2Id")
381
+ sender_id_map = {rowid: uname for rowid, uname in c.fetchall()}
382
+
383
+ for row in rows:
384
+ local_type = row[2] or 0
385
+ create_time = row[5] or 0
386
+ real_sender_id = row[4] or 0
387
+
388
+ # Resolve sender: real_sender_id -> Name2Id -> user_name
389
+ sender_username = sender_id_map.get(real_sender_id, "")
390
+
391
+ # Determine if message is from self
392
+ # own_wxid may have _xxxx suffix (from xwechat_files dir), try both
393
+ is_self = bool(own_wxid and (
394
+ sender_username == own_wxid or
395
+ (own_wxid.endswith('_') is False and sender_username.startswith(own_wxid))
396
+ ))
397
+ if not is_self and own_wxid:
398
+ # Strip _xxxx suffix and retry
399
+ parts = own_wxid.rsplit('_', 1)
400
+ if len(parts) == 2 and len(parts[1]) == 4 and parts[1].isalnum():
401
+ is_self = (sender_username == parts[0])
402
+
403
+ # Resolve sender display name from contact map
404
+ if is_self:
405
+ sender_display = "" # Let the CLI show "我"
406
+ else:
407
+ sender_display = name_map.get(sender_username, sender_username) if sender_username else sender_username
408
+
409
+ # Parse message_content - TEXT column
410
+ content = row[12] if isinstance(row[12], str) else ""
411
+
412
+ messages.append({
413
+ "localId": row[0] or 0,
414
+ "serverId": str(row[1] or ''),
415
+ "localType": local_type,
416
+ "createTime": create_time,
417
+ "isSend": 1 if is_self else 0, # 1 = I sent this
418
+ "senderUsername": sender_username,
419
+ "senderDisplay": sender_display,
420
+ "content": content,
421
+ "rawContent": content,
422
+ "parsedContent": content[:200] if local_type == 1 else "",
423
+ })
424
+
425
+ return {"messages": messages}
426
+ except Exception as e:
427
+ return {"error": str(e)}
428
+
429
+
430
+ def get_contacts(conn, limit=200):
431
+ """Get contacts from NT database."""
432
+ c = conn.cursor()
433
+ try:
434
+ c.execute("SELECT user_name FROM Name2Id LIMIT ?", (limit,))
435
+ rows = c.fetchall()
436
+ contacts = [{"username": r[0], "displayName": r[0]} for r in rows]
437
+ return {"contacts": contacts}
438
+ except Exception as e:
439
+ return {"error": str(e)}
440
+
441
+
442
+ # ========== Main CLI ==========
443
+
444
+ def main():
445
+ import argparse
446
+ parser = argparse.ArgumentParser(description='WeChat NT Database Tool')
447
+ sub = parser.add_subparsers(dest='command')
448
+
449
+ # scan command
450
+ scan_parser = sub.add_parser('scan', help='Scan memory for keys and match NT databases')
451
+ scan_parser.add_argument('--json', action='store_true', help='Output as JSON')
452
+
453
+ # sessions command
454
+ sessions_parser = sub.add_parser('sessions', help='List chat sessions')
455
+ sessions_parser.add_argument('--db', required=True, help='Path to NT database')
456
+ sessions_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
457
+ sessions_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
458
+ sessions_parser.add_argument('--keyword', help='Filter by keyword')
459
+ sessions_parser.add_argument('--contact-db', help='Path to contact.db for display names')
460
+ sessions_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
461
+ sessions_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
462
+
463
+ # messages command
464
+ msg_parser = sub.add_parser('messages', help='Get messages')
465
+ msg_parser.add_argument('--db', required=True, help='Path to NT database')
466
+ msg_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
467
+ msg_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
468
+ msg_parser.add_argument('--talker', required=True, help='Talker username')
469
+ msg_parser.add_argument('--limit', type=int, default=100)
470
+ msg_parser.add_argument('--offset', type=int, default=0)
471
+ msg_parser.add_argument('--contact-db', help='Path to contact.db for sender names')
472
+ msg_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
473
+ msg_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
474
+ msg_parser.add_argument('--own-wxid', help='Account owner wxid (for self-message detection)')
475
+
476
+ # contacts command
477
+ contacts_parser = sub.add_parser('contacts', help='List contacts')
478
+ contacts_parser.add_argument('--db', required=True, help='Path to NT database')
479
+ contacts_parser.add_argument('--key', required=True, help='Key hex (64 chars)')
480
+ contacts_parser.add_argument('--salt', required=True, help='Salt hex (32 chars)')
481
+ contacts_parser.add_argument('--keyword', help='Filter by keyword')
482
+ contacts_parser.add_argument('--limit', type=int, default=200)
483
+ contacts_parser.add_argument('--contact-db', help='Path to contact.db for display names')
484
+ contacts_parser.add_argument('--contact-key', help='Contact DB key hex (64 chars)')
485
+ contacts_parser.add_argument('--contact-salt', help='Contact DB salt hex (32 chars)')
486
+
487
+ args = parser.parse_args()
488
+
489
+ if args.command == 'scan':
490
+ pid = find_weixin_pid()
491
+ if not pid:
492
+ print(json.dumps({"error": "Weixin.exe 未运行"}))
493
+ return
494
+
495
+ if not args.json:
496
+ print(f"扫描进程 PID {pid}...")
497
+
498
+ keys = scan_memory_keys(pid)
499
+ if not args.json:
500
+ print(f"找到 {len(keys)} 个密钥")
501
+
502
+ databases = find_nt_databases()
503
+ if not args.json:
504
+ print(f"找到 {len(databases)} 个 NT 数据库")
505
+
506
+ matched = match_keys_to_databases(keys, databases)
507
+ if not args.json:
508
+ print(f"匹配 {len(matched)} 个数据库")
509
+ for db in matched:
510
+ print(f" {db['name']} ({db['size']/1024/1024:.1f}MB) key={db['key'][:16]}... salt={db['salt'][:16]}...")
511
+ else:
512
+ print(json.dumps({"keys": keys, "databases": databases, "matched": matched}))
513
+
514
+ # Build contact name map once if contact db provided
515
+ contact_name_map = {}
516
+ contact_db = getattr(args, 'contact_db', None)
517
+ contact_key = getattr(args, 'contact_key', None)
518
+ contact_salt = getattr(args, 'contact_salt', None)
519
+ if contact_db and contact_key and contact_salt:
520
+ contact_name_map = load_contact_names(contact_db, contact_key, contact_salt)
521
+
522
+ if args.command == 'sessions':
523
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
524
+ result = get_sessions(conn)
525
+ if 'sessions' in result:
526
+ result['sessions'] = apply_contact_names(result['sessions'], contact_name_map)
527
+ if args.keyword:
528
+ kw = args.keyword.lower()
529
+ result['sessions'] = [
530
+ s for s in result['sessions']
531
+ if kw in (s.get('username', '') + s.get('displayName', '') + s.get('summary', '')).lower()
532
+ ]
533
+ print(json.dumps(result, ensure_ascii=True))
534
+ conn.close()
535
+
536
+ elif args.command == 'messages':
537
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
538
+ own_wxid = getattr(args, 'own_wxid', None)
539
+ result = get_messages(conn, args.talker, args.limit, args.offset, contact_name_map, own_wxid)
540
+ print(json.dumps(result, ensure_ascii=True))
541
+ conn.close()
542
+
543
+ elif args.command == 'contacts':
544
+ conn, _ = connect_nt_db(args.db, args.key, args.salt)
545
+ result = get_contacts(conn, args.limit)
546
+ if 'contacts' in result:
547
+ result['contacts'] = apply_contact_names(result['contacts'], contact_name_map)
548
+ if args.keyword:
549
+ kw = args.keyword.lower()
550
+ result['contacts'] = [
551
+ c for c in result['contacts']
552
+ if kw in (c.get('username', '') + c.get('displayName', '') + c.get('remark', '') + c.get('nickname', '')).lower()
553
+ ]
554
+ print(json.dumps(result, ensure_ascii=True))
555
+ conn.close()
556
+
557
+ else:
558
+ parser.print_help()
559
+
560
+
561
+ if __name__ == '__main__':
562
+ main()