weflow-cli 1.6.0 → 1.6.2
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.
- package/CHANGELOG.md +30 -1
- package/README.md +2 -0
- package/bin/weflow-cli.ts +58 -11
- package/dist/bin/weflow-cli.js +59 -11
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +17 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/chatService.d.ts +7 -0
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +15 -5
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +15 -3
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -1
- package/dist/src/utils/pythonProcessEnv.js +16 -1
- package/dist/src/utils/pythonProcessEnv.js.map +1 -1
- package/docs/HEALTH-CHECK.md +98 -0
- package/docs/RELEASING.md +177 -0
- package/package.json +1 -1
- package/scripts/biz_daily.py +3 -1
- package/scripts/export_chat_html.py +9 -0
- package/scripts/health_check.py +336 -0
- package/scripts/nt_decrypt.py +1352 -1207
- package/scripts/watch_issues.py +212 -0
- package/scripts/watch_mail.py +131 -0
- package/src/core/ntCore.ts +17 -1
- package/src/services/chatService.ts +14 -5
- package/src/services/exportService.ts +15 -3
- package/src/utils/pythonProcessEnv.ts +18 -2
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# 发布清单
|
|
2
|
+
|
|
3
|
+
npm 包与 GitHub 分开发布,中间隔多久都不奇怪——`1.5.0` 与主线之间曾拉开 128 个提交、
|
|
4
|
+
两个多月,直接导致用户照着 README 敲 `init --path` 却报 `unknown option`。
|
|
5
|
+
|
|
6
|
+
按下面顺序走,每一步都有对应的**失败长什么样**。
|
|
7
|
+
|
|
8
|
+
## 1. 定版本号
|
|
9
|
+
|
|
10
|
+
按语义化版本,不是按「改了多少行」:
|
|
11
|
+
|
|
12
|
+
| 改动性质 | 升哪一位 | 例 |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| 新功能、新命令、新参数 | **minor** | 1.5.0 → 1.6.0 |
|
|
15
|
+
| 只修 bug、只改文档 | patch | 1.6.0 → 1.6.1 |
|
|
16
|
+
| 破坏兼容 | major | 1.6.0 → 2.0.0 |
|
|
17
|
+
|
|
18
|
+
已备好但**从未发布**的版本号可以并进下一个:`1.5.1` 准备过却没发,
|
|
19
|
+
它的修复就直接并进了 `1.6.0`。
|
|
20
|
+
|
|
21
|
+
## 2. 整理 CHANGELOG
|
|
22
|
+
|
|
23
|
+
- 把 `## Unreleased` 改名为 `## <新版本号>`
|
|
24
|
+
- 若上一版已写好但没发布,把它的条目并入,删掉那个空段
|
|
25
|
+
- 通用说明(如「npm 与 GitHub 分开发布」)移到文件顶部,别留在版本段里
|
|
26
|
+
|
|
27
|
+
## 3. 构建与测试
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
npm run build
|
|
31
|
+
npm test
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 4. 打包检查(**不能跳过**)
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
npm pack --dry-run
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
本地常有 `output/`(几 GB 产出)、`models/`(几 GB 模型)、`_env.json`(含密钥)
|
|
41
|
+
这类东西。`package.json` 的 `files` 是白名单,理论上挡住——但**白名单也可能写漏**,
|
|
42
|
+
所以逐项确认:
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
npm pack --dry-run 2>&1 | grep -E "npm notice [0-9]" > /tmp/list.txt
|
|
46
|
+
grep -E "^output/|^models/|node_modules|\.env|\.db$|\.dat$|secret|credential" /tmp/list.txt
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**这条命令必须没有输出。** 有输出就停下来查。
|
|
50
|
+
|
|
51
|
+
同时确认新增文件确实**在**包里(白名单是按目录配的,容易漏):
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
grep -E "新文件名" /tmp/list.txt
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 5. 发布
|
|
58
|
+
|
|
59
|
+
需要 npm 凭据。`ribiaosu` 账号开了 2FA,所以两条路:
|
|
60
|
+
|
|
61
|
+
**凭据(推荐,一次配置长期可用)** — 用**带 `Bypass 2FA` 的 Granular Access Token**,
|
|
62
|
+
在 https://www.npmjs.com/settings/ribiaosu/tokens 生成时**必须勾选那个框**。
|
|
63
|
+
漏勾的 token 会在发布时报 `403 ... bypass 2fa enabled is required`。
|
|
64
|
+
|
|
65
|
+
| 现象 | 原因 |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `403 ... two-factor authentication ... required` | token 没勾 Bypass 2FA,或没用 OTP |
|
|
68
|
+
| `ENEEAUTH` | 没登录 / 没配 token |
|
|
69
|
+
|
|
70
|
+
**验证 token 是否可用**(`bypass_2fa` 必须是 `true`):
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
curl -s -H "Authorization: Bearer <token>" https://registry.npmjs.org/-/npm/v1/tokens
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**一次性验证码**:
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
npm publish --otp=<6位码>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 6. 触发国内镜像同步(**新加的必要步骤**)
|
|
83
|
+
|
|
84
|
+
国内用户大多用 `registry.npmmirror.com`(淘宝源),它对新版本有同步延迟。
|
|
85
|
+
不同步的话,发布后几个小时内会有人来提「版本不存在」的 issue——
|
|
86
|
+
`weflow-cli@1.6.0` 就这样被报过一次(Issue #8)。
|
|
87
|
+
|
|
88
|
+
```powershell
|
|
89
|
+
curl -X PUT https://registry.npmmirror.com/-/package/weflow-cli/syncs
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
返回 `{"ok":true,...,"state":"waiting"}` 即已受理。**约 5 分钟后**生效。
|
|
93
|
+
|
|
94
|
+
## 7. 验证两个源都是新版本
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
npm view weflow-cli version --registry=https://registry.npmjs.org
|
|
98
|
+
npm view weflow-cli version --registry=https://registry.npmmirror.com
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
两个都要是新版本号才对外宣告。官方源发布后自身也有几分钟 CDN 传播延迟——
|
|
102
|
+
期间 `npm view` 可能显示旧版本、tarball 可能 404,**这是在传播,不是失败**。
|
|
103
|
+
用这个判断是否真的发出去了:
|
|
104
|
+
|
|
105
|
+
```powershell
|
|
106
|
+
npm publish # 再发一次
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
报 `409 Conflict - Cannot publish over previously staged version` → **已经发布成功**。
|
|
110
|
+
|
|
111
|
+
## 8. 兜底:告诉用户怎么绕开镜像
|
|
112
|
+
|
|
113
|
+
```powershell
|
|
114
|
+
npm config get registry # 看用的哪个源
|
|
115
|
+
npm view weflow-cli version --registry=https://registry.npmjs.org # 用官方源查
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
两个源结果不一致 → 是镜像同步问题,不是包的问题:
|
|
119
|
+
`npm install -g weflow-cli@<版本> --registry=https://registry.npmjs.org` 立刻可用。
|
|
120
|
+
|
|
121
|
+
## 发布后
|
|
122
|
+
|
|
123
|
+
- 回到相关 issue 回复「已发布 + 升级方式」
|
|
124
|
+
- GitHub Release 与本清单解耦:源码、编译包、npm 三者版本差异要对用户可见
|
|
125
|
+
|
|
126
|
+
### 回复用户的规矩
|
|
127
|
+
|
|
128
|
+
报障的人多半不是开发者,也不看长文。**回复写给用户,不是写给同事:**
|
|
129
|
+
|
|
130
|
+
- **先给出结论和做法**(已修复 / 升级到 x.y.z),技术细节不要写进回复
|
|
131
|
+
- **三五句话说完**。根因分析、代码片段、执行顺序论证属于 commit message 和
|
|
132
|
+
本文档,不属于 issue 回复
|
|
133
|
+
- 升级类回复直接给命令,别让人自己找
|
|
134
|
+
- **发出前先给维护者过目**,确认后再发
|
|
135
|
+
|
|
136
|
+
反面例子:一条回复里贴了 Python 代码片段和逐步的执行顺序分析,内容全对,
|
|
137
|
+
但给用户的感受是「看不懂、太长」。
|
|
138
|
+
|
|
139
|
+
### 新动态邮件提醒
|
|
140
|
+
|
|
141
|
+
`scripts/watch_issues.py` 轮询本仓库的 issue 与回复,有非维护者发言时发邮件。
|
|
142
|
+
由 Windows 计划任务 `WeFlow Issue Watch` 每 30 分钟调用一次,不依赖任何编辑器
|
|
143
|
+
或 Claude 会话处于打开状态。
|
|
144
|
+
|
|
145
|
+
邮件凭据放在仓库外,不进版本库:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
// ~/.weflow-issue-watch.json (QQ 邮箱需用「授权码」,不是登录密码)
|
|
149
|
+
{
|
|
150
|
+
"smtp_user": "1473517806@qq.com",
|
|
151
|
+
"smtp_auth": "<授权码>",
|
|
152
|
+
"mail_to": "1473517806@qq.com",
|
|
153
|
+
"repo": "zhuobichen/weflow-cli"
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- 首次运行只记录基线、不发信,避免把历史 issue 一次性全发出去
|
|
158
|
+
- 发送失败不推进记录,下次自动重试
|
|
159
|
+
- 手动跑一次:`py scripts/watch_issues.py`
|
|
160
|
+
- 查看/改频率:`Get-ScheduledTask -TaskName 'WeFlow Issue Watch'`
|
|
161
|
+
|
|
162
|
+
### 发布后自检
|
|
163
|
+
|
|
164
|
+
```powershell
|
|
165
|
+
npm view weflow-cli version --registry=https://registry.npmjs.org
|
|
166
|
+
npm view weflow-cli version --registry=https://registry.npmmirror.com
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
两个源都是新版本,再对外说「已发布」。
|
|
170
|
+
|
|
171
|
+
然后跑一遍体检,确认这次改动没有把读取路径或某个导出格式弄坏:
|
|
172
|
+
|
|
173
|
+
```powershell
|
|
174
|
+
py scripts/health_check.py
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
退出码 `0` 才算干净。检查项与各自的由来见 [HEALTH-CHECK.md](HEALTH-CHECK.md)。
|
package/package.json
CHANGED
package/scripts/biz_daily.py
CHANGED
|
@@ -194,9 +194,11 @@ def get_db_keys(config):
|
|
|
194
194
|
bytes.fromhex(biz_salt), 256000, dklen=32
|
|
195
195
|
).hex()
|
|
196
196
|
if not biz_key:
|
|
197
|
+
# 只指向 passphrase 一条路: 早先还建议 `config set bizKey`, 但该键不在
|
|
198
|
+
# CLI 的可写白名单里, 照做必然报 INVALID_CONFIG_KEY。
|
|
197
199
|
raise SystemExit(
|
|
198
200
|
'缺少公众号数据库密钥: 请运行 weflow-cli fav set-key --passphrase <64位hex> '
|
|
199
|
-
'配置全库 passphrase (自动派生各库密钥)
|
|
201
|
+
'配置全库 passphrase (自动派生各库密钥)'
|
|
200
202
|
)
|
|
201
203
|
|
|
202
204
|
return {
|
|
@@ -2121,6 +2121,8 @@ def main():
|
|
|
2121
2121
|
parser.add_argument('--per-page', type=int, default=0,
|
|
2122
2122
|
help='Messages per file; overrides --parts. Keeps a long history snappy to open')
|
|
2123
2123
|
parser.add_argument('--date', default=os.environ.get('WEFLOW_EXPORT_DATE', ''), help='Only export messages from local date YYYY-MM-DD')
|
|
2124
|
+
parser.add_argument('--limit', type=int, default=int(os.environ.get('WEFLOW_EXPORT_LIMIT') or 0),
|
|
2125
|
+
help='Only export the most recent N messages (0 = all)')
|
|
2124
2126
|
parser.add_argument('--emoticon-seed', default=os.environ.get('WEFLOW_EMOTICON_SEED', ''),
|
|
2125
2127
|
help='Account seed; decrypts custom stickers from the local cache')
|
|
2126
2128
|
parser.add_argument('--passphrase', default=os.environ.get('WEFLOW_NT_PASSPHRASE', ''), help='Shared NT passphrase for deriving shard keys')
|
|
@@ -2220,6 +2222,13 @@ def main():
|
|
|
2220
2222
|
_phase("before fetch")
|
|
2221
2223
|
messages = fetch_messages_from_shards(args.db, args.key, args.salt, args.talker, args.date, args.passphrase)
|
|
2222
2224
|
|
|
2225
|
+
# `--limit` means the most recent N, the same as `messages` and
|
|
2226
|
+
# `export json`. Sliced here, before the media and sender maps are built,
|
|
2227
|
+
# so neither does work for messages that are about to be dropped.
|
|
2228
|
+
if args.limit and args.limit > 0 and len(messages) > args.limit:
|
|
2229
|
+
print(f"Limiting to the most recent {args.limit} of {len(messages)} messages")
|
|
2230
|
+
messages = messages[-args.limit:]
|
|
2231
|
+
|
|
2223
2232
|
if not messages:
|
|
2224
2233
|
if args.date:
|
|
2225
2234
|
file_prefix = sanitize_filename(args.name or args.talker)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Periodic health check for weflow-cli.
|
|
3
|
+
|
|
4
|
+
A pure script - no model, no tokens. Every check here exists because that exact
|
|
5
|
+
thing has silently broken at least once, and the failure was invisible:
|
|
6
|
+
|
|
7
|
+
版本一致 1.6.0 自报 1.5.1, so "which build am I running" was unanswerable
|
|
8
|
+
读取路径一致 只读 message_0.db 时, export json 有 31 条而 export html 有 1145 条
|
|
9
|
+
会话新鲜度 同上, 会话列表停在 18 天前而命令仍然"成功"
|
|
10
|
+
导出格式 excel 全程从未成功过, 报错只有"导出失败"四个字
|
|
11
|
+
Python 依赖 缺 sqlcipher3 时命令报的是"数据库连接失败"
|
|
12
|
+
计划任务 提醒脚本静默停跑, 没人会发现
|
|
13
|
+
|
|
14
|
+
Run it ad hoc, or schedule it and read the summary line:
|
|
15
|
+
|
|
16
|
+
py scripts/health_check.py
|
|
17
|
+
py scripts/health_check.py --json
|
|
18
|
+
|
|
19
|
+
Exit code is 0 only when every check passes, so a scheduler can act on it.
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import datetime
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
33
|
+
CLI = ROOT / 'cli.cjs'
|
|
34
|
+
|
|
35
|
+
# Windows consoles default to a legacy codepage that cannot encode the report.
|
|
36
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
37
|
+
try:
|
|
38
|
+
_stream.reconfigure(encoding='utf-8', errors='replace')
|
|
39
|
+
except (AttributeError, ValueError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
# Each check shells out to the CLI, which takes seconds; keep the sample small.
|
|
43
|
+
SAMPLE_LIMIT = 5
|
|
44
|
+
TIMEOUT = 300
|
|
45
|
+
|
|
46
|
+
# A session list that has not moved in this long means reads are stale. WeChat
|
|
47
|
+
# is not chatty for everyone, so this is deliberately generous.
|
|
48
|
+
STALE_SESSION_DAYS = 7
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(args, timeout=TIMEOUT):
|
|
52
|
+
"""(returncode, stdout, stderr) with Node's experimental warning suppressed."""
|
|
53
|
+
env = {**os.environ, 'NODE_NO_WARNINGS': '1', 'PYTHONIOENCODING': 'utf-8'}
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
['node', str(CLI), *args], capture_output=True, timeout=timeout,
|
|
57
|
+
encoding='utf-8', errors='replace', env=env, cwd=str(ROOT))
|
|
58
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
59
|
+
return 1, '', f'{type(error).__name__}: {error}'
|
|
60
|
+
return proc.returncode, proc.stdout or '', proc.stderr or ''
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_json(args):
|
|
64
|
+
"""Parsed JSON payload, or None.
|
|
65
|
+
|
|
66
|
+
The CLI pretty-prints, so the payload spans many lines - parse from the
|
|
67
|
+
first brace rather than looking for a single-line blob.
|
|
68
|
+
"""
|
|
69
|
+
code, out, _ = run([*args, '--json'])
|
|
70
|
+
start = out.find('{')
|
|
71
|
+
if start < 0:
|
|
72
|
+
return code, None
|
|
73
|
+
try:
|
|
74
|
+
return code, json.JSONDecoder().raw_decode(out[start:])[0]
|
|
75
|
+
except ValueError:
|
|
76
|
+
return code, None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Report:
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self.rows = []
|
|
82
|
+
|
|
83
|
+
def add(self, name, ok, detail, hint=''):
|
|
84
|
+
self.rows.append({'name': name, 'ok': bool(ok), 'detail': detail, 'hint': hint})
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def failed(self):
|
|
88
|
+
return [r for r in self.rows if not r['ok']]
|
|
89
|
+
|
|
90
|
+
def render(self):
|
|
91
|
+
stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
92
|
+
lines = [f'WeFlow 健康检查 · {stamp}', '']
|
|
93
|
+
width = max(len(r['name']) for r in self.rows) + 2
|
|
94
|
+
for row in self.rows:
|
|
95
|
+
mark = 'OK ' if row['ok'] else 'FAIL'
|
|
96
|
+
lines.append(f'[{mark}] {row["name"]:<{width}}{row["detail"]}')
|
|
97
|
+
if not row['ok'] and row['hint']:
|
|
98
|
+
lines.append(f'{"":<7}{"":<{width}}↳ {row["hint"]}')
|
|
99
|
+
lines.append('')
|
|
100
|
+
if self.failed:
|
|
101
|
+
lines.append(f'{len(self.rows) - len(self.failed)} 项通过 / {len(self.failed)} 项失败')
|
|
102
|
+
else:
|
|
103
|
+
lines.append(f'全部通过({len(self.rows)} 项)')
|
|
104
|
+
return '\n'.join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_version(report):
|
|
108
|
+
"""The CLI must report the version it actually is."""
|
|
109
|
+
try:
|
|
110
|
+
declared = json.loads((ROOT / 'package.json').read_text(encoding='utf-8'))['version']
|
|
111
|
+
except (OSError, ValueError, KeyError) as error:
|
|
112
|
+
report.add('版本一致', False, f'读不到 package.json ({type(error).__name__})')
|
|
113
|
+
return
|
|
114
|
+
code, out, _ = run(['--version'])
|
|
115
|
+
reported = out.strip().split('\n')[-1].strip() if out.strip() else ''
|
|
116
|
+
report.add('版本一致', code == 0 and reported == declared,
|
|
117
|
+
f'CLI {reported or "?"} / package {declared}',
|
|
118
|
+
'两者不一致说明 --version 又写死了,或 dist 未重新构建')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_environment(report):
|
|
122
|
+
"""Environment self-check: config present, Python deps importable."""
|
|
123
|
+
_, data = run_json(['check'])
|
|
124
|
+
if not data:
|
|
125
|
+
report.add('环境自检', False, 'check 未返回 JSON', '先跑 weflow-cli check 看原始输出')
|
|
126
|
+
return
|
|
127
|
+
config = data.get('configuration') or {}
|
|
128
|
+
deps = ((data.get('dependencies') or {}).get('required') or {})
|
|
129
|
+
missing = [name for name, status in deps.items() if not status]
|
|
130
|
+
ok = bool(config.get('initialized')) and bool(config.get('messageDatabase')) and not missing
|
|
131
|
+
detail = f'已初始化={config.get("initialized")} 消息库={config.get("messageDatabase")}'
|
|
132
|
+
if missing:
|
|
133
|
+
detail += f' 缺依赖={",".join(missing)}'
|
|
134
|
+
report.add('环境自检', ok, detail,
|
|
135
|
+
'缺依赖: pip install -r requirements.txt;未初始化: weflow-cli init')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def pick_talkers(report):
|
|
139
|
+
"""The most recently active sessions - the likeliest to span shards."""
|
|
140
|
+
_, data = run_json(['sessions', '--limit', str(SAMPLE_LIMIT)])
|
|
141
|
+
sessions = ((data or {}).get('sessions') or [])
|
|
142
|
+
if not sessions:
|
|
143
|
+
report.add('选择样本会话', False, '会话列表为空', '先确认微信已登录且 init 成功')
|
|
144
|
+
return []
|
|
145
|
+
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')]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def check_read_paths(report, talker):
|
|
150
|
+
"""`export json` and `export html` read the same chat by different code.
|
|
151
|
+
|
|
152
|
+
They disagreed by 1114 messages when the JSON path read only the first
|
|
153
|
+
shard, so comparing their newest message is the cheapest way to notice the
|
|
154
|
+
read paths drifting apart again.
|
|
155
|
+
"""
|
|
156
|
+
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
157
|
+
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
|
|
170
|
+
|
|
171
|
+
out_html = Path(tmp) / 'html'
|
|
172
|
+
code_h, out_h, _ = run(['export', talker, 'html', '--limit', '5',
|
|
173
|
+
'--output', str(out_html), '--non-interactive'])
|
|
174
|
+
total_html = None
|
|
175
|
+
for line in out_h.split('\n'):
|
|
176
|
+
line = line.strip()
|
|
177
|
+
if line.startswith('Total:') and 'messages' in line:
|
|
178
|
+
try:
|
|
179
|
+
total_html = int(line.split()[1])
|
|
180
|
+
except (IndexError, ValueError):
|
|
181
|
+
pass
|
|
182
|
+
# Read the pages while the temp directory still exists.
|
|
183
|
+
newest_html = html_newest_stamp(out_html)
|
|
184
|
+
|
|
185
|
+
label = f'读取路径一致 ({talker[:14]}…)'
|
|
186
|
+
if newest_json is None:
|
|
187
|
+
report.add(label, False, 'json 导出没有产出可比对的消息',
|
|
188
|
+
'export json 失败时先单独跑一次看报错')
|
|
189
|
+
return
|
|
190
|
+
if total_html is None:
|
|
191
|
+
report.add(label, False, 'html 导出没有报告条数',
|
|
192
|
+
'export html 失败时先单独跑一次看报错')
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
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:
|
|
202
|
+
# No timestamp in the page (empty chat); the count comparison still holds.
|
|
203
|
+
report.add(label, total_html > 0, f'json 最新 {newest_txt} / html 共 {total_html} 条')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def html_newest_stamp(out_dir):
|
|
207
|
+
"""Newest 'YYYY-MM-DD HH:MM:SS' stamp rendered into the exported pages."""
|
|
208
|
+
import re
|
|
209
|
+
newest = ''
|
|
210
|
+
try:
|
|
211
|
+
for page in sorted(Path(out_dir).glob('*.html')):
|
|
212
|
+
text = page.read_text(encoding='utf-8', errors='replace')
|
|
213
|
+
stamps = re.findall(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', text)
|
|
214
|
+
if stamps and stamps[-1] > newest:
|
|
215
|
+
newest = stamps[-1]
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
return newest
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_session_freshness(report):
|
|
222
|
+
"""A read path that silently stops at an old shard shows up here first."""
|
|
223
|
+
_, data = run_json(['sessions', '--limit', '5'])
|
|
224
|
+
sessions = ((data or {}).get('sessions') or [])
|
|
225
|
+
stamps = [s.get('sortTimestamp') or 0 for s in sessions]
|
|
226
|
+
if not stamps or not max(stamps):
|
|
227
|
+
report.add('会话新鲜度', False, '会话列表没有时间戳', 'init 可能未完成')
|
|
228
|
+
return
|
|
229
|
+
newest = max(stamps)
|
|
230
|
+
age = (time.time() - newest) / 86400
|
|
231
|
+
when = datetime.datetime.fromtimestamp(newest).strftime('%Y-%m-%d %H:%M')
|
|
232
|
+
report.add('会话新鲜度', age <= STALE_SESSION_DAYS,
|
|
233
|
+
f'最新会话 {when}({age:.1f} 天前)',
|
|
234
|
+
f'超过 {STALE_SESSION_DAYS} 天未更新,通常意味着读取只覆盖了部分分片')
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_export_formats(report, talker):
|
|
238
|
+
"""json / txt / excel must each produce a file. html is covered above."""
|
|
239
|
+
with tempfile.TemporaryDirectory(prefix='wf-health-') as tmp:
|
|
240
|
+
for fmt, pattern in (('json', '*.json'), ('txt', '*.txt'), ('excel', '*.xlsx')):
|
|
241
|
+
out = Path(tmp) / fmt
|
|
242
|
+
code, stdout, stderr = run(['export', talker, fmt, '--limit', '3',
|
|
243
|
+
'--output', str(out), '--non-interactive'])
|
|
244
|
+
produced = list(out.glob(pattern)) if out.exists() else []
|
|
245
|
+
size = produced[0].stat().st_size if produced else 0
|
|
246
|
+
detail = f'{size} 字节' if produced else (stderr.strip().split('\n')[-1][:60] or f'exit {code}')
|
|
247
|
+
report.add(f'导出格式 {fmt}', code == 0 and size > 0, detail)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def check_python_deps(report):
|
|
251
|
+
"""The NT reader is a Python subprocess; a missing module looks like a DB error."""
|
|
252
|
+
required = {
|
|
253
|
+
'sqlcipher3': 'sqlcipher3',
|
|
254
|
+
'html2text': 'html2text',
|
|
255
|
+
'zstandard': 'zstandard',
|
|
256
|
+
'cryptography': 'cryptography',
|
|
257
|
+
}
|
|
258
|
+
missing = []
|
|
259
|
+
for module, package in required.items():
|
|
260
|
+
try:
|
|
261
|
+
__import__(module)
|
|
262
|
+
except ImportError:
|
|
263
|
+
missing.append(package)
|
|
264
|
+
report.add('Python 依赖', not missing,
|
|
265
|
+
'齐全' if not missing else f'缺 {", ".join(missing)}',
|
|
266
|
+
'pip install -r requirements.txt')
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def check_scheduled_tasks(report):
|
|
270
|
+
"""The watcher tasks must exist and their last run must have succeeded."""
|
|
271
|
+
if os.name != 'nt':
|
|
272
|
+
report.add('计划任务', True, '非 Windows,跳过')
|
|
273
|
+
return
|
|
274
|
+
if not shutil.which('schtasks'):
|
|
275
|
+
report.add('计划任务', True, '找不到 schtasks,跳过')
|
|
276
|
+
return
|
|
277
|
+
names = ['WeFlow Issue Watch', 'WeFlow Mail Watch']
|
|
278
|
+
broken, absent = [], []
|
|
279
|
+
for name in names:
|
|
280
|
+
try:
|
|
281
|
+
proc = subprocess.run(['schtasks', '/query', '/tn', name, '/fo', 'LIST', '/v'],
|
|
282
|
+
capture_output=True, timeout=60, encoding='utf-8', errors='replace')
|
|
283
|
+
except (OSError, subprocess.SubprocessError):
|
|
284
|
+
absent.append(name)
|
|
285
|
+
continue
|
|
286
|
+
if proc.returncode != 0:
|
|
287
|
+
absent.append(name)
|
|
288
|
+
continue
|
|
289
|
+
text = proc.stdout or ''
|
|
290
|
+
for line in text.split('\n'):
|
|
291
|
+
if 'Last Result' in line or '上次运行结果' in line:
|
|
292
|
+
result = line.split(':', 1)[-1].strip()
|
|
293
|
+
if result not in ('0', '0x0'):
|
|
294
|
+
broken.append(f'{name}={result}')
|
|
295
|
+
if broken:
|
|
296
|
+
report.add('计划任务', False, f'上次运行失败: {", ".join(broken)}',
|
|
297
|
+
'脚本可能抛异常了,手动跑一次看输出')
|
|
298
|
+
elif absent:
|
|
299
|
+
report.add('计划任务', False, f'不存在: {", ".join(absent)}',
|
|
300
|
+
'提醒不会触发;见 docs/HEALTH-CHECK.md 的注册命令')
|
|
301
|
+
else:
|
|
302
|
+
report.add('计划任务', True, '均在位且上次成功')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main():
|
|
306
|
+
parser = argparse.ArgumentParser(description='weflow-cli periodic health check')
|
|
307
|
+
parser.add_argument('--json', action='store_true', help='机器可读输出')
|
|
308
|
+
args = parser.parse_args()
|
|
309
|
+
|
|
310
|
+
if not CLI.exists():
|
|
311
|
+
print(f'找不到 {CLI}', file=sys.stderr)
|
|
312
|
+
return 2
|
|
313
|
+
|
|
314
|
+
report = Report()
|
|
315
|
+
check_version(report)
|
|
316
|
+
check_environment(report)
|
|
317
|
+
check_python_deps(report)
|
|
318
|
+
check_session_freshness(report)
|
|
319
|
+
|
|
320
|
+
talkers = pick_talkers(report)
|
|
321
|
+
if talkers:
|
|
322
|
+
check_read_paths(report, talkers[0])
|
|
323
|
+
check_export_formats(report, talkers[0])
|
|
324
|
+
|
|
325
|
+
check_scheduled_tasks(report)
|
|
326
|
+
|
|
327
|
+
if args.json:
|
|
328
|
+
print(json.dumps({'success': not report.failed, 'checks': report.rows},
|
|
329
|
+
ensure_ascii=False, indent=2))
|
|
330
|
+
else:
|
|
331
|
+
print(report.render())
|
|
332
|
+
return 1 if report.failed else 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == '__main__':
|
|
336
|
+
sys.exit(main())
|