dsh-napcat-keeper 2.7.2 → 2.8.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.
- package/CHANGELOG.md +22 -0
- package/README.md +85 -2
- package/lib/client.js +47 -6
- package/lib/index.js +396 -21
- package/package.json +3 -1
- package/scripts/selftest-probe.mjs +186 -0
- package/scripts/selftest-save.mjs +100 -0
- package/scripts/smoke-client.mjs +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.8.0 — 认得出反向 WebSocket(和 dsh-onebot-qq 共存)
|
|
4
|
+
- **修的什么**:装了 `dsh-onebot-qq` 这类插件、把 NapCat 配成**反向 WebSocket** 连过来的机器上,本插件永远判「离线」,然后无限重启。原因是探测只用 HTTP 打 `get_login_info`,而反向 WS 的端口根本不吃 HTTP(换来 401 / 426);老代码只看返回的 body、不看 HTTP 状态码,于是把「端口明明在回话」当成「端口不通」→ 卡死判定 → 杀掉重拉 → 循环。
|
|
5
|
+
- 现在 `postOneBot` 会把 HTTP 状态码带回来,`probe()` 据此分清四种情况:能问到 `login_info`(普通 HTTP)、只收 WebSocket(反向 WS)、端口要求鉴权(多半是令牌不对)、端口没开。
|
|
6
|
+
- 新增「在线探测方式」(`probeMode`):`auto`(默认,先按 HTTP 问,问不通再看是不是反向 WS)/ `http` / `reverse` / `process`(只看进程,不碰端口)。设置页「连接」那组里能直接选。
|
|
7
|
+
- 反向 WS 模式下「在线」的判定改成:端口上有**客户端连进来**(`Get-NetTCPConnection` 数客户端那一侧的连接)。关键的一条是**进程在 + 端口有服务就别杀** —— 端口回 401/426 说明服务活着,重拉解决不了任何问题,只会把正在连的那条链踹下去。
|
|
8
|
+
- 真等不来客户端时也不再每轮都杀:先按 `stuckSec` 等,超时才**温和重拉**一次,最多两次,还不成就进 `reverse-giveup`,在日志和状态条里说清楚该去 NapCat 里把反向 WS 地址填成 `ws://127.0.0.1:<端口>`。
|
|
9
|
+
- 顺带加了拉起退避:连续拉不起来时宽限期按 2 的幂放大(最多 8 倍),不会再 45 秒一次地反复拉。
|
|
10
|
+
- 状态条能显示「反向 WS 已连接 / 客户端 N」,端口回 401 时显示「需鉴权」,并把该改哪里写在提示里。
|
|
11
|
+
- 补了 `pnpm selftest:probe`(27 项):本地起假服务器分别模拟正常 OneBot / 426 无客户端 / 426 有客户端 / 401 / 端口没开,另加 `probeMode` 的强制值归一化。全程环回 + 临时端口,不碰真配置。
|
|
12
|
+
|
|
13
|
+
## 2.7.3 — 修「设置页保存不进去」+ README 补「装不上」的两条排障
|
|
14
|
+
- **修了个真 bug,2.7.0 起就有**:设置页点「保存」、「用这个」、或者「清除密码」,值当场弹回原样,什么都没存下来。起因是 2.7.0 把插件自己的文件收进 `~/.dsh/dsh-napcat-keeper/` 时,只把**读取**改成了新目录,**写入**还留在老地方 `~/.dsh/config.json` —— 于是存进一个没人读的文件,界面重新渲染时拿到的还是旧值。2.7.0 / 2.7.1 / 2.7.2 都中招。
|
|
15
|
+
- 现在读和写认同一个目录(`saveAndApply` 与「清除密码」都改走 `keeperDir()`)。
|
|
16
|
+
- **老版本留下的那份被误写的配置会被自动认领**:启动时如果发现 `~/.dsh/config.json` 里有 `shellDir` / `onebotPort` 这类自家字段、而且比插件目录里的配置新,就把它的内容并进真配置(多半正是你当初点了保存却没生效的那次),原文归档进 `legacy/config.adopted-*.json`,认领前的旧配置备份成 `legacy/config.before-adopt.json`。不像插件配置的 `config.json` 一概不碰,也不会拿旧文件顶掉更新的配置。
|
|
17
|
+
- 补了 `pnpm selftest:save`(12 项):保存真的落到新目录、认领逻辑、keeper 更新时不认领、别人的 `config.json` 不动。
|
|
18
|
+
- **没发版,顺带补了 README 两条排障**:有人从 GitHub 装插件报 `'git' is not recognized as an internal or external command`,查下来是对方机器没装 Git —— `github:` 规格要 pnpm 调外部 `git ls-remote`,插件一个字节都还没下载,跟插件本身无关(npm 上的 2.7.2 一直正常)。
|
|
19
|
+
- 「安装」那节加了一句:**别加 `github:` 前缀**,用 npm 名装全程不碰 git。
|
|
20
|
+
- 「常见问题」补一条,三条路:npm 名装 / 装 Git 后**彻底重启 DSH**(子进程继承 DSH 启动时的 PATH,不重启照旧报错)/ 无 git 时走 `codeload` 的 tarball。
|
|
21
|
+
- 又有人报 `无法识别这个包名或地址:not a package name the registry accepts`:那是 DSH「设置 → 插件 → 添加插件」输入框的**规格校验**,跟插件和网络都无关 —— 他把整条命令(或 `add dsh-napcat-keeper` 这种)粘进了只认「包名 / 仓库地址 / 本地目录」的那一栏。桌面版安装目录里根本没有 `dsh` 命令,用户只能走界面,于是把命令行原样贴进去。
|
|
22
|
+
- README「安装」改成**先桌面版点界面、再命令行**两条路,并点明那一栏只填 `dsh-napcat-keeper`;「常见问题」再加一条,用表格列清楚哪些写法会被拒(整条命令 / 多出的 `add` / 大写 / 全角标点)。
|
|
23
|
+
- 已经在 2.7.0~2.7.2 上装过的,升级到 2.7.3 就行:配置会被自动认领回来,不用手改任何文件。
|
|
24
|
+
|
|
3
25
|
## 2.7.2 — 根上一点不留
|
|
4
26
|
- **修的什么**:2.7.0 的搬迁兜底扫描会跳过「规范老名字」(`napcat-keeper-state.json` 这种已经安排好去处的),本意是别把刚搬完的东西又归一次档。但碰上**目标位已经有同名文件**的情况(新目录里先躺着一份 `state.json`),根上那份老的既搬不走、也不归档,就永远留在 `~/.dsh` 根上了 —— 正是这次要收拾的那种乱。
|
|
5
27
|
- 现在兜底扫描不再挑名字:只要还留在根上、名字对得上插件的老命名,一律归档进 `legacy/`;被别的进程攥着搬不动的,下次启动再搬。跑完根上一个不剩。
|
package/README.md
CHANGED
|
@@ -40,10 +40,26 @@
|
|
|
40
40
|
|
|
41
41
|
## 安装
|
|
42
42
|
|
|
43
|
+
**桌面版(点界面的)**:设置 → 插件 → **添加插件**,「包名或地址」那一栏只填包名 ——
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
dsh-napcat-keeper
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> 只填包名,**别把整条命令粘进去**:那一栏是按「包名 / 仓库地址 / 本地目录」解析的,
|
|
50
|
+
> 多一个空格、多一个 `add`、多一个大写字母,就会报 `无法识别这个包名或地址:not a package name the registry accepts`。
|
|
51
|
+
> 顺带一提,桌面版装完之后 DSH 目录里并没有 `dsh` 命令,所以下面那条命令行不是人人都能跑 —— 用不了就走上面这条路。
|
|
52
|
+
|
|
53
|
+
**命令行**:
|
|
54
|
+
|
|
43
55
|
```bash
|
|
44
56
|
dsh plugin --profile <profile> add dsh-napcat-keeper
|
|
45
57
|
```
|
|
46
58
|
|
|
59
|
+
> **别加 `github:` 前缀。** 写成 `add github:waning-sun/dsh-napcat-keeper` 走的是 git 通道,pnpm 会去调外部 `git ls-remote`;
|
|
60
|
+
> 机器上没装 Git(或者 Git 不在 PATH 里)就当场报 `'git' is not recognized as an internal or external command`。
|
|
61
|
+
> 上面这条是 npm 名安装,全程不碰 git。真踩到了看「常见问题」里 `'git' is not recognized` 那条。
|
|
62
|
+
|
|
47
63
|
装完重启一次 DSH(第一次装载才生效),然后去 **设置 → napcat桥**。
|
|
48
64
|
|
|
49
65
|
## 首次配置:其实就三个空
|
|
@@ -177,6 +193,34 @@ QQ 那条进程链挂在本 DSH 实例下面——DSH 开着它就在,DSH 关
|
|
|
177
193
|
|
|
178
194
|
> 顺带一提:`versions\*\resources\app\napcat\napcat.mjs` 那份**不会**被当成入口——它是等 QQ 注入用的,拿普通 Node 直接跑必崩。
|
|
179
195
|
|
|
196
|
+
## 反向 WebSocket:和 dsh-onebot-qq 共存
|
|
197
|
+
|
|
198
|
+
NapCat 连 OneBot 有两条相反的路:
|
|
199
|
+
|
|
200
|
+
- **正向(HTTP / 正向 WS)**:NapCat 自己开端口(`httpServers`),外面去问它 —— 本插件默认就是这么看它的;
|
|
201
|
+
- **反向 WebSocket**:端口由**别人**开着(比如 `dsh-onebot-qq` 插件起的反向 WS 服务,或者你自己写的服务),**NapCat 反过来连进去**。
|
|
202
|
+
|
|
203
|
+
第二条路上没有 HTTP 接口可问:拿 HTTP 去敲那个端口,换来的是 `401` / `426`。2.7.3 及更早的版本只看返回内容、不看 HTTP 状态码,于是把「端口明明在回话」当成「端口不通」→ 判卡死 → 杀掉重拉 → 无限循环。现在不会了:
|
|
204
|
+
|
|
205
|
+
| 探测到的情况 | 插件怎么判 | 会不会重启 |
|
|
206
|
+
| --- | --- | --- |
|
|
207
|
+
| 能问到 `get_login_info` | 在线(普通 HTTP) | 不会 |
|
|
208
|
+
| 端口回 `426`(只收 WebSocket) | **反向 WS 模式** | 不重启;等 NapCat 连进来 |
|
|
209
|
+
| 端口回 `401` | 需鉴权(多半是令牌不对) | 不重启,提示你去对 `accessToken` |
|
|
210
|
+
| 端口开着、也有客户端连进来 | 在线(反向 WS) | 不会 |
|
|
211
|
+
| 端口连不上、进程也不在 | 离线 | 拉起(带退避) |
|
|
212
|
+
|
|
213
|
+
配置上只要两步:
|
|
214
|
+
|
|
215
|
+
1. 把 `onebotPort` 填成**反向 WS 服务监听的端口**(不是 NapCat 里那个 `httpServers` 的端口——反向模式下 NapCat 根本不监听);
|
|
216
|
+
2. 「在线探测方式」留 `auto` 就行;想明确指定就选 `反向 WebSocket`。
|
|
217
|
+
|
|
218
|
+
然后在 NapCat 那边把反向 WS 地址填成 `ws://127.0.0.1:<上面那个端口>`,令牌对上 `accessToken`。
|
|
219
|
+
|
|
220
|
+
真等不来客户端时,插件也不会每轮都杀进程:先按 `stuckSec`(默认 420 秒)等,超时才**温和重拉**一次,最多两次;还不行就进 `reverse-giveup`,把「该去 NapCat 里改反向 WS 地址」写进日志和状态条,不再折磨那条链。
|
|
221
|
+
|
|
222
|
+
> 同一个端口没法既当 HTTP 接口又当反向 WS 服务 —— 谁先占着就听谁的。两样都想要,就给它们分两个端口。
|
|
223
|
+
|
|
180
224
|
## 给 agent 用的三个工具
|
|
181
225
|
|
|
182
226
|
| 工具 | 作用 |
|
|
@@ -197,8 +241,9 @@ QQ 那条进程链挂在本 DSH 实例下面——DSH 开着它就在,DSH 关
|
|
|
197
241
|
| `nodePath` | 空(自动) | 指定 `node.exe`;留空按「DSH 自带 → 系统 PATH → 常见安装目录 → NapCat 目录」找,见上一节 |
|
|
198
242
|
| `rememberPassword` | `false` | 记住 QQ 密码、令牌失效时自动密码登录(**明文落盘,详见上节**) |
|
|
199
243
|
| `quickPassword` | 空 | QQ 密码;只在保存时写入,接口不回显、日志不打印 |
|
|
200
|
-
| `onebotPort` | `3000` | OneBot
|
|
201
|
-
| `accessToken` | 空 | OneBot access token |
|
|
244
|
+
| `onebotPort` | `3000` | OneBot 端口。普通部署填 NapCat 里 `httpServers` 的端口;反向 WebSocket 部署填**反向 WS 服务**监听的端口 |
|
|
245
|
+
| `accessToken` | 空 | OneBot access token(跟 NapCat 或反向 WS 服务里填的一致) |
|
|
246
|
+
| `probeMode` | `auto` | 在线探测方式:`auto` 先按 HTTP 问、问不通再看是不是反向 WS;`http` 只认 HTTP 接口;`reverse` 只认反向 WebSocket;`process` 只看进程不碰端口 |
|
|
202
247
|
| `taskName` | 空(= 用 cscript,QQ 跟着 DSH 生灭) | 想脱离 DSH 才填计划任务名 |
|
|
203
248
|
| `autoCreateTask` | `false` | 填了 `taskName` 且任务不存在时自动创建 |
|
|
204
249
|
| `hideConsole` | `true` | 拉起时不显示黑框(用 wscript 壳藏窗口) |
|
|
@@ -279,6 +324,14 @@ npm run check:privacy # 只查隐私(发版前必跑)
|
|
|
279
324
|
**换端口了?**
|
|
280
325
|
改「OneBot HTTP 端口」保存即可;如果同时换了 Shell 目录,跨进程锁会自动清掉。
|
|
281
326
|
|
|
327
|
+
**用着 dsh-onebot-qq(反向 WebSocket),插件永远显示离线、进程反复重启?**
|
|
328
|
+
2.7.3 及更早的版本只会用 HTTP 去问 `get_login_info`,而反向 WS 的端口不吃 HTTP(回 `401` / `426`),
|
|
329
|
+
于是被当成「端口不通」→ 判卡死 → 杀掉重拉,一轮一轮地折腾。
|
|
330
|
+
现在的版本认这条路:端口回 426 就判成**反向 WS 模式**,改成看「端口上有没有客户端连进来」,
|
|
331
|
+
进程在 + 端口有服务时**不再杀**。设置上只要把「OneBot HTTP 端口」改成反向 WS 服务监听的端口,
|
|
332
|
+
「在线探测方式」留 `auto`(或直接选「反向 WebSocket」),细节见前面「反向 WebSocket:和 dsh-onebot-qq 共存」那节。
|
|
333
|
+
状态条里会显示 `探测:反向 WebSocket · 客户端 N`,卡住时给的提示也是「去 NapCat 里把反向 WS 地址填成 `ws://127.0.0.1:<端口>`」。
|
|
334
|
+
|
|
282
335
|
**老是掉线、每次都要扫码?**
|
|
283
336
|
令牌过期了。要么每次手机扫,要么开「记住密码」让它自己用密码登(先看上上节的风险)。
|
|
284
337
|
`napcat_status` 会直接告诉你现在是 `password` 还是 `token` 在撑、以及密码文件在不在。
|
|
@@ -293,6 +346,36 @@ npm run check:privacy # 只查隐私(发版前必跑)
|
|
|
293
346
|
2.2.1 之前是 `settings.section` 钩子签名对不上新版 DSH(抛 `Cannot add property N, object is not extensible`),异常冒到设置面板层,整个面板一起白。升级后重开 DSH 即可。
|
|
294
347
|
另外设置页会轮询 `/state`,2.2.1 起热路径有 300 秒进程快照缓存,不再每次都新起 PowerShell;如果你自己写脚本调这个接口,加 `?light=1` 只读缓存、不扫描进程。
|
|
295
348
|
|
|
349
|
+
**装的时候报 `'git' is not recognized as an internal or external command`?**
|
|
350
|
+
不是插件的问题。那句是 pnpm 在解析 **git 规格的依赖**(`github:owner/repo`、`git+https://…`)时调的外部 `git`;
|
|
351
|
+
DSH 自带的 pnpm 不捎带 git,Windows 上又默认没装,于是就卡在「拉代码」这一步,插件一个字节都还没下载。
|
|
352
|
+
包里的 `lib/` 本来就是入库的、没有构建步骤,所以跟「源码要编译」也没关系。三条路,越靠前越省事:
|
|
353
|
+
|
|
354
|
+
1. **用 npm 名装** —— 就是上面「安装」那节那条命令,全程不碰 git。
|
|
355
|
+
2. **装 Git**:`winget install --id Git.Git -e`,或者从 [git-scm.com](https://git-scm.com/download/win) 下安装包。
|
|
356
|
+
装完 **必须彻底退出 DSH 再重开**:pnpm 是 DSH 的子进程,继承的是 DSH 启动那一刻的 PATH,
|
|
357
|
+
不重启会继续报一模一样的错(很多人以为白装了)。重开后在新的终端里 `git --version` 有输出,再去装插件。
|
|
358
|
+
3. **不装 git、又非要从 GitHub 拉**:改用源码 tarball,pnpm 走 HTTPS 下载、不调 git ——
|
|
359
|
+
`dsh plugin --profile <profile> add https://codeload.github.com/waning-sun/dsh-napcat-keeper/tar.gz/refs/heads/main`
|
|
360
|
+
仓库目前没打 tag,所以只能指 `refs/heads/main`;缺点是版本号只能读包里的 `package.json`,也不跟着发版更新。
|
|
361
|
+
|
|
362
|
+
**装的时候报 `无法识别这个包名或地址:not a package name the registry accepts`?**
|
|
363
|
+
这句是 DSH 在**解析你填的那串字**,还没到下载那一步 —— 跟插件、跟网络都没关系。它是「设置 → 插件 → 添加插件」
|
|
364
|
+
那个输入框的校验:那一栏认三种东西,**npm 包名**(`dsh-napcat-keeper`、`@作者/名字`)、**仓库地址**(`https://github.com/…`)、
|
|
365
|
+
**本地目录**。包名只允许小写字母、数字和 `. _ ~ -`,所以下面这些写法一律被拒:
|
|
366
|
+
|
|
367
|
+
| 填进去的 | 结果 |
|
|
368
|
+
| --- | --- |
|
|
369
|
+
| `dsh-napcat-keeper` | ✅ |
|
|
370
|
+
| `dsh-napcat-keeper@2.8.0` | ✅(指定版本) |
|
|
371
|
+
| `dsh plugin --profile desktop add dsh-napcat-keeper` | ❌ 整条命令 |
|
|
372
|
+
| `add dsh-napcat-keeper` | ❌ 多了 `add` |
|
|
373
|
+
| `Dsh-napcat-keeper` | ❌ 大写 |
|
|
374
|
+
| `dsh-napcat-keeper。`、`dsh-napcat-keeper;` | ❌ 全角标点 |
|
|
375
|
+
|
|
376
|
+
改法就一句:**那一栏只填 `dsh-napcat-keeper`**(要指定版本就 `dsh-napcat-keeper@2.8.0`)。想跑命令行的话,得在终端里跑,
|
|
377
|
+
不是在输入框里跑。
|
|
378
|
+
|
|
296
379
|
## License
|
|
297
380
|
|
|
298
381
|
MIT
|
package/lib/client.js
CHANGED
|
@@ -126,8 +126,10 @@ window.__ModuleLoader__.load({
|
|
|
126
126
|
['launcherScript', '启动脚本', '留空自动挑(quick 优先,其次 napcat*.bat)', 'text'],
|
|
127
127
|
['nodePath', 'Node 运行时(node.exe)', '留空 = 自动:DSH 自带那份 → 系统 PATH → 常见安装目录 → NapCat 目录。只有「Shell 型」NapCat(目录顶层有 napcat.mjs)才真的用到它;OneKey 注入型跑在 QQ 自己的 Node 里,跟这里无关', 'text'],
|
|
128
128
|
['launchAccount', '要快登的 QQ 号', '填了它、脚本又找不到时,会自动生成一个直接调 NapCatWinBootMain.exe 的脚本', 'text'],
|
|
129
|
-
['onebotPort', 'OneBot HTTP 端口', 'NapCat 里 onebot11 的 httpServers 端口,默认 3000', 'number', true],
|
|
130
|
-
['accessToken', 'OneBot 访问令牌', '没设就留空;设了要跟 NapCat
|
|
129
|
+
['onebotPort', 'OneBot HTTP 端口', 'NapCat 里 onebot11 的 httpServers 端口,默认 3000。反向 WebSocket 模式下填反向 WS 服务占的那个端口(装了 dsh-onebot-qq 的话通常是它监听的那个)', 'number', true],
|
|
130
|
+
['accessToken', 'OneBot 访问令牌', '没设就留空;设了要跟 NapCat(或反向 WS 服务)里填的一致', 'password'],
|
|
131
|
+
['probeMode', '在线探测方式', '自动 = 先按 HTTP 接口问;端口只收 WebSocket 时会认成「反向 WS」。如果端口是 dsh-onebot-qq 这类反向 WS 服务在听(HTTP 换来 401/426、老被判离线),明确选「反向 WebSocket」最省事', 'select', false,
|
|
132
|
+
[['auto', '自动(推荐)'], ['http', '只认 HTTP 接口'], ['reverse', '反向 WebSocket'], ['process', '只看进程']]],
|
|
131
133
|
],
|
|
132
134
|
},
|
|
133
135
|
{
|
|
@@ -177,11 +179,42 @@ window.__ModuleLoader__.load({
|
|
|
177
179
|
function 状态条(runtime) {
|
|
178
180
|
if (!runtime) return null
|
|
179
181
|
const 在 = runtime.online
|
|
182
|
+
// 反向 WS:端口是别人在听(dsh-onebot-qq 之类),NapCat 反过来连进去,
|
|
183
|
+
// 「在线」= 端口上有客户端连进来了,跟 HTTP 那套不是一回事,词得写清楚
|
|
184
|
+
const 反向 = runtime.mode === 'reverse'
|
|
185
|
+
const 模式名 = 反向
|
|
186
|
+
? '反向 WebSocket'
|
|
187
|
+
: runtime.mode === 'auth'
|
|
188
|
+
? '端口要求鉴权'
|
|
189
|
+
: runtime.mode === 'process'
|
|
190
|
+
? '只看进程'
|
|
191
|
+
: runtime.mode === 'http'
|
|
192
|
+
? 'HTTP 接口'
|
|
193
|
+
: '端口不通'
|
|
194
|
+
const 阶段名 = {
|
|
195
|
+
idle: '待机',
|
|
196
|
+
online: '在线',
|
|
197
|
+
launching: '正在拉起',
|
|
198
|
+
starting: '正在启动',
|
|
199
|
+
'need-scan': '等扫码',
|
|
200
|
+
'need-scan-giveup': '扫码放弃',
|
|
201
|
+
'reverse-wait': '等反向 WS 客户端连进来',
|
|
202
|
+
'reverse-giveup': '反向 WS 等不到客户端',
|
|
203
|
+
'need-token': '令牌不对',
|
|
204
|
+
'port-busy': '端口被别的程序占着',
|
|
205
|
+
'dedupe-blocked': '去重没杀干净',
|
|
206
|
+
offline: '离线',
|
|
207
|
+
}[String(runtime.phase || '')] || String(runtime.phase || '?')
|
|
180
208
|
return h('div', { style: S.card },
|
|
181
209
|
h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' } },
|
|
182
|
-
h('span', { style: 在 ? S.ok : S.bad }, 在 ? '● 在线' : '○ 离线'),
|
|
183
|
-
在
|
|
184
|
-
|
|
210
|
+
h('span', { style: 在 ? S.ok : S.bad }, 在 ? (反向 ? '● 反向 WS 已连接' : '● 在线') : '○ 离线'),
|
|
211
|
+
在 && (runtime.nickname || runtime.userId)
|
|
212
|
+
? h('span', null, String(runtime.nickname || '') + '(' + String(runtime.userId || '') + ')')
|
|
213
|
+
: null,
|
|
214
|
+
h('span', { style: S.dim }, '探测:' + 模式名 + (runtime.portStatus ? ' · HTTP ' + String(runtime.portStatus) : '')),
|
|
215
|
+
反向 ? h('span', { style: S.dim },
|
|
216
|
+
'客户端 ' + (runtime.clients === null || runtime.clients === undefined ? '查不到' : String(runtime.clients))) : null,
|
|
217
|
+
h('span', { style: S.dim }, '阶段:' + 阶段名),
|
|
185
218
|
h('span', { style: S.dim }, '启动 ' + String(runtime.launchCount || 0) + ' 次'),
|
|
186
219
|
runtime.dedupeCount ? h('span', { style: S.dim }, '去重 ' + String(runtime.dedupeCount) + ' 次') : null,
|
|
187
220
|
),
|
|
@@ -214,6 +247,7 @@ window.__ModuleLoader__.load({
|
|
|
214
247
|
h('span', null, runtime.bridge && runtime.bridge.ageSec !== null ? String(runtime.bridge.ageSec) + ' 秒前' : '没读到'),
|
|
215
248
|
),
|
|
216
249
|
runtime.error ? h('div', { style: { ...S.bad, marginTop: 6 } }, '最近错误:' + String(runtime.error)) : null,
|
|
250
|
+
runtime.hint ? h('div', { style: { ...S.dim, marginTop: 4 } }, '提示:' + String(runtime.hint)) : null,
|
|
217
251
|
)
|
|
218
252
|
}
|
|
219
253
|
|
|
@@ -661,7 +695,7 @@ window.__ModuleLoader__.load({
|
|
|
661
695
|
}
|
|
662
696
|
|
|
663
697
|
const 渲染项 = (item) => {
|
|
664
|
-
const [key, label, hint, type] = item
|
|
698
|
+
const [key, label, hint, type, , options] = item
|
|
665
699
|
const val = 取(草, key)
|
|
666
700
|
let ctl
|
|
667
701
|
if (type === 'bool') {
|
|
@@ -672,6 +706,13 @@ window.__ModuleLoader__.load({
|
|
|
672
706
|
onChange: (e) => 改(key, e.target.checked),
|
|
673
707
|
}),
|
|
674
708
|
h('span', null, val !== false && String(val) !== 'false' ? '开' : '关'))
|
|
709
|
+
} else if (type === 'select') {
|
|
710
|
+
ctl = h('select', {
|
|
711
|
+
style: { ...S.input, maxWidth: 260 },
|
|
712
|
+
value: val === null || val === undefined || val === '' ? 'auto' : String(val),
|
|
713
|
+
onChange: (e) => 改(key, e.target.value),
|
|
714
|
+
},
|
|
715
|
+
(options || []).map(([v, text]) => h('option', { key: v, value: v }, text)))
|
|
675
716
|
} else {
|
|
676
717
|
ctl = h('input', {
|
|
677
718
|
style: S.input,
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-napcat-keeper —— NapCat 看门狗 v2.
|
|
2
|
+
* dsh-napcat-keeper —— NapCat 看门狗 v2.8.0(可发布通用版)
|
|
3
3
|
*
|
|
4
4
|
* 解决的问题:DSH 是桌面应用,重启 DSH 时 NapCat(QQ 协议端) 不会跟着起来,
|
|
5
5
|
* 于是 dsh-qq-bridge 一直轮询一个死端口,你在 QQ 里说的话就石沉大海。
|
|
6
6
|
*
|
|
7
|
+
* v2.8.0:认得出反向 WebSocket(和 dsh-onebot-qq 共存)
|
|
8
|
+
* - NapCat 走反向 WS 时端口是别人在听(dsh-onebot-qq 之类),HTTP 换来 401/426;
|
|
9
|
+
* postOneBot 原先只看 body 不看状态码,于是把「端口在回话」当成「端口不通」
|
|
10
|
+
* → 判卡死 → 杀掉重拉 → 无限循环;
|
|
11
|
+
* - 现在 probe() 分出 http / reverse / auth / process / dead,反向模式下「在线」=
|
|
12
|
+
* 端口上有客户端连进来(Get-NetTCPConnection -RemotePort 数),进程在 + 端口有服务
|
|
13
|
+
* 就不杀;真等不来客户端也只温和重拉两次,之后给提示而不是继续杀;
|
|
14
|
+
* - 新增 probeMode(auto / http / reverse / process);拉起连续失败时宽限期按 2 的幂退避。
|
|
15
|
+
*
|
|
16
|
+
* v2.7.3:设置页保存不再空转
|
|
17
|
+
* - 2.7.0 把配置文件收进 keeperDir 时只改了「读」,saveAndApply / clearPassword
|
|
18
|
+
* 还在写 ~/.dsh/config.json,于是点保存 / 点「用这个」之后值立刻弹回原样;
|
|
19
|
+
* - 现在读写都走 keeperDir;启动时还会把那份误写的 ~/.dsh/config.json 认领回来
|
|
20
|
+
* (带自家字段且比 keeper 里的新才认领,原文归档进 legacy/)。
|
|
21
|
+
*
|
|
7
22
|
* v2.7.2:根上一点不留
|
|
8
23
|
* - 搬迁的兜底扫描原先跳过「规范老名字」,于是目标位已经有同名文件时(比如新目录里
|
|
9
24
|
* 已经躺着一份 state.json),根上那份老文件既搬不走、也不归档,永远留在那儿;
|
|
@@ -132,7 +147,7 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
132
147
|
import path from 'node:path'
|
|
133
148
|
|
|
134
149
|
export const name = 'napcat-keeper'
|
|
135
|
-
export const version = '2.
|
|
150
|
+
export const version = '2.8.0'
|
|
136
151
|
|
|
137
152
|
/** tools 用来注册 napcat_* 工具;webServer 用来挂设置界面接口。拿不到也不至于把 DSH 拖崩。 */
|
|
138
153
|
export const inject = ['tools', 'webServer']
|
|
@@ -165,6 +180,14 @@ const DEFAULTS = {
|
|
|
165
180
|
onebotPort: 3000,
|
|
166
181
|
/** OneBot access token,没有就留空 */
|
|
167
182
|
accessToken: '',
|
|
183
|
+
/**
|
|
184
|
+
* 怎么判断「NapCat 在线」。
|
|
185
|
+
* auto 先当 HTTP 接口问,问不通再看端口是不是反向 WebSocket(默认,两边都能用)
|
|
186
|
+
* http 只认 HTTP OneBot 接口
|
|
187
|
+
* reverse 只认反向 WebSocket:端口上有没有客户端连进来(配合 dsh-onebot-qq 这类反向 WS 服务)
|
|
188
|
+
* process 只看进程在不在,不碰端口
|
|
189
|
+
*/
|
|
190
|
+
probeMode: 'auto',
|
|
168
191
|
/** 只在这些路径下找 QQ/NapCat 进程;留空 = 由 shellDir 推导(上层目录) */
|
|
169
192
|
procPathMatch: '',
|
|
170
193
|
/** 复查间隔(秒) */
|
|
@@ -225,12 +248,16 @@ const STATE_KEY = Symbol.for('dsh.napcat-keeper.state')
|
|
|
225
248
|
const state =
|
|
226
249
|
globalThis[STATE_KEY] ||
|
|
227
250
|
(globalThis[STATE_KEY] = {
|
|
228
|
-
phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | offline
|
|
251
|
+
phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | reverse-wait | reverse-giveup | need-token | port-busy | offline
|
|
229
252
|
lastProbeAt: null,
|
|
230
253
|
lastOnlineAt: 0,
|
|
231
254
|
lastLaunchAt: 0,
|
|
232
255
|
lastError: '',
|
|
233
256
|
launchCount: 0,
|
|
257
|
+
/** 连续拉起几次都没等到上线:宽限期按 2 的幂放大,免得每 45 秒杀一次 */
|
|
258
|
+
launchFails: 0,
|
|
259
|
+
/** 反向 WS 模式下「温和重拉」的次数,用完就认命给提示,不再折腾进程 */
|
|
260
|
+
reverseRetries: 0,
|
|
234
261
|
dedupeCount: 0,
|
|
235
262
|
scanSince: 0,
|
|
236
263
|
scanRetries: 0,
|
|
@@ -309,6 +336,59 @@ function moveOne(from, to) {
|
|
|
309
336
|
}
|
|
310
337
|
}
|
|
311
338
|
|
|
339
|
+
/**
|
|
340
|
+
* 认领 2.7.0~2.7.2 因保存 bug 误写到 ~/.dsh/config.json 的那份配置。
|
|
341
|
+
*
|
|
342
|
+
* 那三个版本的 saveAndApply 写的是 dshHome()/config.json,而所有读取都走 keeperDir(),
|
|
343
|
+
* 于是用户每次「保存设置」只是往一个没人读的文件里抄了一遍 —— 值当场弹回旧的样子。
|
|
344
|
+
* 升级到修好的版本后,把那份(可能是用户最后一次真正想保存的内容)并进真配置里,原文归档进 legacy/。
|
|
345
|
+
*
|
|
346
|
+
* 只在不含糊的前提下动手:根上得有 config.json、得能解析成对象、得带至少两个自家字段
|
|
347
|
+
* (别把别的程序放在 ~/.dsh 的 config.json 抢走),而且它得比 keeper 里的新 ——
|
|
348
|
+
* 修好以后再保存就会写 keeper,mtime 立刻反超,这条认领自然不会再触发。
|
|
349
|
+
*/
|
|
350
|
+
function adoptStrayConfig(dir) {
|
|
351
|
+
const home = dshHome()
|
|
352
|
+
const stray = path.join(home, 'config.json')
|
|
353
|
+
const target = configFilePath(dir)
|
|
354
|
+
let strayAt
|
|
355
|
+
try {
|
|
356
|
+
strayAt = statSync(stray).mtimeMs
|
|
357
|
+
} catch {
|
|
358
|
+
return 0
|
|
359
|
+
}
|
|
360
|
+
let mine = false
|
|
361
|
+
let parsed = null
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(readFileSync(stray, 'utf8').replace(/^\uFEFF/, ''))
|
|
364
|
+
} catch {
|
|
365
|
+
return 0
|
|
366
|
+
}
|
|
367
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return 0
|
|
368
|
+
const marks = ['shellDir', 'onebotPort', 'launchAccount', 'taskName', 'procPathMatch', 'scanTimeoutSec']
|
|
369
|
+
if (marks.filter((k) => Object.prototype.hasOwnProperty.call(parsed, k)).length < 2) return 0
|
|
370
|
+
try {
|
|
371
|
+
mine = statSync(target).mtimeMs < strayAt
|
|
372
|
+
} catch {
|
|
373
|
+
mine = true // keeper 里还没有配置,那它就是唯一的一份
|
|
374
|
+
}
|
|
375
|
+
if (!mine) return 0
|
|
376
|
+
const keep = readConfigFile(dir)
|
|
377
|
+
const merged = { ...keep, ...parsed }
|
|
378
|
+
try {
|
|
379
|
+
mkdirSync(path.join(dir, 'legacy'), { recursive: true })
|
|
380
|
+
if (existsSync(target)) {
|
|
381
|
+
copyFileSync(target, path.join(dir, 'legacy', 'config.before-adopt.json'))
|
|
382
|
+
}
|
|
383
|
+
writeConfigFile(dir, merged)
|
|
384
|
+
// 归档原文,不留一份会被下次认领的副本
|
|
385
|
+
moveOne(stray, path.join(dir, 'legacy', `config.adopted-${new Date().toISOString().replace(/[:.]/g, '-')}.json`))
|
|
386
|
+
return 1
|
|
387
|
+
} catch {
|
|
388
|
+
return 0
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
312
392
|
/** 把老版本散在 ~/.dsh 根上的文件搬进 keeperDir;重复调用是空操作,返回搬了几个 */
|
|
313
393
|
function migrateLegacyFiles(dir) {
|
|
314
394
|
const target = dir || keeperDir()
|
|
@@ -343,6 +423,8 @@ function migrateLegacyFiles(dir) {
|
|
|
343
423
|
} catch {}
|
|
344
424
|
}
|
|
345
425
|
} catch {}
|
|
426
|
+
// 根上那份 config.json 是 2.7.0~2.7.2 的保存 bug 留下的,认领回来
|
|
427
|
+
if (adoptStrayConfig(target)) moved += 1
|
|
346
428
|
lastMigrated = moved
|
|
347
429
|
return moved
|
|
348
430
|
}
|
|
@@ -703,6 +785,9 @@ function normalizeConfig(cfg) {
|
|
|
703
785
|
cfg.launchAccount = String(cfg.launchAccount || '').trim().replace(/\D/g, '')
|
|
704
786
|
cfg.launchArgs = String(cfg.launchArgs || '').trim()
|
|
705
787
|
cfg.accessToken = String(cfg.accessToken || '').trim()
|
|
788
|
+
cfg.probeMode = PROBE_MODES.has(String(cfg.probeMode || '').trim().toLowerCase())
|
|
789
|
+
? String(cfg.probeMode).trim().toLowerCase()
|
|
790
|
+
: 'auto'
|
|
706
791
|
cfg.procPathMatch = String(cfg.procPathMatch || '').trim()
|
|
707
792
|
cfg.taskName = String(cfg.taskName || '').trim()
|
|
708
793
|
cfg.onebotPort = clampNum(cfg.onebotPort, 1, 65535, 3000)
|
|
@@ -804,6 +889,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
804
889
|
/* OneBot */
|
|
805
890
|
/* ------------------------------------------------------------------ */
|
|
806
891
|
|
|
892
|
+
/**
|
|
893
|
+
* 发一个 OneBot HTTP 动作。
|
|
894
|
+
*
|
|
895
|
+
* 返回值里带上 HTTP 状态码:反向 WebSocket 模式下(比如 dsh-onebot-qq 占着这个端口)
|
|
896
|
+
* 对方根本不会回 OneBot 的 JSON,能拿到的唯一线索就是 401 / 426 这类状态码 ——
|
|
897
|
+
* 老版本把状态码丢了,于是一律当成「端口不通」,接着就是杀掉重拉,无限循环。
|
|
898
|
+
*/
|
|
807
899
|
function postOneBot(cfg, action, payload, timeoutMs = 4000) {
|
|
808
900
|
return new Promise((resolve) => {
|
|
809
901
|
const body = JSON.stringify(payload || {})
|
|
@@ -824,50 +916,177 @@ function postOneBot(cfg, action, payload, timeoutMs = 4000) {
|
|
|
824
916
|
timeout: timeoutMs,
|
|
825
917
|
},
|
|
826
918
|
(res) => {
|
|
919
|
+
const status = Number(res.statusCode) || 0
|
|
827
920
|
let buf = ''
|
|
828
921
|
res.setEncoding('utf8')
|
|
829
922
|
res.on('data', (c) => {
|
|
830
923
|
buf += c
|
|
831
924
|
})
|
|
832
925
|
res.on('end', () => {
|
|
926
|
+
const raw = String(buf).slice(0, 200)
|
|
927
|
+
let parsed = null
|
|
833
928
|
try {
|
|
834
|
-
|
|
835
|
-
} catch {
|
|
836
|
-
|
|
929
|
+
parsed = JSON.parse(buf)
|
|
930
|
+
} catch {}
|
|
931
|
+
if (parsed && typeof parsed === 'object') {
|
|
932
|
+
resolve({ ...parsed, status })
|
|
933
|
+
return
|
|
837
934
|
}
|
|
935
|
+
resolve({ retcode: -1, status, raw })
|
|
838
936
|
})
|
|
839
937
|
},
|
|
840
938
|
)
|
|
841
939
|
} catch (error) {
|
|
842
|
-
return resolve({ retcode: -1, error: String((error && error.message) || error) })
|
|
940
|
+
return resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
|
|
843
941
|
}
|
|
844
942
|
req.on('timeout', () => {
|
|
845
943
|
try {
|
|
846
944
|
req.destroy(new Error('timeout'))
|
|
847
945
|
} catch {}
|
|
848
|
-
resolve({ retcode: -1, error: 'timeout' })
|
|
946
|
+
resolve({ retcode: -1, status: 0, error: 'timeout' })
|
|
849
947
|
})
|
|
850
|
-
req.on('error', (error) =>
|
|
948
|
+
req.on('error', (error) =>
|
|
949
|
+
resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) }),
|
|
950
|
+
)
|
|
851
951
|
try {
|
|
852
952
|
req.write(body)
|
|
853
953
|
req.end()
|
|
854
954
|
} catch (error) {
|
|
855
|
-
resolve({ retcode: -1, error: String((error && error.message) || error) })
|
|
955
|
+
resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
|
|
856
956
|
}
|
|
857
957
|
})
|
|
858
958
|
}
|
|
859
959
|
|
|
960
|
+
const PROBE_MODES = new Set(['auto', 'http', 'reverse', 'process'])
|
|
961
|
+
|
|
962
|
+
/** portClients 的缓存:设置页轮询很密,别每 1.5 秒起一个 powershell */
|
|
963
|
+
const linksCache = { port: 0, at: 0, value: null, inflight: null }
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* 这个端口上现在挂着几个「已经连进来」的客户端?
|
|
967
|
+
*
|
|
968
|
+
* 反向 WebSocket 的形态是:端口由 DSH 这边的插件监听(常见是 dsh-onebot-qq),
|
|
969
|
+
* NapCat 反过来连进去。这种部署下没有任何 HTTP 接口能问「你在线吗」,
|
|
970
|
+
* 但有件事可以从系统侧看见:这个端口上有没有一条 ESTABLISHED 的回环连接 ——
|
|
971
|
+
* 有,就说明对面真的连上了。
|
|
972
|
+
*
|
|
973
|
+
* 查不出来就返回 count: null。「查不到」和「没连上」是两回事,
|
|
974
|
+
* 混成一个就会又变成瞎判离线、瞎杀进程。
|
|
975
|
+
*/
|
|
976
|
+
async function portClients(cfg, maxAgeMs = 5000) {
|
|
977
|
+
const port = Number(cfg.onebotPort) || 0
|
|
978
|
+
if (!port) return { count: null, pids: [], source: 'no-port' }
|
|
979
|
+
// 设置页 1.5s 轮询 /state,而这个函数要新开一个 powershell:
|
|
980
|
+
// 缓存 + 同刻只允许一个子进程,否则又是一场 PowerShell 风暴(见 uiCache 那段注释)
|
|
981
|
+
if (linksCache.port === port && linksCache.value && Date.now() - linksCache.at < maxAgeMs) {
|
|
982
|
+
return linksCache.value
|
|
983
|
+
}
|
|
984
|
+
if (linksCache.port === port && linksCache.inflight) return linksCache.inflight
|
|
985
|
+
linksCache.port = port
|
|
986
|
+
linksCache.inflight = (async () => {
|
|
987
|
+
const self = String(process.pid)
|
|
988
|
+
// 看的是「谁连进了这个端口」,所以按 RemotePort 找客户端那一侧:
|
|
989
|
+
// 按 LocalPort 找会命中服务端那条 socket,而它的所有者是跑反向 WS 服务的进程(就是 DSH 自己),
|
|
990
|
+
// 把它排除掉就一条都数不到了 —— 这个坑第一版就踩了。
|
|
991
|
+
const script =
|
|
992
|
+
"$ErrorActionPreference='SilentlyContinue';" +
|
|
993
|
+
`$c=@(Get-NetTCPConnection -State Established -RemotePort ${port} -ErrorAction SilentlyContinue | ` +
|
|
994
|
+
`Where-Object { [string]$_.OwningProcess -ne '${self}' });` +
|
|
995
|
+
`'CLIENTS=' + $c.Count + ' PIDS=' + (($c | Select-Object -ExpandProperty OwningProcess -Unique) -join ',')`
|
|
996
|
+
const out = await runPs(script, 12000)
|
|
997
|
+
const m = /CLIENTS=(\d+)\s+PIDS=([0-9,]*)/.exec(String(out || ''))
|
|
998
|
+
if (!m) return { count: null, pids: [], source: 'ps-fail' }
|
|
999
|
+
const pids = m[2] ? m[2].split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0) : []
|
|
1000
|
+
return { count: Number(m[1]) || 0, pids, source: 'ps' }
|
|
1001
|
+
})()
|
|
1002
|
+
.then((value) => {
|
|
1003
|
+
linksCache.value = value
|
|
1004
|
+
linksCache.at = Date.now()
|
|
1005
|
+
return value
|
|
1006
|
+
})
|
|
1007
|
+
.finally(() => {
|
|
1008
|
+
linksCache.inflight = null
|
|
1009
|
+
})
|
|
1010
|
+
return linksCache.inflight
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 问一句「NapCat 现在怎么样」,顺带分辨端口是被谁占着的。
|
|
1015
|
+
*
|
|
1016
|
+
* 返回的 mode 决定看门狗后面怎么走:
|
|
1017
|
+
* http 正常 HTTP OneBot 接口,能问到 login_info
|
|
1018
|
+
* reverse 端口只收 WebSocket(反向 WS 服务在跑,HTTP 换来 426/401)
|
|
1019
|
+
* auth 端口有响应但拒绝鉴权,多半是 accessToken 对不上
|
|
1020
|
+
* http 其它 HTTP 响应(不是 OneBot API 该有的样子)
|
|
1021
|
+
* dead 连不上,端口没开
|
|
1022
|
+
* process 配置里关了端口探测,只看进程
|
|
1023
|
+
*/
|
|
860
1024
|
async function probe(cfg) {
|
|
1025
|
+
const want = PROBE_MODES.has(cfg.probeMode) ? cfg.probeMode : 'auto'
|
|
861
1026
|
const login = await postOneBot(cfg, 'get_login_info')
|
|
862
1027
|
const data = login && login.data
|
|
1028
|
+
const status = Number(login && login.status) || 0
|
|
863
1029
|
const online = Boolean(login && login.retcode === 0 && data && (data.user_id || data.uin))
|
|
864
|
-
|
|
1030
|
+
const portAlive = status > 0
|
|
1031
|
+
const result = {
|
|
865
1032
|
online,
|
|
866
1033
|
retcode: login && login.retcode,
|
|
1034
|
+
status,
|
|
1035
|
+
portAlive,
|
|
1036
|
+
mode: 'dead',
|
|
1037
|
+
clients: null,
|
|
867
1038
|
nickname: online ? data.nickname : '',
|
|
868
1039
|
userId: online ? data.user_id || data.uin : '',
|
|
869
|
-
error:
|
|
1040
|
+
error: '',
|
|
1041
|
+
hint: '',
|
|
1042
|
+
}
|
|
1043
|
+
if (online) {
|
|
1044
|
+
result.mode = 'http'
|
|
1045
|
+
return result
|
|
1046
|
+
}
|
|
1047
|
+
if (want === 'process') {
|
|
1048
|
+
result.mode = 'process'
|
|
1049
|
+
result.error = '按配置只看进程(probeMode=process)'
|
|
1050
|
+
return result
|
|
1051
|
+
}
|
|
1052
|
+
if (!portAlive) {
|
|
1053
|
+
result.error = String((login && (login.error || login.raw || login.wording)) || 'no response')
|
|
1054
|
+
result.hint = '端口连不上'
|
|
1055
|
+
return result
|
|
1056
|
+
}
|
|
1057
|
+
// 端口肯回 HTTP,说明有个服务在听 —— 这本身就意味着「进程是活的」,不该被杀
|
|
1058
|
+
const links = await portClients(cfg)
|
|
1059
|
+
result.clients = links.count
|
|
1060
|
+
if (want === 'reverse') {
|
|
1061
|
+
result.mode = 'reverse'
|
|
1062
|
+
} else if (status === 426) {
|
|
1063
|
+
// 426 = 服务端只收 WebSocket 升级。auto 直接判反向;设了「只认 HTTP」就老实说端口回的是 426
|
|
1064
|
+
result.mode = want === 'http' ? 'http' : 'reverse'
|
|
1065
|
+
} else if (status === 401) {
|
|
1066
|
+
// 反向 WS 服务对任何 HTTP 请求都回 401,NapCat 的 HTTP API 令牌不对也回 401,
|
|
1067
|
+
// 靠「有没有客户端连进来」区分这两件事
|
|
1068
|
+
result.mode = links.count !== null && links.count > 0 ? 'reverse' : 'auth'
|
|
1069
|
+
} else {
|
|
1070
|
+
result.mode = 'http'
|
|
1071
|
+
}
|
|
1072
|
+
if (result.mode === 'reverse') {
|
|
1073
|
+
if (links.count === null) {
|
|
1074
|
+
result.hint = '反向 WebSocket 服务在跑(这个端口不收 HTTP),客户端数暂时查不到'
|
|
1075
|
+
} else if (links.count > 0) {
|
|
1076
|
+
result.online = true
|
|
1077
|
+
result.hint = `反向 WebSocket 已连上 ${links.count} 个客户端`
|
|
1078
|
+
} else {
|
|
1079
|
+
result.hint = '反向 WebSocket 服务在跑,但还没有客户端连进来'
|
|
1080
|
+
}
|
|
1081
|
+
result.error = result.online ? '' : result.hint
|
|
1082
|
+
return result
|
|
870
1083
|
}
|
|
1084
|
+
result.error = `端口有响应但 ${status === 401 ? '要求鉴权' : `回的是 HTTP ${status}`}`
|
|
1085
|
+
result.hint =
|
|
1086
|
+
status === 401
|
|
1087
|
+
? 'HTTP 401:多半是 accessToken 和对面配的不一致;如果对面只开反向 WebSocket,把探测方式改成「反向 WebSocket」'
|
|
1088
|
+
: `HTTP ${status}:这个端口上跑的可能不是 OneBot 接口`
|
|
1089
|
+
return result
|
|
871
1090
|
}
|
|
872
1091
|
|
|
873
1092
|
/* ------------------------------------------------------------------ */
|
|
@@ -2218,6 +2437,7 @@ function launchNapcat(cfg, reason = '') {
|
|
|
2218
2437
|
writeLock(cfg)
|
|
2219
2438
|
state.lastLaunchAt = Date.now()
|
|
2220
2439
|
state.launchCount += 1
|
|
2440
|
+
state.launchFails += 1
|
|
2221
2441
|
state.phase = 'launching'
|
|
2222
2442
|
state.scanSince = 0
|
|
2223
2443
|
state.lastError = ''
|
|
@@ -2275,6 +2495,7 @@ function launchNapcat(cfg, reason = '') {
|
|
|
2275
2495
|
writeLock(cfg)
|
|
2276
2496
|
state.lastLaunchAt = Date.now()
|
|
2277
2497
|
state.launchCount += 1
|
|
2498
|
+
state.launchFails += 1
|
|
2278
2499
|
state.phase = 'launching'
|
|
2279
2500
|
state.scanSince = 0
|
|
2280
2501
|
state.lastError = ''
|
|
@@ -2361,12 +2582,23 @@ async function tick(cfg) {
|
|
|
2361
2582
|
const health = await probe(cfg)
|
|
2362
2583
|
|
|
2363
2584
|
if (health.online) {
|
|
2364
|
-
if (state.phase !== 'online')
|
|
2585
|
+
if (state.phase !== 'online') {
|
|
2586
|
+
const who =
|
|
2587
|
+
health.nickname || health.userId
|
|
2588
|
+
? `${health.nickname} (${health.userId})`
|
|
2589
|
+
: health.mode === 'reverse'
|
|
2590
|
+
? '反向 WebSocket 已连接'
|
|
2591
|
+
: '已连接'
|
|
2592
|
+
log(cfg, `ONLINE${health.mode === 'reverse' ? '[reverse]' : ''}: ${who}`)
|
|
2593
|
+
}
|
|
2365
2594
|
state.phase = 'online'
|
|
2366
2595
|
state.lastOnlineAt = Date.now()
|
|
2367
2596
|
state.lastError = ''
|
|
2368
2597
|
state.scanSince = 0
|
|
2369
2598
|
state.scanRetries = 0
|
|
2599
|
+
// 真连上了就把失败计数清掉,下次出事重新给足宽限
|
|
2600
|
+
state.launchFails = 0
|
|
2601
|
+
state.reverseRetries = 0
|
|
2370
2602
|
const bridge = bridgeAge(cfg)
|
|
2371
2603
|
if (bridge.ageSec !== null && bridge.ageSec > cfg.bridgeStaleSec) {
|
|
2372
2604
|
log(cfg, `WARN bridge state stale ${bridge.ageSec}s (${bridge.path}) —— 桥接可能卡住`)
|
|
@@ -2390,6 +2622,8 @@ async function tick(cfg) {
|
|
|
2390
2622
|
nickname: health.nickname,
|
|
2391
2623
|
userId: health.userId,
|
|
2392
2624
|
port: cfg.onebotPort,
|
|
2625
|
+
mode: health.mode,
|
|
2626
|
+
clients: health.clients,
|
|
2393
2627
|
bridge,
|
|
2394
2628
|
launches: state.launchCount,
|
|
2395
2629
|
dedupes: state.dedupeCount,
|
|
@@ -2442,10 +2676,116 @@ async function tick(cfg) {
|
|
|
2442
2676
|
return { phase: state.phase, health, procs, deduped: true, launched }
|
|
2443
2677
|
}
|
|
2444
2678
|
|
|
2679
|
+
const hasProcess = procs.boot > 0 || procs.qq > 0
|
|
2680
|
+
|
|
2681
|
+
/*
|
|
2682
|
+
* 反向 WebSocket(dsh-onebot-qq 这类插件占着端口,NapCat 反过来连进去):
|
|
2683
|
+
* 这种部署下 HTTP 永远问不出「在线」,只能看端口上有没有客户端连进来。
|
|
2684
|
+
* 关键的一条:进程在 + 端口有服务,就别杀 —— 老版本在这儿一路判离线,无限重拉。
|
|
2685
|
+
*/
|
|
2686
|
+
if (health.mode === 'reverse' && hasProcess) {
|
|
2687
|
+
if (health.clients === null) {
|
|
2688
|
+
// 客户端数查不到:模糊信息不杀人,先按在线处理,日志里留一句
|
|
2689
|
+
if (state.phase !== 'online') log(cfg, 'REVERSE: 查不到反向 WS 客户端数,按「在线」处理(不重启)')
|
|
2690
|
+
state.phase = 'online'
|
|
2691
|
+
state.lastOnlineAt = Date.now()
|
|
2692
|
+
state.lastError = ''
|
|
2693
|
+
state.launchFails = 0
|
|
2694
|
+
writeSnapshot(cfg, {
|
|
2695
|
+
phase: state.phase,
|
|
2696
|
+
online: true,
|
|
2697
|
+
mode: health.mode,
|
|
2698
|
+
clients: null,
|
|
2699
|
+
procs,
|
|
2700
|
+
port: cfg.onebotPort,
|
|
2701
|
+
updatedAt: new Date().toISOString(),
|
|
2702
|
+
})
|
|
2703
|
+
return { phase: state.phase, health, procs }
|
|
2704
|
+
}
|
|
2705
|
+
const waitedSec = state.lastLaunchAt ? Math.round((Date.now() - state.lastLaunchAt) / 1000) : 0
|
|
2706
|
+
const overStuck = state.lastLaunchAt ? Date.now() - state.lastLaunchAt > cfg.stuckSec * 1000 : true
|
|
2707
|
+
if (overStuck && state.reverseRetries < 2) {
|
|
2708
|
+
state.reverseRetries += 1
|
|
2709
|
+
log(
|
|
2710
|
+
cfg,
|
|
2711
|
+
`REVERSE-WAIT ${waitedSec}s 端口上还没有客户端连进来 → 温和重拉一次 ` +
|
|
2712
|
+
`(${state.reverseRetries}/2)。若反复这样,检查 NapCat 里的反向 WS 地址是不是 ` +
|
|
2713
|
+
`ws://127.0.0.1:${cfg.onebotPort}(令牌要对上 accessToken)`,
|
|
2714
|
+
)
|
|
2715
|
+
const cleaned = await killNapcat(cfg)
|
|
2716
|
+
if (cleaned) {
|
|
2717
|
+
await sleep(1200)
|
|
2718
|
+
launchNapcat(cfg, 'reverse-wait')
|
|
2719
|
+
} else {
|
|
2720
|
+
log(cfg, 'REVERSE-WAIT: 旧链没清干净,先不拉新链')
|
|
2721
|
+
}
|
|
2722
|
+
writeSnapshot(cfg, {
|
|
2723
|
+
phase: 'launching',
|
|
2724
|
+
online: false,
|
|
2725
|
+
mode: health.mode,
|
|
2726
|
+
procs,
|
|
2727
|
+
relaunched: cleaned,
|
|
2728
|
+
updatedAt: new Date().toISOString(),
|
|
2729
|
+
})
|
|
2730
|
+
return { phase: 'launching', health, procs, relaunched: cleaned }
|
|
2731
|
+
}
|
|
2732
|
+
const before = state.phase
|
|
2733
|
+
state.phase = overStuck ? 'reverse-giveup' : 'reverse-wait'
|
|
2734
|
+
state.lastError = health.hint || health.error
|
|
2735
|
+
if (before !== state.phase) {
|
|
2736
|
+
if (state.phase === 'reverse-giveup') {
|
|
2737
|
+
log(
|
|
2738
|
+
cfg,
|
|
2739
|
+
`REVERSE-GIVEUP: ${waitedSec}s 里 NapCat 一直没连上反向 WS,重拉 ${state.reverseRetries} 次也没用。` +
|
|
2740
|
+
`去 NapCat 配置里把「反向 WebSocket」地址填成 ws://127.0.0.1:${cfg.onebotPort}` +
|
|
2741
|
+
'(令牌对上 accessToken),改完把设置里的「在线探测方式」留 auto 就行。',
|
|
2742
|
+
)
|
|
2743
|
+
} else {
|
|
2744
|
+
log(cfg, `REVERSE-WAIT: ${health.hint || '等 NapCat 连反向 WS'}(不重启,等它自己连)`)
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
writeSnapshot(cfg, {
|
|
2748
|
+
phase: state.phase,
|
|
2749
|
+
online: false,
|
|
2750
|
+
mode: health.mode,
|
|
2751
|
+
clients: health.clients,
|
|
2752
|
+
procs,
|
|
2753
|
+
port: cfg.onebotPort,
|
|
2754
|
+
updatedAt: new Date().toISOString(),
|
|
2755
|
+
})
|
|
2756
|
+
return { phase: state.phase, health, procs }
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
/*
|
|
2760
|
+
* 端口肯回 HTTP,但回的是 401 或别的状态:说明有服务在听、NapCat 进程也在。
|
|
2761
|
+
* 这时候「杀掉重拉」什么也解决不了(令牌不对、或者端口被别的程序占着),
|
|
2762
|
+
* 单独报出来,别再掉进 STUCK 那条死循环。
|
|
2763
|
+
*/
|
|
2764
|
+
if (health.portAlive && health.mode !== 'reverse' && hasProcess) {
|
|
2765
|
+
const before = state.phase
|
|
2766
|
+
state.phase = health.mode === 'auth' ? 'need-token' : 'port-busy'
|
|
2767
|
+
state.lastError = health.error || health.hint
|
|
2768
|
+
if (before !== state.phase) {
|
|
2769
|
+
log(cfg, `${state.phase.toUpperCase()}: ${health.error} —— ${health.hint}(不重启)`)
|
|
2770
|
+
}
|
|
2771
|
+
writeSnapshot(cfg, {
|
|
2772
|
+
phase: state.phase,
|
|
2773
|
+
online: false,
|
|
2774
|
+
mode: health.mode,
|
|
2775
|
+
status: health.status,
|
|
2776
|
+
procs,
|
|
2777
|
+
port: cfg.onebotPort,
|
|
2778
|
+
updatedAt: new Date().toISOString(),
|
|
2779
|
+
})
|
|
2780
|
+
return { phase: state.phase, health, procs }
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2445
2783
|
// ② 完全没有进程:拉起(带宽限期 + 跨进程锁)
|
|
2446
2784
|
if (procs.boot === 0 && procs.qq === 0) {
|
|
2447
2785
|
const since = Date.now() - state.lastLaunchAt
|
|
2448
|
-
|
|
2786
|
+
// 连续拉不起来就别每轮的宽限期都硬等一遍:2 的幂放大,最多 8 倍
|
|
2787
|
+
const backoff = Math.min(2 ** Math.min(state.launchFails, 3), 8)
|
|
2788
|
+
if (since < cfg.launchGraceSec * backoff * 1000) {
|
|
2449
2789
|
state.phase = 'launching'
|
|
2450
2790
|
writeSnapshot(cfg, { phase: state.phase, online: false, procs, launches: state.launchCount })
|
|
2451
2791
|
return { phase: state.phase, health, procs }
|
|
@@ -2643,6 +2983,13 @@ function registerTools(ctx, cfg) {
|
|
|
2643
2983
|
onebot: {
|
|
2644
2984
|
port: cfg.onebotPort,
|
|
2645
2985
|
online: health.online,
|
|
2986
|
+
/** http / reverse / auth / http / process / dead —— 反向 WS 时看这个 */
|
|
2987
|
+
mode: health.mode,
|
|
2988
|
+
portAlive: health.portAlive,
|
|
2989
|
+
httpStatus: health.status,
|
|
2990
|
+
/** 反向 WS 模式下端口上连进来的客户端数(null = 查不到) */
|
|
2991
|
+
clients: health.clients,
|
|
2992
|
+
hint: health.hint,
|
|
2646
2993
|
nickname: health.nickname,
|
|
2647
2994
|
user_id: health.userId,
|
|
2648
2995
|
error: health.error,
|
|
@@ -2673,7 +3020,21 @@ function registerTools(ctx, cfg) {
|
|
|
2673
3020
|
logTail: tail,
|
|
2674
3021
|
note: health.online
|
|
2675
3022
|
? '在线。若 QQ 里还是没回音,问题在 dsh-qq-bridge 那一侧(绑定/试运行开关)。'
|
|
2676
|
-
:
|
|
3023
|
+
: health.mode === 'reverse'
|
|
3024
|
+
? health.clients === null
|
|
3025
|
+
? '反向 WebSocket 模式:端口是反向 WS 服务在听(HTTP 问不出在线),客户端数还查不到——按在线处理,不会重启。'
|
|
3026
|
+
: '反向 WebSocket 模式:端口活着,但还没有客户端连进来。让 NapCat 把反向 WS 地址填成 ws://127.0.0.1:' +
|
|
3027
|
+
String(cfg.onebotPort) +
|
|
3028
|
+
'(令牌对上 accessToken);超过 stuckSec 才会温和重拉,最多两次。'
|
|
3029
|
+
: health.mode === 'auth'
|
|
3030
|
+
? '端口有响应但要求鉴权(HTTP ' +
|
|
3031
|
+
String(health.status) +
|
|
3032
|
+
'):多半是 accessToken 跟 NapCat(或反向 WS 服务)里填的不一致。不会重启,先把令牌对一下。'
|
|
3033
|
+
: health.portAlive
|
|
3034
|
+
? '端口活着但回的不是 OneBot 接口(HTTP ' +
|
|
3035
|
+
String(health.status) +
|
|
3036
|
+
'):看看是不是别的程序占着这个端口。不会重启。'
|
|
3037
|
+
: qr && qr.ageSec <= cfg.qrFreshSec
|
|
2677
3038
|
? passwordActive(cfg)
|
|
2678
3039
|
? '离线,且在等扫码——但「记住密码」是开着的:看一眼日志里“快速登录错误”后面是不是仍回退到二维码(密码登录可能被腾讯要求验证)。'
|
|
2679
3040
|
: '离线,且二维码是新的:需要手机扫码(或在设置里打开「记住密码」让 NapCat 自己用密码登录)。文件在 qrCopyPath。'
|
|
@@ -2856,6 +3217,7 @@ function publicConfig(cfg) {
|
|
|
2856
3217
|
passwordActive: passwordActive(cfg),
|
|
2857
3218
|
onebotPort: cfg.onebotPort,
|
|
2858
3219
|
accessToken: cfg.accessToken,
|
|
3220
|
+
probeMode: cfg.probeMode,
|
|
2859
3221
|
procPathMatch: cfg.procPathMatch,
|
|
2860
3222
|
probeIntervalSec: cfg.probeIntervalSec,
|
|
2861
3223
|
launchGraceSec: cfg.launchGraceSec,
|
|
@@ -3020,6 +3382,12 @@ async function uiState(cfg, options = {}) {
|
|
|
3020
3382
|
enabled: cfg.enabled,
|
|
3021
3383
|
phase: state.phase,
|
|
3022
3384
|
online: !!health.online,
|
|
3385
|
+
/** http / reverse / auth / http-odd / process / dead —— 设置页据此显示「反向 WS」那套词 */
|
|
3386
|
+
mode: health.mode || '',
|
|
3387
|
+
portAlive: !!health.portAlive,
|
|
3388
|
+
portStatus: health.status || 0,
|
|
3389
|
+
clients: health.clients,
|
|
3390
|
+
hint: health.hint || '',
|
|
3023
3391
|
nickname: health.nickname || '',
|
|
3024
3392
|
userId: health.userId || 0,
|
|
3025
3393
|
error: health.error || '',
|
|
@@ -3069,8 +3437,12 @@ function uiStateFull(cfg) {
|
|
|
3069
3437
|
|
|
3070
3438
|
/** 保存配置:写文件 → 重新解析 → 热应用(不用重启 DSH) */
|
|
3071
3439
|
function saveAndApply(ctx, patch) {
|
|
3072
|
-
|
|
3073
|
-
|
|
3440
|
+
// ⚠ 必须读写的都是 keeperDir():resolveConfig 读的是 <keeperDir>/config.json。
|
|
3441
|
+
// 2.7.0 把配置收进 keeperDir 时只改了「读」,这两行还留着 dshHome(),
|
|
3442
|
+
// 于是 2.7.0~2.7.2 的「保存设置」全写进了 ~/.dsh/config.json —— 谁都不读它。
|
|
3443
|
+
// 表现:设置页点保存/点「用这个」之后,值立刻弹回空。
|
|
3444
|
+
const dir = keeperDir()
|
|
3445
|
+
const fileCfg = { ...readConfigFile(dir) }
|
|
3074
3446
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
3075
3447
|
if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) continue
|
|
3076
3448
|
if (key === 'quickPassword') {
|
|
@@ -3098,7 +3470,7 @@ function saveAndApply(ctx, patch) {
|
|
|
3098
3470
|
}
|
|
3099
3471
|
fileCfg[key] = value == null ? '' : String(value).trim()
|
|
3100
3472
|
}
|
|
3101
|
-
const written = writeConfigFile(
|
|
3473
|
+
const written = writeConfigFile(dir, fileCfg)
|
|
3102
3474
|
const prev = state.cfg
|
|
3103
3475
|
const next = resolveConfig(state.patchConfig || {})
|
|
3104
3476
|
state.cfg = next
|
|
@@ -3190,11 +3562,12 @@ async function doAction(ctx, cfg, action, payload) {
|
|
|
3190
3562
|
}
|
|
3191
3563
|
case 'clearPassword': {
|
|
3192
3564
|
// 把密码从本机彻底删掉:配置文件两条 + 中间层 bat,再让任务回到不带密码的样子
|
|
3193
|
-
|
|
3194
|
-
const
|
|
3565
|
+
// 同样必须落在 keeperDir() 的 config.json 上,否则删的是一份没人读的文件(见 saveAndApply 注释)
|
|
3566
|
+
const dir = keeperDir()
|
|
3567
|
+
const fileCfg = { ...readConfigFile(dir) }
|
|
3195
3568
|
fileCfg.rememberPassword = false
|
|
3196
3569
|
fileCfg.quickPassword = ''
|
|
3197
|
-
writeConfigFile(
|
|
3570
|
+
writeConfigFile(dir, fileCfg)
|
|
3198
3571
|
const next = resolveConfig(state.patchConfig || {})
|
|
3199
3572
|
state.cfg = next
|
|
3200
3573
|
removeEnvBat(next)
|
|
@@ -3422,6 +3795,8 @@ export const __internals = {
|
|
|
3422
3795
|
procRoot,
|
|
3423
3796
|
probe,
|
|
3424
3797
|
probeProcs,
|
|
3798
|
+
portClients,
|
|
3799
|
+
PROBE_MODES,
|
|
3425
3800
|
qrInfo,
|
|
3426
3801
|
bridgeAge,
|
|
3427
3802
|
launchNapcat,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-napcat-keeper",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "发现没有能够随着dsh打开自动启动napcat的插件,遂使用大肥鱼制作此插件,建议搭配Yuuyuko-uu的dsh-qq-bridge使用~",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"check:fix": "node scripts/check-manifest.mjs --fix",
|
|
25
25
|
"smoke:client": "node scripts/smoke-client.mjs",
|
|
26
26
|
"selftest:scan": "node scripts/selftest-scan.mjs",
|
|
27
|
+
"selftest:save": "node scripts/selftest-save.mjs",
|
|
28
|
+
"selftest:probe": "node scripts/selftest-probe.mjs",
|
|
27
29
|
"selftest:setup": "node scripts/selftest-setup.mjs"
|
|
28
30
|
},
|
|
29
31
|
"keywords": [
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「在线探测」回归自检。
|
|
3
|
+
*
|
|
4
|
+
* 守的是这次适配反向 WebSocket 的那个坑:老版本 postOneBot 只看 body 不看状态码,
|
|
5
|
+
* 于是 dsh-onebot-qq 之类反向 WS 服务占着端口时(HTTP 换来 401/426),
|
|
6
|
+
* 一律被当成「端口不通」→ 卡死判定 → 杀掉重拉 → 无限循环。
|
|
7
|
+
*
|
|
8
|
+
* 现在 probe() 要能把这几种情况分清楚:
|
|
9
|
+
* 200 OneBot JSON → online / http
|
|
10
|
+
* 426 → reverse(端口只收 WebSocket)
|
|
11
|
+
* 401 → auth(令牌不对)或 reverse(有客户端连进来时)
|
|
12
|
+
* 端口没人听 → dead
|
|
13
|
+
* probeMode=process→ 直接不碰端口
|
|
14
|
+
*
|
|
15
|
+
* 全程在本地环回上起假服务器,端口用临时的,不碰真配置、不碰真 NapCat。
|
|
16
|
+
* node scripts/selftest-probe.mjs
|
|
17
|
+
*/
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import net from 'node:net'
|
|
20
|
+
import { spawn } from 'node:child_process'
|
|
21
|
+
|
|
22
|
+
let pass = 0
|
|
23
|
+
let fail = 0
|
|
24
|
+
const ok = (cond, name, extra = '') => {
|
|
25
|
+
if (cond) {
|
|
26
|
+
pass += 1
|
|
27
|
+
console.log(` ✓ ${name}`)
|
|
28
|
+
} else {
|
|
29
|
+
fail += 1
|
|
30
|
+
console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const loadInternals = async () => (await import('../lib/index.js?t=' + Date.now())).__internals
|
|
35
|
+
|
|
36
|
+
/** 起一个假服务器,返回 { port, close } */
|
|
37
|
+
const serve = (handler) =>
|
|
38
|
+
new Promise((resolve) => {
|
|
39
|
+
const srv = http.createServer(handler)
|
|
40
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
41
|
+
resolve({
|
|
42
|
+
port: srv.address().port,
|
|
43
|
+
close: () => new Promise((r) => srv.close(r)),
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** 找一个没人用的端口(开着再关掉),用来模拟「端口没开」 */
|
|
49
|
+
const deadPort = () =>
|
|
50
|
+
new Promise((resolve) => {
|
|
51
|
+
const srv = net.createServer()
|
|
52
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
53
|
+
const port = srv.address().port
|
|
54
|
+
srv.close(() => resolve(port))
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** 起一个孩子进程,朝 port 建一条常连接(模拟 NapCat 反向连进来) */
|
|
59
|
+
const connectAndHold = (port) => {
|
|
60
|
+
const code =
|
|
61
|
+
`const net=require('net');` +
|
|
62
|
+
`const s=net.connect(${port},'127.0.0.1',()=>{process.stdout.write('up\\n')});` +
|
|
63
|
+
's.on("error",()=>{});' +
|
|
64
|
+
'setInterval(()=>{},1000)'
|
|
65
|
+
const child = spawn(process.execPath, ['-e', code], { stdio: 'ignore', windowsHide: true })
|
|
66
|
+
return {
|
|
67
|
+
child,
|
|
68
|
+
up: () => new Promise((r) => setTimeout(r, 900)),
|
|
69
|
+
stop: () => {
|
|
70
|
+
try {
|
|
71
|
+
child.kill()
|
|
72
|
+
} catch {}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { probe, portClients, PROBE_MODES } = await loadInternals()
|
|
78
|
+
|
|
79
|
+
/* 1. 普通 NapCat HTTP 接口 ------------------------------------------------- */
|
|
80
|
+
console.log('1) 端口上是正常的 OneBot HTTP 接口')
|
|
81
|
+
let port1
|
|
82
|
+
{
|
|
83
|
+
const srv = await serve((req, res) => {
|
|
84
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
85
|
+
res.end(JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10086, nickname: '测试号' } }))
|
|
86
|
+
})
|
|
87
|
+
port1 = srv.port
|
|
88
|
+
const health = await probe({ onebotPort: port1, accessToken: '', probeMode: 'auto' })
|
|
89
|
+
ok(health.online === true, '判为在线')
|
|
90
|
+
ok(health.mode === 'http', 'mode = http', health.mode)
|
|
91
|
+
ok(health.status === 200, '抓到 HTTP 200', String(health.status))
|
|
92
|
+
await srv.close()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* 2. 只收 WebSocket 的端口:426,且没人连 -------------------------------- */
|
|
96
|
+
console.log('\n2) 端口只收 WebSocket(426)、还没有客户端连进来')
|
|
97
|
+
let port2
|
|
98
|
+
{
|
|
99
|
+
const srv = await serve((req, res) => {
|
|
100
|
+
res.writeHead(426, { 'content-type': 'text/plain' })
|
|
101
|
+
res.end('Upgrade Required')
|
|
102
|
+
})
|
|
103
|
+
port2 = srv.port
|
|
104
|
+
const health = await probe({ onebotPort: port2, accessToken: '', probeMode: 'auto' })
|
|
105
|
+
ok(health.online === false, '没客户端时不判在线')
|
|
106
|
+
ok(health.mode === 'reverse', 'mode = reverse', health.mode)
|
|
107
|
+
ok(health.portAlive === true, '认得出「端口是活的」', JSON.stringify(health.portAlive))
|
|
108
|
+
ok(health.status === 426, '抓到 HTTP 426', String(health.status))
|
|
109
|
+
const links = await portClients({ onebotPort: port2 }, 0)
|
|
110
|
+
ok(links.count === 0 || links.count === null, '客户端数 = 0 或查不到(不是 1)', String(links.count))
|
|
111
|
+
await srv.close()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* 3. 426 + 真有客户端连进来 → 反向 WS 在线 -------------------------------- */
|
|
115
|
+
console.log('\n3) 反向 WS:端口上有客户端连进来了')
|
|
116
|
+
{
|
|
117
|
+
const srv = await serve((req, res) => {
|
|
118
|
+
res.writeHead(426, { 'content-type': 'text/plain' })
|
|
119
|
+
res.end('Upgrade Required')
|
|
120
|
+
})
|
|
121
|
+
const client = connectAndHold(srv.port)
|
|
122
|
+
await client.up()
|
|
123
|
+
const health = await probe({ onebotPort: srv.port, accessToken: '', probeMode: 'auto' })
|
|
124
|
+
ok(health.mode === 'reverse', 'mode = reverse', health.mode)
|
|
125
|
+
ok(health.online === true, '有客户端就判在线')
|
|
126
|
+
ok(health.clients !== null && health.clients >= 1, '数到了客户端', String(health.clients))
|
|
127
|
+
client.stop()
|
|
128
|
+
await srv.close()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* 4. 401:可能是反向 WS 服务,也可能是令牌不对 --------------------------- */
|
|
132
|
+
console.log('\n4) 端口回 401')
|
|
133
|
+
{
|
|
134
|
+
const srv = await serve((req, res) => {
|
|
135
|
+
res.writeHead(401, { 'content-type': 'text/plain' })
|
|
136
|
+
res.end('Unauthorized')
|
|
137
|
+
})
|
|
138
|
+
const health = await probe({ onebotPort: srv.port, accessToken: '', probeMode: 'auto' })
|
|
139
|
+
ok(health.mode === 'auth', '没客户端时判 auth(多半是令牌不对)', health.mode)
|
|
140
|
+
ok(health.online === false, '不判在线')
|
|
141
|
+
ok(health.portAlive === true, '但仍认得出端口是活的(所以不会去杀进程)')
|
|
142
|
+
ok(String(health.hint || '').includes('401'), '给得出 401 的提示', health.hint)
|
|
143
|
+
await srv.close()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* 5. 端口没开 ------------------------------------------------------------ */
|
|
147
|
+
console.log('\n5) 端口没人听')
|
|
148
|
+
{
|
|
149
|
+
const port = await deadPort()
|
|
150
|
+
const health = await probe({ onebotPort: port, accessToken: '', probeMode: 'auto' })
|
|
151
|
+
ok(health.online === false, '不判在线')
|
|
152
|
+
ok(health.mode === 'dead', 'mode = dead', health.mode)
|
|
153
|
+
ok(health.portAlive === false, 'portAlive = false')
|
|
154
|
+
ok(health.status === 0, '没有 HTTP 状态码', String(health.status))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* 6. probeMode 的几种强制值 --------------------------------------------- */
|
|
158
|
+
console.log('\n6) probeMode 强制值')
|
|
159
|
+
{
|
|
160
|
+
const srv = await serve((req, res) => {
|
|
161
|
+
res.writeHead(426)
|
|
162
|
+
res.end('Upgrade Required')
|
|
163
|
+
})
|
|
164
|
+
const asProcess = await probe({ onebotPort: srv.port, accessToken: '', probeMode: 'process' })
|
|
165
|
+
ok(asProcess.mode === 'process', 'process:连端口都不看', asProcess.mode)
|
|
166
|
+
const asHttp = await probe({ onebotPort: srv.port, accessToken: '', probeMode: 'http' })
|
|
167
|
+
ok(asHttp.mode === 'auth' || asHttp.mode === 'http', 'http:不认反向(判成 auth/http)', asHttp.mode)
|
|
168
|
+
const asReverse = await probe({ onebotPort: srv.port, accessToken: '', probeMode: 'reverse' })
|
|
169
|
+
ok(asReverse.mode === 'reverse', 'reverse:直接按反向 WS 处理', asReverse.mode)
|
|
170
|
+
const bad = await probe({ onebotPort: srv.port, accessToken: '', probeMode: '乱填' })
|
|
171
|
+
ok(bad.mode === 'reverse', '乱填的 probeMode 退回 auto(这里应判成 reverse)', bad.mode)
|
|
172
|
+
await srv.close()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* 7. 归一化 -------------------------------------------------------------- */
|
|
176
|
+
console.log('\n7) 配置归一化')
|
|
177
|
+
{
|
|
178
|
+
const { normalizeConfig } = await loadInternals()
|
|
179
|
+
ok(normalizeConfig({ probeMode: 'REVERSE' }).probeMode === 'reverse', '大小写归一')
|
|
180
|
+
ok(normalizeConfig({ probeMode: '' }).probeMode === 'auto', '空值 → auto')
|
|
181
|
+
ok(normalizeConfig({ probeMode: 'xyz' }).probeMode === 'auto', '非法值 → auto')
|
|
182
|
+
ok(PROBE_MODES.has('auto') && PROBE_MODES.has('reverse'), 'PROBE_MODES 导出可用')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`)
|
|
186
|
+
process.exit(fail ? 1 : 0)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「保存设置」回归自检。
|
|
3
|
+
*
|
|
4
|
+
* 守的是 2.7.0 引入、2.7.3 修掉的那个 bug:saveAndApply 写的是 $DSH_HOME/config.json,
|
|
5
|
+
* 而所有读取都走 <keeperDir>/config.json,于是设置页点保存/点「用这个」之后值立刻弹回空。
|
|
6
|
+
*
|
|
7
|
+
* 全程在临时 DSH_HOME 里跑,不碰真实配置。
|
|
8
|
+
* node scripts/selftest-save.mjs
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, utimesSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
let pass = 0
|
|
15
|
+
let fail = 0
|
|
16
|
+
const ok = (cond, name, extra = '') => {
|
|
17
|
+
if (cond) {
|
|
18
|
+
pass += 1
|
|
19
|
+
console.log(` ✓ ${name}`)
|
|
20
|
+
} else {
|
|
21
|
+
fail += 1
|
|
22
|
+
console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const freshHome = (tag) => {
|
|
27
|
+
const home = mkdtempSync(path.join(tmpdir(), `npk-save-${tag}-`))
|
|
28
|
+
process.env.DSH_HOME = home
|
|
29
|
+
return { home, dir: path.join(home, 'dsh-napcat-keeper') }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const loadInternals = async () => (await import('../lib/index.js?t=' + Date.now())).__internals
|
|
33
|
+
|
|
34
|
+
const fakeCtx = { effect: (fn) => fn(), logger: null, tools: { register() {} } }
|
|
35
|
+
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
|
|
36
|
+
|
|
37
|
+
/* 1. 保存必须落到 keeperDir/config.json,回读一致 --------------------------- */
|
|
38
|
+
console.log('1) saveAndApply 真的写进 keeperDir 了吗')
|
|
39
|
+
{
|
|
40
|
+
const { home, dir } = freshHome('write')
|
|
41
|
+
const { resolveConfig, saveAndApply } = await loadInternals()
|
|
42
|
+
const target = 'D:/fake/NapCat.Shell'
|
|
43
|
+
const next = saveAndApply(fakeCtx, { shellDir: target, onebotPort: 3011 })
|
|
44
|
+
ok(next.shellDir === target, 'saveAndApply 返回值里就有新值', JSON.stringify(next.shellDir))
|
|
45
|
+
ok(existsSync(path.join(dir, 'config.json')), 'keeperDir/config.json 被写出来了')
|
|
46
|
+
ok(readJson(path.join(dir, 'config.json')).shellDir === target, '落盘内容对得上')
|
|
47
|
+
ok(!existsSync(path.join(home, 'config.json')), '根上没再冒出 config.json')
|
|
48
|
+
ok(resolveConfig({}).shellDir === target, '重新 resolveConfig 读回新值(重启 DSH 后也生效)')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* 2. 老版本误写的那份要被认领回来 ------------------------------------------- */
|
|
52
|
+
console.log('\n2) 认领 2.7.0~2.7.2 误写到根上的 config.json')
|
|
53
|
+
{
|
|
54
|
+
const { home, dir } = freshHome('adopt')
|
|
55
|
+
mkdirSync(dir, { recursive: true })
|
|
56
|
+
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ shellDir: '', onebotPort: 3000 }, null, 2) + '\n')
|
|
57
|
+
// 根上那份是「用户最后一次点保存」的内容,比 keeper 的新
|
|
58
|
+
const stray = path.join(home, 'config.json')
|
|
59
|
+
writeFileSync(stray, JSON.stringify({ shellDir: 'E:/NapCat.Shell', onebotPort: 3000, launchAccount: '10001' }, null, 2) + '\n')
|
|
60
|
+
const old = (Date.now() - 60000) / 1000
|
|
61
|
+
utimesSync(path.join(dir, 'config.json'), old, old)
|
|
62
|
+
|
|
63
|
+
const { resolveConfig } = await loadInternals()
|
|
64
|
+
ok(resolveConfig({}).shellDir === 'E:/NapCat.Shell', '认领后读到的就是用户当初想存的那份')
|
|
65
|
+
ok(!existsSync(stray), '根上那份已归档,不会下次又被认领一遍')
|
|
66
|
+
const leg = readdirSync(path.join(dir, 'legacy'))
|
|
67
|
+
ok(leg.some((f) => f.startsWith('config.adopted-')), '原文留在 legacy/ 里(可回滚)', leg.join(','))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* 3. keeper 更新时不许被根上那份覆盖 ---------------------------------------- */
|
|
71
|
+
console.log('\n3) keeper 比根上新时不认领(别把新配置写旧)')
|
|
72
|
+
{
|
|
73
|
+
const { home, dir } = freshHome('noadopt')
|
|
74
|
+
mkdirSync(dir, { recursive: true })
|
|
75
|
+
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ shellDir: 'F:/good', onebotPort: 3000, launchAccount: '1' }, null, 2) + '\n')
|
|
76
|
+
const stray = path.join(home, 'config.json')
|
|
77
|
+
writeFileSync(stray, JSON.stringify({ shellDir: 'G:/stale', onebotPort: 3000, launchAccount: '1' }, null, 2) + '\n')
|
|
78
|
+
const old = (Date.now() - 120000) / 1000
|
|
79
|
+
utimesSync(stray, old, old)
|
|
80
|
+
|
|
81
|
+
const { resolveConfig } = await loadInternals()
|
|
82
|
+
ok(resolveConfig({}).shellDir === 'F:/good', 'keeper 里的值没被旧文件顶掉')
|
|
83
|
+
ok(existsSync(stray), '不认领就不动它')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* 4. 不是自家的 config.json 不许抢 ----------------------------------------- */
|
|
87
|
+
console.log('\n4) 根上的 config.json 若不像插件的,一概不碰')
|
|
88
|
+
{
|
|
89
|
+
const { home, dir } = freshHome('foreign')
|
|
90
|
+
mkdirSync(dir, { recursive: true })
|
|
91
|
+
const stray = path.join(home, 'config.json')
|
|
92
|
+
writeFileSync(stray, JSON.stringify({ theme: 'dark', language: 'zh-CN' }, null, 2) + '\n')
|
|
93
|
+
const { resolveConfig } = await loadInternals()
|
|
94
|
+
resolveConfig({})
|
|
95
|
+
ok(existsSync(stray), '别人的 config.json 原样留着')
|
|
96
|
+
ok(!existsSync(path.join(dir, 'config.json')), '也没凭空造一份 keeper 配置')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`)
|
|
100
|
+
process.exit(fail ? 1 : 0)
|
package/scripts/smoke-client.mjs
CHANGED
|
@@ -41,6 +41,7 @@ function render() { idx = 0; return Section({}); }
|
|
|
41
41
|
function collect(tree) {
|
|
42
42
|
const buttons = [];
|
|
43
43
|
const boxes = [];
|
|
44
|
+
const selects = [];
|
|
44
45
|
const texts = [];
|
|
45
46
|
(function walk(n) {
|
|
46
47
|
if (n === null || n === undefined || n === false) return;
|
|
@@ -51,10 +52,16 @@ function collect(tree) {
|
|
|
51
52
|
if (n.type === 'input' && n.props && n.props.type === 'checkbox') {
|
|
52
53
|
boxes.push({ name: String(n.props.name || ''), checked: Boolean(n.props.checked), disabled: Boolean(n.props.disabled) });
|
|
53
54
|
}
|
|
55
|
+
if (n.type === 'select') {
|
|
56
|
+
selects.push({
|
|
57
|
+
value: String(n.props && n.props.value),
|
|
58
|
+
options: n.children.filter((c) => c && c.__el && c.type === 'option').map((o) => String(o.props.value)),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
54
61
|
n.children.forEach(walk);
|
|
55
62
|
}
|
|
56
63
|
})(tree);
|
|
57
|
-
return { buttons, boxes, text: texts.join(' ') };
|
|
64
|
+
return { buttons, boxes, selects, text: texts.join(' ') };
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
render(); // 第一次:态=null 的 loading 分支
|
|
@@ -121,6 +128,25 @@ check('三块盘三个按钮', ['扫 C:', '扫 D:', '扫 E:'].every((b) => base.
|
|
|
121
128
|
check('盘数提示 3 块', /读到 3 块盘:C:、D:、E:/.test(base.text));
|
|
122
129
|
check('shellDir 指向分盘扫描', base.text.includes('不想手填就用上面「分盘扫描」'));
|
|
123
130
|
|
|
131
|
+
// 反向 WebSocket:下拉得在、默认 auto、四项齐;runtime 报反向态时状态条得换词
|
|
132
|
+
const modeSel = base.selects[0];
|
|
133
|
+
check('有「在线探测方式」下拉,默认 auto', Boolean(modeSel) && modeSel.value === 'auto', JSON.stringify(modeSel));
|
|
134
|
+
check('下拉四项齐全', Boolean(modeSel) && ['auto', 'http', 'reverse', 'process'].every((v) => modeSel.options.includes(v)),
|
|
135
|
+
JSON.stringify(modeSel && modeSel.options));
|
|
136
|
+
states[0].runtime.mode = 'reverse';
|
|
137
|
+
states[0].runtime.portStatus = 426;
|
|
138
|
+
states[0].runtime.clients = 2;
|
|
139
|
+
const rev = collect(render());
|
|
140
|
+
check('反向 WS 时状态条说「反向 WS 已连接」', rev.text.includes('反向 WS 已连接'));
|
|
141
|
+
check('反向 WS 时显示客户端数', /客户端 2/.test(rev.text));
|
|
142
|
+
check('反向 WS 时显示探测方式与 HTTP 状态', rev.text.includes('反向 WebSocket') && rev.text.includes('HTTP 426'));
|
|
143
|
+
states[0].runtime.mode = 'auth';
|
|
144
|
+
states[0].runtime.online = false;
|
|
145
|
+
states[0].runtime.portStatus = 401;
|
|
146
|
+
delete states[0].runtime.clients;
|
|
147
|
+
const auth = collect(render());
|
|
148
|
+
check('401 时显示「端口要求鉴权」', auth.text.includes('端口要求鉴权'));
|
|
149
|
+
|
|
124
150
|
// 盘数跟 drives 走:1 块 / 5 块 / 0 块
|
|
125
151
|
for (const drives of [['C:'], ['C:', 'D:', 'E:', 'F:', 'G:'], []]) {
|
|
126
152
|
states[0].drives = drives;
|