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/CHANGELOG.md +21 -0
- package/README.md +40 -4
- package/lib/client.js +59 -3
- package/lib/index.js +610 -23
- package/package.json +2 -1
- package/scripts/selftest-companion.mjs +488 -0
- package/scripts/selftest-probe.mjs +6 -1
- package/scripts/smoke-client.mjs +68 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-napcat-keeper —— NapCat 看门狗 v2.8.
|
|
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.
|
|
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
|
|
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
|
|
978
|
-
if (!
|
|
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 ===
|
|
1409
|
+
if (linksCache.port === p && linksCache.value && Date.now() - linksCache.at < maxAgeMs) {
|
|
982
1410
|
return linksCache.value
|
|
983
1411
|
}
|
|
984
|
-
if (linksCache.port ===
|
|
985
|
-
linksCache.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 ${
|
|
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
|
|
1025
|
-
const
|
|
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:
|
|
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:
|
|
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:${
|
|
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:${
|
|
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:
|
|
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:
|
|
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,
|