weflow-cli 1.9.0 → 1.11.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 (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -10,6 +10,8 @@ thing has silently broken at least once, and the failure was invisible:
10
10
  导出格式 excel 全程从未成功过, 报错只有"导出失败"四个字
11
11
  Python 依赖 缺 sqlcipher3 时命令报的是"数据库连接失败"
12
12
  计划任务 提醒脚本静默停跑, 没人会发现
13
+ 微信客户端 从没报过客户端版本; 而"读 exe 里的版本字面量"是错的判据(实测那只是兜底),
14
+ 磁盘上还可能留着一个不能用的版本目录(指向它的加载会失败)
13
15
 
14
16
  Run it ad hoc, or schedule it and read the summary line:
15
17
 
@@ -136,14 +138,41 @@ def check_environment(report):
136
138
 
137
139
 
138
140
  def pick_talkers(report):
139
- """The most recently active sessions - the likeliest to span shards."""
141
+ """The most recently active sessions - the likeliest to span shards.
142
+
143
+ **挑不出来时要说出来**:没有会话是"没登录/没 init"(会报红),而**有会话、却一条都拿不到
144
+ `username`** —— 那是"字段长变了"或"列表是半截的"。这一支原来直接返回空,`main()` 里
145
+ `if talkers:` 于是**静默跳过**后两项检查,报告照样写"全部通过"(只是项数悄悄少了两个)。
146
+ 同一个假绿在"计划任务"那一项也出现过(见那一段的注释)。
147
+ """
140
148
  _, data = run_json(['sessions', '--limit', str(SAMPLE_LIMIT)])
141
149
  sessions = ((data or {}).get('sessions') or [])
142
150
  if not sessions:
143
151
  report.add('选择样本会话', False, '会话列表为空', '先确认微信已登录且 init 成功')
144
152
  return []
145
153
  ordered = sorted(sessions, key=lambda s: s.get('sortTimestamp') or 0, reverse=True)
146
- return [s['username'] for s in ordered[:2] if s.get('username')]
154
+ picked = [s['username'] for s in ordered[:2] if s.get('username')]
155
+ if not picked:
156
+ # 有会话却挑不出样本 ⇒ 后面两项(读取路径一致 / 导出格式)会整段不跑,
157
+ # 不说的话这一份报告就是"少了两个检查的全绿"
158
+ report.add('选择样本会话', False, f'会话有 {len(sessions)} 条,但都没有 username 字段',
159
+ 'sessions 的输出字段可能变了 —— 这两项检查因此没跑,不代表通过')
160
+ return picked
161
+
162
+
163
+ def read_newest_json(out_dir):
164
+ """那个目录里 json 导出的最新消息时间(秒);读不出来给 None。"""
165
+ newest = None
166
+ for path in Path(out_dir).glob('*.json'):
167
+ try:
168
+ rows = json.loads(path.read_text(encoding='utf-8'))
169
+ except (OSError, ValueError):
170
+ continue
171
+ times = [int(r.get('createTime') or 0) for r in rows]
172
+ if times:
173
+ newest = max(times)
174
+ break
175
+ return newest
147
176
 
148
177
 
149
178
  def check_read_paths(report, talker):
@@ -152,21 +181,24 @@ def check_read_paths(report, talker):
152
181
  They disagreed by 1114 messages when the JSON path read only the first
153
182
  shard, so comparing their newest message is the cheapest way to notice the
154
183
  read paths drifting apart again.
184
+
185
+ **但"最新消息"是会动的**:json 先导、html 后导,中间隔着一两分钟 —— 这期间聊天里来了
186
+ 一条新消息,两条路径就会"不一致",而那是**聊天活着**,不是读取断了。原来判据是精确到分钟
187
+ 的相等(2026-10-11 实测撞到过:`json 13:38 / html 13:40:14` 判红,紧接着同一条聊天
188
+ `13:40 vs 13:40:14` 判绿)。放宽容差解决不了:繁忙的群里分片轮转本来就在分钟级,
189
+ 放宽就会把这个检查唯一要抓的东西放过去。
190
+
191
+ 所以不一致时**再读一次 json 做仲裁**:那一读能看到 html 那条 ⇒ 差别来自"消息是新的";
192
+ 看不到 ⇒ 两条路径真的不一致(这才是要报的)。仲裁只在**本来就不一致**时才花这一次导出。
155
193
  """
156
194
  with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
157
195
  out_json = Path(tmp) / 'json'
158
- code_j, err_j = run(['export', talker, 'json', '--limit', '5',
159
- '--output', str(out_json), '--non-interactive'])[0::2]
160
- newest_json = None
161
- for path in out_json.glob('*.json'):
162
- try:
163
- rows = json.loads(path.read_text(encoding='utf-8'))
164
- except (OSError, ValueError):
165
- continue
166
- times = [int(r.get('createTime') or 0) for r in rows]
167
- if times:
168
- newest_json = max(times)
169
- break
196
+ # 这次导出的退出码与 stderr **不看**:这一项判的是"两条路径读到的内容一致不一致",
197
+ # 而 json 读不出来时 `read_newest_json` 会给 None、下面照样报红。留两个不用的变量
198
+ # 只会让"检查了什么"看起来比实际多。
199
+ run(['export', talker, 'json', '--limit', '5',
200
+ '--output', str(out_json), '--non-interactive'])
201
+ newest_json = read_newest_json(out_json)
170
202
 
171
203
  out_html = Path(tmp) / 'html'
172
204
  code_h, out_h, _ = run(['export', talker, 'html', '--limit', '5',
@@ -193,14 +225,37 @@ def check_read_paths(report, talker):
193
225
  return
194
226
 
195
227
  newest_txt = datetime.datetime.fromtimestamp(newest_json).strftime('%Y-%m-%d %H:%M')
196
- if newest_html:
197
- match = newest_html[:16] == newest_txt
198
- report.add(label, match,
199
- f'json 最新 {newest_txt} / html 最新 {newest_html}',
200
- '两条路径读到不同的最新消息,通常又只剩一个分片被读了')
201
- else:
228
+ if not newest_html:
202
229
  # No timestamp in the page (empty chat); the count comparison still holds.
203
230
  report.add(label, total_html > 0, f'json 最新 {newest_txt} / html 共 {total_html} 条')
231
+ return
232
+
233
+ if newest_html[:16] == newest_txt:
234
+ report.add(label, True, f'json 最新 {newest_txt} / html 最新 {newest_html}')
235
+ return
236
+
237
+ # 不一致 —— **仲裁**:再读一次 json。它能看到 html 那条,说明差别只是"消息是新的"。
238
+ with tempfile.TemporaryDirectory(prefix='wf-health-arbiter-') as tmp:
239
+ again = Path(tmp) / 'json'
240
+ run(['export', talker, 'json', '--limit', '5',
241
+ '--output', str(again), '--non-interactive'])
242
+ arbiter = read_newest_json(again)
243
+ if arbiter is not None and arbiter >= newest_json:
244
+ arbiter_txt = datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
245
+ if arbiter_txt == newest_html[:16]:
246
+ report.add(label, True,
247
+ f'json 最新 {newest_txt} → 复查 {arbiter_txt} / html 最新 {newest_html}',
248
+ '差别来自检查期间来了新消息(这一项防的是"只剩一个分片被读了")')
249
+ return
250
+
251
+ # 仲裁没说通(复查也没读到 html 那条,或者根本没读出来)⇒ 这是真的不一致
252
+ if arbiter is None:
253
+ reason = '复查 json 也没读出来'
254
+ else:
255
+ reason = '复查 json 仍是 %s,没有更近的消息' % \
256
+ datetime.datetime.fromtimestamp(arbiter).strftime('%Y-%m-%d %H:%M')
257
+ report.add(label, False, f'json 最新 {newest_txt} / html 最新 {newest_html}',
258
+ '两条路径读到不同的最新消息,通常又只剩一个分片被读了(%s)' % reason)
204
259
 
205
260
 
206
261
  def html_newest_stamp(out_dir):
@@ -219,7 +274,13 @@ def html_newest_stamp(out_dir):
219
274
 
220
275
 
221
276
  def check_session_freshness(report):
222
- """A read path that silently stops at an old shard shows up here first."""
277
+ """A read path that silently stops at an old shard shows up here first.
278
+
279
+ ⚠️ **但"最旧"与"只是没聊天"是同一个观测**:这一项看的是会话列表里最新那条消息有多旧,
280
+ 而一个人一周没发消息,读数与"读取停在某个旧分片"**完全一样**。所以提示里必须把两种解释
281
+ 都说出来,并且点出判别器在隔壁:`读取路径一致` 绿着 ⇒ 多半只是这段时间没聊天;
282
+ 两项一起红才更像读取断了。**别在这里替人下结论。**
283
+ """
223
284
  _, data = run_json(['sessions', '--limit', '5'])
224
285
  sessions = ((data or {}).get('sessions') or [])
225
286
  stamps = [s.get('sortTimestamp') or 0 for s in sessions]
@@ -231,7 +292,9 @@ def check_session_freshness(report):
231
292
  when = datetime.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M')
232
293
  report.add('会话新鲜度', age <= STALE_SESSION_DAYS,
233
294
  f'最新会话 {when}({age:.1f} 天前)',
234
- f'超过 {STALE_SESSION_DAYS} 天未更新,通常意味着读取只覆盖了部分分片')
295
+ f'超过 {STALE_SESSION_DAYS} 天没有新消息。两种解释:**这段时间没聊天**(最可能)'
296
+ f'或**读取只覆盖了部分分片** —— 看「读取路径一致」那一项:它绿着就多半是前者,'
297
+ f'两项一起红才更像读取断了')
235
298
 
236
299
 
237
300
  def check_export_formats(report, talker):
@@ -267,7 +330,13 @@ def check_python_deps(report):
267
330
 
268
331
 
269
332
  def check_scheduled_tasks(report):
270
- """The watcher tasks must exist and their last run must have succeeded."""
333
+ """The watcher tasks must exist and their last run must have succeeded.
334
+
335
+ **"读不出来"必须与"上次成功"分开**:这一项的判据是从 `schtasks` 的文本输出里找
336
+ `Last Result` / `上次运行结果` 那一行 —— 系统语言或输出格式一变,那一行就找不到了,
337
+ 而**两个清单都会是空的**,于是它落到 `else`、报"均在位且上次成功"。
338
+ 那正是把"没验证"说成"通过"(同一类:`docs/DECISIONS.md` D-107 里那条"取不到就说未知")。
339
+ """
271
340
  if os.name != 'nt':
272
341
  report.add('计划任务', True, '非 Windows,跳过')
273
342
  return
@@ -275,7 +344,7 @@ def check_scheduled_tasks(report):
275
344
  report.add('计划任务', True, '找不到 schtasks,跳过')
276
345
  return
277
346
  names = ['WeFlow Issue Watch', 'WeFlow Mail Watch']
278
- broken, absent = [], []
347
+ broken, absent, unreadable, not_yet_run = [], [], [], []
279
348
  for name in names:
280
349
  try:
281
350
  proc = subprocess.run(['schtasks', '/query', '/tn', name, '/fo', 'LIST', '/v'],
@@ -287,21 +356,88 @@ def check_scheduled_tasks(report):
287
356
  absent.append(name)
288
357
  continue
289
358
  text = proc.stdout or ''
359
+ result = None
290
360
  for line in text.split('\n'):
291
361
  if 'Last Result' in line or '上次运行结果' in line:
292
362
  result = line.split(':', 1)[-1].strip()
293
- if result not in ('0', '0x0'):
294
- broken.append(f'{name}={result}')
363
+ break
364
+ if result is None:
365
+ # 输出里**没有那个字段**:要么语言/格式变了,要么它没在汇报这一项。
366
+ # 这时该说的是"读不出来",不是"上次成功"。
367
+ unreadable.append(name)
368
+ elif result in ('267011', '0x41303'):
369
+ # `SCHED_S_TASK_HAS_NOT_RUN`(267011 / 0x41303)—— **不是失败**:注册之后还没到
370
+ # 第一次触发。原来它落进下面那支"上次运行失败",提示还写着"脚本可能抛异常了",把刚
371
+ # 注册的人引去查一个不存在的异常(2026-10-11 本机注册这两个任务时实测到)。
372
+ # 但也**不算通过**:这一项要的是"跑过一次且成功",而任务一直"没跑过"正是它要防的事
373
+ # (触发条件写坏,或设成了"仅在用户登录时运行"而没人登录)。
374
+ not_yet_run.append(name)
375
+ elif result not in ('0', '0x0'):
376
+ broken.append(f'{name}={result}')
295
377
  if broken:
296
378
  report.add('计划任务', False, f'上次运行失败: {", ".join(broken)}',
297
379
  '脚本可能抛异常了,手动跑一次看输出')
298
380
  elif absent:
299
381
  report.add('计划任务', False, f'不存在: {", ".join(absent)}',
300
382
  '提醒不会触发;见 docs/HEALTH-CHECK.md 的注册命令')
383
+ elif unreadable:
384
+ # **不算通过**:这一项没被验证过(不是"上次成功")
385
+ report.add('计划任务', False, f'读不出上次运行结果: {", ".join(unreadable)}',
386
+ 'schtasks 的输出格式或系统语言变了 —— 这一项**没有验证**,不代表成功;'
387
+ '手动跑 `schtasks /query /tn "WeFlow Issue Watch" /fo LIST /v` 看一眼')
388
+ elif not_yet_run:
389
+ report.add('计划任务', False, f'还没跑过: {", ".join(not_yet_run)}',
390
+ '不算通过 —— 这一项要的是"跑过一次且成功"。刚注册的话等一次触发(30 分钟);'
391
+ '一直如此就查触发条件,以及任务是不是设成了"仅在用户登录时运行"')
301
392
  else:
302
393
  report.add('计划任务', True, '均在位且上次成功')
303
394
 
304
395
 
396
+ def check_wechat_client(report):
397
+ """**微信客户端版本**,以及磁盘上有没有"不能用的版本目录"。
398
+
399
+ 判据在 CLI 那一侧(`src/services/wechatClient.ts`):版本来自**活进程命令行的
400
+ `--user-lib-dir`** —— 不是 exe 里的版本字面量(实测那只是兜底),也不是"磁盘上有没有
401
+ 这个版本目录"(本机就有一个只剩 1 个文件的残留目录,指向它的加载会失败)。
402
+
403
+ 取不到就写"未知",**不写"没问题"**:与仓库里那条"没找到必须报 UNKNOWN,别报 CLEAR"同一条纪律。
404
+ 字段整个不在 = CLI 是该重建的旧版本 ⇒ **不算通过**(这正是"打开成功≠能用"那一类:
405
+ 少了一项却一片绿)。
406
+ """
407
+ _, data = run_json(['check'])
408
+ if not data:
409
+ report.add('微信客户端', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
410
+ return
411
+ client = data.get('wechatClient')
412
+ if not isinstance(client, dict):
413
+ report.add('微信客户端', False, 'check 没报这一项',
414
+ 'CLI 可能没重新构建(npm run build),或 dist 是旧的')
415
+ return
416
+ version = client.get('version')
417
+ loaded = client.get('loadedDirs') or []
418
+ if version:
419
+ detail = str(version) + (f'({loaded[0]})' if loaded else '')
420
+ else:
421
+ detail = '未知 —— ' + str(client.get('note') or '')
422
+ dead = client.get('unusableVersionDirs') or []
423
+ if dead:
424
+ shown = '、'.join(f'{d.get("dir")}({d.get("files")} 个文件)' for d in dead)
425
+ detail += f' ⚠ 残留版本目录(没有 Weixin.dll,加载会失败): {shown}'
426
+ # 客户端自报的数据目录 vs 配置推出来的根:不一致 = 两边可能不是同一份库(CLI 那边已按真身比过,
427
+ # 本机数据目录有 D 盘软链,所以这里只报结论)
428
+ if client.get('dataDirMatchesConfig') is False:
429
+ detail += ' ⚠ 客户端自报的数据目录与配置里那个数据库推出来的根**不是同一份**'
430
+ crashes = client.get('crashRecords') or {}
431
+ count = int(crashes.get('reports') or 0) + int(crashes.get('records') or 0)
432
+ if count:
433
+ newest = str(crashes.get('newest') or '时间读不到')[:16]
434
+ detail += f' · 客户端崩溃记录 {count} 份(最近 {newest})—— 崩过时数据读不出来先看这条'
435
+ # `update.data` 在场 ⇒ 上面那个版本**未必**是加载的那个:这句必须跟着版本一起出现
436
+ for caveat in client.get('caveats') or []:
437
+ detail += f' ⚠ {caveat}'
438
+ report.add('微信客户端', True, detail)
439
+
440
+
305
441
  def main():
306
442
  parser = argparse.ArgumentParser(description='weflow-cli periodic health check')
307
443
  parser.add_argument('--json', action='store_true', help='机器可读输出')
@@ -314,6 +450,7 @@ def main():
314
450
  report = Report()
315
451
  check_version(report)
316
452
  check_environment(report)
453
+ check_wechat_client(report)
317
454
  check_python_deps(report)
318
455
  check_session_freshness(report)
319
456
 
@@ -91,7 +91,7 @@ def _is_linux_wechat_process(pid):
91
91
  if pid == os.getpid():
92
92
  return False
93
93
  try:
94
- with open(f'/proc/{pid}/comm') as f:
94
+ with open(f'/proc/{pid}/comm', encoding='utf-8') as f:
95
95
  comm = f.read().strip().lower()
96
96
  if comm in _LINUX_WECHAT_COMMS:
97
97
  return True
@@ -122,7 +122,7 @@ def find_weixin_pid_linux():
122
122
  if not _is_linux_wechat_process(pid):
123
123
  continue
124
124
  try:
125
- with open(f'/proc/{pid}/statm') as f:
125
+ with open(f'/proc/{pid}/statm', encoding='utf-8') as f:
126
126
  rss_kb = int(f.read().split()[1]) * 4
127
127
  except (OSError, IndexError, ValueError):
128
128
  rss_kb = 0
@@ -205,7 +205,7 @@ def scan_memory_keys_linux(pid):
205
205
  """
206
206
  regions = []
207
207
  try:
208
- with open(f'/proc/{pid}/maps') as f:
208
+ with open(f'/proc/{pid}/maps', encoding='utf-8') as f:
209
209
  for line in f:
210
210
  parts = line.split()
211
211
  if len(parts) < 2 or 'r' not in parts[1]: