dsh-napcat-keeper 2.7.3 → 2.8.1

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/lib/index.js CHANGED
@@ -1,9 +1,33 @@
1
1
  /**
2
- * dsh-napcat-keeper —— NapCat 看门狗 v2.7.3(可发布通用版)
2
+ * dsh-napcat-keeper —— NapCat 看门狗 v2.8.1(可发布通用版)
3
3
  *
4
4
  * 解决的问题:DSH 是桌面应用,重启 DSH 时 NapCat(QQ 协议端) 不会跟着起来,
5
5
  * 于是 dsh-qq-bridge 一直轮询一个死端口,你在 QQ 里说的话就石沉大海。
6
6
  *
7
+ * v2.8.1:认得出两个共存插件,端口不用自己抄
8
+ * - 读 dsh-onebot-qq 的配置(profile 的 package.json、cordis.patch.yml / cordis.yml),
9
+ * 得出「装没装 / 挂没挂 / 反向还是正向 / 监听哪个端口 / 令牌配没配」;
10
+ * 你填的端口不通、它确实挂着反向 WS 时自动跟随(companionFollow,默认开),
11
+ * 你填的端口一通就绝不插手;没挂进 profile 或它是正向模式则不跟,并说明为什么;
12
+ * - 读 dsh-qq-bridge 的 ~/.dsh/qq-bridge-config.json,把它填的 NapCat 地址与本插件
13
+ * 看到的实际情况交叉比对,五种结论(没挂载 / 连了反向 WS 端口 / 端口对不上 /
14
+ * 总开关关着 / 试运行中)写进设置页「桥接插件」行与 napcat_status;只看配置,
15
+ * 不碰它的进程和文件;
16
+ * - 两个插件共用一套 profileHits 挂载判定;napcat_status.companion 改成
17
+ * { onebot, bridge }(2.8.0 那份单值结构没发出去,直接换掉);
18
+ * - 「跟随」那一行跟着探测结果走:dsh-onebot-qq 没装 / 没挂载就整行不画、值按关算,
19
+ * 挂进 profile 才出现且默认开;用户显式关过就一直关,升级重启不会掰回开;
20
+ * 设置页保存改成只发有变化的键,saveAndApply 也挡住未启用时的 companionFollow 落盘。
21
+ *
22
+ * v2.8.0:认得出反向 WebSocket(和 dsh-onebot-qq 共存)
23
+ * - NapCat 走反向 WS 时端口是别人在听(dsh-onebot-qq 之类),HTTP 换来 401/426;
24
+ * postOneBot 原先只看 body 不看状态码,于是把「端口在回话」当成「端口不通」
25
+ * → 判卡死 → 杀掉重拉 → 无限循环;
26
+ * - 现在 probe() 分出 http / reverse / auth / process / dead,反向模式下「在线」=
27
+ * 端口上有客户端连进来(Get-NetTCPConnection -RemotePort 数),进程在 + 端口有服务
28
+ * 就不杀;真等不来客户端也只温和重拉两次,之后给提示而不是继续杀;
29
+ * - 新增 probeMode(auto / http / reverse / process);拉起连续失败时宽限期按 2 的幂退避。
30
+ *
7
31
  * v2.7.3:设置页保存不再空转
8
32
  * - 2.7.0 把配置文件收进 keeperDir 时只改了「读」,saveAndApply / clearPassword
9
33
  * 还在写 ~/.dsh/config.json,于是点保存 / 点「用这个」之后值立刻弹回原样;
@@ -138,7 +162,7 @@ import { homedir, tmpdir } from 'node:os'
138
162
  import path from 'node:path'
139
163
 
140
164
  export const name = 'napcat-keeper'
141
- export const version = '2.7.3'
165
+ export const version = '2.8.1'
142
166
 
143
167
  /** tools 用来注册 napcat_* 工具;webServer 用来挂设置界面接口。拿不到也不至于把 DSH 拖崩。 */
144
168
  export const inject = ['tools', 'webServer']
@@ -171,6 +195,20 @@ const DEFAULTS = {
171
195
  onebotPort: 3000,
172
196
  /** OneBot access token,没有就留空 */
173
197
  accessToken: '',
198
+ /**
199
+ * 怎么判断「NapCat 在线」。
200
+ * auto 先当 HTTP 接口问,问不通再看端口是不是反向 WebSocket(默认,两边都能用)
201
+ * http 只认 HTTP OneBot 接口
202
+ * reverse 只认反向 WebSocket:端口上有没有客户端连进来(配合 dsh-onebot-qq 这类反向 WS 服务)
203
+ * process 只看进程在不在,不碰端口
204
+ */
205
+ probeMode: 'auto',
206
+ /**
207
+ * 跟随 dsh-onebot-qq:自己填的端口连不上、而机器上挂着的 dsh-onebot-qq 是反向 WS 模式时,
208
+ * 自动把探测指向它监听的那个端口(用户显式填的端口一旦通了就还是用用户的)。
209
+ * 默认开 —— 这正是「装了反向 WS 插件却一直判离线」那个坑的解药。
210
+ */
211
+ companionFollow: true,
174
212
  /** 只在这些路径下找 QQ/NapCat 进程;留空 = 由 shellDir 推导(上层目录) */
175
213
  procPathMatch: '',
176
214
  /** 复查间隔(秒) */
@@ -231,12 +269,16 @@ const STATE_KEY = Symbol.for('dsh.napcat-keeper.state')
231
269
  const state =
232
270
  globalThis[STATE_KEY] ||
233
271
  (globalThis[STATE_KEY] = {
234
- phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | offline
272
+ phase: 'idle', // idle | online | launching | starting | need-scan | need-scan-giveup | reverse-wait | reverse-giveup | need-token | port-busy | offline
235
273
  lastProbeAt: null,
236
274
  lastOnlineAt: 0,
237
275
  lastLaunchAt: 0,
238
276
  lastError: '',
239
277
  launchCount: 0,
278
+ /** 连续拉起几次都没等到上线:宽限期按 2 的幂放大,免得每 45 秒杀一次 */
279
+ launchFails: 0,
280
+ /** 反向 WS 模式下「温和重拉」的次数,用完就认命给提示,不再折腾进程 */
281
+ reverseRetries: 0,
240
282
  dedupeCount: 0,
241
283
  scanSince: 0,
242
284
  scanRetries: 0,
@@ -764,6 +806,10 @@ function normalizeConfig(cfg) {
764
806
  cfg.launchAccount = String(cfg.launchAccount || '').trim().replace(/\D/g, '')
765
807
  cfg.launchArgs = String(cfg.launchArgs || '').trim()
766
808
  cfg.accessToken = String(cfg.accessToken || '').trim()
809
+ cfg.probeMode = PROBE_MODES.has(String(cfg.probeMode || '').trim().toLowerCase())
810
+ ? String(cfg.probeMode).trim().toLowerCase()
811
+ : 'auto'
812
+ cfg.companionFollow = cfg.companionFollow !== false && cfg.companionFollow !== 'false'
767
813
  cfg.procPathMatch = String(cfg.procPathMatch || '').trim()
768
814
  cfg.taskName = String(cfg.taskName || '').trim()
769
815
  cfg.onebotPort = clampNum(cfg.onebotPort, 1, 65535, 3000)
@@ -792,8 +838,23 @@ function resolveConfig(raw) {
792
838
  // 老版本的东西摊在 ~/.dsh 根上,先搬进 dir 再读配置(否则读的是搬走前的那份)
793
839
  migrateLegacyFiles(dir)
794
840
  // 优先级:内置默认 < cordis 里的 config < 设置界面存下来的 json
795
- const cfg = { ...DEFAULTS, ...(raw || {}), ...readConfigFile(dir) }
841
+ const inline = raw || {}
842
+ const stored = readConfigFile(dir)
843
+ // 用户(或本插件自己)显式写过 companionFollow 吗?写过就尊重它,没写过才轮到动态默认值
844
+ const followSet =
845
+ Object.prototype.hasOwnProperty.call(stored, 'companionFollow') ||
846
+ Object.prototype.hasOwnProperty.call(inline, 'companionFollow')
847
+ const cfg = { ...DEFAULTS, ...inline, ...stored }
796
848
  normalizeConfig(cfg)
849
+ // 跟随 dsh-onebot-qq:装了且挂进 profile(= 启用)才算数,可用时默认开;
850
+ // 没装或没启用直接按关算 —— 设置页也据此不渲染这一行。
851
+ // companionFollowWant 存「你自己想要的值」(显式关过就是 false,没写过就是默认 true),
852
+ // 真正的 cfg.companionFollow 由「可用吗」压一道闸;这样对面后来才挂上 / 又摘掉时,
853
+ // 不用重启也能算对,而且不会把显式关掉的选择弄丢。
854
+ cfg.companionFollowWant = followSet ? cfg.companionFollow !== false : true
855
+ const comp = detectCompanion()
856
+ cfg.companionReady = companionReady(comp)
857
+ cfg.companionFollow = cfg.companionReady ? cfg.companionFollowWant : false
797
858
  // v2.2.2:shellDir 不再自动探测/自动填,空着就等用户在设置页里扫盘选或者手填
798
859
  if (!cfg.launcherScript) cfg.launcherScript = detectLauncher(cfg.shellDir, cfg.launchAccount)
799
860
  if (!cfg.logPath) cfg.logPath = path.join(dir, 'keeper.log')
@@ -865,7 +926,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
865
926
  /* OneBot */
866
927
  /* ------------------------------------------------------------------ */
867
928
 
868
- function postOneBot(cfg, action, payload, timeoutMs = 4000) {
929
+ /**
930
+ * 发一个 OneBot HTTP 动作。
931
+ *
932
+ * 返回值里带上 HTTP 状态码:反向 WebSocket 模式下(比如 dsh-onebot-qq 占着这个端口)
933
+ * 对方根本不会回 OneBot 的 JSON,能拿到的唯一线索就是 401 / 426 这类状态码 ——
934
+ * 老版本把状态码丢了,于是一律当成「端口不通」,接着就是杀掉重拉,无限循环。
935
+ */
936
+ function postOneBot(cfg, action, payload, timeoutMs = 4000, port = 0) {
869
937
  return new Promise((resolve) => {
870
938
  const body = JSON.stringify(payload || {})
871
939
  const headers = {
@@ -878,57 +946,677 @@ function postOneBot(cfg, action, payload, timeoutMs = 4000) {
878
946
  req = http.request(
879
947
  {
880
948
  host: '127.0.0.1',
881
- port: cfg.onebotPort,
949
+ port: Number(port) || cfg.onebotPort,
882
950
  path: `/${action}`,
883
951
  method: 'POST',
884
952
  headers,
885
953
  timeout: timeoutMs,
886
954
  },
887
955
  (res) => {
956
+ const status = Number(res.statusCode) || 0
888
957
  let buf = ''
889
958
  res.setEncoding('utf8')
890
959
  res.on('data', (c) => {
891
960
  buf += c
892
961
  })
893
962
  res.on('end', () => {
963
+ const raw = String(buf).slice(0, 200)
964
+ let parsed = null
894
965
  try {
895
- resolve(JSON.parse(buf))
896
- } catch {
897
- resolve({ retcode: -1, raw: String(buf).slice(0, 200) })
966
+ parsed = JSON.parse(buf)
967
+ } catch {}
968
+ if (parsed && typeof parsed === 'object') {
969
+ resolve({ ...parsed, status })
970
+ return
898
971
  }
972
+ resolve({ retcode: -1, status, raw })
899
973
  })
900
974
  },
901
975
  )
902
976
  } catch (error) {
903
- return resolve({ retcode: -1, error: String((error && error.message) || error) })
977
+ return resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
904
978
  }
905
979
  req.on('timeout', () => {
906
980
  try {
907
981
  req.destroy(new Error('timeout'))
908
982
  } catch {}
909
- resolve({ retcode: -1, error: 'timeout' })
983
+ resolve({ retcode: -1, status: 0, error: 'timeout' })
910
984
  })
911
- req.on('error', (error) => resolve({ retcode: -1, error: String((error && error.message) || error) }))
985
+ req.on('error', (error) =>
986
+ resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) }),
987
+ )
912
988
  try {
913
989
  req.write(body)
914
990
  req.end()
915
991
  } catch (error) {
916
- resolve({ retcode: -1, error: String((error && error.message) || error) })
992
+ resolve({ retcode: -1, status: 0, error: String((error && error.message) || error) })
917
993
  }
918
994
  })
919
995
  }
920
996
 
921
- async function probe(cfg) {
922
- const login = await postOneBot(cfg, 'get_login_info')
997
+ /* ------------------------------------------------------------------ */
998
+ /* 和 dsh-onebot-qq 共存:认得出对面那个反向 WebSocket 服务 */
999
+ /* */
1000
+ /* 反向 WS 部署里端口是「别人」在听(最常见就是 dsh-onebot-qq),NapCat */
1001
+ /* 反过来连进去。这种部署下用户得自己把两边的端口/令牌抄成一样,抄错就表现 */
1002
+ /* 成「永远离线 + 反复重启」。 */
1003
+ /* 那两样东西其实就在本机的 profile 里,读得出来: */
1004
+ /* <DSH_HOME>/profiles/<profile>/package.json 装了没 / 挂进树没 */
1005
+ /* <DSH_HOME>/profiles/<profile>/cordis.patch.yml 它自己的 config */
1006
+ /* 读出来以后:用户自己填的端口优先,填的那个不通再跟随它的端口;令牌对不上 */
1007
+ /* 就直接说清楚。 */
1008
+ /* */
1009
+ /* 铁律:只读文件、只发 HTTP 探测。绝不对它做 WebSocket 握手 —— */
1010
+ /* dsh-onebot-qq 是 last-wins,真握手会把 NapCat 那条连接顶掉。 */
1011
+ /* ------------------------------------------------------------------ */
1012
+
1013
+ const COMPANION_PACKAGE = 'dsh-onebot-qq'
1014
+ /** 它在 cordis 树里的插件 id(patch 里 insert 的是这个,不是包名) */
1015
+ const COMPANION_ID = 'dsh-onebot'
1016
+ /** dsh-onebot-qq lib/index.js:67-77 那份 Config 的默认值;读不到配置就用它 */
1017
+ const COMPANION_DEFAULTS = {
1018
+ mode: 'reverse',
1019
+ host: '127.0.0.1',
1020
+ port: 8643,
1021
+ url: 'ws://127.0.0.1:3001',
1022
+ accessToken: '',
1023
+ }
1024
+
1025
+ const companionCache = { at: 0, value: null }
1026
+
1027
+ /** 读 JSON,读不出来就 null(profile 里的文件可能正被编辑,不能崩) */
1028
+ function readJsonQuiet(file) {
1029
+ try {
1030
+ const parsed = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''))
1031
+ return parsed && typeof parsed === 'object' ? parsed : null
1032
+ } catch {
1033
+ return null
1034
+ }
1035
+ }
1036
+
1037
+ /** YAML 标量:去引号、去行尾注释(` #` 之后才算注释,URL 里的 // 不算)、`!!js` 认不出就当空 */
1038
+ function yamlScalar(raw) {
1039
+ let text = String(raw == null ? '' : raw).trim()
1040
+ if (!text) return ''
1041
+ if (text.startsWith('!!js')) return ''
1042
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(text)
1043
+ if (quoted) return quoted[2]
1044
+ const hash = text.search(/\s#/)
1045
+ if (hash >= 0) text = text.slice(0, hash).trim()
1046
+ if (text === '~' || text === 'null') return ''
1047
+ // 纯整数当数字(端口这类),别的原样留着
1048
+ if (/^-?\d+$/.test(text)) return Number(text)
1049
+ return text
1050
+ }
1051
+
1052
+ /** 行内 `{ a: 1, b: c }` → [[a,'1'],[b,'c']](不处理嵌套,够用了) */
1053
+ function inlinePairs(text) {
1054
+ const pairs = []
1055
+ const body = String(text || '').trim().replace(/^\{/, '').replace(/\}$/, '')
1056
+ if (!body) return pairs
1057
+ for (const chunk of body.split(',')) {
1058
+ const m = /^\s*([A-Za-z_][\w-]*)\s*:\s*([\s\S]*)$/.exec(chunk)
1059
+ if (m) pairs.push([m[1], yamlScalar(m[2])])
1060
+ }
1061
+ return pairs
1062
+ }
1063
+
1064
+ /**
1065
+ * 从 cordis.patch.yml / cordis.yml 的文本里挖出 dsh-onebot 那一项的 config。
1066
+ *
1067
+ * 不引 YAML 库:这文件是我们自己读的,认不出的写法(`!!js` 表达式等)宁可当没有,
1068
+ * 也不要把半个值当真配置拿去用。
1069
+ */
1070
+ function parseCompanionConfig(text) {
1071
+ const out = {}
1072
+ const lines = String(text || '')
1073
+ .replace(/^\uFEFF/, '')
1074
+ .split(/\r?\n/)
1075
+ const isEntryLine = (body) => /(^|[{,\s])id:\s*['"]?dsh-onebot['"]?(\s|,|}|$)/.test(body)
1076
+ let inEntry = false
1077
+ let entryIndent = 0
1078
+ let configIndent = -1
1079
+ for (const line of lines) {
1080
+ if (!line.trim() || /^\s*#/.test(line)) continue
1081
+ const indent = line.length - line.trimStart().length
1082
+ if (/^\s*-/.test(line)) {
1083
+ const body = line.replace(/^\s*-\s*/, '')
1084
+ if (isEntryLine(body)) {
1085
+ inEntry = true
1086
+ entryIndent = indent
1087
+ configIndent = -1
1088
+ const cm = /config:\s*(\{[\s\S]*\})\s*$/.exec(body)
1089
+ if (cm) {
1090
+ for (const [k, v] of inlinePairs(cm[1])) if (v !== '') out[k] = v
1091
+ }
1092
+ continue
1093
+ }
1094
+ // 同级的下一条 insert 项:上一条结束了
1095
+ if (indent <= entryIndent) {
1096
+ inEntry = false
1097
+ configIndent = -1
1098
+ }
1099
+ continue
1100
+ }
1101
+ if (!inEntry) continue
1102
+ if (indent <= entryIndent) {
1103
+ inEntry = false
1104
+ configIndent = -1
1105
+ continue
1106
+ }
1107
+ if (/^\s*config:\s*$/.test(line)) {
1108
+ configIndent = indent
1109
+ continue
1110
+ }
1111
+ const inline = /^\s*config:\s*(\{[\s\S]*\})\s*$/.exec(line)
1112
+ if (inline) {
1113
+ for (const [k, v] of inlinePairs(inline[1])) if (v !== '') out[k] = v
1114
+ configIndent = -1
1115
+ continue
1116
+ }
1117
+ if (configIndent >= 0 && indent > configIndent) {
1118
+ const m = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
1119
+ if (m) {
1120
+ const value = yamlScalar(m[2])
1121
+ if (value !== '') out[m[1]] = value
1122
+ }
1123
+ }
1124
+ }
1125
+ return out
1126
+ }
1127
+
1128
+ /**
1129
+ * 扫一遍 `~/.dsh/profiles/*`,找出「装了这个包」的 profile。
1130
+ *
1131
+ * 两个共存插件(dsh-onebot-qq / dsh-qq-bridge)用的是同一套判断:
1132
+ * - 装了 = 它的 package.json 在 node_modules 里,或者写在 profile 的 dependencies 里;
1133
+ * - 挂了 = 出现在 `dsh.profile.bundles` 里,或者被 `cordis.patch.yml` / `cordis.yml`
1134
+ * 单独 insert 过(认 `id: xxx`)。只装在 dependencies 里而没进 bundles 的,DSH 不会加载它,
1135
+ * 端口/路由都不会起来 —— 这个区别很要紧,不能混成一句「已安装」。
1136
+ *
1137
+ * 正在跑的那个 profile(desktop)排最前:多 profile 并存时优先信它。
1138
+ */
1139
+ function profileHits(pkgName, bundleId) {
1140
+ const root = path.join(dshHome(), 'profiles')
1141
+ let dirs = []
1142
+ try {
1143
+ dirs = readdirSync(root, { withFileTypes: true })
1144
+ .filter((d) => d.isDirectory())
1145
+ .map((d) => path.join(root, d.name))
1146
+ } catch {
1147
+ dirs = []
1148
+ }
1149
+ const current = path.join(root, 'desktop')
1150
+ dirs.sort((a, b) => (a === current ? -1 : b === current ? 1 : a.localeCompare(b)))
1151
+
1152
+ const idRe = new RegExp(`(^|[{,\\s])id:\\s*['"]?${bundleId}['"]?(\\s|,|}|$)`)
1153
+ const hits = []
1154
+ for (const dir of dirs) {
1155
+ const pkg = readJsonQuiet(path.join(dir, 'package.json'))
1156
+ const deps = (pkg && pkg.dependencies) || {}
1157
+ const meta = readJsonQuiet(path.join(dir, 'node_modules', pkgName, 'package.json'))
1158
+ if (!meta && !Object.prototype.hasOwnProperty.call(deps, pkgName)) continue
1159
+
1160
+ const bundles = (pkg && pkg.dsh && pkg.dsh.profile && pkg.dsh.profile.bundles) || []
1161
+ const patches = []
1162
+ for (const name of ['cordis.patch.yml', 'cordis.yml']) {
1163
+ const file = path.join(dir, name)
1164
+ let text = ''
1165
+ try {
1166
+ text = readFileSync(file, 'utf8')
1167
+ } catch {
1168
+ continue
1169
+ }
1170
+ if (text.includes(pkgName) || idRe.test(text)) patches.push({ name, text })
1171
+ }
1172
+ hits.push({
1173
+ dir,
1174
+ profile: path.basename(dir),
1175
+ version: String((meta && meta.version) || deps[pkgName] || ''),
1176
+ bundles,
1177
+ patches,
1178
+ mounted: bundles.includes(pkgName) || patches.length > 0,
1179
+ })
1180
+ }
1181
+ return hits
1182
+ }
1183
+
1184
+ /** 它装没装、挂没挂、自己配成什么样(20 秒缓存;改了 profile 也不用等太久) */
1185
+ function detectCompanion(options = {}) {
1186
+ const force = Boolean(options && options.force)
1187
+ const maxAgeMs = Number.isFinite(Number(options && options.maxAgeMs))
1188
+ ? Math.max(0, Number(options.maxAgeMs))
1189
+ : 20000
1190
+ if (!force && companionCache.value && Date.now() - companionCache.at < maxAgeMs) {
1191
+ return companionCache.value
1192
+ }
1193
+ const result = {
1194
+ found: false,
1195
+ packageVersion: '',
1196
+ profile: '',
1197
+ mounted: false,
1198
+ mode: '',
1199
+ host: '',
1200
+ port: 0,
1201
+ url: '',
1202
+ /** 只有能读到明文时才有值;界面上永远不外发,只回「配没配、一致不」 */
1203
+ token: '',
1204
+ tokenKnown: false,
1205
+ source: '',
1206
+ candidates: [],
1207
+ }
1208
+ for (const hit of profileHits(COMPANION_PACKAGE, COMPANION_ID)) {
1209
+ const patchConfig = {}
1210
+ for (const p of hit.patches) Object.assign(patchConfig, parseCompanionConfig(p.text))
1211
+
1212
+ const cfg = { ...COMPANION_DEFAULTS, ...patchConfig }
1213
+ const candidate = {
1214
+ found: true,
1215
+ packageVersion: hit.version,
1216
+ profile: hit.dir,
1217
+ mounted: hit.mounted,
1218
+ mode: String(cfg.mode || '').toLowerCase() === 'forward' ? 'forward' : 'reverse',
1219
+ host: String(cfg.host || '').trim() || COMPANION_DEFAULTS.host,
1220
+ port: Number(cfg.port) || COMPANION_DEFAULTS.port,
1221
+ url: String(cfg.url || '').trim() || COMPANION_DEFAULTS.url,
1222
+ token: String(cfg.accessToken || ''),
1223
+ tokenKnown: Object.prototype.hasOwnProperty.call(patchConfig, 'accessToken'),
1224
+ source: Object.keys(patchConfig).length ? 'patch' : 'default',
1225
+ }
1226
+ result.candidates.push(candidate)
1227
+ if (!result.found || (candidate.mounted && !result.mounted)) Object.assign(result, candidate)
1228
+ if (candidate.mounted) break
1229
+ }
1230
+ companionCache.at = Date.now()
1231
+ companionCache.value = result
1232
+ return result
1233
+ }
1234
+
1235
+ /**
1236
+ * 「跟随 dsh-onebot-qq」能不能开:装了不算数,得真挂进 profile(bundles 或 cordis patch)。
1237
+ * 只在 dependencies 里躺着的时候它根本不会起,跟着它去看端口只会白等。
1238
+ * 两种入参都吃:detectCompanion() 的完整结果(带 found),以及 companionPublic() 的瘦身版(没有 found,只带 version/profile)。
1239
+ */
1240
+ function companionReady(comp) {
1241
+ if (!comp) return false
1242
+ const found = Object.prototype.hasOwnProperty.call(comp, 'found')
1243
+ ? Boolean(comp.found)
1244
+ : Boolean(comp.version || comp.profile)
1245
+ return found && Boolean(comp.mounted)
1246
+ }
1247
+
1248
+ /** 给界面看的那部分(token / tokenKnown 之外的敏感信息一律不出这个函数) */
1249
+ function companionPublic(comp) {
1250
+ if (!comp || !comp.found) return null
1251
+ return {
1252
+ version: comp.packageVersion,
1253
+ profile: path.basename(String(comp.profile || '')),
1254
+ mounted: Boolean(comp.mounted),
1255
+ mode: comp.mode,
1256
+ host: comp.host,
1257
+ port: comp.port,
1258
+ url: comp.url,
1259
+ tokenKnown: Boolean(comp.tokenKnown),
1260
+ source: comp.source,
1261
+ }
1262
+ }
1263
+
1264
+ /** 两边的 accessToken 一样吗?读不到对面的就返回 null(未知 ≠ 不一致) */
1265
+ function companionTokenMatch(cfg, comp) {
1266
+ if (!comp || !comp.tokenKnown) return null
1267
+ return String(cfg.accessToken || '') === String(comp.token || '')
1268
+ }
1269
+
1270
+ /* ------------------------------------------------------------------ *
1271
+ * 第二个共存插件:dsh-qq-bridge(把 NapCat 的消息接到 QQ 聊天里)
1272
+ *
1273
+ * 它和 dsh-onebot-qq 是两种东西:onebot 提供「接口」,qq-bridge 是「客户端」——
1274
+ * 它拿 HTTP/WS 去连 NapCat,所以两边端口必须对上,对不上就静默不工作。
1275
+ * 它的配置不在 profile 里,摊在 ~/.dsh/qq-bridge-config.json(中文键名)。
1276
+ * ------------------------------------------------------------------ */
1277
+
1278
+ const BRIDGE_PACKAGE = 'dsh-qq-bridge'
1279
+ const BRIDGE_ID = 'qq-bridge'
1280
+ const BRIDGE_DEFAULTS = { napcat: 'http://127.0.0.1:3000', enabled: false, dryRun: true }
1281
+ const bridgeCache = { at: 0, value: null }
1282
+
1283
+ /** 从 OneBot 地址里抠端口(认不出就 0,不要瞎猜) */
1284
+ function urlPort(raw) {
1285
+ try {
1286
+ const u = new URL(String(raw || ''))
1287
+ if (u.port) return Number(u.port)
1288
+ return u.protocol === 'https:' ? 443 : 80
1289
+ } catch {
1290
+ return 0
1291
+ }
1292
+ }
1293
+
1294
+ /** 读它自己的配置文件;读不到就当「不知道」,别当成「没配」 */
1295
+ function readBridgeConfig() {
1296
+ const file = path.join(dshHome(), 'qq-bridge-config.json')
1297
+ const json = readJsonQuiet(file)
1298
+ return { file, known: Boolean(json && typeof json === 'object'), cfg: json || null }
1299
+ }
1300
+
1301
+ /** 它装没装、挂没挂、桥接指向哪个端口(20 秒缓存) */
1302
+ function detectBridge(options = {}) {
1303
+ const force = Boolean(options && options.force)
1304
+ const maxAgeMs = Number.isFinite(Number(options && options.maxAgeMs))
1305
+ ? Math.max(0, Number(options.maxAgeMs))
1306
+ : 20000
1307
+ if (!force && bridgeCache.value && Date.now() - bridgeCache.at < maxAgeMs) return bridgeCache.value
1308
+
1309
+ const hits = profileHits(BRIDGE_PACKAGE, BRIDGE_ID)
1310
+ const top = hits.find((h) => h.mounted) || hits[0] || null
1311
+ const rc = readBridgeConfig()
1312
+ const cfg = rc.cfg || {}
1313
+ const napcatUrl = String(cfg.NapCat || '').trim() || BRIDGE_DEFAULTS.napcat
1314
+
1315
+ const result = {
1316
+ found: Boolean(top),
1317
+ packageVersion: String((top && top.version) || ''),
1318
+ profile: String((top && top.profile) || ''),
1319
+ mounted: Boolean(top && top.mounted),
1320
+ configFile: rc.file,
1321
+ configKnown: rc.known,
1322
+ enabled: rc.known ? cfg.总开关 === true : BRIDGE_DEFAULTS.enabled,
1323
+ dryRun: rc.known ? cfg.试运行 !== false : BRIDGE_DEFAULTS.dryRun,
1324
+ napcatUrl,
1325
+ napcatPort: urlPort(napcatUrl),
1326
+ tokenKnown: rc.known ? String(cfg.令牌 || '').trim() !== '' : false,
1327
+ bindings: rc.known && Array.isArray(cfg.绑定) ? cfg.绑定.length : 0,
1328
+ candidates: hits.map((h) => ({ profile: h.profile, mounted: h.mounted, version: h.version })),
1329
+ }
1330
+ bridgeCache.at = Date.now()
1331
+ bridgeCache.value = result
1332
+ return result
1333
+ }
1334
+
1335
+ /** 给界面/工具看的那部分(令牌只回「配没配」,不回来路不明的明文) */
1336
+ function bridgePublic(bridge) {
1337
+ if (!bridge || !bridge.found) return null
1338
+ return {
1339
+ version: bridge.packageVersion,
1340
+ profile: bridge.profile,
1341
+ mounted: bridge.mounted,
1342
+ enabled: bridge.enabled,
1343
+ dryRun: bridge.dryRun,
1344
+ napcatUrl: bridge.napcatUrl,
1345
+ napcatPort: bridge.napcatPort,
1346
+ tokenKnown: bridge.tokenKnown,
1347
+ bindings: bridge.bindings,
1348
+ configKnown: bridge.configKnown,
1349
+ }
1350
+ }
1351
+
1352
+ /**
1353
+ * 拿看护的探测结果和桥接配置对一遍:谁的端口指错了、谁的开关没开。
1354
+ *
1355
+ * 顺序按「改起来最省事、后果最严重」排:
1356
+ * 没挂进 profile → 根本没跑,什么都别谈
1357
+ * 指着反向 WS 端口 → HTTP 客户端敲 WebSocket 端口,永远没响应
1358
+ * 端口对不上 → 敲的是空气
1359
+ * 总开关关 / 试运行 → 程序在跑,但一句都不发
1360
+ */
1361
+ function bridgeCheck(health, bridge) {
1362
+ if (!bridge || !bridge.found) return { state: 'absent', note: '' }
1363
+ const portUsed = Number((health && health.portUsed) || 0)
1364
+ const bp = Number(bridge.napcatPort || 0)
1365
+ const reverse = String((health && health.mode) || '') === 'reverse'
1366
+
1367
+ let state = 'ok'
1368
+ let note = ''
1369
+ if (!bridge.mounted) {
1370
+ state = 'not-mounted'
1371
+ note = 'dsh-qq-bridge 装了但没挂进 profile(bundles 里没有它、patch 也没 insert),桥接不会跑'
1372
+ } else if (reverse && bp && portUsed && bp === portUsed) {
1373
+ state = 'bridge-hits-reverse'
1374
+ note = `dsh-qq-bridge 连的是 ${bridge.napcatUrl},而那个端口现在是反向 WebSocket(只收 WebSocket 握手)—— 桥接用 HTTP 敲它就是 426/401。让它改连 NapCat 自己 httpServers 的端口`
1375
+ } else if (!reverse && bp && portUsed && bp !== portUsed) {
1376
+ state = 'port-mismatch'
1377
+ note = `dsh-qq-bridge 连的是 ${bridge.napcatUrl}(端口 ${bp}),NapCat 的 OneBot 接口在 ${portUsed} —— 两边对不上,消息发不出去`
1378
+ } else if (bridge.configKnown && !bridge.enabled) {
1379
+ state = 'disabled'
1380
+ note = 'dsh-qq-bridge 的「总开关」是关的:它在跑,但一句都不会发'
1381
+ } else if (bridge.configKnown && bridge.dryRun) {
1382
+ state = 'dry-run'
1383
+ note = 'dsh-qq-bridge 开着「试运行」:只记账,不真发到 QQ'
1384
+ }
1385
+ return { state, note, bridgePort: bp, portUsed, reverse }
1386
+ }
1387
+
1388
+ const PROBE_MODES = new Set(['auto', 'http', 'reverse', 'process'])
1389
+
1390
+ /** portClients 的缓存:设置页轮询很密,别每 1.5 秒起一个 powershell */
1391
+ const linksCache = { port: 0, at: 0, value: null, inflight: null }
1392
+
1393
+ /**
1394
+ * 这个端口上现在挂着几个「已经连进来」的客户端?
1395
+ *
1396
+ * 反向 WebSocket 的形态是:端口由 DSH 这边的插件监听(常见是 dsh-onebot-qq),
1397
+ * NapCat 反过来连进去。这种部署下没有任何 HTTP 接口能问「你在线吗」,
1398
+ * 但有件事可以从系统侧看见:这个端口上有没有一条 ESTABLISHED 的回环连接 ——
1399
+ * 有,就说明对面真的连上了。
1400
+ *
1401
+ * 查不出来就返回 count: null。「查不到」和「没连上」是两回事,
1402
+ * 混成一个就会又变成瞎判离线、瞎杀进程。
1403
+ */
1404
+ async function portClients(cfg, port = 0, maxAgeMs = 5000) {
1405
+ const p = Number(port) || Number(cfg.onebotPort) || 0
1406
+ if (!p) return { count: null, pids: [], source: 'no-port' }
1407
+ // 设置页 1.5s 轮询 /state,而这个函数要新开一个 powershell:
1408
+ // 缓存 + 同刻只允许一个子进程,否则又是一场 PowerShell 风暴(见 uiCache 那段注释)
1409
+ if (linksCache.port === p && linksCache.value && Date.now() - linksCache.at < maxAgeMs) {
1410
+ return linksCache.value
1411
+ }
1412
+ if (linksCache.port === p && linksCache.inflight) return linksCache.inflight
1413
+ linksCache.port = p
1414
+ linksCache.inflight = (async () => {
1415
+ const self = String(process.pid)
1416
+ // 看的是「谁连进了这个端口」,所以按 RemotePort 找客户端那一侧:
1417
+ // 按 LocalPort 找会命中服务端那条 socket,而它的所有者是跑反向 WS 服务的进程(就是 DSH 自己),
1418
+ // 把它排除掉就一条都数不到了 —— 这个坑第一版就踩了。
1419
+ const script =
1420
+ "$ErrorActionPreference='SilentlyContinue';" +
1421
+ `$c=@(Get-NetTCPConnection -State Established -RemotePort ${p} -ErrorAction SilentlyContinue | ` +
1422
+ `Where-Object { [string]$_.OwningProcess -ne '${self}' });` +
1423
+ `'CLIENTS=' + $c.Count + ' PIDS=' + (($c | Select-Object -ExpandProperty OwningProcess -Unique) -join ',')`
1424
+ const out = await runPs(script, 12000)
1425
+ const m = /CLIENTS=(\d+)\s+PIDS=([0-9,]*)/.exec(String(out || ''))
1426
+ if (!m) return { count: null, pids: [], source: 'ps-fail' }
1427
+ const pids = m[2] ? m[2].split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0) : []
1428
+ return { count: Number(m[1]) || 0, pids, source: 'ps' }
1429
+ })()
1430
+ .then((value) => {
1431
+ linksCache.value = value
1432
+ linksCache.at = Date.now()
1433
+ return value
1434
+ })
1435
+ .finally(() => {
1436
+ linksCache.inflight = null
1437
+ })
1438
+ return linksCache.inflight
1439
+ }
1440
+
1441
+ /**
1442
+ * 问一句「NapCat 现在怎么样」,顺带分辨端口是被谁占着的。
1443
+ *
1444
+ * 返回的 mode 决定看门狗后面怎么走:
1445
+ * http 正常 HTTP OneBot 接口,能问到 login_info
1446
+ * reverse 端口只收 WebSocket(反向 WS 服务在跑,HTTP 换来 426/401)
1447
+ * auth 端口有响应但拒绝鉴权,多半是 accessToken 对不上
1448
+ * http 其它 HTTP 响应(不是 OneBot API 该有的样子)
1449
+ * dead 连不上,端口没开
1450
+ * process 配置里关了端口探测,只看进程
1451
+ */
1452
+ async function probeOnPort(cfg, port, want) {
1453
+ const login = await postOneBot(cfg, 'get_login_info', {}, 4000, port)
923
1454
  const data = login && login.data
1455
+ const status = Number(login && login.status) || 0
924
1456
  const online = Boolean(login && login.retcode === 0 && data && (data.user_id || data.uin))
925
- return {
1457
+ const portAlive = status > 0
1458
+ const result = {
926
1459
  online,
927
1460
  retcode: login && login.retcode,
1461
+ status,
1462
+ portAlive,
1463
+ mode: 'dead',
1464
+ clients: null,
928
1465
  nickname: online ? data.nickname : '',
929
1466
  userId: online ? data.user_id || data.uin : '',
930
- error: online ? '' : String((login && (login.error || login.raw || login.wording)) || 'no response'),
1467
+ error: '',
1468
+ hint: '',
1469
+ }
1470
+ if (online) {
1471
+ result.mode = 'http'
1472
+ return result
1473
+ }
1474
+ if (want === 'process') {
1475
+ result.mode = 'process'
1476
+ result.error = '按配置只看进程(probeMode=process)'
1477
+ return result
1478
+ }
1479
+ if (!portAlive) {
1480
+ result.error = String((login && (login.error || login.raw || login.wording)) || 'no response')
1481
+ result.hint = '端口连不上'
1482
+ return result
1483
+ }
1484
+ // 端口肯回 HTTP,说明有个服务在听 —— 这本身就意味着「进程是活的」,不该被杀
1485
+ const links = await portClients(cfg, port)
1486
+ result.clients = links.count
1487
+ if (want === 'reverse') {
1488
+ result.mode = 'reverse'
1489
+ } else if (status === 426) {
1490
+ // 426 = 服务端只收 WebSocket 升级。auto 直接判反向;设了「只认 HTTP」就老实说端口回的是 426
1491
+ result.mode = want === 'http' ? 'http' : 'reverse'
1492
+ } else if (status === 401) {
1493
+ // 反向 WS 服务对任何 HTTP 请求都回 401,NapCat 的 HTTP API 令牌不对也回 401,
1494
+ // 靠「有没有客户端连进来」区分这两件事
1495
+ result.mode = links.count !== null && links.count > 0 ? 'reverse' : 'auth'
1496
+ } else {
1497
+ result.mode = 'http'
931
1498
  }
1499
+ if (result.mode === 'reverse') {
1500
+ if (links.count === null) {
1501
+ result.hint = '反向 WebSocket 服务在跑(这个端口不收 HTTP),客户端数暂时查不到'
1502
+ } else if (links.count > 0) {
1503
+ result.online = true
1504
+ result.hint = `反向 WebSocket 已连上 ${links.count} 个客户端`
1505
+ } else {
1506
+ result.hint = '反向 WebSocket 服务在跑,但还没有客户端连进来'
1507
+ }
1508
+ result.error = result.online ? '' : result.hint
1509
+ return result
1510
+ }
1511
+ result.error = `端口有响应但 ${status === 401 ? '要求鉴权' : `回的是 HTTP ${status}`}`
1512
+ result.hint =
1513
+ status === 401
1514
+ ? 'HTTP 401:多半是 accessToken 和对面配的不一致;如果对面只开反向 WebSocket,把探测方式改成「反向 WebSocket」'
1515
+ : `HTTP ${status}:这个端口上跑的可能不是 OneBot 接口`
1516
+ return result
1517
+ }
1518
+
1519
+ /**
1520
+ * 探测「NapCat 现在在线吗」,并顺手处理「端口是别人在听」这件事。
1521
+ *
1522
+ * 顺序:
1523
+ * 1. 先探用户自己填的端口(onebotPort)—— 用户显式配置永远优先;
1524
+ * 2. 那个端口不通/不像 OneBot,而机器上挂着 dsh-onebot-qq(反向 WS),
1525
+ * 就去它的端口再问一次:那边只收 WebSocket,会回 426/401,但那已经能说明
1526
+ * 「服务在听、NapCat 有地方连」,这时把 portUsed 指过去(followed=true);
1527
+ * 3. 提示里带上令牌一致性 / 有没有真的挂载 —— 这两条是最常见的人为卡点。
1528
+ *
1529
+ * 所有分支都返回 portUsed(真正在看的端口)和 followed(是不是跟随了对面),
1530
+ * 看门狗和界面都拿这两个值说话,别再各自猜 cfg.onebotPort。
1531
+ */
1532
+ async function probe(cfg, options = {}) {
1533
+ const want = PROBE_MODES.has(cfg.probeMode) ? cfg.probeMode : 'auto'
1534
+ const ownPort = Number(cfg.onebotPort) || 0
1535
+ const comp =
1536
+ options && Object.prototype.hasOwnProperty.call(options, 'companion')
1537
+ ? options.companion
1538
+ : detectCompanion()
1539
+ const tokenMatch = companionTokenMatch(cfg, comp)
1540
+ // 桥接(dsh-qq-bridge)只是「顺便看一眼」,不参与「探哪个端口」的决策,所以可以注入桩
1541
+ const bridge =
1542
+ options && Object.prototype.hasOwnProperty.call(options, 'bridge') ? options.bridge : detectBridge()
1543
+ const wrap = (health, extra = {}) => {
1544
+ const out = {
1545
+ ...health,
1546
+ portUsed: health.portUsed || ownPort,
1547
+ followed: Boolean(extra.followed),
1548
+ primaryPort: ownPort,
1549
+ primaryMode: extra.primaryMode || health.mode,
1550
+ companion: companionPublic(comp),
1551
+ companionTokenMatch: tokenMatch,
1552
+ }
1553
+ const check = bridgeCheck(out, bridge)
1554
+ const pub = bridgePublic(bridge)
1555
+ // 提示文案统一挂在 bridge.note 上,不塞进 hint:现有自检对 hint 有精确断言,
1556
+ // 而且「桥接指错端口」和「NapCat 自己正不正常」是两件事,混在一句话里反而看不清。
1557
+ out.bridge = pub ? { ...pub, check: check.state, note: check.note } : null
1558
+ return out
1559
+ }
1560
+
1561
+ const first = await probeOnPort(cfg, ownPort, want)
1562
+ first.portUsed = ownPort
1563
+ if (first.online || want === 'process') return wrap(first)
1564
+
1565
+ const follow =
1566
+ cfg.companionFollow !== false &&
1567
+ comp &&
1568
+ comp.found &&
1569
+ comp.mounted &&
1570
+ comp.mode === 'reverse' &&
1571
+ Number(comp.port) > 0 &&
1572
+ Number(comp.port) !== ownPort
1573
+
1574
+ if (follow) {
1575
+ const alt = await probeOnPort(cfg, Number(comp.port), 'auto')
1576
+ alt.portUsed = Number(comp.port)
1577
+ if (alt.mode === 'reverse' || alt.online) {
1578
+ const hintBits = []
1579
+ if (alt.hint) hintBits.push(alt.hint)
1580
+ hintBits.push(
1581
+ `你填的 ${ownPort} 上没有 OneBot 服务,已跟随 dsh-onebot-qq 的反向 WS 端口 ${comp.port}`,
1582
+ )
1583
+ if (tokenMatch === false) {
1584
+ hintBits.push('⚠ 两边的 accessToken 不一致:NapCat 连上去会被 4401 拒绝,改成一样再试')
1585
+ } else if (comp.tokenKnown && tokenMatch === true && comp.token) {
1586
+ hintBits.push('accessToken 两边一致')
1587
+ }
1588
+ alt.hint = hintBits.join(';')
1589
+ if (alt.error) alt.error = alt.hint
1590
+ return wrap(alt, { followed: true, primaryMode: first.mode })
1591
+ }
1592
+ }
1593
+
1594
+ // 没跟上:把「为什么没跟上」讲清楚,别让人自己去猜
1595
+ const bits = []
1596
+ if (first.hint) bits.push(first.hint)
1597
+ if (comp && comp.found) {
1598
+ if (!comp.mounted) {
1599
+ bits.push(
1600
+ `检测到 ${COMPANION_PACKAGE} 已安装(${comp.packageVersion || '版本未知'})但没挂进 profile:` +
1601
+ 'bundles 里没有它、patch 里也没有 insert,所以它的反向 WS 端口根本不会开',
1602
+ )
1603
+ } else if (comp.mode === 'forward') {
1604
+ bits.push(
1605
+ `${COMPANION_PACKAGE} 现在是 forward 模式(主动连 ${comp.url}):此时端口该由 NapCat 自己开,` +
1606
+ '反向 WS 那条路用不上',
1607
+ )
1608
+ } else if (comp.port && comp.port !== ownPort && cfg.companionFollow === false) {
1609
+ bits.push(`${COMPANION_PACKAGE} 的反向 WS 在 ${comp.port},但「跟随 dsh-onebot-qq」关了`)
1610
+ }
1611
+ }
1612
+
1613
+ // 端口回 401 且我们有令牌:多半就是两边令牌不一致,这里直接点破
1614
+ if (first.mode === 'auth' && comp && comp.found && tokenMatch === false) {
1615
+ bits.push(`⚠ ${COMPANION_PACKAGE} 配的 accessToken 和本插件不一致(它的是 ${comp.token ? '另一串' : '空'}),这件事光重启 NapCat 解决不了`)
1616
+ }
1617
+ const merged = bits.filter(Boolean).join(';')
1618
+ if (merged) first.hint = merged
1619
+ return wrap(first, { primaryMode: first.mode })
932
1620
  }
933
1621
 
934
1622
  /* ------------------------------------------------------------------ */
@@ -2279,6 +2967,7 @@ function launchNapcat(cfg, reason = '') {
2279
2967
  writeLock(cfg)
2280
2968
  state.lastLaunchAt = Date.now()
2281
2969
  state.launchCount += 1
2970
+ state.launchFails += 1
2282
2971
  state.phase = 'launching'
2283
2972
  state.scanSince = 0
2284
2973
  state.lastError = ''
@@ -2336,6 +3025,7 @@ function launchNapcat(cfg, reason = '') {
2336
3025
  writeLock(cfg)
2337
3026
  state.lastLaunchAt = Date.now()
2338
3027
  state.launchCount += 1
3028
+ state.launchFails += 1
2339
3029
  state.phase = 'launching'
2340
3030
  state.scanSince = 0
2341
3031
  state.lastError = ''
@@ -2420,14 +3110,27 @@ async function tick(cfg) {
2420
3110
  state.cfg = cfg
2421
3111
  state.lastProbeAt = new Date().toISOString()
2422
3112
  const health = await probe(cfg)
3113
+ /** 真正在看的端口:可能跟随了 dsh-onebot-qq 的反向 WS 端口 */
3114
+ const portShown = health.portUsed || cfg.onebotPort
2423
3115
 
2424
3116
  if (health.online) {
2425
- if (state.phase !== 'online') log(cfg, `ONLINE: ${health.nickname} (${health.userId})`)
3117
+ if (state.phase !== 'online') {
3118
+ const who =
3119
+ health.nickname || health.userId
3120
+ ? `${health.nickname} (${health.userId})`
3121
+ : health.mode === 'reverse'
3122
+ ? '反向 WebSocket 已连接'
3123
+ : '已连接'
3124
+ log(cfg, `ONLINE${health.mode === 'reverse' ? '[reverse]' : ''}: ${who}`)
3125
+ }
2426
3126
  state.phase = 'online'
2427
3127
  state.lastOnlineAt = Date.now()
2428
3128
  state.lastError = ''
2429
3129
  state.scanSince = 0
2430
3130
  state.scanRetries = 0
3131
+ // 真连上了就把失败计数清掉,下次出事重新给足宽限
3132
+ state.launchFails = 0
3133
+ state.reverseRetries = 0
2431
3134
  const bridge = bridgeAge(cfg)
2432
3135
  if (bridge.ageSec !== null && bridge.ageSec > cfg.bridgeStaleSec) {
2433
3136
  log(cfg, `WARN bridge state stale ${bridge.ageSec}s (${bridge.path}) —— 桥接可能卡住`)
@@ -2450,7 +3153,9 @@ async function tick(cfg) {
2450
3153
  online: true,
2451
3154
  nickname: health.nickname,
2452
3155
  userId: health.userId,
2453
- port: cfg.onebotPort,
3156
+ port: portShown,
3157
+ mode: health.mode,
3158
+ clients: health.clients,
2454
3159
  bridge,
2455
3160
  launches: state.launchCount,
2456
3161
  dedupes: state.dedupeCount,
@@ -2503,10 +3208,116 @@ async function tick(cfg) {
2503
3208
  return { phase: state.phase, health, procs, deduped: true, launched }
2504
3209
  }
2505
3210
 
3211
+ const hasProcess = procs.boot > 0 || procs.qq > 0
3212
+
3213
+ /*
3214
+ * 反向 WebSocket(dsh-onebot-qq 这类插件占着端口,NapCat 反过来连进去):
3215
+ * 这种部署下 HTTP 永远问不出「在线」,只能看端口上有没有客户端连进来。
3216
+ * 关键的一条:进程在 + 端口有服务,就别杀 —— 老版本在这儿一路判离线,无限重拉。
3217
+ */
3218
+ if (health.mode === 'reverse' && hasProcess) {
3219
+ if (health.clients === null) {
3220
+ // 客户端数查不到:模糊信息不杀人,先按在线处理,日志里留一句
3221
+ if (state.phase !== 'online') log(cfg, 'REVERSE: 查不到反向 WS 客户端数,按「在线」处理(不重启)')
3222
+ state.phase = 'online'
3223
+ state.lastOnlineAt = Date.now()
3224
+ state.lastError = ''
3225
+ state.launchFails = 0
3226
+ writeSnapshot(cfg, {
3227
+ phase: state.phase,
3228
+ online: true,
3229
+ mode: health.mode,
3230
+ clients: null,
3231
+ procs,
3232
+ port: portShown,
3233
+ updatedAt: new Date().toISOString(),
3234
+ })
3235
+ return { phase: state.phase, health, procs }
3236
+ }
3237
+ const waitedSec = state.lastLaunchAt ? Math.round((Date.now() - state.lastLaunchAt) / 1000) : 0
3238
+ const overStuck = state.lastLaunchAt ? Date.now() - state.lastLaunchAt > cfg.stuckSec * 1000 : true
3239
+ if (overStuck && state.reverseRetries < 2) {
3240
+ state.reverseRetries += 1
3241
+ log(
3242
+ cfg,
3243
+ `REVERSE-WAIT ${waitedSec}s 端口上还没有客户端连进来 → 温和重拉一次 ` +
3244
+ `(${state.reverseRetries}/2)。若反复这样,检查 NapCat 里的反向 WS 地址是不是 ` +
3245
+ `ws://127.0.0.1:${portShown}(令牌要对上 accessToken)`,
3246
+ )
3247
+ const cleaned = await killNapcat(cfg)
3248
+ if (cleaned) {
3249
+ await sleep(1200)
3250
+ launchNapcat(cfg, 'reverse-wait')
3251
+ } else {
3252
+ log(cfg, 'REVERSE-WAIT: 旧链没清干净,先不拉新链')
3253
+ }
3254
+ writeSnapshot(cfg, {
3255
+ phase: 'launching',
3256
+ online: false,
3257
+ mode: health.mode,
3258
+ procs,
3259
+ relaunched: cleaned,
3260
+ updatedAt: new Date().toISOString(),
3261
+ })
3262
+ return { phase: 'launching', health, procs, relaunched: cleaned }
3263
+ }
3264
+ const before = state.phase
3265
+ state.phase = overStuck ? 'reverse-giveup' : 'reverse-wait'
3266
+ state.lastError = health.hint || health.error
3267
+ if (before !== state.phase) {
3268
+ if (state.phase === 'reverse-giveup') {
3269
+ log(
3270
+ cfg,
3271
+ `REVERSE-GIVEUP: ${waitedSec}s 里 NapCat 一直没连上反向 WS,重拉 ${state.reverseRetries} 次也没用。` +
3272
+ `去 NapCat 配置里把「反向 WebSocket」地址填成 ws://127.0.0.1:${portShown}` +
3273
+ '(令牌对上 accessToken),改完把设置里的「在线探测方式」留 auto 就行。',
3274
+ )
3275
+ } else {
3276
+ log(cfg, `REVERSE-WAIT: ${health.hint || '等 NapCat 连反向 WS'}(不重启,等它自己连)`)
3277
+ }
3278
+ }
3279
+ writeSnapshot(cfg, {
3280
+ phase: state.phase,
3281
+ online: false,
3282
+ mode: health.mode,
3283
+ clients: health.clients,
3284
+ procs,
3285
+ port: portShown,
3286
+ updatedAt: new Date().toISOString(),
3287
+ })
3288
+ return { phase: state.phase, health, procs }
3289
+ }
3290
+
3291
+ /*
3292
+ * 端口肯回 HTTP,但回的是 401 或别的状态:说明有服务在听、NapCat 进程也在。
3293
+ * 这时候「杀掉重拉」什么也解决不了(令牌不对、或者端口被别的程序占着),
3294
+ * 单独报出来,别再掉进 STUCK 那条死循环。
3295
+ */
3296
+ if (health.portAlive && health.mode !== 'reverse' && hasProcess) {
3297
+ const before = state.phase
3298
+ state.phase = health.mode === 'auth' ? 'need-token' : 'port-busy'
3299
+ state.lastError = health.error || health.hint
3300
+ if (before !== state.phase) {
3301
+ log(cfg, `${state.phase.toUpperCase()}: ${health.error} —— ${health.hint}(不重启)`)
3302
+ }
3303
+ writeSnapshot(cfg, {
3304
+ phase: state.phase,
3305
+ online: false,
3306
+ mode: health.mode,
3307
+ status: health.status,
3308
+ procs,
3309
+ port: portShown,
3310
+ updatedAt: new Date().toISOString(),
3311
+ })
3312
+ return { phase: state.phase, health, procs }
3313
+ }
3314
+
2506
3315
  // ② 完全没有进程:拉起(带宽限期 + 跨进程锁)
2507
3316
  if (procs.boot === 0 && procs.qq === 0) {
2508
3317
  const since = Date.now() - state.lastLaunchAt
2509
- if (since < cfg.launchGraceSec * 1000) {
3318
+ // 连续拉不起来就别每轮的宽限期都硬等一遍:2 的幂放大,最多 8 倍
3319
+ const backoff = Math.min(2 ** Math.min(state.launchFails, 3), 8)
3320
+ if (since < cfg.launchGraceSec * backoff * 1000) {
2510
3321
  state.phase = 'launching'
2511
3322
  writeSnapshot(cfg, { phase: state.phase, online: false, procs, launches: state.launchCount })
2512
3323
  return { phase: state.phase, health, procs }
@@ -2703,11 +3514,31 @@ function registerTools(ctx, cfg) {
2703
3514
  phase: state.phase,
2704
3515
  onebot: {
2705
3516
  port: cfg.onebotPort,
3517
+ /** 真正在看的端口:跟随了 dsh-onebot-qq 时和上面那个不一样 */
3518
+ portUsed: health.portUsed || cfg.onebotPort,
3519
+ followedCompanion: Boolean(health.followed),
2706
3520
  online: health.online,
3521
+ /** http / reverse / auth / http / process / dead —— 反向 WS 时看这个 */
3522
+ mode: health.mode,
3523
+ portAlive: health.portAlive,
3524
+ httpStatus: health.status,
3525
+ /** 反向 WS 模式下端口上连进来的客户端数(null = 查不到) */
3526
+ clients: health.clients,
3527
+ hint: health.hint,
2707
3528
  nickname: health.nickname,
2708
3529
  user_id: health.userId,
2709
3530
  error: health.error,
2710
3531
  },
3532
+ companion: {
3533
+ /** 本机装没装 dsh-onebot-qq、挂没挂、它自己配成什么样(不含令牌明文) */
3534
+ onebot: {
3535
+ ...(health.companion || companionPublic(detectCompanion()) || { found: false }),
3536
+ tokenMatches:
3537
+ health.companionTokenMatch === undefined ? null : health.companionTokenMatch,
3538
+ },
3539
+ /** dsh-qq-bridge:装没装、挂没挂、桥接指向的端口和 NapCat 对不上没有 */
3540
+ bridge: health.bridge || bridgePublic(detectBridge()) || { found: false },
3541
+ },
2711
3542
  procs,
2712
3543
  node: nodePublic(cfg),
2713
3544
  qr,
@@ -2734,7 +3565,21 @@ function registerTools(ctx, cfg) {
2734
3565
  logTail: tail,
2735
3566
  note: health.online
2736
3567
  ? '在线。若 QQ 里还是没回音,问题在 dsh-qq-bridge 那一侧(绑定/试运行开关)。'
2737
- : qr && qr.ageSec <= cfg.qrFreshSec
3568
+ : health.mode === 'reverse'
3569
+ ? health.clients === null
3570
+ ? '反向 WebSocket 模式:端口是反向 WS 服务在听(HTTP 问不出在线),客户端数还查不到——按在线处理,不会重启。'
3571
+ : '反向 WebSocket 模式:端口活着,但还没有客户端连进来。让 NapCat 把反向 WS 地址填成 ws://127.0.0.1:' +
3572
+ String(health.portUsed || cfg.onebotPort) +
3573
+ '(令牌对上 accessToken);超过 stuckSec 才会温和重拉,最多两次。'
3574
+ : health.mode === 'auth'
3575
+ ? '端口有响应但要求鉴权(HTTP ' +
3576
+ String(health.status) +
3577
+ '):多半是 accessToken 跟 NapCat(或反向 WS 服务)里填的不一致。不会重启,先把令牌对一下。'
3578
+ : health.portAlive
3579
+ ? '端口活着但回的不是 OneBot 接口(HTTP ' +
3580
+ String(health.status) +
3581
+ '):看看是不是别的程序占着这个端口。不会重启。'
3582
+ : qr && qr.ageSec <= cfg.qrFreshSec
2738
3583
  ? passwordActive(cfg)
2739
3584
  ? '离线,且在等扫码——但「记住密码」是开着的:看一眼日志里“快速登录错误”后面是不是仍回退到二维码(密码登录可能被腾讯要求验证)。'
2740
3585
  : '离线,且二维码是新的:需要手机扫码(或在设置里打开「记住密码」让 NapCat 自己用密码登录)。文件在 qrCopyPath。'
@@ -2917,6 +3762,10 @@ function publicConfig(cfg) {
2917
3762
  passwordActive: passwordActive(cfg),
2918
3763
  onebotPort: cfg.onebotPort,
2919
3764
  accessToken: cfg.accessToken,
3765
+ probeMode: cfg.probeMode,
3766
+ companionFollow: cfg.companionFollow,
3767
+ /** 这一行该不该出现在设置页:装了且挂进 profile 才给界面渲染 */
3768
+ companionReady: Boolean(cfg.companionReady),
2920
3769
  procPathMatch: cfg.procPathMatch,
2921
3770
  probeIntervalSec: cfg.probeIntervalSec,
2922
3771
  launchGraceSec: cfg.launchGraceSec,
@@ -3072,6 +3921,14 @@ async function uiState(cfg, options = {}) {
3072
3921
  const p = procs || { boot: 0, qq: 0, chains: 0, dup: false }
3073
3922
  const qr = cachedQr(cfg)
3074
3923
  const bridge = bridgeAge(cfg)
3924
+ // 面板每轮都来问一次(轻量档 1.5s 一轮),顺手按最新探测结果校准「跟随」那一项:
3925
+ // 用户中途把 dsh-onebot-qq 挂进 profile(或者摘掉),不用重启 DSH —— 界面上那一行立刻跟着出现/消失,
3926
+ // 值仍然是「他自己想要的那个」被闸门压过之后的结果(显式关过就一直关)。
3927
+ const liveReady = companionReady(health.companion || detectCompanion())
3928
+ if (liveReady !== cfg.companionReady) {
3929
+ cfg.companionReady = liveReady
3930
+ cfg.companionFollow = liveReady ? cfg.companionFollowWant !== false : false
3931
+ }
3075
3932
  return {
3076
3933
  ok: true,
3077
3934
  version,
@@ -3081,6 +3938,21 @@ async function uiState(cfg, options = {}) {
3081
3938
  enabled: cfg.enabled,
3082
3939
  phase: state.phase,
3083
3940
  online: !!health.online,
3941
+ /** http / reverse / auth / http-odd / process / dead —— 设置页据此显示「反向 WS」那套词 */
3942
+ mode: health.mode || '',
3943
+ portAlive: !!health.portAlive,
3944
+ portStatus: health.status || 0,
3945
+ /** 真正在看的端口(可能跟随了 dsh-onebot-qq) */
3946
+ portUsed: health.portUsed || cfg.onebotPort,
3947
+ followed: Boolean(health.followed),
3948
+ companion: health.companion || companionPublic(detectCompanion()),
3949
+ /** 设置页「跟随 dsh-onebot-qq」那一行显不显示,就看它(装了且挂进 profile) */
3950
+ companionReady: Boolean(cfg.companionReady),
3951
+ companionTokenMatch: health.companionTokenMatch === undefined ? null : health.companionTokenMatch,
3952
+ /** dsh-qq-bridge 的共存情况(键名避开下面表示心跳新鲜度的 bridge) */
3953
+ bridgePlugin: health.bridge || bridgePublic(detectBridge()),
3954
+ clients: health.clients,
3955
+ hint: health.hint || '',
3084
3956
  nickname: health.nickname || '',
3085
3957
  userId: health.userId || 0,
3086
3958
  error: health.error || '',
@@ -3138,6 +4010,9 @@ function saveAndApply(ctx, patch) {
3138
4010
  const fileCfg = { ...readConfigFile(dir) }
3139
4011
  for (const [key, value] of Object.entries(patch || {})) {
3140
4012
  if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) continue
4013
+ // 界面上没有这一行时(dsh-onebot-qq 没装/没启用),前端发的全量 patch 里可能带着假值,
4014
+ // 别让它把用户以前存的选择覆盖掉 —— 等它真的启用了再说。
4015
+ if (key === 'companionFollow' && !companionReady(detectCompanion())) continue
3141
4016
  if (key === 'quickPassword') {
3142
4017
  // 设置页不回显密码:空字符串 = 别动它(要清空走 clearPassword 动作)
3143
4018
  const text = String(value == null ? '' : value).replace(/[\r\n]/g, '')
@@ -3488,6 +4363,27 @@ export const __internals = {
3488
4363
  procRoot,
3489
4364
  probe,
3490
4365
  probeProcs,
4366
+ portClients,
4367
+ PROBE_MODES,
4368
+ // 与 dsh-onebot-qq 共存:识别它的安装/挂载/配置(自检脚本直接调这几个)
4369
+ detectCompanion,
4370
+ parseCompanionConfig,
4371
+ companionPublic,
4372
+ companionReady,
4373
+ companionTokenMatch,
4374
+ probeOnPort,
4375
+ COMPANION_DEFAULTS,
4376
+ COMPANION_PACKAGE,
4377
+ // 与 dsh-qq-bridge 共存:它是「客户端」,配置摊在 ~/.dsh/qq-bridge-config.json
4378
+ profileHits,
4379
+ detectBridge,
4380
+ bridgePublic,
4381
+ bridgeCheck,
4382
+ readBridgeConfig,
4383
+ urlPort,
4384
+ BRIDGE_DEFAULTS,
4385
+ BRIDGE_PACKAGE,
4386
+ BRIDGE_ID,
3491
4387
  qrInfo,
3492
4388
  bridgeAge,
3493
4389
  launchNapcat,