dsh-napcat-keeper 2.8.0 → 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,24 @@
1
1
  /**
2
- * dsh-napcat-keeper —— NapCat 看门狗 v2.8.0(可发布通用版)
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
+ *
7
22
  * v2.8.0:认得出反向 WebSocket(和 dsh-onebot-qq 共存)
8
23
  * - NapCat 走反向 WS 时端口是别人在听(dsh-onebot-qq 之类),HTTP 换来 401/426;
9
24
  * postOneBot 原先只看 body 不看状态码,于是把「端口在回话」当成「端口不通」
@@ -147,7 +162,7 @@ import { homedir, tmpdir } from 'node:os'
147
162
  import path from 'node:path'
148
163
 
149
164
  export const name = 'napcat-keeper'
150
- export const version = '2.8.0'
165
+ export const version = '2.8.1'
151
166
 
152
167
  /** tools 用来注册 napcat_* 工具;webServer 用来挂设置界面接口。拿不到也不至于把 DSH 拖崩。 */
153
168
  export const inject = ['tools', 'webServer']
@@ -188,6 +203,12 @@ const DEFAULTS = {
188
203
  * process 只看进程在不在,不碰端口
189
204
  */
190
205
  probeMode: 'auto',
206
+ /**
207
+ * 跟随 dsh-onebot-qq:自己填的端口连不上、而机器上挂着的 dsh-onebot-qq 是反向 WS 模式时,
208
+ * 自动把探测指向它监听的那个端口(用户显式填的端口一旦通了就还是用用户的)。
209
+ * 默认开 —— 这正是「装了反向 WS 插件却一直判离线」那个坑的解药。
210
+ */
211
+ companionFollow: true,
191
212
  /** 只在这些路径下找 QQ/NapCat 进程;留空 = 由 shellDir 推导(上层目录) */
192
213
  procPathMatch: '',
193
214
  /** 复查间隔(秒) */
@@ -788,6 +809,7 @@ function normalizeConfig(cfg) {
788
809
  cfg.probeMode = PROBE_MODES.has(String(cfg.probeMode || '').trim().toLowerCase())
789
810
  ? String(cfg.probeMode).trim().toLowerCase()
790
811
  : 'auto'
812
+ cfg.companionFollow = cfg.companionFollow !== false && cfg.companionFollow !== 'false'
791
813
  cfg.procPathMatch = String(cfg.procPathMatch || '').trim()
792
814
  cfg.taskName = String(cfg.taskName || '').trim()
793
815
  cfg.onebotPort = clampNum(cfg.onebotPort, 1, 65535, 3000)
@@ -816,8 +838,23 @@ function resolveConfig(raw) {
816
838
  // 老版本的东西摊在 ~/.dsh 根上,先搬进 dir 再读配置(否则读的是搬走前的那份)
817
839
  migrateLegacyFiles(dir)
818
840
  // 优先级:内置默认 < cordis 里的 config < 设置界面存下来的 json
819
- 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 }
820
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
821
858
  // v2.2.2:shellDir 不再自动探测/自动填,空着就等用户在设置页里扫盘选或者手填
822
859
  if (!cfg.launcherScript) cfg.launcherScript = detectLauncher(cfg.shellDir, cfg.launchAccount)
823
860
  if (!cfg.logPath) cfg.logPath = path.join(dir, 'keeper.log')
@@ -896,7 +933,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
896
933
  * 对方根本不会回 OneBot 的 JSON,能拿到的唯一线索就是 401 / 426 这类状态码 ——
897
934
  * 老版本把状态码丢了,于是一律当成「端口不通」,接着就是杀掉重拉,无限循环。
898
935
  */
899
- function postOneBot(cfg, action, payload, timeoutMs = 4000) {
936
+ function postOneBot(cfg, action, payload, timeoutMs = 4000, port = 0) {
900
937
  return new Promise((resolve) => {
901
938
  const body = JSON.stringify(payload || {})
902
939
  const headers = {
@@ -909,7 +946,7 @@ function postOneBot(cfg, action, payload, timeoutMs = 4000) {
909
946
  req = http.request(
910
947
  {
911
948
  host: '127.0.0.1',
912
- port: cfg.onebotPort,
949
+ port: Number(port) || cfg.onebotPort,
913
950
  path: `/${action}`,
914
951
  method: 'POST',
915
952
  headers,
@@ -957,6 +994,397 @@ function postOneBot(cfg, action, payload, timeoutMs = 4000) {
957
994
  })
958
995
  }
959
996
 
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
+
960
1388
  const PROBE_MODES = new Set(['auto', 'http', 'reverse', 'process'])
961
1389
 
962
1390
  /** portClients 的缓存:设置页轮询很密,别每 1.5 秒起一个 powershell */
@@ -973,16 +1401,16 @@ const linksCache = { port: 0, at: 0, value: null, inflight: null }
973
1401
  * 查不出来就返回 count: null。「查不到」和「没连上」是两回事,
974
1402
  * 混成一个就会又变成瞎判离线、瞎杀进程。
975
1403
  */
976
- async function portClients(cfg, maxAgeMs = 5000) {
977
- const port = Number(cfg.onebotPort) || 0
978
- if (!port) return { count: null, pids: [], source: 'no-port' }
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' }
979
1407
  // 设置页 1.5s 轮询 /state,而这个函数要新开一个 powershell:
980
1408
  // 缓存 + 同刻只允许一个子进程,否则又是一场 PowerShell 风暴(见 uiCache 那段注释)
981
- if (linksCache.port === port && linksCache.value && Date.now() - linksCache.at < maxAgeMs) {
1409
+ if (linksCache.port === p && linksCache.value && Date.now() - linksCache.at < maxAgeMs) {
982
1410
  return linksCache.value
983
1411
  }
984
- if (linksCache.port === port && linksCache.inflight) return linksCache.inflight
985
- linksCache.port = port
1412
+ if (linksCache.port === p && linksCache.inflight) return linksCache.inflight
1413
+ linksCache.port = p
986
1414
  linksCache.inflight = (async () => {
987
1415
  const self = String(process.pid)
988
1416
  // 看的是「谁连进了这个端口」,所以按 RemotePort 找客户端那一侧:
@@ -990,7 +1418,7 @@ async function portClients(cfg, maxAgeMs = 5000) {
990
1418
  // 把它排除掉就一条都数不到了 —— 这个坑第一版就踩了。
991
1419
  const script =
992
1420
  "$ErrorActionPreference='SilentlyContinue';" +
993
- `$c=@(Get-NetTCPConnection -State Established -RemotePort ${port} -ErrorAction SilentlyContinue | ` +
1421
+ `$c=@(Get-NetTCPConnection -State Established -RemotePort ${p} -ErrorAction SilentlyContinue | ` +
994
1422
  `Where-Object { [string]$_.OwningProcess -ne '${self}' });` +
995
1423
  `'CLIENTS=' + $c.Count + ' PIDS=' + (($c | Select-Object -ExpandProperty OwningProcess -Unique) -join ',')`
996
1424
  const out = await runPs(script, 12000)
@@ -1021,9 +1449,8 @@ async function portClients(cfg, maxAgeMs = 5000) {
1021
1449
  * dead 连不上,端口没开
1022
1450
  * process 配置里关了端口探测,只看进程
1023
1451
  */
1024
- async function probe(cfg) {
1025
- const want = PROBE_MODES.has(cfg.probeMode) ? cfg.probeMode : 'auto'
1026
- const login = await postOneBot(cfg, 'get_login_info')
1452
+ async function probeOnPort(cfg, port, want) {
1453
+ const login = await postOneBot(cfg, 'get_login_info', {}, 4000, port)
1027
1454
  const data = login && login.data
1028
1455
  const status = Number(login && login.status) || 0
1029
1456
  const online = Boolean(login && login.retcode === 0 && data && (data.user_id || data.uin))
@@ -1055,7 +1482,7 @@ async function probe(cfg) {
1055
1482
  return result
1056
1483
  }
1057
1484
  // 端口肯回 HTTP,说明有个服务在听 —— 这本身就意味着「进程是活的」,不该被杀
1058
- const links = await portClients(cfg)
1485
+ const links = await portClients(cfg, port)
1059
1486
  result.clients = links.count
1060
1487
  if (want === 'reverse') {
1061
1488
  result.mode = 'reverse'
@@ -1089,6 +1516,109 @@ async function probe(cfg) {
1089
1516
  return result
1090
1517
  }
1091
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 })
1620
+ }
1621
+
1092
1622
  /* ------------------------------------------------------------------ */
1093
1623
  /* 进程链 / 二维码 / 桥接新鲜度 */
1094
1624
  /* ------------------------------------------------------------------ */
@@ -2580,6 +3110,8 @@ async function tick(cfg) {
2580
3110
  state.cfg = cfg
2581
3111
  state.lastProbeAt = new Date().toISOString()
2582
3112
  const health = await probe(cfg)
3113
+ /** 真正在看的端口:可能跟随了 dsh-onebot-qq 的反向 WS 端口 */
3114
+ const portShown = health.portUsed || cfg.onebotPort
2583
3115
 
2584
3116
  if (health.online) {
2585
3117
  if (state.phase !== 'online') {
@@ -2621,7 +3153,7 @@ async function tick(cfg) {
2621
3153
  online: true,
2622
3154
  nickname: health.nickname,
2623
3155
  userId: health.userId,
2624
- port: cfg.onebotPort,
3156
+ port: portShown,
2625
3157
  mode: health.mode,
2626
3158
  clients: health.clients,
2627
3159
  bridge,
@@ -2697,7 +3229,7 @@ async function tick(cfg) {
2697
3229
  mode: health.mode,
2698
3230
  clients: null,
2699
3231
  procs,
2700
- port: cfg.onebotPort,
3232
+ port: portShown,
2701
3233
  updatedAt: new Date().toISOString(),
2702
3234
  })
2703
3235
  return { phase: state.phase, health, procs }
@@ -2710,7 +3242,7 @@ async function tick(cfg) {
2710
3242
  cfg,
2711
3243
  `REVERSE-WAIT ${waitedSec}s 端口上还没有客户端连进来 → 温和重拉一次 ` +
2712
3244
  `(${state.reverseRetries}/2)。若反复这样,检查 NapCat 里的反向 WS 地址是不是 ` +
2713
- `ws://127.0.0.1:${cfg.onebotPort}(令牌要对上 accessToken)`,
3245
+ `ws://127.0.0.1:${portShown}(令牌要对上 accessToken)`,
2714
3246
  )
2715
3247
  const cleaned = await killNapcat(cfg)
2716
3248
  if (cleaned) {
@@ -2737,7 +3269,7 @@ async function tick(cfg) {
2737
3269
  log(
2738
3270
  cfg,
2739
3271
  `REVERSE-GIVEUP: ${waitedSec}s 里 NapCat 一直没连上反向 WS,重拉 ${state.reverseRetries} 次也没用。` +
2740
- `去 NapCat 配置里把「反向 WebSocket」地址填成 ws://127.0.0.1:${cfg.onebotPort}` +
3272
+ `去 NapCat 配置里把「反向 WebSocket」地址填成 ws://127.0.0.1:${portShown}` +
2741
3273
  '(令牌对上 accessToken),改完把设置里的「在线探测方式」留 auto 就行。',
2742
3274
  )
2743
3275
  } else {
@@ -2750,7 +3282,7 @@ async function tick(cfg) {
2750
3282
  mode: health.mode,
2751
3283
  clients: health.clients,
2752
3284
  procs,
2753
- port: cfg.onebotPort,
3285
+ port: portShown,
2754
3286
  updatedAt: new Date().toISOString(),
2755
3287
  })
2756
3288
  return { phase: state.phase, health, procs }
@@ -2774,7 +3306,7 @@ async function tick(cfg) {
2774
3306
  mode: health.mode,
2775
3307
  status: health.status,
2776
3308
  procs,
2777
- port: cfg.onebotPort,
3309
+ port: portShown,
2778
3310
  updatedAt: new Date().toISOString(),
2779
3311
  })
2780
3312
  return { phase: state.phase, health, procs }
@@ -2982,6 +3514,9 @@ function registerTools(ctx, cfg) {
2982
3514
  phase: state.phase,
2983
3515
  onebot: {
2984
3516
  port: cfg.onebotPort,
3517
+ /** 真正在看的端口:跟随了 dsh-onebot-qq 时和上面那个不一样 */
3518
+ portUsed: health.portUsed || cfg.onebotPort,
3519
+ followedCompanion: Boolean(health.followed),
2985
3520
  online: health.online,
2986
3521
  /** http / reverse / auth / http / process / dead —— 反向 WS 时看这个 */
2987
3522
  mode: health.mode,
@@ -2994,6 +3529,16 @@ function registerTools(ctx, cfg) {
2994
3529
  user_id: health.userId,
2995
3530
  error: health.error,
2996
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
+ },
2997
3542
  procs,
2998
3543
  node: nodePublic(cfg),
2999
3544
  qr,
@@ -3024,7 +3569,7 @@ function registerTools(ctx, cfg) {
3024
3569
  ? health.clients === null
3025
3570
  ? '反向 WebSocket 模式:端口是反向 WS 服务在听(HTTP 问不出在线),客户端数还查不到——按在线处理,不会重启。'
3026
3571
  : '反向 WebSocket 模式:端口活着,但还没有客户端连进来。让 NapCat 把反向 WS 地址填成 ws://127.0.0.1:' +
3027
- String(cfg.onebotPort) +
3572
+ String(health.portUsed || cfg.onebotPort) +
3028
3573
  '(令牌对上 accessToken);超过 stuckSec 才会温和重拉,最多两次。'
3029
3574
  : health.mode === 'auth'
3030
3575
  ? '端口有响应但要求鉴权(HTTP ' +
@@ -3218,6 +3763,9 @@ function publicConfig(cfg) {
3218
3763
  onebotPort: cfg.onebotPort,
3219
3764
  accessToken: cfg.accessToken,
3220
3765
  probeMode: cfg.probeMode,
3766
+ companionFollow: cfg.companionFollow,
3767
+ /** 这一行该不该出现在设置页:装了且挂进 profile 才给界面渲染 */
3768
+ companionReady: Boolean(cfg.companionReady),
3221
3769
  procPathMatch: cfg.procPathMatch,
3222
3770
  probeIntervalSec: cfg.probeIntervalSec,
3223
3771
  launchGraceSec: cfg.launchGraceSec,
@@ -3373,6 +3921,14 @@ async function uiState(cfg, options = {}) {
3373
3921
  const p = procs || { boot: 0, qq: 0, chains: 0, dup: false }
3374
3922
  const qr = cachedQr(cfg)
3375
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
+ }
3376
3932
  return {
3377
3933
  ok: true,
3378
3934
  version,
@@ -3386,6 +3942,15 @@ async function uiState(cfg, options = {}) {
3386
3942
  mode: health.mode || '',
3387
3943
  portAlive: !!health.portAlive,
3388
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()),
3389
3954
  clients: health.clients,
3390
3955
  hint: health.hint || '',
3391
3956
  nickname: health.nickname || '',
@@ -3445,6 +4010,9 @@ function saveAndApply(ctx, patch) {
3445
4010
  const fileCfg = { ...readConfigFile(dir) }
3446
4011
  for (const [key, value] of Object.entries(patch || {})) {
3447
4012
  if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) continue
4013
+ // 界面上没有这一行时(dsh-onebot-qq 没装/没启用),前端发的全量 patch 里可能带着假值,
4014
+ // 别让它把用户以前存的选择覆盖掉 —— 等它真的启用了再说。
4015
+ if (key === 'companionFollow' && !companionReady(detectCompanion())) continue
3448
4016
  if (key === 'quickPassword') {
3449
4017
  // 设置页不回显密码:空字符串 = 别动它(要清空走 clearPassword 动作)
3450
4018
  const text = String(value == null ? '' : value).replace(/[\r\n]/g, '')
@@ -3797,6 +4365,25 @@ export const __internals = {
3797
4365
  probeProcs,
3798
4366
  portClients,
3799
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,
3800
4387
  qrInfo,
3801
4388
  bridgeAge,
3802
4389
  launchNapcat,