weflow-cli 1.16.0 → 1.19.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 (81) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +19 -4
  3. package/bin/weflow-cli.ts +515 -6
  4. package/dist/bin/weflow-cli.js +580 -8
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/core/ntCore.d.ts +47 -0
  7. package/dist/src/core/ntCore.d.ts.map +1 -1
  8. package/dist/src/core/ntCore.js +24 -0
  9. package/dist/src/core/ntCore.js.map +1 -1
  10. package/dist/src/services/assistantService.d.ts +8 -1
  11. package/dist/src/services/assistantService.d.ts.map +1 -1
  12. package/dist/src/services/assistantService.js +47 -8
  13. package/dist/src/services/assistantService.js.map +1 -1
  14. package/dist/src/services/assistantTools.d.ts +8 -0
  15. package/dist/src/services/assistantTools.d.ts.map +1 -1
  16. package/dist/src/services/assistantTools.js +67 -11
  17. package/dist/src/services/assistantTools.js.map +1 -1
  18. package/dist/src/services/configService.d.ts.map +1 -1
  19. package/dist/src/services/configService.js +3 -2
  20. package/dist/src/services/configService.js.map +1 -1
  21. package/dist/src/services/cursorProbe.d.ts +44 -0
  22. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  23. package/dist/src/services/cursorProbe.js +183 -0
  24. package/dist/src/services/cursorProbe.js.map +1 -0
  25. package/dist/src/services/diagnosticReport.d.ts +6 -0
  26. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  27. package/dist/src/services/diagnosticReport.js +16 -0
  28. package/dist/src/services/diagnosticReport.js.map +1 -1
  29. package/dist/src/services/qchatImport.d.ts +89 -0
  30. package/dist/src/services/qchatImport.d.ts.map +1 -0
  31. package/dist/src/services/qchatImport.js +273 -0
  32. package/dist/src/services/qchatImport.js.map +1 -0
  33. package/dist/src/services/setupVenv.d.ts +43 -0
  34. package/dist/src/services/setupVenv.d.ts.map +1 -0
  35. package/dist/src/services/setupVenv.js +198 -0
  36. package/dist/src/services/setupVenv.js.map +1 -0
  37. package/dist/src/services/wechatCompat.d.ts +29 -0
  38. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  39. package/dist/src/services/wechatCompat.js +65 -0
  40. package/dist/src/services/wechatCompat.js.map +1 -0
  41. package/dist/src/utils/python.d.ts +10 -0
  42. package/dist/src/utils/python.d.ts.map +1 -1
  43. package/dist/src/utils/python.js +27 -4
  44. package/dist/src/utils/python.js.map +1 -1
  45. package/docs/DATA_CONTRACT.md +25 -0
  46. package/docs/DECISIONS.md +118 -30
  47. package/docs/NT_DATABASES.md +47 -0
  48. package/docs/PROJECT_STATE.md +7 -3
  49. package/docs/RELEASING.md +4 -0
  50. package/docs/REPLAY.md +169 -0
  51. package/docs/SYNC_CONTRACT.md +197 -180
  52. package/package.json +89 -88
  53. package/resources/js/graph3d/viewer2d.js +550 -448
  54. package/scripts/annual_report.py +3 -1
  55. package/scripts/chat_notes.py +86 -3
  56. package/scripts/check_readme_commands.py +427 -426
  57. package/scripts/extract_todos.py +3 -1
  58. package/scripts/graph_2d.py +156 -151
  59. package/scripts/graph_3d.py +672 -646
  60. package/scripts/imported_chats.py +68 -0
  61. package/scripts/mcp_bridge.py +3 -1
  62. package/scripts/media_index.py +150 -0
  63. package/scripts/nt_common.py +32 -0
  64. package/scripts/nt_decrypt.py +154 -16
  65. package/scripts/on_this_day.py +178 -178
  66. package/scripts/promises.py +185 -185
  67. package/scripts/replay.py +803 -0
  68. package/scripts/run_hidden.vbs +25 -0
  69. package/scripts/semantic_search.py +78 -6
  70. package/scripts/wechat_avatar.py +185 -0
  71. package/scripts/wechat_ledger.py +190 -0
  72. package/src/core/ntCore.ts +59 -0
  73. package/src/services/assistantService.ts +48 -8
  74. package/src/services/assistantTools.ts +73 -10
  75. package/src/services/configService.ts +3 -2
  76. package/src/services/cursorProbe.ts +222 -0
  77. package/src/services/diagnosticReport.ts +22 -0
  78. package/src/services/qchatImport.ts +327 -0
  79. package/src/services/setupVenv.ts +238 -0
  80. package/src/services/wechatCompat.ts +67 -0
  81. package/src/utils/python.ts +33 -4
@@ -0,0 +1,25 @@
1
+ ' Silent launcher for the WeFlow daily task.
2
+ '
3
+ ' Why this file exists: a scheduled action that runs "powershell.exe -File ..." starts a
4
+ ' CONSOLE program, so Windows creates a console window before PowerShell can hide it - you
5
+ ' see a black box appear, and it is especially intrusive over a full-screen game. wscript.exe
6
+ ' is a GUI process with no console at all, and WshShell.Run with window style 0 keeps the
7
+ ' child hidden too.
8
+ '
9
+ ' Arguments are forwarded verbatim, so "run_hidden.vbs noon" is the noon pass.
10
+ '
11
+ ' ASCII only on purpose: Windows Script Host reads this file with the ANSI code page.
12
+
13
+ Dim fso, here, script, cmd, sh
14
+ Set fso = CreateObject("Scripting.FileSystemObject")
15
+ here = fso.GetParentFolderName(WScript.ScriptFullName)
16
+ script = fso.BuildPath(here, "daily_task.ps1")
17
+
18
+ cmd = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & script & """"
19
+ If WScript.Arguments.Count > 0 Then
20
+ cmd = cmd & " " & WScript.Arguments(0)
21
+ End If
22
+
23
+ Set sh = CreateObject("WScript.Shell")
24
+ ' 0 = hidden window, True = wait for the child so the task's last result reflects the run.
25
+ sh.Run cmd, 0, True
@@ -63,6 +63,7 @@ except:
63
63
 
64
64
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
65
65
  from _utils import load_config, decrypt_lock, get_dashscope_key, get_api_key, LINE_LABELS # noqa: E402
66
+ from nt_common import decode_content # noqa: E402
66
67
  from jev_client import create_client
67
68
 
68
69
  OUTPUT_ROOT = 'output'
@@ -248,7 +249,10 @@ def collect_chat_messages(conns, name_map, days=90, per_talker=500):
248
249
  except Exception:
249
250
  continue
250
251
  for rid, ts, sender, content in rows:
251
- if not content or not isinstance(content, str) or len(content) < 10:
252
+ # 正文可能是 zstd 压缩的 BLOB(issue #12):先解码再判断长度,
253
+ # 否则这些消息会被整条跳过、索引里永远缺一段。
254
+ content = decode_content(content)
255
+ if len(content) < 10:
252
256
  continue
253
257
  item_id = f"chat:{talker}:{ts}:{rid}"
254
258
  if item_id in seen:
@@ -268,6 +272,36 @@ def collect_chat_messages(conns, name_map, days=90, per_talker=500):
268
272
  return items
269
273
 
270
274
 
275
+ def collect_imported_chat_items(days=90):
276
+ """导入的 QQ 会话 → 与微信聊天同形状的索引记录(--include-imports)。
277
+
278
+ 形状必须与 collect_chat_messages 逐字段一致(id/type/talker/sender/time/text),
279
+ 否则同一条记录在两半索引里语义不同。id 带 qq: 段:与微信记录的
280
+ chat:<talker>:<ts>:<rid> 天然不撞,也永远可分辨来源。文本阈值(>=10 字)与
281
+ 截断(500 字)同微信侧——索引是要花钱嵌的,两侧口径不同就是两种相关性。
282
+ """
283
+ import imported_chats
284
+
285
+ items = []
286
+ for sess in imported_chats.sessions():
287
+ meta = sess.get('session') or {}
288
+ talker = 'QQ-%s' % (meta.get('name') or meta.get('id') or '未命名')
289
+ for m in imported_chats.session_messages(sess, days=days):
290
+ text = (m.get('parsedContent') or '').strip()
291
+ if len(text) < 10:
292
+ continue
293
+ dt = datetime.fromtimestamp(m.get('createTime') or 0, tz=TZ)
294
+ items.append({
295
+ 'id': 'chat:qq:%s:%s' % (meta.get('id') or '', m.get('serverId') or ''),
296
+ 'type': 'chat',
297
+ 'talker': talker,
298
+ 'sender': m.get('senderName') or '',
299
+ 'time': dt.strftime('%Y-%m-%d %H:%M'),
300
+ 'text': text[:500],
301
+ })
302
+ return items
303
+
304
+
271
305
  _ARTICLE_BOILERPLATE = frozenset({
272
306
  '在小说阅读器读本章', '去阅读', '在公众号小说中沉浸阅读',
273
307
  })
@@ -396,7 +430,7 @@ def _save_index(base_vectors, base_meta, pairs):
396
430
  os.replace(tmp_m, META_FILE)
397
431
  return len(meta)
398
432
 
399
- def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30):
433
+ def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30, include_imports: bool = False):
400
434
  """Build or update the semantic index.
401
435
 
402
436
  `days` / `article_days` 决定这个索引**记多久**:它们原先写死在两处调用里
@@ -421,8 +455,12 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
421
455
  # Load DB
422
456
  config = load_config()
423
457
  nt_db = config.get('ntDbPath', '')
424
- if not nt_db:
458
+ # --include-imports 时没有微信库也能建索引(纯 QQ 用户是真实场景);
459
+ # 但要说出"聊天半边只有导入的",不然窗口行读起来像微信也收了。
460
+ if not nt_db and not include_imports:
425
461
  return {"error": "未初始化,请先运行 weflow-cli init"}
462
+ if not nt_db and include_imports:
463
+ print('[WARN] 未配置微信库:聊天半边只用导入的会话', file=sys.stderr)
426
464
 
427
465
  nt_key = decrypt_lock(config.get('ntKey', ''))
428
466
  nt_salt = config.get('ntSalt', '')
@@ -439,6 +477,16 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
439
477
  # Collect items
440
478
  items = []
441
479
  shards_failed = []
480
+ if not nt_db:
481
+ # 没有微信库:跳过 NT 那半(name_map/分片都不存在),导入与文章照收。
482
+ # 收完直接走同一个收尾(嵌入与写盘),不在这里复制粘贴后半段。
483
+ if include_imports:
484
+ items.extend(collect_imported_chat_items(days=days))
485
+ items.extend(collect_articles(article_days=article_days))
486
+ new_items = [item for item in items if item['id'] not in existing_ids]
487
+ print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
488
+ return _build_embed_and_write(api_key, items, new_items, existing_ids,
489
+ full, days, article_days, shards_failed=[])
442
490
  try:
443
491
  import nt_decrypt # 懒加载:纯逻辑路径(关键词/重排/测试)不该被它拖住
444
492
  passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
@@ -460,13 +508,29 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
460
508
  except Exception as e:
461
509
  print(f"[WARN] 聊天消息收集失败: {e}", file=sys.stderr)
462
510
 
511
+ if include_imports:
512
+ imported = collect_imported_chat_items(days=days)
513
+ items.extend(imported)
514
+ print(f"导入会话: +{len(imported)} 条", file=sys.stderr)
515
+
463
516
  article_items = collect_articles(article_days=article_days)
464
517
  items.extend(article_items)
465
518
 
466
- # Filter new items
467
519
  new_items = [item for item in items if item['id'] not in existing_ids]
468
520
  print(f"总数据: {len(items)} 条, 新数据: {len(new_items)} 条", file=sys.stderr)
469
521
 
522
+ return _build_embed_and_write(api_key, items, new_items, existing_ids,
523
+ full, days, article_days, shards_failed)
524
+
525
+
526
+
527
+ def _build_embed_and_write(api_key, items, new_items, existing_ids, full,
528
+ days, article_days, shards_failed):
529
+ """build_index 的收尾:去重过滤在调用方做完了,这里管嵌入、断点落盘与返回。
530
+
531
+ 抽出来是因为聊天半边现在有两条收集路(微信库 / 导入会话),尾巴必须只有一份——
532
+ 复制一份的代价是断点续跑、全零批次不落盘这些坑只在一半路径上修过。
533
+ """
470
534
  if not new_items:
471
535
  return {"status": "up_to_date", "total": len(items),
472
536
  "chatDays": days, "articleDays": article_days,
@@ -1522,11 +1586,15 @@ def main():
1522
1586
  p = subparsers.add_parser('build')
1523
1587
  p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
1524
1588
  p.add_argument('--full', action='store_true', help='全量重建')
1589
+ p.add_argument('--include-imports', action='store_true',
1590
+ help='把 weflow-cli import qchat 导入的 QQ 会话也收进索引(聊天半边;id 带 qq: 段)')
1525
1591
  add_window_args(p)
1526
1592
 
1527
1593
  # update
1528
1594
  p = subparsers.add_parser('update')
1529
1595
  p.add_argument('--api-key', help='DeepSeek API key(优先从 config 读取)')
1596
+ p.add_argument('--include-imports', action='store_true',
1597
+ help='同 build:把导入的 QQ 会话收进增量')
1530
1598
  add_window_args(p)
1531
1599
 
1532
1600
  # search
@@ -1582,9 +1650,13 @@ def main():
1582
1650
  or get_dashscope_key(config))
1583
1651
 
1584
1652
  if args.command == 'build':
1585
- result = build_index(api_key, full=args.full, days=args.days, article_days=args.article_days)
1653
+ result = build_index(api_key, full=args.full, days=args.days,
1654
+ article_days=args.article_days,
1655
+ include_imports=getattr(args, 'include_imports', False))
1586
1656
  elif args.command == 'update':
1587
- result = build_index(api_key, full=False, days=args.days, article_days=args.article_days)
1657
+ result = build_index(api_key, full=False, days=args.days,
1658
+ article_days=args.article_days,
1659
+ include_imports=getattr(args, 'include_imports', False))
1588
1660
  elif args.command == 'phrase-build':
1589
1661
  result = build_phrase_index(line=args.line)
1590
1662
  result['success'] = 'error' not in result
@@ -0,0 +1,185 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 head_image.db:联系人的头像(JPEG/PNG 完整字节内嵌库里,按 username 键控)。
3
+
4
+ 为什么从库里拿而不是文件缓存:缓存在 resource 下按 md5 命名、随清理消失,
5
+ 而库里永远在、按 username 键控、带 update_time——**一人多行 = 头像变更历史**
6
+ (2026-10-26 普查:本机 1083 行,见 docs/NT_DATABASES.md)。
7
+
8
+ 只读、本地、不联网。输出元数据不含图片内容(--json 也只报路径与字节数)。
9
+ """
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+ import nt_common # noqa: E402
18
+ import nt_decrypt # noqa: E402
19
+ from _utils import decrypt_lock, load_config # noqa: E402
20
+
21
+
22
+ def head_image_db_path(message_db):
23
+ """message_0.db 同级的 head_image/head_image.db(与 contact.db 同一层推导)。"""
24
+ msg_dir = Path(message_db).parent.parent
25
+ return msg_dir / 'head_image' / 'head_image.db'
26
+
27
+
28
+ def connect(db_path, key_hex, salt_hex, passphrase=''):
29
+ import sqlcipher3
30
+ derived_key, derived_salt = nt_common.derive_database_key(
31
+ str(db_path), key_hex, salt_hex, passphrase)
32
+ conn = sqlcipher3.connect(str(db_path))
33
+ c = conn.cursor()
34
+ c.execute("PRAGMA key = \"x'%s%s'\"" % (derived_key, derived_salt))
35
+ # 探测解密是否真的成功(读 sqlite_master 才触发解密;错误钥匙在这里报 file is not a database)
36
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()
37
+ return conn
38
+
39
+
40
+ def avatar_rows(conn, username):
41
+ """一个 username 的全部头像,新→旧。返回 [(update_time, bytes)]。"""
42
+ c = conn.cursor()
43
+ rows = c.execute(
44
+ 'SELECT update_time, image_buffer FROM head_image WHERE username = ? '
45
+ 'ORDER BY update_time DESC', (username,)).fetchall()
46
+ return [(r[0], bytes(r[1])) for r in rows if r[1]]
47
+
48
+
49
+ def user_rows(conn):
50
+ """全部 username:[(username, 头像数, 最新 update_time)],最新在前。"""
51
+ c = conn.cursor()
52
+ return c.execute(
53
+ 'SELECT username, COUNT(*), MAX(update_time) FROM head_image '
54
+ 'GROUP BY username ORDER BY MAX(update_time) DESC').fetchall()
55
+
56
+
57
+ def resolve_username(conn, query, name_map):
58
+ """查询词 → head_image 里的 username。精确 id 命中优先;否则用 contact 名字反查,
59
+ 歧义/找不到都报错并给候选——不猜(同 resolveTalker 的纪律)。"""
60
+ usernames = {r[0] for r in user_rows(conn)}
61
+ if query in usernames:
62
+ return query
63
+ hits = [u for u, display in name_map.items()
64
+ if display == query and u in usernames]
65
+ if len(hits) == 1:
66
+ return hits[0]
67
+ if len(hits) > 1:
68
+ raise SystemExit('「%s」匹配到多个头像所有者(%s)——请用完整 id 指定'
69
+ % (query, '、'.join(hits[:5])))
70
+ raise SystemExit('没有「%s」的头像(head_image 库里共 %d 个 username;'
71
+ '没有头像或名字对不上都可能是原因)' % (query, len(usernames)))
72
+
73
+
74
+ def ext_of(buf):
75
+ return '.png' if buf[:4] == b'\x89PNG' else '.jpg'
76
+
77
+
78
+ def main():
79
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
80
+ ap = argparse.ArgumentParser(
81
+ description='联系人头像(读 head_image.db;本地只读,不联网)')
82
+ ap.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'),
83
+ help='head_image.db 路径(默认从 WEFLOW_DB_PATH/message 库推导)')
84
+ ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'),
85
+ help='联系人(id 或备注/昵称;--all 时不需要)')
86
+ ap.add_argument('--all', action='store_true',
87
+ help='导出所有 username 的最新头像到 --out 目录')
88
+ ap.add_argument('--out', default=os.environ.get('WEFLOW_AVATAR_OUT') or '',
89
+ help='输出文件(单联系人)或目录(--all)')
90
+ ap.add_argument('--list', action='store_true', help='只列出 username 与头像数')
91
+ ap.add_argument('--json', action='store_true')
92
+ args = ap.parse_args()
93
+
94
+ config = load_config()
95
+ message_db = config.get('ntDbPath', '')
96
+ # --db / WEFLOW_DB_PATH 两种都认:直接给 head_image.db,或给 message 库(推导同级)
97
+ if args.db:
98
+ p = Path(args.db)
99
+ db = p if p.name == 'head_image.db' else head_image_db_path(p)
100
+ elif message_db:
101
+ db = head_image_db_path(message_db)
102
+ else:
103
+ db = None
104
+ if not db or not db.exists():
105
+ print(json.dumps({'success': False, 'error': '找不到 head_image.db(先 weflow-cli init)'}))
106
+ return 1
107
+ passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
108
+ try:
109
+ conn = connect(db, decrypt_lock(config.get('ntKey') or ''),
110
+ config.get('ntSalt') or '', passphrase)
111
+ except Exception as e:
112
+ print(json.dumps({'success': False, 'error': 'head_image.db 打不开:%s' % str(e)[:80]}))
113
+ return 1
114
+
115
+ try:
116
+ if args.list:
117
+ rows = user_rows(conn)
118
+ if args.json:
119
+ print(json.dumps({'success': True, 'users': [
120
+ {'username': u, 'avatars': n, 'newestUpdateTime': t}
121
+ for u, n, t in rows]}, ensure_ascii=False))
122
+ else:
123
+ print('共 %d 个 username 有头像' % len(rows))
124
+ for u, n, t in rows[:20]:
125
+ print(' %s(%d 张,最新 %s)' % (u, n, t))
126
+ return 0
127
+
128
+ if args.all:
129
+ out_dir = Path(args.out or 'output/avatars')
130
+ out_dir.mkdir(parents=True, exist_ok=True)
131
+ written = 0
132
+ for username, _n, _t in user_rows(conn):
133
+ rows = avatar_rows(conn, username)
134
+ if not rows:
135
+ continue
136
+ _ts, buf = rows[0]
137
+ safe = ''.join(ch if ch.isalnum() or ch in '_-@.' else '_'
138
+ for ch in username)[:80]
139
+ path = out_dir / (safe + ext_of(buf))
140
+ path.write_bytes(buf)
141
+ written += 1
142
+ result = {'success': True, 'action': 'avatar.export-all',
143
+ 'dir': str(out_dir), 'written': written,
144
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True}
145
+ print(json.dumps(result, ensure_ascii=False) if args.json
146
+ else '已导出 %d 个最新头像到 %s' % (written, out_dir))
147
+ return 0
148
+
149
+ if not args.talker:
150
+ print(json.dumps({'success': False, 'error': '缺 --talker(或 WEFLOW_TALKER)' }))
151
+ return 1
152
+ name_map = {}
153
+ contact_db = nt_decrypt.find_contact_db_path(str(message_db)) if message_db else None
154
+ if contact_db:
155
+ name_map = nt_decrypt.load_contact_names(
156
+ contact_db, decrypt_lock(config.get('contactKey') or ''),
157
+ config.get('contactSalt') or '')
158
+ username = resolve_username(conn, args.talker, name_map)
159
+ rows = avatar_rows(conn, username)
160
+ if not rows:
161
+ print(json.dumps({'success': False, 'error': '「%s」在库里但头像字节为空' % username},
162
+ ensure_ascii=False))
163
+ return 1
164
+ out = Path(args.out or ('output/avatars/' + username + ext_of(rows[0][1])))
165
+ out.parent.mkdir(parents=True, exist_ok=True)
166
+ out.write_bytes(rows[0][1])
167
+ result = {
168
+ 'success': True, 'action': 'avatar.export', 'username': username,
169
+ 'out': str(out), 'bytes': len(rows[0][1]),
170
+ 'historyCount': len(rows),
171
+ 'newestUpdateTime': rows[0][0],
172
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True,
173
+ }
174
+ if args.json:
175
+ print(json.dumps(result, ensure_ascii=False))
176
+ else:
177
+ print('已导出「%s」的最新头像(%d 字节,历史 %d 张)→ %s'
178
+ % (username, len(rows[0][1]), len(rows), out))
179
+ return 0
180
+ finally:
181
+ conn.close()
182
+
183
+
184
+ if __name__ == '__main__':
185
+ sys.exit(main())
@@ -0,0 +1,190 @@
1
+ # -*- coding: utf-8 -*-
2
+ """读 general.db 的资金流水:红包(redEnvelopeTable)与转账(transferTable)。
3
+
4
+ 2026-10-26 普查产物(docs/NT_DATABASES.md)。两个如实的限制(不猜、不补):
5
+ · 红包表**没有时间列、没有金额列**——时间/金额都在消息正文里。行按
6
+ message_server_id 排序(服务端 id 单调增,可看先后),溯源字段随行输出,
7
+ 关联消息分片是后续工作。
8
+ · 状态枚举(hb_status/receive_status/pay_sub_type)的语义**未逐值验证**,
9
+ 原样输出数字并注明,不翻译成没有根据的词。
10
+ 只读、本地、不联网;方向判定按 wxid 前缀匹配(配置 wxid 可能带 Name2Id 没有的
11
+ 后缀,1.15.1 的教训)。
12
+ """
13
+ import argparse
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
+ import nt_common # noqa: E402
22
+ import nt_decrypt # noqa: E402
23
+ from _utils import decrypt_lock, load_config # noqa: E402
24
+
25
+
26
+ def general_db_path(message_db):
27
+ """message_0.db 同级的 general/general.db。"""
28
+ return Path(message_db).parent.parent / 'general' / 'general.db'
29
+
30
+
31
+ def connect(db_path, key_hex, salt_hex, passphrase=''):
32
+ import sqlcipher3
33
+ derived_key, derived_salt = nt_common.derive_database_key(
34
+ str(db_path), key_hex, salt_hex, passphrase)
35
+ conn = sqlcipher3.connect(str(db_path))
36
+ c = conn.cursor()
37
+ c.execute("PRAGMA key = \"x'%s%s'\"" % (derived_key, derived_salt))
38
+ c.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()
39
+ return conn
40
+
41
+
42
+ def is_own(name, own_wxid):
43
+ if not name or not own_wxid:
44
+ return False
45
+ return name == own_wxid or name.startswith(own_wxid) or own_wxid.startswith(name)
46
+
47
+
48
+ def ledger_rows(conn, name_map=None, session_filter=None, own_wxid=''):
49
+ """两表 → 归一化行(原样字段 + 方向 + 显示名),新→旧。
50
+
51
+ 红包没有时间列,用 message_server_id 兜底排序(服务端 id 单调增);
52
+ 转账按 begin_transfer_time 排。两表合并后统一按 sortKey 降序。
53
+ """
54
+ name_map = name_map or {}
55
+ c = conn.cursor()
56
+ rows = []
57
+ for sid, session, sender, send_id, hb_status, hb_type, receive_status in c.execute(
58
+ 'SELECT message_server_id, session_name, sender_user_name, send_id, '
59
+ 'hb_status, hb_type, receive_status FROM redEnvelopeTable'):
60
+ if session_filter and session_filter not in (session, name_map.get(session, session)):
61
+ continue
62
+ rows.append({
63
+ 'kind': '红包',
64
+ 'session': session,
65
+ 'sessionDisplay': name_map.get(session, session),
66
+ 'direction': '我发' if is_own(sender, own_wxid) else '对方发',
67
+ # 状态原样:语义未逐值验证(本机观测 hb_status ∈ {1,4}、receive_status ∈ {0,1,2})
68
+ 'hbStatus': hb_status, 'hbType': hb_type, 'receiveStatus': receive_status,
69
+ 'time': None, # 库内无时间列——要时间得按 messageServerId 关联消息分片(未实现)
70
+ 'messageServerId': str(sid or ''),
71
+ 'sortKey': int(sid or 0),
72
+ })
73
+ for tid, _txn, sid, session, sub_type, receiver, payer, begin_ts, invalid_ts in c.execute(
74
+ 'SELECT transfer_id, transcation_id, message_server_id, session_name, '
75
+ 'pay_sub_type, pay_receiver, pay_payer, begin_transfer_time, invalid_time '
76
+ 'FROM transferTable'):
77
+ if session_filter and session_filter not in (session, name_map.get(session, session)):
78
+ continue
79
+ direction = '未知'
80
+ if is_own(payer, own_wxid) and not is_own(receiver, own_wxid):
81
+ direction = '我付'
82
+ elif is_own(receiver, own_wxid) and not is_own(payer, own_wxid):
83
+ direction = '我收'
84
+ rows.append({
85
+ 'kind': '转账',
86
+ 'session': session,
87
+ 'sessionDisplay': name_map.get(session, session),
88
+ 'direction': direction,
89
+ 'paySubType': sub_type, # 本机观测全为 3;语义未验证
90
+ 'counterparty': (receiver if direction == '我付'
91
+ else payer if direction == '我收' else ''),
92
+ 'counterpartyDisplay': name_map.get(
93
+ receiver if direction == '我付' else payer if direction == '我收' else '', ''),
94
+ 'time': int(begin_ts or 0),
95
+ 'invalidTime': int(invalid_ts or 0), # >0 的语义(过期/退还)未验证,原样给出
96
+ 'messageServerId': str(sid or ''),
97
+ 'transferId': str(tid or ''),
98
+ 'sortKey': int(begin_ts or 0) or int(sid or 0),
99
+ })
100
+ rows.sort(key=lambda r: r['sortKey'], reverse=True)
101
+ return rows
102
+
103
+
104
+ def keep_within_days(rows, cutoff):
105
+ """--days 过滤:只滤**有时间戳**的行;红包无时间列不受限(如实保留)。
106
+
107
+ 独立成函数以便测试钉住"红包不被时间窗误杀"——那是把两类行的可用字段
108
+ 混为一谈的错误。
109
+ """
110
+ return [r for r in rows if r['time'] is None or r['time'] >= cutoff]
111
+
112
+
113
+ def main():
114
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
115
+ ap = argparse.ArgumentParser(
116
+ description='资金流水:红包与转账(读 general.db;本地只读,不联网)')
117
+ ap.add_argument('--db', default=os.environ.get('WEFLOW_DB_PATH'),
118
+ help='general.db 路径(默认从 message 库推导同级)')
119
+ ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER'),
120
+ help='可选:只看某个会话(id 或备注/昵称子串)')
121
+ ap.add_argument('--days', type=int, default=0,
122
+ help='只看最近 N 天(按转账时间过滤;红包无时间列不受此限)')
123
+ ap.add_argument('--json', action='store_true')
124
+ args = ap.parse_args()
125
+
126
+ config = load_config()
127
+ message_db = config.get('ntDbPath', '')
128
+ if args.db:
129
+ p = Path(args.db)
130
+ db = p if p.name == 'general.db' else general_db_path(p)
131
+ elif message_db:
132
+ db = general_db_path(message_db)
133
+ else:
134
+ db = None
135
+ if not db or not db.exists():
136
+ print(json.dumps({'success': False, 'error': '找不到 general.db(先 weflow-cli init)'}))
137
+ return 1
138
+ passphrase = decrypt_lock(config.get('favPassphrase') or config.get('decryptKey') or '')
139
+ try:
140
+ conn = connect(db, decrypt_lock(config.get('ntKey') or ''),
141
+ config.get('ntSalt') or '', passphrase)
142
+ except Exception as e:
143
+ print(json.dumps({'success': False, 'error': 'general.db 打不开:%s' % str(e)[:80]}))
144
+ return 1
145
+
146
+ try:
147
+ name_map = {}
148
+ contact_db = nt_decrypt.find_contact_db_path(message_db) if message_db else None
149
+ if contact_db:
150
+ name_map = nt_decrypt.load_contact_names(
151
+ contact_db, decrypt_lock(config.get('contactKey') or ''),
152
+ config.get('contactSalt') or '')
153
+ rows = ledger_rows(conn, name_map, args.talker or None,
154
+ config.get('wxid', ''))
155
+ if args.days and args.days > 0:
156
+ rows = keep_within_days(rows, int(time.time()) - args.days * 86400)
157
+ red = [r for r in rows if r['kind'] == '红包']
158
+ transfers = [r for r in rows if r['kind'] == '转账']
159
+ result = {
160
+ 'success': True, 'action': 'ledger',
161
+ 'redEnvelopes': len(red), 'transfers': len(transfers),
162
+ 'notes': [
163
+ '红包表无时间/金额列:时间与金额在消息正文里,行按 messageServerId 排序(可看先后)',
164
+ '状态枚举(hbStatus/receiveStatus/paySubType)语义未逐值验证,原样输出',
165
+ ],
166
+ 'rows': rows,
167
+ 'readsLocalData': True, 'invokesAI': False, 'sendsNothing': True,
168
+ }
169
+ if args.json:
170
+ print(json.dumps(result, ensure_ascii=False))
171
+ else:
172
+ print('红包 %d 笔、转账 %d 笔(状态原样,语义未验证)' % (len(red), len(transfers)))
173
+ for r in rows[:40]:
174
+ when = (time.strftime('%Y-%m-%d %H:%M', time.localtime(r['time']))
175
+ if r.get('time') else '(无时间列)')
176
+ extra = ('status=%s/%s' % (r['hbStatus'], r['receiveStatus'])
177
+ if r['kind'] == '红包' else
178
+ ('对方=%s invalid=%s' % (r.get('counterpartyDisplay') or r.get('counterparty') or '?',
179
+ r.get('invalidTime') or 0)))
180
+ print(' [%s] %s · %s · %s · %s' % (
181
+ when, r['kind'], r['sessionDisplay'], r['direction'], extra))
182
+ if len(rows) > 40:
183
+ print(' …共 %d 行(--json 看全表)' % len(rows))
184
+ return 0
185
+ finally:
186
+ conn.close()
187
+
188
+
189
+ if __name__ == '__main__':
190
+ sys.exit(main())
@@ -75,6 +75,42 @@ export interface NtMessagesResult extends NtResult {
75
75
  shards?: ShardReport
76
76
  }
77
77
 
78
+ /** One shard's outcome in a `cursor-probe` run (nt_decrypt.py cursor-probe). */
79
+ export interface NtCursorProbeShard {
80
+ /** basename only - the child never reports a full path */
81
+ name: string
82
+ opened: boolean
83
+ reason: string | null
84
+ /** number of Msg_* tables; null when the shard could not be read at all */
85
+ tables: number | null
86
+ tablesSampled?: number
87
+ /** per-table missing anchor-column sets, deduplicated; empty = every table has all three */
88
+ anchorColumnsMissing?: string[][]
89
+ rowsSampled?: number
90
+ /** sampled rows whose server_id was 0 or NULL (identity degrades for them) */
91
+ serverIdMissing?: number
92
+ }
93
+
94
+ export interface NtCursorProbeSample {
95
+ tablesPerShard: number
96
+ rowsPerTable: number
97
+ exhaustive: boolean
98
+ tablesTotal: number
99
+ tablesSampled: number
100
+ rowsSampled: number
101
+ serverIdMissing: number
102
+ /** sampled nonzero server ids seen in more than one (shard, table) location */
103
+ duplicateServerIds: number
104
+ duplicateServerIdSamples?: Array<{ serverId: string; locations: string[] }>
105
+ }
106
+
107
+ export interface NtCursorProbeResult extends NtResult {
108
+ shards?: NtCursorProbeShard[]
109
+ /** shards with at least one message table missing an anchor column */
110
+ schemaAnchorMissingTables?: number
111
+ sample?: NtCursorProbeSample
112
+ }
113
+
78
114
  export interface NtContactsResult extends NtResult {
79
115
  contacts?: Contact[]
80
116
  }
@@ -392,6 +428,29 @@ export class NtCore {
392
428
  return { success: true, contacts: result.contacts || [] }
393
429
  }
394
430
 
431
+ /**
432
+ * The NT backend's data-side answer to D-027's stable-cursor question:
433
+ * anchor columns per message table (exhaustive) and a bounded sample of
434
+ * `server_id` coverage at both rowid ends of a few tables per shard.
435
+ * Read-only; the sampling bounds are labelled `exhaustive: false` in the
436
+ * result because they are a measurement, not a guarantee.
437
+ */
438
+ async cursorProbe(opts: { tablesPerShard?: number; rowsPerTable?: number } = {}): Promise<NtCursorProbeResult> {
439
+ const args: string[] = ['cursor-probe']
440
+ if (opts.tablesPerShard != null) args.push('--tables-per-shard', String(opts.tablesPerShard))
441
+ if (opts.rowsPerTable != null) args.push('--rows-per-table', String(opts.rowsPerTable))
442
+ const result = await this.callPython(args)
443
+ if (result.error) {
444
+ return { success: false, error: result.error }
445
+ }
446
+ return {
447
+ success: true,
448
+ shards: result.shards || [],
449
+ schemaAnchorMissingTables: result.schemaAnchorMissingTables,
450
+ sample: result.sample,
451
+ }
452
+ }
453
+
395
454
  // ====== SNS (朋友圈) ======
396
455
 
397
456
  /** 查询 SNS 朋友圈时间线 */