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 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 11 HTTP 端口 |
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.7.3` | ✅(指定版本) |
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.7.3`)。想跑命令行的话,得在终端里跑,
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 里填的一致', 'password'],
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
- 在 ? h('span', null, String(runtime.nickname || '') + '(' + String(runtime.userId || '') + ')') : null,
184
- h('span', { style: S.dim }, '阶段:' + String(runtime.phase || '?')),
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.7.3(可发布通用版)
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.7.3'
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
- resolve(JSON.parse(buf))
896
- } catch {
897
- resolve({ retcode: -1, raw: String(buf).slice(0, 200) })
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) => resolve({ retcode: -1, error: String((error && error.message) || 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
- return {
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: online ? '' : String((login && (login.error || login.raw || login.wording)) || 'no response'),
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') log(cfg, `ONLINE: ${health.nickname} (${health.userId})`)
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
- if (since < cfg.launchGraceSec * 1000) {
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
- : qr && qr.ageSec <= cfg.qrFreshSec
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.7.3",
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)
@@ -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;