dsh-napcat-keeper 2.7.3 → 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 +10 -0
- package/README.md +41 -4
- package/lib/client.js +47 -6
- package/lib/index.js +324 -15
- package/package.json +2 -1
- package/scripts/selftest-probe.mjs +186 -0
- package/scripts/smoke-client.mjs +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 2.7.3 — 修「设置页保存不进去」+ README 补「装不上」的两条排障
|
|
4
14
|
- **修了个真 bug,2.7.0 起就有**:设置页点「保存」、「用这个」、或者「清除密码」,值当场弹回原样,什么都没存下来。起因是 2.7.0 把插件自己的文件收进 `~/.dsh/dsh-napcat-keeper/` 时,只把**读取**改成了新目录,**写入**还留在老地方 `~/.dsh/config.json` —— 于是存进一个没人读的文件,界面重新渲染时拿到的还是旧值。2.7.0 / 2.7.1 / 2.7.2 都中招。
|
|
5
15
|
- 现在读和写认同一个目录(`saveAndApply` 与「清除密码」都改走 `keeperDir()`)。
|
package/README.md
CHANGED
|
@@ -193,6 +193,34 @@ QQ 那条进程链挂在本 DSH 实例下面——DSH 开着它就在,DSH 关
|
|
|
193
193
|
|
|
194
194
|
> 顺带一提:`versions\*\resources\app\napcat\napcat.mjs` 那份**不会**被当成入口——它是等 QQ 注入用的,拿普通 Node 直接跑必崩。
|
|
195
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
|
+
|
|
196
224
|
## 给 agent 用的三个工具
|
|
197
225
|
|
|
198
226
|
| 工具 | 作用 |
|
|
@@ -213,8 +241,9 @@ QQ 那条进程链挂在本 DSH 实例下面——DSH 开着它就在,DSH 关
|
|
|
213
241
|
| `nodePath` | 空(自动) | 指定 `node.exe`;留空按「DSH 自带 → 系统 PATH → 常见安装目录 → NapCat 目录」找,见上一节 |
|
|
214
242
|
| `rememberPassword` | `false` | 记住 QQ 密码、令牌失效时自动密码登录(**明文落盘,详见上节**) |
|
|
215
243
|
| `quickPassword` | 空 | QQ 密码;只在保存时写入,接口不回显、日志不打印 |
|
|
216
|
-
| `onebotPort` | `3000` | OneBot
|
|
217
|
-
| `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` 只看进程不碰端口 |
|
|
218
247
|
| `taskName` | 空(= 用 cscript,QQ 跟着 DSH 生灭) | 想脱离 DSH 才填计划任务名 |
|
|
219
248
|
| `autoCreateTask` | `false` | 填了 `taskName` 且任务不存在时自动创建 |
|
|
220
249
|
| `hideConsole` | `true` | 拉起时不显示黑框(用 wscript 壳藏窗口) |
|
|
@@ -295,6 +324,14 @@ npm run check:privacy # 只查隐私(发版前必跑)
|
|
|
295
324
|
**换端口了?**
|
|
296
325
|
改「OneBot HTTP 端口」保存即可;如果同时换了 Shell 目录,跨进程锁会自动清掉。
|
|
297
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
|
+
|
|
298
335
|
**老是掉线、每次都要扫码?**
|
|
299
336
|
令牌过期了。要么每次手机扫,要么开「记住密码」让它自己用密码登(先看上上节的风险)。
|
|
300
337
|
`napcat_status` 会直接告诉你现在是 `password` 还是 `token` 在撑、以及密码文件在不在。
|
|
@@ -330,13 +367,13 @@ DSH 自带的 pnpm 不捎带 git,Windows 上又默认没装,于是就卡在
|
|
|
330
367
|
| 填进去的 | 结果 |
|
|
331
368
|
| --- | --- |
|
|
332
369
|
| `dsh-napcat-keeper` | ✅ |
|
|
333
|
-
| `dsh-napcat-keeper@2.
|
|
370
|
+
| `dsh-napcat-keeper@2.8.0` | ✅(指定版本) |
|
|
334
371
|
| `dsh plugin --profile desktop add dsh-napcat-keeper` | ❌ 整条命令 |
|
|
335
372
|
| `add dsh-napcat-keeper` | ❌ 多了 `add` |
|
|
336
373
|
| `Dsh-napcat-keeper` | ❌ 大写 |
|
|
337
374
|
| `dsh-napcat-keeper。`、`dsh-napcat-keeper;` | ❌ 全角标点 |
|
|
338
375
|
|
|
339
|
-
改法就一句:**那一栏只填 `dsh-napcat-keeper`**(要指定版本就 `dsh-napcat-keeper@2.
|
|
376
|
+
改法就一句:**那一栏只填 `dsh-napcat-keeper`**(要指定版本就 `dsh-napcat-keeper@2.8.0`)。想跑命令行的话,得在终端里跑,
|
|
340
377
|
不是在输入框里跑。
|
|
341
378
|
|
|
342
379
|
## License
|
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,18 @@
|
|
|
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
|
+
*
|
|
7
16
|
* v2.7.3:设置页保存不再空转
|
|
8
17
|
* - 2.7.0 把配置文件收进 keeperDir 时只改了「读」,saveAndApply / clearPassword
|
|
9
18
|
* 还在写 ~/.dsh/config.json,于是点保存 / 点「用这个」之后值立刻弹回原样;
|
|
@@ -138,7 +147,7 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
138
147
|
import path from 'node:path'
|
|
139
148
|
|
|
140
149
|
export const name = 'napcat-keeper'
|
|
141
|
-
export const version = '2.
|
|
150
|
+
export const version = '2.8.0'
|
|
142
151
|
|
|
143
152
|
/** tools 用来注册 napcat_* 工具;webServer 用来挂设置界面接口。拿不到也不至于把 DSH 拖崩。 */
|
|
144
153
|
export const inject = ['tools', 'webServer']
|
|
@@ -171,6 +180,14 @@ const DEFAULTS = {
|
|
|
171
180
|
onebotPort: 3000,
|
|
172
181
|
/** OneBot access token,没有就留空 */
|
|
173
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',
|
|
174
191
|
/** 只在这些路径下找 QQ/NapCat 进程;留空 = 由 shellDir 推导(上层目录) */
|
|
175
192
|
procPathMatch: '',
|
|
176
193
|
/** 复查间隔(秒) */
|
|
@@ -231,12 +248,16 @@ const STATE_KEY = Symbol.for('dsh.napcat-keeper.state')
|
|
|
231
248
|
const state =
|
|
232
249
|
globalThis[STATE_KEY] ||
|
|
233
250
|
(globalThis[STATE_KEY] = {
|
|
234
|
-
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
|
|
235
252
|
lastProbeAt: null,
|
|
236
253
|
lastOnlineAt: 0,
|
|
237
254
|
lastLaunchAt: 0,
|
|
238
255
|
lastError: '',
|
|
239
256
|
launchCount: 0,
|
|
257
|
+
/** 连续拉起几次都没等到上线:宽限期按 2 的幂放大,免得每 45 秒杀一次 */
|
|
258
|
+
launchFails: 0,
|
|
259
|
+
/** 反向 WS 模式下「温和重拉」的次数,用完就认命给提示,不再折腾进程 */
|
|
260
|
+
reverseRetries: 0,
|
|
240
261
|
dedupeCount: 0,
|
|
241
262
|
scanSince: 0,
|
|
242
263
|
scanRetries: 0,
|
|
@@ -764,6 +785,9 @@ function normalizeConfig(cfg) {
|
|
|
764
785
|
cfg.launchAccount = String(cfg.launchAccount || '').trim().replace(/\D/g, '')
|
|
765
786
|
cfg.launchArgs = String(cfg.launchArgs || '').trim()
|
|
766
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'
|
|
767
791
|
cfg.procPathMatch = String(cfg.procPathMatch || '').trim()
|
|
768
792
|
cfg.taskName = String(cfg.taskName || '').trim()
|
|
769
793
|
cfg.onebotPort = clampNum(cfg.onebotPort, 1, 65535, 3000)
|
|
@@ -865,6 +889,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
865
889
|
/* OneBot */
|
|
866
890
|
/* ------------------------------------------------------------------ */
|
|
867
891
|
|
|
892
|
+
/**
|
|
893
|
+
* 发一个 OneBot HTTP 动作。
|
|
894
|
+
*
|
|
895
|
+
* 返回值里带上 HTTP 状态码:反向 WebSocket 模式下(比如 dsh-onebot-qq 占着这个端口)
|
|
896
|
+
* 对方根本不会回 OneBot 的 JSON,能拿到的唯一线索就是 401 / 426 这类状态码 ——
|
|
897
|
+
* 老版本把状态码丢了,于是一律当成「端口不通」,接着就是杀掉重拉,无限循环。
|
|
898
|
+
*/
|
|
868
899
|
function postOneBot(cfg, action, payload, timeoutMs = 4000) {
|
|
869
900
|
return new Promise((resolve) => {
|
|
870
901
|
const body = JSON.stringify(payload || {})
|
|
@@ -885,50 +916,177 @@ function postOneBot(cfg, action, payload, timeoutMs = 4000) {
|
|
|
885
916
|
timeout: timeoutMs,
|
|
886
917
|
},
|
|
887
918
|
(res) => {
|
|
919
|
+
const status = Number(res.statusCode) || 0
|
|
888
920
|
let buf = ''
|
|
889
921
|
res.setEncoding('utf8')
|
|
890
922
|
res.on('data', (c) => {
|
|
891
923
|
buf += c
|
|
892
924
|
})
|
|
893
925
|
res.on('end', () => {
|
|
926
|
+
const raw = String(buf).slice(0, 200)
|
|
927
|
+
let parsed = null
|
|
894
928
|
try {
|
|
895
|
-
|
|
896
|
-
} catch {
|
|
897
|
-
|
|
929
|
+
parsed = JSON.parse(buf)
|
|
930
|
+
} catch {}
|
|
931
|
+
if (parsed && typeof parsed === 'object') {
|
|
932
|
+
resolve({ ...parsed, status })
|
|
933
|
+
return
|
|
898
934
|
}
|
|
935
|
+
resolve({ retcode: -1, status, raw })
|
|
899
936
|
})
|
|
900
937
|
},
|
|
901
938
|
)
|
|
902
939
|
} catch (error) {
|
|
903
|
-
return resolve({ retcode: -1, error: String((error && error.message) || error) })
|
|
940
|
+
return resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
|
|
904
941
|
}
|
|
905
942
|
req.on('timeout', () => {
|
|
906
943
|
try {
|
|
907
944
|
req.destroy(new Error('timeout'))
|
|
908
945
|
} catch {}
|
|
909
|
-
resolve({ retcode: -1, error: 'timeout' })
|
|
946
|
+
resolve({ retcode: -1, status: 0, error: 'timeout' })
|
|
910
947
|
})
|
|
911
|
-
req.on('error', (error) =>
|
|
948
|
+
req.on('error', (error) =>
|
|
949
|
+
resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) }),
|
|
950
|
+
)
|
|
912
951
|
try {
|
|
913
952
|
req.write(body)
|
|
914
953
|
req.end()
|
|
915
954
|
} catch (error) {
|
|
916
|
-
resolve({ retcode: -1, error: String((error && error.message) || error) })
|
|
955
|
+
resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
|
|
917
956
|
}
|
|
918
957
|
})
|
|
919
958
|
}
|
|
920
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
|
+
*/
|
|
921
1024
|
async function probe(cfg) {
|
|
1025
|
+
const want = PROBE_MODES.has(cfg.probeMode) ? cfg.probeMode : 'auto'
|
|
922
1026
|
const login = await postOneBot(cfg, 'get_login_info')
|
|
923
1027
|
const data = login && login.data
|
|
1028
|
+
const status = Number(login && login.status) || 0
|
|
924
1029
|
const online = Boolean(login && login.retcode === 0 && data && (data.user_id || data.uin))
|
|
925
|
-
|
|
1030
|
+
const portAlive = status > 0
|
|
1031
|
+
const result = {
|
|
926
1032
|
online,
|
|
927
1033
|
retcode: login && login.retcode,
|
|
1034
|
+
status,
|
|
1035
|
+
portAlive,
|
|
1036
|
+
mode: 'dead',
|
|
1037
|
+
clients: null,
|
|
928
1038
|
nickname: online ? data.nickname : '',
|
|
929
1039
|
userId: online ? data.user_id || data.uin : '',
|
|
930
|
-
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
|
|
931
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
|
|
932
1090
|
}
|
|
933
1091
|
|
|
934
1092
|
/* ------------------------------------------------------------------ */
|
|
@@ -2279,6 +2437,7 @@ function launchNapcat(cfg, reason = '') {
|
|
|
2279
2437
|
writeLock(cfg)
|
|
2280
2438
|
state.lastLaunchAt = Date.now()
|
|
2281
2439
|
state.launchCount += 1
|
|
2440
|
+
state.launchFails += 1
|
|
2282
2441
|
state.phase = 'launching'
|
|
2283
2442
|
state.scanSince = 0
|
|
2284
2443
|
state.lastError = ''
|
|
@@ -2336,6 +2495,7 @@ function launchNapcat(cfg, reason = '') {
|
|
|
2336
2495
|
writeLock(cfg)
|
|
2337
2496
|
state.lastLaunchAt = Date.now()
|
|
2338
2497
|
state.launchCount += 1
|
|
2498
|
+
state.launchFails += 1
|
|
2339
2499
|
state.phase = 'launching'
|
|
2340
2500
|
state.scanSince = 0
|
|
2341
2501
|
state.lastError = ''
|
|
@@ -2422,12 +2582,23 @@ async function tick(cfg) {
|
|
|
2422
2582
|
const health = await probe(cfg)
|
|
2423
2583
|
|
|
2424
2584
|
if (health.online) {
|
|
2425
|
-
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
|
+
}
|
|
2426
2594
|
state.phase = 'online'
|
|
2427
2595
|
state.lastOnlineAt = Date.now()
|
|
2428
2596
|
state.lastError = ''
|
|
2429
2597
|
state.scanSince = 0
|
|
2430
2598
|
state.scanRetries = 0
|
|
2599
|
+
// 真连上了就把失败计数清掉,下次出事重新给足宽限
|
|
2600
|
+
state.launchFails = 0
|
|
2601
|
+
state.reverseRetries = 0
|
|
2431
2602
|
const bridge = bridgeAge(cfg)
|
|
2432
2603
|
if (bridge.ageSec !== null && bridge.ageSec > cfg.bridgeStaleSec) {
|
|
2433
2604
|
log(cfg, `WARN bridge state stale ${bridge.ageSec}s (${bridge.path}) —— 桥接可能卡住`)
|
|
@@ -2451,6 +2622,8 @@ async function tick(cfg) {
|
|
|
2451
2622
|
nickname: health.nickname,
|
|
2452
2623
|
userId: health.userId,
|
|
2453
2624
|
port: cfg.onebotPort,
|
|
2625
|
+
mode: health.mode,
|
|
2626
|
+
clients: health.clients,
|
|
2454
2627
|
bridge,
|
|
2455
2628
|
launches: state.launchCount,
|
|
2456
2629
|
dedupes: state.dedupeCount,
|
|
@@ -2503,10 +2676,116 @@ async function tick(cfg) {
|
|
|
2503
2676
|
return { phase: state.phase, health, procs, deduped: true, launched }
|
|
2504
2677
|
}
|
|
2505
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
|
+
|
|
2506
2783
|
// ② 完全没有进程:拉起(带宽限期 + 跨进程锁)
|
|
2507
2784
|
if (procs.boot === 0 && procs.qq === 0) {
|
|
2508
2785
|
const since = Date.now() - state.lastLaunchAt
|
|
2509
|
-
|
|
2786
|
+
// 连续拉不起来就别每轮的宽限期都硬等一遍:2 的幂放大,最多 8 倍
|
|
2787
|
+
const backoff = Math.min(2 ** Math.min(state.launchFails, 3), 8)
|
|
2788
|
+
if (since < cfg.launchGraceSec * backoff * 1000) {
|
|
2510
2789
|
state.phase = 'launching'
|
|
2511
2790
|
writeSnapshot(cfg, { phase: state.phase, online: false, procs, launches: state.launchCount })
|
|
2512
2791
|
return { phase: state.phase, health, procs }
|
|
@@ -2704,6 +2983,13 @@ function registerTools(ctx, cfg) {
|
|
|
2704
2983
|
onebot: {
|
|
2705
2984
|
port: cfg.onebotPort,
|
|
2706
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,
|
|
2707
2993
|
nickname: health.nickname,
|
|
2708
2994
|
user_id: health.userId,
|
|
2709
2995
|
error: health.error,
|
|
@@ -2734,7 +3020,21 @@ function registerTools(ctx, cfg) {
|
|
|
2734
3020
|
logTail: tail,
|
|
2735
3021
|
note: health.online
|
|
2736
3022
|
? '在线。若 QQ 里还是没回音,问题在 dsh-qq-bridge 那一侧(绑定/试运行开关)。'
|
|
2737
|
-
:
|
|
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
|
|
2738
3038
|
? passwordActive(cfg)
|
|
2739
3039
|
? '离线,且在等扫码——但「记住密码」是开着的:看一眼日志里“快速登录错误”后面是不是仍回退到二维码(密码登录可能被腾讯要求验证)。'
|
|
2740
3040
|
: '离线,且二维码是新的:需要手机扫码(或在设置里打开「记住密码」让 NapCat 自己用密码登录)。文件在 qrCopyPath。'
|
|
@@ -2917,6 +3217,7 @@ function publicConfig(cfg) {
|
|
|
2917
3217
|
passwordActive: passwordActive(cfg),
|
|
2918
3218
|
onebotPort: cfg.onebotPort,
|
|
2919
3219
|
accessToken: cfg.accessToken,
|
|
3220
|
+
probeMode: cfg.probeMode,
|
|
2920
3221
|
procPathMatch: cfg.procPathMatch,
|
|
2921
3222
|
probeIntervalSec: cfg.probeIntervalSec,
|
|
2922
3223
|
launchGraceSec: cfg.launchGraceSec,
|
|
@@ -3081,6 +3382,12 @@ async function uiState(cfg, options = {}) {
|
|
|
3081
3382
|
enabled: cfg.enabled,
|
|
3082
3383
|
phase: state.phase,
|
|
3083
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 || '',
|
|
3084
3391
|
nickname: health.nickname || '',
|
|
3085
3392
|
userId: health.userId || 0,
|
|
3086
3393
|
error: health.error || '',
|
|
@@ -3488,6 +3795,8 @@ export const __internals = {
|
|
|
3488
3795
|
procRoot,
|
|
3489
3796
|
probe,
|
|
3490
3797
|
probeProcs,
|
|
3798
|
+
portClients,
|
|
3799
|
+
PROBE_MODES,
|
|
3491
3800
|
qrInfo,
|
|
3492
3801
|
bridgeAge,
|
|
3493
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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"smoke:client": "node scripts/smoke-client.mjs",
|
|
26
26
|
"selftest:scan": "node scripts/selftest-scan.mjs",
|
|
27
27
|
"selftest:save": "node scripts/selftest-save.mjs",
|
|
28
|
+
"selftest:probe": "node scripts/selftest-probe.mjs",
|
|
28
29
|
"selftest:setup": "node scripts/selftest-setup.mjs"
|
|
29
30
|
},
|
|
30
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)
|
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;
|