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
@@ -1,178 +1,178 @@
1
- """「那年今日」:把几年前的今天你在聊什么捞出来。
2
-
3
- 为什么只有本地全量数据能做到:微信的搜索只覆盖当前会话、而且历史一长就翻不动;
4
- 这里直接按 `create_time` 扫所有分片里的每一张会话表,所以"五年前的今天"是能查的。
5
-
6
- 用法:
7
- python scripts/on_this_day.py # 1 / 3 / 5 年前的今天
8
- python scripts/on_this_day.py --years 2,4,6
9
- python scripts/on_this_day.py --talker 老表亲戚群 # 只看某个会话
10
- python scripts/on_this_day.py --json
11
- """
12
- from __future__ import annotations
13
-
14
- import argparse
15
- import re
16
- import json
17
- import pathlib
18
- import sys
19
- import time
20
- from collections import Counter, defaultdict
21
- from datetime import datetime, timezone, timedelta
22
-
23
- SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
24
- sys.path.insert(0, str(SCRIPT_DIR))
25
-
26
- from _utils import load_config, decrypt_lock # noqa: E402
27
- import nt_decrypt as nd # noqa: E402
28
-
29
- TZ = timezone(timedelta(hours=8))
30
- DEFAULT_YEARS = (1, 3, 5)
31
-
32
-
33
- def _keys(cfg):
34
- key = decrypt_lock(cfg.get('ntKey', '') or '')
35
- salt = decrypt_lock(cfg.get('ntSalt', '') or '')
36
- pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
- return key, salt, pw
38
-
39
-
40
- def _text_of(content, local_type=0):
41
- """消息正文 → 一行字。
42
-
43
- **不能只看 `message_content` 是不是以 `<` 开头**:appmsg 那一族(链接/文件/小程序)
44
- 的内容是 zstd 压缩的二进制,原样打出来就是 `(/`$` 这种碎片(第一版就是这么翻车的)。
45
- 这里直接复用 `nt_decrypt` 里那套已经处理过压缩、类型标签与引用消息的显示层。
46
- """
47
- lt = int(local_type or 0)
48
- base = lt & 0xFFFFFFFF
49
- if base == 1: # 纯文本
50
- text = nd._decode_content(content)
51
- text = ' '.join(str(text).split())[:120]
52
- # 群消息的 `message_content` 自带 `wxid_xxx: ` 前缀,而发言人已经在左边显示了一次
53
- return re.sub(r'^[A-Za-z0-9_\-]+@?(chatroom|openim)?\s*:\s*', '', text)[:120]
54
- try:
55
- out = nd.non_text_display(lt, content)[:120]
56
- return re.sub(r'^[A-Za-z0-9_\-]+:\s*', '', out)[:120]
57
- except Exception:
58
- return ''
59
-
60
-
61
- def day_bounds(dt):
62
- start = datetime(dt.year, dt.month, dt.day, tzinfo=TZ)
63
- return int(start.timestamp()), int((start + timedelta(days=1)).timestamp())
64
-
65
-
66
- def collect_for_day(conns, name_map, target_day, talker_filter=''):
67
- """{talker: [(ts, sender, text), ...]} for one calendar day."""
68
- start, end = day_bounds(target_day)
69
- out = defaultdict(list)
70
- for conn in conns:
71
- c = conn.cursor()
72
- try:
73
- c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
74
- sessions = [r[0] for r in c.fetchall() if r[0]]
75
- except Exception:
76
- continue
77
- # `real_sender_id` 是 **Name2Id 的 rowid**,不是 wxid —— 少这一步映射,
78
- # 输出里发言人就全是 `65`、`4`、`8` 这样的数字(第一版就是这样)。
79
- try:
80
- c.execute('SELECT rowid, user_name FROM Name2Id')
81
- sender_map = {r[0]: r[1] for r in c.fetchall()}
82
- except Exception:
83
- sender_map = {}
84
- for talker in sessions:
85
- if talker_filter and talker_filter not in (talker, name_map.get(talker, talker)):
86
- continue
87
- tbl = f'Msg_{__import__("hashlib").md5(talker.encode()).hexdigest()}'
88
- try:
89
- c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
90
- if c.fetchone()[0] == 0:
91
- continue
92
- c.execute(f'SELECT create_time, real_sender_id, local_type, message_content '
93
- 'FROM "{0}" WHERE create_time >= ? AND create_time < ? ORDER BY create_time'.format(tbl),
94
- (start, end))
95
- for ts, sender, ltype, content in c.fetchall():
96
- text = _text_of(content, ltype)
97
- if text:
98
- who = sender_map.get(sender, '')
99
- out[name_map.get(talker, talker)].append(
100
- (int(ts), name_map.get(who, who), text))
101
- except Exception:
102
- continue
103
- return out
104
-
105
-
106
- def main():
107
- ap = argparse.ArgumentParser(description='那几年的今天你在聊什么')
108
- ap.add_argument('--years', default=','.join(str(y) for y in DEFAULT_YEARS),
109
- help='往前看几年,逗号分隔(默认 1,3,5)')
110
- ap.add_argument('--talker', default='', help='只看某个会话(wxid 或昵称)')
111
- ap.add_argument('--limit', type=int, default=6, help='每天最多列几条(默认 6)')
112
- ap.add_argument('--json', action='store_true')
113
- args = ap.parse_args()
114
-
115
- cfg = load_config()
116
- db = cfg.get('ntDbPath') or ''
117
- if not db:
118
- raise SystemExit('配置里没有 ntDbPath,先跑 weflow-cli init')
119
- key, salt, pw = _keys(cfg)
120
- conns = nd.connect_message_shards(db, key, salt, pw)
121
- if not conns:
122
- raise SystemExit('打不开任何消息分片')
123
-
124
- try:
125
- # 联系人在 **contact.db**,不在消息库里 —— 从消息库找 `contact` 表一定落空,
126
- # 输出里就只剩 `47966561144@chatroom` 和 `wxid_xxx`(第一版就是这样)。
127
- name_map = {}
128
- try:
129
- from network import load_graph
130
- people, _rooms, _members = load_graph(cfg)
131
- name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
132
- for p in people.values()}
133
- except Exception as exc:
134
- print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),下面显示 id',
135
- file=sys.stderr)
136
-
137
- today = datetime.now(TZ)
138
- years = [int(y) for y in args.years.split(',') if y.strip().isdigit()]
139
- result = []
140
- for y in years:
141
- try:
142
- day = today.replace(year=today.year - y)
143
- except ValueError: # 2 月 29 日
144
- day = today.replace(year=today.year - y, day=28)
145
- by_talker = collect_for_day(conns, name_map, day, args.talker)
146
- total = sum(len(v) for v in by_talker.values())
147
- top = Counter({k: len(v) for k, v in by_talker.items()}).most_common(3)
148
- picks = []
149
- for talker, msgs in sorted(by_talker.items(), key=lambda kv: -len(kv[1]))[:3]:
150
- for ts, sender, text in msgs[:max(1, args.limit // 3)]:
151
- picks.append({'talker': talker, 'time': datetime.fromtimestamp(ts, tz=TZ).strftime('%H:%M'),
152
- 'sender': sender, 'text': text})
153
- result.append({'date': day.strftime('%Y-%m-%d'), 'yearsAgo': y,
154
- 'total': total, 'threads': top, 'samples': picks[:args.limit]})
155
- finally:
156
- for conn in conns:
157
- try:
158
- conn.close()
159
- except Exception:
160
- pass
161
-
162
- if args.json:
163
- print(json.dumps({'success': True, 'days': result}, ensure_ascii=False, indent=2))
164
- return
165
-
166
- for item in result:
167
- head = f"{item['date']}({item['yearsAgo']} 年前的今天)"
168
- if not item['total']:
169
- print(f'\n{head}\n 那天没有聊天记录')
170
- continue
171
- threads = '、'.join(f'{n}({c})' for n, c in item['threads'])
172
- print(f"\n{head} {item['total']} 条 {threads}")
173
- for s in item['samples']:
174
- print(f" {s['time']} {s['talker']} · {s['sender'] or '?'}:{s['text']}")
175
-
176
-
177
- if __name__ == '__main__':
178
- main()
1
+ """「那年今日」:把几年前的今天你在聊什么捞出来。
2
+
3
+ 为什么只有本地全量数据能做到:微信的搜索只覆盖当前会话、而且历史一长就翻不动;
4
+ 这里直接按 `create_time` 扫所有分片里的每一张会话表,所以"五年前的今天"是能查的。
5
+
6
+ 用法:
7
+ python scripts/on_this_day.py # 1 / 3 / 5 年前的今天
8
+ python scripts/on_this_day.py --years 2,4,6
9
+ python scripts/on_this_day.py --talker 老表亲戚群 # 只看某个会话
10
+ python scripts/on_this_day.py --json
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import re
16
+ import json
17
+ import pathlib
18
+ import sys
19
+ import time
20
+ from collections import Counter, defaultdict
21
+ from datetime import datetime, timezone, timedelta
22
+
23
+ SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
24
+ sys.path.insert(0, str(SCRIPT_DIR))
25
+
26
+ from _utils import load_config, decrypt_lock # noqa: E402
27
+ import nt_decrypt as nd # noqa: E402
28
+
29
+ TZ = timezone(timedelta(hours=8))
30
+ DEFAULT_YEARS = (1, 3, 5)
31
+
32
+
33
+ def _keys(cfg):
34
+ key = decrypt_lock(cfg.get('ntKey', '') or '')
35
+ salt = decrypt_lock(cfg.get('ntSalt', '') or '')
36
+ pw = decrypt_lock(cfg.get('favPassphrase', '') or cfg.get('decryptKey', '') or '')
37
+ return key, salt, pw
38
+
39
+
40
+ def _text_of(content, local_type=0):
41
+ """消息正文 → 一行字。
42
+
43
+ **不能只看 `message_content` 是不是以 `<` 开头**:appmsg 那一族(链接/文件/小程序)
44
+ 的内容是 zstd 压缩的二进制,原样打出来就是 `(/`$` 这种碎片(第一版就是这么翻车的)。
45
+ 这里直接复用 `nt_decrypt` 里那套已经处理过压缩、类型标签与引用消息的显示层。
46
+ """
47
+ lt = int(local_type or 0)
48
+ base = lt & 0xFFFFFFFF
49
+ if base == 1: # 纯文本
50
+ text = nd._decode_content(content)
51
+ text = ' '.join(str(text).split())[:120]
52
+ # 群消息的 `message_content` 自带 `wxid_xxx: ` 前缀,而发言人已经在左边显示了一次
53
+ return re.sub(r'^[A-Za-z0-9_\-]+@?(chatroom|openim)?\s*:\s*', '', text)[:120]
54
+ try:
55
+ out = nd.non_text_display(lt, content)[:120]
56
+ return re.sub(r'^[A-Za-z0-9_\-]+:\s*', '', out)[:120]
57
+ except Exception:
58
+ return ''
59
+
60
+
61
+ def day_bounds(dt):
62
+ start = datetime(dt.year, dt.month, dt.day, tzinfo=TZ)
63
+ return int(start.timestamp()), int((start + timedelta(days=1)).timestamp())
64
+
65
+
66
+ def collect_for_day(conns, name_map, target_day, talker_filter=''):
67
+ """{talker: [(ts, sender, text), ...]} for one calendar day."""
68
+ start, end = day_bounds(target_day)
69
+ out = defaultdict(list)
70
+ for conn in conns:
71
+ c = conn.cursor()
72
+ try:
73
+ c.execute('SELECT user_name FROM Name2Id WHERE is_session = 1')
74
+ sessions = [r[0] for r in c.fetchall() if r[0]]
75
+ except Exception:
76
+ continue
77
+ # `real_sender_id` 是 **Name2Id 的 rowid**,不是 wxid —— 少这一步映射,
78
+ # 输出里发言人就全是 `65`、`4`、`8` 这样的数字(第一版就是这样)。
79
+ try:
80
+ c.execute('SELECT rowid, user_name FROM Name2Id')
81
+ sender_map = {r[0]: r[1] for r in c.fetchall()}
82
+ except Exception:
83
+ sender_map = {}
84
+ for talker in sessions:
85
+ if talker_filter and talker_filter not in (talker, name_map.get(talker, talker)):
86
+ continue
87
+ tbl = f'Msg_{__import__("hashlib").md5(talker.encode()).hexdigest()}'
88
+ try:
89
+ c.execute('SELECT COUNT(*) FROM sqlite_master WHERE name=?', (tbl,))
90
+ if c.fetchone()[0] == 0:
91
+ continue
92
+ c.execute(f'SELECT create_time, real_sender_id, local_type, message_content '
93
+ 'FROM "{0}" WHERE create_time >= ? AND create_time < ? ORDER BY create_time'.format(tbl),
94
+ (start, end))
95
+ for ts, sender, ltype, content in c.fetchall():
96
+ text = _text_of(content, ltype)
97
+ if text:
98
+ who = sender_map.get(sender, '')
99
+ out[name_map.get(talker, talker)].append(
100
+ (int(ts), name_map.get(who, who), text))
101
+ except Exception:
102
+ continue
103
+ return out
104
+
105
+
106
+ def main():
107
+ ap = argparse.ArgumentParser(description='那几年的今天你在聊什么')
108
+ ap.add_argument('--years', default=','.join(str(y) for y in DEFAULT_YEARS),
109
+ help='往前看几年,逗号分隔(默认 1,3,5)')
110
+ ap.add_argument('--talker', default=os.environ.get('WEFLOW_TALKER', ''), help='只看某个会话(wxid 或昵称)')
111
+ ap.add_argument('--limit', type=int, default=6, help='每天最多列几条(默认 6)')
112
+ ap.add_argument('--json', action='store_true')
113
+ args = ap.parse_args()
114
+
115
+ cfg = load_config()
116
+ db = cfg.get('ntDbPath') or ''
117
+ if not db:
118
+ raise SystemExit('配置里没有 ntDbPath,先跑 weflow-cli init')
119
+ key, salt, pw = _keys(cfg)
120
+ conns = nd.connect_message_shards(db, key, salt, pw)
121
+ if not conns:
122
+ raise SystemExit('打不开任何消息分片')
123
+
124
+ try:
125
+ # 联系人在 **contact.db**,不在消息库里 —— 从消息库找 `contact` 表一定落空,
126
+ # 输出里就只剩 `47966561144@chatroom` 和 `wxid_xxx`(第一版就是这样)。
127
+ name_map = {}
128
+ try:
129
+ from network import load_graph
130
+ people, _rooms, _members = load_graph(cfg)
131
+ name_map = {p['username']: (p['remark'] or p['nick'] or p['username'])
132
+ for p in people.values()}
133
+ except Exception as exc:
134
+ print(f'[WARN] 读联系人失败({type(exc).__name__}: {exc}),下面显示 id',
135
+ file=sys.stderr)
136
+
137
+ today = datetime.now(TZ)
138
+ years = [int(y) for y in args.years.split(',') if y.strip().isdigit()]
139
+ result = []
140
+ for y in years:
141
+ try:
142
+ day = today.replace(year=today.year - y)
143
+ except ValueError: # 2 月 29 日
144
+ day = today.replace(year=today.year - y, day=28)
145
+ by_talker = collect_for_day(conns, name_map, day, args.talker)
146
+ total = sum(len(v) for v in by_talker.values())
147
+ top = Counter({k: len(v) for k, v in by_talker.items()}).most_common(3)
148
+ picks = []
149
+ for talker, msgs in sorted(by_talker.items(), key=lambda kv: -len(kv[1]))[:3]:
150
+ for ts, sender, text in msgs[:max(1, args.limit // 3)]:
151
+ picks.append({'talker': talker, 'time': datetime.fromtimestamp(ts, tz=TZ).strftime('%H:%M'),
152
+ 'sender': sender, 'text': text})
153
+ result.append({'date': day.strftime('%Y-%m-%d'), 'yearsAgo': y,
154
+ 'total': total, 'threads': top, 'samples': picks[:args.limit]})
155
+ finally:
156
+ for conn in conns:
157
+ try:
158
+ conn.close()
159
+ except Exception:
160
+ pass
161
+
162
+ if args.json:
163
+ print(json.dumps({'success': True, 'days': result}, ensure_ascii=False, indent=2))
164
+ return
165
+
166
+ for item in result:
167
+ head = f"{item['date']}({item['yearsAgo']} 年前的今天)"
168
+ if not item['total']:
169
+ print(f'\n{head}\n 那天没有聊天记录')
170
+ continue
171
+ threads = '、'.join(f'{n}({c})' for n, c in item['threads'])
172
+ print(f"\n{head} {item['total']} 条 {threads}")
173
+ for s in item['samples']:
174
+ print(f" {s['time']} {s['talker']} · {s['sender'] or '?'}:{s['text']}")
175
+
176
+
177
+ if __name__ == '__main__':
178
+ main()