dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.2
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/client.js +703 -115
- package/lib/index.js +487 -31
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/runtime/dsh-setup.mjs +78 -7
package/lib/index.js
CHANGED
|
@@ -210,6 +210,38 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
|
|
|
210
210
|
return upstreamDiscoveryInFlight;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 上游地址是否已经"够用"(不需要在**响应路径里现扫**)。
|
|
215
|
+
* 【B1 · 2026-11-07】登录/首屏慢的主因之一就是在响应路径里 await 端口发现。
|
|
216
|
+
* 只要下面任一成立就够用:显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离 / 平台模拟。
|
|
217
|
+
*/
|
|
218
|
+
function upstreamDiscoverySettled() {
|
|
219
|
+
// 测试钩子**最优先**(true=强制"未 settled",false=强制"已 settled";null=不干预):
|
|
220
|
+
// DSH 宿主里跑测试时环境常带 DSH_WEB_URL(会被 boundWebPort 认成"已知端口"),
|
|
221
|
+
// 不放在最前面就没法模拟"端口未知、需要发现"的现场。
|
|
222
|
+
if (upstreamForceUnsettled === true) return false;
|
|
223
|
+
if (upstreamForceUnsettled === false) return true;
|
|
224
|
+
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return true;
|
|
225
|
+
if (boundWebPort()) return true;
|
|
226
|
+
if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return true;
|
|
227
|
+
if (skipsSystemOps()) return true;
|
|
228
|
+
if (osPlatform() !== process.platform) return true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 测试钩子:替换发现实现 / 强制"未 settled"(只给用例用,生产永远走真实实现)。 */
|
|
233
|
+
let discoverImpl = discoverUpstreamSelf;
|
|
234
|
+
let upstreamForceUnsettled = null;
|
|
235
|
+
function setUpstreamTestState(opts = {}) {
|
|
236
|
+
if (typeof opts.discover === "function") discoverImpl = opts.discover;
|
|
237
|
+
if (typeof opts.unsettled === "boolean") upstreamForceUnsettled = opts.unsettled;
|
|
238
|
+
return { discover: discoverImpl, unsettled: upstreamForceUnsettled };
|
|
239
|
+
}
|
|
240
|
+
function resetUpstreamTestState() {
|
|
241
|
+
discoverImpl = discoverUpstreamSelf;
|
|
242
|
+
upstreamForceUnsettled = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
/**
|
|
214
246
|
* 把上游地址落盘(`<relayDir>/.dsh-upstream`),供**没有环境变量**的那些守护读取:
|
|
215
247
|
* 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
|
|
@@ -1461,7 +1493,7 @@ function writeLinuxUnit(relayDir) {
|
|
|
1461
1493
|
if (!unitPath) return null;
|
|
1462
1494
|
const setup = join(relayDir, "dsh-setup.mjs");
|
|
1463
1495
|
const unit = `[Unit]
|
|
1464
|
-
Description=dsh-remote bridge (auto-starts with dsh web)
|
|
1496
|
+
Description=dsh-remote-web bridge (auto-starts with dsh web)
|
|
1465
1497
|
|
|
1466
1498
|
[Service]
|
|
1467
1499
|
ExecStart=${NODE_BIN} ${setup} run
|
|
@@ -1648,6 +1680,31 @@ function launchTarget() {
|
|
|
1648
1680
|
*/
|
|
1649
1681
|
const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
1650
1682
|
|
|
1683
|
+
// ── B1:shell 探测短 TTL 缓存(2026-11-07 登录慢优化)─────────────────────────────
|
|
1684
|
+
// launchctl / ps / systemctl 每次调用都是一次进程 spawn(实测 30~120ms,慢机器更久),
|
|
1685
|
+
// 而状态轮询 2.5s 一次会反复重跑同一份"没变"的事实。这里给 10s TTL:
|
|
1686
|
+
// · 结论按 key 缓存;**变更动作(启动/停止/卸载/账号切换)会主动清缓存**,不会让面板显示过期状态。
|
|
1687
|
+
const STATUS_PROBE_TTL_MS = 10 * 1000;
|
|
1688
|
+
const statusProbeCache = new Map();
|
|
1689
|
+
function memoStatusProbe(key, fn) {
|
|
1690
|
+
// 测试/诊断隔离下**不缓存**:这类环境里 shell 探测是假命令/假结果(skipsSystemOps 或 DSH_TEST_*
|
|
1691
|
+
// 注入的 fake PATH),缓存只会让用例互相污染(一个用例伪造的 pid/服务状态被下一个用例读到 → 假红)。
|
|
1692
|
+
// 生产路径(真实 launchctl/ps)才走 10s TTL。
|
|
1693
|
+
if (skipsSystemOps()) return fn();
|
|
1694
|
+
try {
|
|
1695
|
+
if (Object.keys(process.env).some((k) => k.startsWith("DSH_TEST_"))) return fn();
|
|
1696
|
+
} catch { /* 忽略 */ }
|
|
1697
|
+
const now = Date.now();
|
|
1698
|
+
const hit = statusProbeCache.get(key);
|
|
1699
|
+
if (hit && now - hit.at < STATUS_PROBE_TTL_MS) return hit.value;
|
|
1700
|
+
const value = fn();
|
|
1701
|
+
statusProbeCache.set(key, { at: now, value });
|
|
1702
|
+
return value;
|
|
1703
|
+
}
|
|
1704
|
+
function clearStatusProbeCache() {
|
|
1705
|
+
try { statusProbeCache.clear(); } catch { /* 忽略 */ }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1651
1708
|
function launchdStatus() {
|
|
1652
1709
|
// 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
|
|
1653
1710
|
// 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
|
|
@@ -2655,6 +2712,7 @@ function stopBridgeDetached(relayDir, opts = {}) {
|
|
|
2655
2712
|
|
|
2656
2713
|
/** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
2657
2714
|
function startBridge(relayDir) {
|
|
2715
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2658
2716
|
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
2659
2717
|
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
2660
2718
|
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
@@ -2872,6 +2930,7 @@ const lastSelfhealReadyLine = new Map();
|
|
|
2872
2930
|
|
|
2873
2931
|
/** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
|
|
2874
2932
|
function stopBridge(relayDir) {
|
|
2933
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2875
2934
|
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
2876
2935
|
// 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
|
|
2877
2936
|
// 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
|
|
@@ -4008,8 +4067,9 @@ function maskPhone(p) {
|
|
|
4008
4067
|
|
|
4009
4068
|
async function composeStatus(relayDir) {
|
|
4010
4069
|
const cfg = loadConfig(relayDir);
|
|
4011
|
-
|
|
4012
|
-
const
|
|
4070
|
+
// B1:同样的 10s 缓存(状态轮询时不重复 spawn launchctl/ps)。
|
|
4071
|
+
const launchd = memoStatusProbe("launchd", launchdStatus);
|
|
4072
|
+
const manual = memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
4013
4073
|
// 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
|
|
4014
4074
|
let bindError = null;
|
|
4015
4075
|
try {
|
|
@@ -5704,12 +5764,18 @@ async function accountDeviceBound(relayDir, cfg) {
|
|
|
5704
5764
|
*/
|
|
5705
5765
|
async function composeConnect(relayDir, opts = {}) {
|
|
5706
5766
|
const cfg = opts.cfg || loadConfig(relayDir);
|
|
5707
|
-
// 【
|
|
5708
|
-
//
|
|
5709
|
-
//
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
const
|
|
5767
|
+
// 【B1 · 2026-11-07 登录慢优化】端口发现**绝不再阻塞状态/登录响应**:
|
|
5768
|
+
// · 已有可用结论(显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离)→ 直接用;
|
|
5769
|
+
// · 否则**放后台**跑(void),本次先用 hint/回退地址把状态返回 —— 首屏不再等信息;
|
|
5770
|
+
// 发现完成后的结果会在下一次状态轮询自然带上(面板每 2.5~30s 轮询,无需推送)。
|
|
5771
|
+
// 旧实现的 await 会把 lsof/netstat + 数百端口探测整个塞进首屏路径(业主实测 7~8 秒)。
|
|
5772
|
+
const discoverySettled = upstreamDiscoverySettled();
|
|
5773
|
+
if (!discoverySettled) {
|
|
5774
|
+
void discoverImpl(relayDir).catch(() => FALLBACK_UPSTREAM);
|
|
5775
|
+
}
|
|
5776
|
+
// B1:这两次 shell 探测走 10s TTL 缓存(见 memoStatusProbe);变更动作会清缓存。
|
|
5777
|
+
const launchd = opts.launchd || memoStatusProbe("launchd", launchdStatus);
|
|
5778
|
+
const manual = opts.manual || memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
5713
5779
|
const book = connectBook(relayDir);
|
|
5714
5780
|
const now = Date.now();
|
|
5715
5781
|
const hasAcct = hasAccountCreds(cfg);
|
|
@@ -5958,6 +6024,8 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5958
6024
|
selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
|
|
5959
6025
|
upstreamUrl: upstreamUrl(),
|
|
5960
6026
|
upstreamSource: upstreamSourceLabel(),
|
|
6027
|
+
// B1:这次响应是否"端口发现还没完成、已放后台跑"(只作诊断/文案;下一次轮询会自然收敛)
|
|
6028
|
+
discoveryPending: Boolean(!discoverySettled),
|
|
5961
6029
|
// 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
|
|
5962
6030
|
// 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
|
|
5963
6031
|
// 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
|
|
@@ -7627,6 +7695,24 @@ function wecomStatusOf(status) {
|
|
|
7627
7695
|
boundSessionId: str(w.boundSessionId),
|
|
7628
7696
|
// 「上次预览卡片」已处理到的时间戳(桥写;面板只用来显示「上次预览:HH:MM」)
|
|
7629
7697
|
lastPreviewCardAt: num(w.lastPreviewCardAt),
|
|
7698
|
+
// ── 会员档位 / 额度(2026-11-07 桥侧新增,字段名锁死;只白名单,不整包吐)──
|
|
7699
|
+
// tier 只认三个枚举值;拿不到就是空串(面板据此**不渲染**档位条,绝不猜)。
|
|
7700
|
+
tier: (() => {
|
|
7701
|
+
const t = str(w.tier);
|
|
7702
|
+
return (t === "free" || t === "pro" || t === "pro_max") ? t : "";
|
|
7703
|
+
})(),
|
|
7704
|
+
// caps 是字符串数组:逐个转字符串 + 截断 + 上限 50 条(桥以后往里塞对象也别透出去)
|
|
7705
|
+
caps: (Array.isArray(w.caps) ? w.caps : []).slice(0, 50).map((c) => str(c).slice(0, 80)).filter(Boolean),
|
|
7706
|
+
capsCount: num(w.capsCount),
|
|
7707
|
+
bindingGated: !!w.bindingGated,
|
|
7708
|
+
messagesPerMonth: num(w.messagesPerMonth),
|
|
7709
|
+
// null = 不限或未知(**必须和 0 区分**:0 条剩余 ≠ 不限量)
|
|
7710
|
+
remainingMessages: typeof w.remainingMessages === "number" && Number.isFinite(w.remainingMessages) ? w.remainingMessages : null,
|
|
7711
|
+
usage: (() => {
|
|
7712
|
+
const u = w.usage && typeof w.usage === "object" ? w.usage : null;
|
|
7713
|
+
if (!u) return null;
|
|
7714
|
+
return { month: str(u.month), messages: num(u.messages) };
|
|
7715
|
+
})(),
|
|
7630
7716
|
sessions: (Array.isArray(w.sessions) ? w.sessions : []).slice(0, 50).map(function (it) {
|
|
7631
7717
|
const o = it && typeof it === "object" ? it : {};
|
|
7632
7718
|
return {
|
|
@@ -7669,7 +7755,36 @@ function imBotsView(relayDir) {
|
|
|
7669
7755
|
sessionId: w.sessionId,
|
|
7670
7756
|
sessionTitle: w.sessionTitle,
|
|
7671
7757
|
},
|
|
7672
|
-
|
|
7758
|
+
// 钉钉 / 飞书:与 wecom 同一份文件里的两段(secret 永不回显,只给 hasSecret)
|
|
7759
|
+
dingtalk: {
|
|
7760
|
+
enabled: dingtalkCfgOf(bots).enabled,
|
|
7761
|
+
clientId: dingtalkCfgOf(bots).clientId,
|
|
7762
|
+
hasSecret: dingtalkCfgOf(bots).clientSecret.length > 0,
|
|
7763
|
+
robotCode: dingtalkCfgOf(bots).robotCode,
|
|
7764
|
+
replyMode: dingtalkCfgOf(bots).replyMode,
|
|
7765
|
+
sessionWebhook: dingtalkCfgOf(bots).sessionWebhook,
|
|
7766
|
+
boundChatId: dingtalkCfgOf(bots).boundChatId,
|
|
7767
|
+
allowFrom: dingtalkCfgOf(bots).allowFrom,
|
|
7768
|
+
cardTemplateId: dingtalkCfgOf(bots).cardTemplateId,
|
|
7769
|
+
streamUrl: dingtalkCfgOf(bots).streamUrl,
|
|
7770
|
+
},
|
|
7771
|
+
feishu: {
|
|
7772
|
+
enabled: feishuCfgOf(bots).enabled,
|
|
7773
|
+
appId: feishuCfgOf(bots).appId,
|
|
7774
|
+
hasSecret: feishuCfgOf(bots).appSecret.length > 0,
|
|
7775
|
+
domain: feishuCfgOf(bots).domain,
|
|
7776
|
+
receiveId: feishuCfgOf(bots).receiveId,
|
|
7777
|
+
receiveIdType: feishuCfgOf(bots).receiveIdType,
|
|
7778
|
+
boundChatId: feishuCfgOf(bots).boundChatId,
|
|
7779
|
+
allowFrom: feishuCfgOf(bots).allowFrom,
|
|
7780
|
+
cardApproval: feishuCfgOf(bots).cardApproval,
|
|
7781
|
+
sdkVersion: feishuCfgOf(bots).sdkVersion,
|
|
7782
|
+
},
|
|
7783
|
+
status: Object.assign({}, wecomStatusOf(stFile), {
|
|
7784
|
+
dingtalk: imPlatformStatusOf(stFile, "dingtalk"),
|
|
7785
|
+
feishu: imPlatformStatusOf(stFile, "feishu"),
|
|
7786
|
+
deps: { feishu: feishuDepsInfo(relayDir) },
|
|
7787
|
+
}),
|
|
7673
7788
|
updatedAt: typeof stFile.updatedAt === "number" ? stFile.updatedAt : 0,
|
|
7674
7789
|
};
|
|
7675
7790
|
}
|
|
@@ -7756,38 +7871,314 @@ function saveWecomConfig(relayDir, input) {
|
|
|
7756
7871
|
}
|
|
7757
7872
|
}
|
|
7758
7873
|
|
|
7874
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7875
|
+
// 钉钉 / 飞书通道(2026-11-08):与 wecom 同一套文件(im-bots.json / im-bot-status.json),
|
|
7876
|
+
// 但配置/状态字段按契约分开。**secret 永不返回浏览器**(只给 hasSecret)。
|
|
7877
|
+
// 飞书 SDK 走「按需下载」:~/.dsh-remote/im-deps 下 npm install 一个钉死版本,不随插件打包。
|
|
7878
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7879
|
+
const IM_SECTION_MAX = 400; // 单个字符串字段上限(凭据/ID 都是短串)
|
|
7880
|
+
const IM_DEPS_DIR = "im-deps";
|
|
7881
|
+
const IM_DEPS_STATUS_FILE = "install-status.json";
|
|
7882
|
+
const FEISHU_SDK_PKG = "@larksuiteoapi/node-sdk";
|
|
7883
|
+
/** 钉死版本:升级要走发版流程(桥/插件对 SDK 行为有依赖,不能装 latest 漂移)。 */
|
|
7884
|
+
const FEISHU_SDK_PIN = "1.40.0";
|
|
7885
|
+
const FEISHU_SDK_SPEC = `${FEISHU_SDK_PKG}@${FEISHU_SDK_PIN}`;
|
|
7886
|
+
const IM_PLATFORMS = ["wecom", "dingtalk", "feishu"];
|
|
7887
|
+
const FEISHU_RECEIVE_ID_TYPES = ["open_id", "chat_id", "email", "user_id"];
|
|
7888
|
+
|
|
7889
|
+
const strOf = (v) => (typeof v === "string" ? v : "");
|
|
7890
|
+
const numOf = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
7891
|
+
const strListOf = (v) => (Array.isArray(v) ? v.slice(0, 50).map((x) => strOf(x).slice(0, IM_SECTION_MAX)).filter(Boolean) : []);
|
|
7892
|
+
|
|
7893
|
+
/** 归一化 dingtalk 配置(读/写共用;未知字段由调用方 ...bots 保留)。 */
|
|
7894
|
+
function dingtalkCfgOf(bots) {
|
|
7895
|
+
const d = bots && bots.dingtalk && typeof bots.dingtalk === "object" ? bots.dingtalk : {};
|
|
7896
|
+
return {
|
|
7897
|
+
enabled: !!d.enabled,
|
|
7898
|
+
clientId: strOf(d.clientId),
|
|
7899
|
+
clientSecret: strOf(d.clientSecret),
|
|
7900
|
+
robotCode: strOf(d.robotCode),
|
|
7901
|
+
replyMode: strOf(d.replyMode) || "stream",
|
|
7902
|
+
sessionWebhook: strOf(d.sessionWebhook),
|
|
7903
|
+
boundChatId: strOf(d.boundChatId),
|
|
7904
|
+
allowFrom: strListOf(d.allowFrom),
|
|
7905
|
+
cardTemplateId: strOf(d.cardTemplateId),
|
|
7906
|
+
streamUrl: strOf(d.streamUrl),
|
|
7907
|
+
};
|
|
7908
|
+
}
|
|
7909
|
+
|
|
7910
|
+
/** 归一化 feishu 配置。 */
|
|
7911
|
+
function feishuCfgOf(bots) {
|
|
7912
|
+
const f = bots && bots.feishu && typeof bots.feishu === "object" ? bots.feishu : {};
|
|
7913
|
+
const type = strOf(f.receiveIdType);
|
|
7914
|
+
return {
|
|
7915
|
+
enabled: !!f.enabled,
|
|
7916
|
+
appId: strOf(f.appId),
|
|
7917
|
+
appSecret: strOf(f.appSecret),
|
|
7918
|
+
domain: strOf(f.domain) || "https://open.feishu.cn",
|
|
7919
|
+
receiveId: strOf(f.receiveId),
|
|
7920
|
+
receiveIdType: FEISHU_RECEIVE_ID_TYPES.includes(type) ? type : "open_id",
|
|
7921
|
+
boundChatId: strOf(f.boundChatId),
|
|
7922
|
+
allowFrom: strListOf(f.allowFrom),
|
|
7923
|
+
cardApproval: f.cardApproval !== false,
|
|
7924
|
+
sdkVersion: strOf(f.sdkVersion),
|
|
7925
|
+
};
|
|
7926
|
+
}
|
|
7927
|
+
|
|
7928
|
+
/** 状态文件里 dingtalk/feishu 段 → 契约形状(白名单;桥塞什么都不带出去)。 */
|
|
7929
|
+
function imPlatformStatusOf(status, platform) {
|
|
7930
|
+
const p = status && status[platform] && typeof status[platform] === "object" ? status[platform] : {};
|
|
7931
|
+
const connected = !!p.connected;
|
|
7932
|
+
const rawState = strOf(p.state);
|
|
7933
|
+
const states = ["idle", "connecting", "connected", "reconnecting", "failed"];
|
|
7934
|
+
const state = states.includes(rawState) ? rawState
|
|
7935
|
+
: connected ? "connected" : (p.enabled ? "connecting" : "idle");
|
|
7936
|
+
const t = strOf(p.tier);
|
|
7937
|
+
return {
|
|
7938
|
+
enabled: !!p.enabled,
|
|
7939
|
+
state,
|
|
7940
|
+
connected,
|
|
7941
|
+
authenticated: !!p.authenticated,
|
|
7942
|
+
lastError: strOf(p.lastError).slice(0, 500),
|
|
7943
|
+
boundChatId: strOf(p.boundChatId),
|
|
7944
|
+
tier: (t === "free" || t === "pro" || t === "pro_max") ? t : "",
|
|
7945
|
+
caps: strListOf(p.caps).map((x) => x.slice(0, 80)),
|
|
7946
|
+
capsCount: numOf(p.capsCount),
|
|
7947
|
+
bindingGated: !!p.bindingGated,
|
|
7948
|
+
messagesPerMonth: numOf(p.messagesPerMonth),
|
|
7949
|
+
remainingMessages: typeof p.remainingMessages === "number" && Number.isFinite(p.remainingMessages) ? p.remainingMessages : null,
|
|
7950
|
+
usage: (() => {
|
|
7951
|
+
const u = p.usage && typeof p.usage === "object" ? p.usage : null;
|
|
7952
|
+
return u ? { month: strOf(u.month), messages: numOf(u.messages) } : null;
|
|
7953
|
+
})(),
|
|
7954
|
+
};
|
|
7955
|
+
}
|
|
7956
|
+
|
|
7957
|
+
function imDepsDirOf(relayDir) { return join(relayDir, IM_DEPS_DIR); }
|
|
7958
|
+
function imDepsStatusPathOf(relayDir) { return join(relayDir, IM_DEPS_DIR, IM_DEPS_STATUS_FILE); }
|
|
7959
|
+
|
|
7960
|
+
/** 飞书 SDK 依赖现状(GET /dsh-remote/im-deps 的数据源;phase 用于面板进度轮询)。 */
|
|
7961
|
+
function feishuDepsInfo(relayDir) {
|
|
7962
|
+
const base = imDepsDirOf(relayDir);
|
|
7963
|
+
const pkgDir = join(base, "node_modules", "@larksuiteoapi", "node-sdk");
|
|
7964
|
+
let installed = false;
|
|
7965
|
+
let version = "";
|
|
7966
|
+
try {
|
|
7967
|
+
const pj = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
|
7968
|
+
installed = true;
|
|
7969
|
+
version = strOf(pj.version);
|
|
7970
|
+
} catch { /* 未安装 */ }
|
|
7971
|
+
const st = readJsonFileSoft(imDepsStatusPathOf(relayDir));
|
|
7972
|
+
const f = st && st.feishu && typeof st.feishu === "object" ? st.feishu : {};
|
|
7973
|
+
const phases = ["idle", "downloading", "installing", "done", "failed"];
|
|
7974
|
+
let phase = phases.includes(strOf(f.phase)) ? strOf(f.phase) : (installed ? "done" : "idle");
|
|
7975
|
+
if (!installed && phase === "done") phase = "idle";
|
|
7976
|
+
return {
|
|
7977
|
+
installed,
|
|
7978
|
+
version: installed ? version : "",
|
|
7979
|
+
path: installed ? pkgDir : "",
|
|
7980
|
+
lastError: strOf(f.lastError).slice(0, 500),
|
|
7981
|
+
phase,
|
|
7982
|
+
};
|
|
7983
|
+
}
|
|
7984
|
+
|
|
7985
|
+
function writeFeishuDepsPhase(relayDir, patch) {
|
|
7986
|
+
try {
|
|
7987
|
+
const dir = imDepsDirOf(relayDir);
|
|
7988
|
+
mkdirSync(dir, { recursive: true });
|
|
7989
|
+
const file = imDepsStatusPathOf(relayDir);
|
|
7990
|
+
const cur = readJsonFileSoft(file);
|
|
7991
|
+
const next = { ...cur, feishu: { ...(cur.feishu && typeof cur.feishu === "object" ? cur.feishu : {}), ...patch } };
|
|
7992
|
+
const tmp = `${file}.tmp`;
|
|
7993
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
7994
|
+
try { chmodSync(tmp, 0o600); } catch { /* 非 POSIX */ }
|
|
7995
|
+
renameSync(tmp, file);
|
|
7996
|
+
try { chmodSync(file, 0o600); } catch { /* 忽略 */ }
|
|
7997
|
+
} catch { /* 进度记录失败不致命 */ }
|
|
7998
|
+
}
|
|
7999
|
+
|
|
8000
|
+
/** npm 安装失败的归因码(面板据此给人话 + 可重试建议)。 */
|
|
8001
|
+
function classifySdkInstallError(text) {
|
|
8002
|
+
const t = String(text || "");
|
|
8003
|
+
if (/ENOSPC|no space/i.test(t)) return { code: "disk_full", message: "磁盘空间不足:清出约 200MB 后重试" };
|
|
8004
|
+
if (/EACCES|EPERM|permission denied/i.test(t)) return { code: "permission_denied", message: "没有写入权限:检查 ~/.dsh-remote 目录权限后重试" };
|
|
8005
|
+
if (/ETIMEDOUT|ENOTFOUND|ECONN|EAI_AGAIN|network|socket hang up|registry/i.test(t)) return { code: "network", message: "网络不通:检查能否访问 npm registry(可换镜像后重试)" };
|
|
8006
|
+
return { code: "sdk_install_failed", message: `依赖安装失败:${t.split("\n").filter(Boolean).slice(-3).join(" ").slice(0, 300) || "未知原因"}` };
|
|
8007
|
+
}
|
|
8008
|
+
|
|
8009
|
+
const feishuInstallTasks = new Map(); // relayDir → { startedAt, running }
|
|
8010
|
+
|
|
8011
|
+
/**
|
|
8012
|
+
* 后台下载安装飞书 SDK(POST /dsh-remote/im-deps/feishu)。
|
|
8013
|
+
* 不阻塞请求:立刻回 taskId + 状态,进度由面板轮询 GET /dsh-remote/im-deps 的 phase。
|
|
8014
|
+
* 测试注入:opts.imDepsSpawn(cmd, args, opts) 可替换真实 npm(用例绝不允许真的联网装包)。
|
|
8015
|
+
*/
|
|
8016
|
+
function startFeishuInstall(relayDir, opts = {}) {
|
|
8017
|
+
const cur = feishuDepsInfo(relayDir);
|
|
8018
|
+
if (cur.installed) return { ok: true, started: false, alreadyInstalled: true, deps: { feishu: cur } };
|
|
8019
|
+
const running = feishuInstallTasks.get(relayDir);
|
|
8020
|
+
if (running && running.running) return { ok: true, started: false, running: true, taskId: running.taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8021
|
+
const taskId = `feishu-sdk-${Date.now().toString(36)}`;
|
|
8022
|
+
const task = { running: true, startedAt: Date.now(), taskId };
|
|
8023
|
+
feishuInstallTasks.set(relayDir, task);
|
|
8024
|
+
const finish = (patch) => {
|
|
8025
|
+
task.running = false;
|
|
8026
|
+
task.endedAt = Date.now();
|
|
8027
|
+
writeFeishuDepsPhase(relayDir, { ...patch, endedAt: task.endedAt, startedAt: task.startedAt, taskId });
|
|
8028
|
+
};
|
|
8029
|
+
writeFeishuDepsPhase(relayDir, { phase: "downloading", lastError: "", startedAt: task.startedAt });
|
|
8030
|
+
let child = null;
|
|
8031
|
+
try {
|
|
8032
|
+
const base = imDepsDirOf(relayDir);
|
|
8033
|
+
mkdirSync(base, { recursive: true });
|
|
8034
|
+
const args = ["install", "--prefix", base, "--no-audit", "--no-fund", "--loglevel=error", FEISHU_SDK_SPEC];
|
|
8035
|
+
const spawnImpl = typeof opts.imDepsSpawn === "function"
|
|
8036
|
+
? opts.imDepsSpawn
|
|
8037
|
+
: (cmd, a, o) => spawn(cmd, a, { ...o, windowsHide: true }); // Windows 不闪黑窗
|
|
8038
|
+
const isWin = isWindows();
|
|
8039
|
+
const cmd = isWin ? "npm.cmd" : "npm";
|
|
8040
|
+
child = spawnImpl(cmd, args, { cwd: base, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
8041
|
+
let output = "";
|
|
8042
|
+
const collect = (buf) => { try { output = (output + String(buf)).slice(-4000); } catch { /* 忽略 */ } };
|
|
8043
|
+
try { child.stdout?.on?.("data", collect); child.stderr?.on?.("data", collect); } catch { /* 忽略 */ }
|
|
8044
|
+
const done = (code, err) => {
|
|
8045
|
+
if (feishuInstallTasks.get(relayDir) !== task) return; // 已被新任务取代
|
|
8046
|
+
const after = feishuDepsInfo(relayDir);
|
|
8047
|
+
if (after.installed) return finish({ phase: "done", lastError: "" });
|
|
8048
|
+
const reason = classifySdkInstallError(err || output || `exit ${code}`);
|
|
8049
|
+
finish({ phase: "failed", lastError: reason.message, errorCode: reason.code });
|
|
8050
|
+
};
|
|
8051
|
+
try {
|
|
8052
|
+
child.on?.("exit", (code) => done(code, ""));
|
|
8053
|
+
child.on?.("error", (e) => done(-1, String(e?.message || e)));
|
|
8054
|
+
} catch { done(-1, "无法启动 npm"); }
|
|
8055
|
+
} catch (e) {
|
|
8056
|
+
const reason = classifySdkInstallError(String(e?.message || e));
|
|
8057
|
+
finish({ phase: "failed", lastError: reason.message, errorCode: reason.code });
|
|
8058
|
+
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8059
|
+
}
|
|
8060
|
+
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8061
|
+
}
|
|
8062
|
+
|
|
8063
|
+
/** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
|
|
8064
|
+
function saveDingtalkConfig(relayDir, input) {
|
|
8065
|
+
const src = input && typeof input === "object" ? input : {};
|
|
8066
|
+
const bots = readImBots(relayDir);
|
|
8067
|
+
const cur = dingtalkCfgOf(bots);
|
|
8068
|
+
const next = { ...cur };
|
|
8069
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientId")) next.clientId = strOf(src.clientId).trim();
|
|
8070
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientSecret")) {
|
|
8071
|
+
const v = strOf(src.clientSecret).trim();
|
|
8072
|
+
if (v) next.clientSecret = v; // 空 = 不修改(面板不回显 secret)
|
|
8073
|
+
}
|
|
8074
|
+
if (Object.prototype.hasOwnProperty.call(src, "robotCode")) next.robotCode = strOf(src.robotCode).trim();
|
|
8075
|
+
for (const k of ["replyMode", "sessionWebhook", "boundChatId", "cardTemplateId", "streamUrl"]) {
|
|
8076
|
+
if (Object.prototype.hasOwnProperty.call(src, k)) next[k] = strOf(src[k]).trim();
|
|
8077
|
+
}
|
|
8078
|
+
if (Object.prototype.hasOwnProperty.call(src, "allowFrom")) next.allowFrom = strListOf(src.allowFrom);
|
|
8079
|
+
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
8080
|
+
else if (!next.enabled && next.clientId && next.clientSecret && next.robotCode) next.enabled = true;
|
|
8081
|
+
if (!next.clientId) return { ok: false, code: "missing_client_id", message: "请填写 Client ID(= 钉钉应用的 AppKey)" };
|
|
8082
|
+
if (!next.clientSecret) return { ok: false, code: "missing_client_secret", message: "请填写 Client Secret(= 钉钉应用的 AppSecret,只保存在本机)" };
|
|
8083
|
+
if (!next.robotCode) return { ok: false, code: "missing_robot_code", message: "请填写 Robot Code(机器人编码,在钉钉开放平台机器人页复制)" };
|
|
8084
|
+
const out = { ...bots, version: 1, dingtalk: next };
|
|
8085
|
+
try {
|
|
8086
|
+
writeImBots(relayDir, out);
|
|
8087
|
+
return { ok: true };
|
|
8088
|
+
} catch (e) {
|
|
8089
|
+
return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
8092
|
+
|
|
8093
|
+
/** 保存 feishu 配置。 */
|
|
8094
|
+
function saveFeishuConfig(relayDir, input) {
|
|
8095
|
+
const src = input && typeof input === "object" ? input : {};
|
|
8096
|
+
const bots = readImBots(relayDir);
|
|
8097
|
+
const cur = feishuCfgOf(bots);
|
|
8098
|
+
const next = { ...cur };
|
|
8099
|
+
for (const k of ["appId", "domain", "receiveId", "boundChatId", "sdkVersion"]) {
|
|
8100
|
+
if (Object.prototype.hasOwnProperty.call(src, k)) next[k] = strOf(src[k]).trim();
|
|
8101
|
+
}
|
|
8102
|
+
if (Object.prototype.hasOwnProperty.call(src, "appSecret")) {
|
|
8103
|
+
const v = strOf(src.appSecret).trim();
|
|
8104
|
+
if (v) next.appSecret = v; // 空 = 不修改
|
|
8105
|
+
}
|
|
8106
|
+
if (Object.prototype.hasOwnProperty.call(src, "receiveIdType")) {
|
|
8107
|
+
const t = strOf(src.receiveIdType).trim();
|
|
8108
|
+
if (t && !FEISHU_RECEIVE_ID_TYPES.includes(t)) {
|
|
8109
|
+
return { ok: false, code: "invalid_receive_id_type", message: "接收对象类型只能是 open_id / chat_id / email / user_id" };
|
|
8110
|
+
}
|
|
8111
|
+
next.receiveIdType = t || "open_id";
|
|
8112
|
+
}
|
|
8113
|
+
if (Object.prototype.hasOwnProperty.call(src, "allowFrom")) next.allowFrom = strListOf(src.allowFrom);
|
|
8114
|
+
if (Object.prototype.hasOwnProperty.call(src, "cardApproval")) next.cardApproval = !!src.cardApproval;
|
|
8115
|
+
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
8116
|
+
else if (!next.enabled && next.appId && next.appSecret && next.receiveId) next.enabled = true;
|
|
8117
|
+
if (!next.appId) return { ok: false, code: "missing_app_id", message: "请填写 App ID(飞书开放平台应用的 App ID)" };
|
|
8118
|
+
if (!next.appSecret) return { ok: false, code: "missing_app_secret", message: "请填写 App Secret(只保存在本机,不会回显)" };
|
|
8119
|
+
if (!next.receiveId) return { ok: false, code: "missing_receive_id", message: "请填写接收人 open_id(或 chat_id)" };
|
|
8120
|
+
const out = { ...bots, version: 1, feishu: next };
|
|
8121
|
+
try {
|
|
8122
|
+
writeImBots(relayDir, out);
|
|
8123
|
+
return { ok: true };
|
|
8124
|
+
} catch (e) {
|
|
8125
|
+
return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
|
|
8126
|
+
}
|
|
8127
|
+
}
|
|
8128
|
+
|
|
8129
|
+
/** 按平台分发保存(PUT / POST test 共用)。 */
|
|
8130
|
+
function saveImPlatformConfig(relayDir, platform, input) {
|
|
8131
|
+
if (platform === "wecom") return saveWecomConfig(relayDir, input);
|
|
8132
|
+
if (platform === "dingtalk") return saveDingtalkConfig(relayDir, input);
|
|
8133
|
+
if (platform === "feishu") return saveFeishuConfig(relayDir, input);
|
|
8134
|
+
return { ok: false, code: "bad_platform", message: `不支持的平台:${platform}` };
|
|
8135
|
+
}
|
|
8136
|
+
|
|
8137
|
+
/** 各平台「是否已保存 secret」——决定测试前能不能直接连。 */
|
|
8138
|
+
function imPlatformHasSecret(relayDir, platform) {
|
|
8139
|
+
const bots = readImBots(relayDir);
|
|
8140
|
+
if (platform === "wecom") return !!wecomCfgOf(bots).secret;
|
|
8141
|
+
if (platform === "dingtalk") return !!dingtalkCfgOf(bots).clientSecret;
|
|
8142
|
+
if (platform === "feishu") return !!feishuCfgOf(bots).appSecret;
|
|
8143
|
+
return false;
|
|
8144
|
+
}
|
|
8145
|
+
|
|
8146
|
+
/** 状态文件里某平台的连接结论(测试轮询用)。 */
|
|
8147
|
+
function imPlatformStatusNow(relayDir, platform) {
|
|
8148
|
+
const st = readImBotStatus(relayDir);
|
|
8149
|
+
if (platform === "wecom") return wecomStatusOf(st);
|
|
8150
|
+
return imPlatformStatusOf(st, platform);
|
|
8151
|
+
}
|
|
8152
|
+
|
|
7759
8153
|
/**
|
|
7760
8154
|
* POST /dsh-remote/im-bots/test:保存(若带凭据)→ 轮询桥写的状态文件 ≤ waitMs。
|
|
7761
8155
|
* 返回面板直接可渲染的结论:连接中 / 已连接 / 失败原因(桥的 lastError 原文照登)。
|
|
7762
8156
|
*/
|
|
7763
|
-
async function imBotsTest(relayDir, waitMs, input) {
|
|
8157
|
+
async function imBotsTest(relayDir, waitMs, input, platform = "wecom") {
|
|
8158
|
+
const plat = IM_PLATFORMS.includes(platform) ? platform : "wecom";
|
|
7764
8159
|
const hasInput = input && typeof input === "object" && Object.keys(input).length > 0;
|
|
7765
8160
|
if (hasInput) {
|
|
7766
|
-
const saved =
|
|
8161
|
+
const saved = saveImPlatformConfig(relayDir, plat, input);
|
|
7767
8162
|
if (!saved.ok) return { httpCode: 400, body: { ok: false, error: saved.code, message: saved.message } };
|
|
7768
8163
|
}
|
|
7769
|
-
|
|
7770
|
-
if (!w.secret) {
|
|
8164
|
+
if (!imPlatformHasSecret(relayDir, plat)) {
|
|
7771
8165
|
return { httpCode: 400, body: { ok: false, error: "missing_secret", message: "还没有保存 Secret:请先填写并保存(Secret 只保存在本机)" } };
|
|
7772
8166
|
}
|
|
7773
8167
|
const startedAt = Date.now();
|
|
7774
8168
|
const total = Math.max(200, Number(waitMs) || IM_BOT_STATUS_WAIT_MS);
|
|
7775
|
-
|
|
8169
|
+
const okBody = (last) => ({ ok: true, platform: plat, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" });
|
|
8170
|
+
let last = imPlatformStatusNow(relayDir, plat);
|
|
7776
8171
|
while (Date.now() - startedAt < total) {
|
|
7777
|
-
if (last.connected) {
|
|
7778
|
-
return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
|
|
7779
|
-
}
|
|
8172
|
+
if (last.connected) return { httpCode: 200, body: okBody(last) };
|
|
7780
8173
|
await new Promise((r) => { const t = setTimeout(r, IM_BOT_STATUS_POLL_MS); try { t.unref?.(); } catch { /* 忽略 */ } });
|
|
7781
|
-
last =
|
|
8174
|
+
last = imPlatformStatusNow(relayDir, plat);
|
|
7782
8175
|
if (last.lastError && !last.connected) break; // 桥已经明确报错:不用傻等到超时
|
|
7783
8176
|
}
|
|
7784
|
-
if (last.connected) {
|
|
7785
|
-
return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
|
|
7786
|
-
}
|
|
8177
|
+
if (last.connected) return { httpCode: 200, body: okBody(last) };
|
|
7787
8178
|
if (last.lastError) {
|
|
7788
|
-
return { httpCode: 502, body: { ok: false, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
|
|
8179
|
+
return { httpCode: 502, body: { ok: false, platform: plat, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
|
|
7789
8180
|
}
|
|
7790
|
-
return { httpCode: 504, body: { ok: false, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
|
|
8181
|
+
return { httpCode: 504, body: { ok: false, platform: plat, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
|
|
7791
8182
|
}
|
|
7792
8183
|
|
|
7793
8184
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
@@ -8290,7 +8681,26 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8290
8681
|
const bridgeRestart = startBridge(relayDir);
|
|
8291
8682
|
// 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」约定);失败静默。
|
|
8292
8683
|
maybeReportInstall(relayDir, { force: true });
|
|
8293
|
-
|
|
8684
|
+
// 【B2 · 2026-11-07】登录响应**立即返回**:旧实现 `...(await composeStatus())` 会把
|
|
8685
|
+
// 端口发现 / launchctl+ps / 中继探测全塞进登录路径(业主实测 7~8 秒)。
|
|
8686
|
+
// 这里只回"保存成功 + bridge 重启结果 + 最小配置回显",衍生信息由面板登录后异步拉
|
|
8687
|
+
// (GET /dsh-remote/status & /bridge-status),UI 已经先切到已登录态。
|
|
8688
|
+
const cfgEcho = loadConfig(relayDir);
|
|
8689
|
+
sendJson(res, 200, {
|
|
8690
|
+
ok: true,
|
|
8691
|
+
bridgeRestart,
|
|
8692
|
+
statusPending: true,
|
|
8693
|
+
config: {
|
|
8694
|
+
phone: maskPhone(cfgEcho.phone || ""),
|
|
8695
|
+
hasPhone: Boolean(cfgEcho.phone),
|
|
8696
|
+
hasPassword: Boolean(cfgEcho.password),
|
|
8697
|
+
deviceId: cfgEcho.device_id || "",
|
|
8698
|
+
apiUrl: cfgEcho.api_url || DEFAULT_API,
|
|
8699
|
+
mode: cfgEcho.local_key ? "local" : "saas",
|
|
8700
|
+
selfHostUrl: cfgEcho.tunnel_url ? String(cfgEcho.tunnel_url).replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
|
|
8701
|
+
hasLocalKey: Boolean(cfgEcho.local_key)
|
|
8702
|
+
}
|
|
8703
|
+
});
|
|
8294
8704
|
},
|
|
8295
8705
|
},
|
|
8296
8706
|
{
|
|
@@ -8608,13 +9018,45 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8608
9018
|
}
|
|
8609
9019
|
const body = await readJsonBody(req);
|
|
8610
9020
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "bad_request", message: "请求体不是合法 JSON" });
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
9021
|
+
// 三种写法都认(向后兼容):
|
|
9022
|
+
// { wecom:{...} } / { dingtalk:{...} } / { feishu:{...} } —— 面板老写法
|
|
9023
|
+
// { platform:"dingtalk", config:{...} } —— 统一写法
|
|
9024
|
+
// 一次可传多个段;某段校验失败就整体 400(不写半截配置)。
|
|
9025
|
+
const platform = String((body && body.platform) || "");
|
|
9026
|
+
const cfg = body && body.config && typeof body.config === "object" ? body.config : null;
|
|
9027
|
+
const sections = IM_PLATFORMS.filter((k) => body && body[k] && typeof body[k] === "object");
|
|
9028
|
+
if (!sections.length && !(platform && cfg)) {
|
|
9029
|
+
return sendJson(res, 400, { ok: false, error: "bad_request", message: "缺少通道配置(wecom / dingtalk / feishu)" });
|
|
9030
|
+
}
|
|
9031
|
+
if (platform && cfg) {
|
|
9032
|
+
if (!IM_PLATFORMS.includes(platform)) return sendJson(res, 400, { ok: false, error: "bad_platform", message: `不支持的平台:${platform}` });
|
|
9033
|
+
const saved = saveImPlatformConfig(relayDir, platform, cfg);
|
|
9034
|
+
if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
|
|
9035
|
+
}
|
|
9036
|
+
for (const plat of sections) {
|
|
9037
|
+
const saved = saveImPlatformConfig(relayDir, plat, body[plat]);
|
|
9038
|
+
if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
|
|
9039
|
+
}
|
|
8615
9040
|
sendJson(res, 200, imBotsView(relayDir));
|
|
8616
9041
|
},
|
|
8617
9042
|
},
|
|
9043
|
+
{
|
|
9044
|
+
// 飞书 SDK「按需下载」现状(面板据此显示未装/已装/下载中/失败)。
|
|
9045
|
+
method: "GET",
|
|
9046
|
+
path: "/dsh-remote/im-deps",
|
|
9047
|
+
handler: async (_req, res) => {
|
|
9048
|
+
sendJson(res, 200, { ok: true, feishu: feishuDepsInfo(relayDir) });
|
|
9049
|
+
},
|
|
9050
|
+
},
|
|
9051
|
+
{
|
|
9052
|
+
// 触发下载安装(后台执行,立刻返回;进度靠轮询 GET /dsh-remote/im-deps 的 phase)。
|
|
9053
|
+
method: "POST",
|
|
9054
|
+
path: "/dsh-remote/im-deps/feishu",
|
|
9055
|
+
handler: async (_req, res) => {
|
|
9056
|
+
const r = startFeishuInstall(relayDir, { imDepsSpawn: opts.imDepsSpawn });
|
|
9057
|
+
sendJson(res, 200, { ok: true, ...r });
|
|
9058
|
+
},
|
|
9059
|
+
},
|
|
8618
9060
|
{
|
|
8619
9061
|
method: "ALL",
|
|
8620
9062
|
path: "/dsh-remote/im-bots/test",
|
|
@@ -8626,8 +9068,15 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8626
9068
|
}
|
|
8627
9069
|
const body = await readJsonBody(req);
|
|
8628
9070
|
// body 可选:不传 = 只测**已保存**的配置;传 = 先保存再测(与面板「保存并连接」一次点完一致)
|
|
8629
|
-
|
|
8630
|
-
const
|
|
9071
|
+
// platform 默认 wecom(老前端不传时行为不变);config 支持统一写法与 {wecom} 老写法。
|
|
9072
|
+
const platform = String((body && !body.__parseError && body.platform) || "wecom");
|
|
9073
|
+
let input = null;
|
|
9074
|
+
if (body && !body.__parseError) {
|
|
9075
|
+
if (IM_PLATFORMS.includes(platform) && body[platform] && typeof body[platform] === "object") input = body[platform];
|
|
9076
|
+
else if (body.config && typeof body.config === "object") input = body.config;
|
|
9077
|
+
else if (body.wecom && typeof body.wecom === "object" && platform === "wecom") input = body.wecom;
|
|
9078
|
+
}
|
|
9079
|
+
const r = await imBotsTest(relayDir, opts.imBotsWaitMs, input, platform);
|
|
8631
9080
|
sendJson(res, r.httpCode, r.body);
|
|
8632
9081
|
},
|
|
8633
9082
|
},
|
|
@@ -8977,6 +9426,8 @@ export function apply(ctx, config = {}) {
|
|
|
8977
9426
|
ctx.effect(() => registerRoutes(ctx, relayDir, {
|
|
8978
9427
|
// 测试钩子:POST /dsh-remote/im-bots/test 的等待上限(默认 10s,见 imBotsTest 注释)。
|
|
8979
9428
|
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9429
|
+
// 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
|
|
9430
|
+
imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
|
|
8980
9431
|
}), "dsh-remote-web: /dsh-remote routes");
|
|
8981
9432
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
8982
9433
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
|
@@ -9025,6 +9476,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
|
|
|
9025
9476
|
setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
|
|
9026
9477
|
// 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
|
|
9027
9478
|
composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
|
|
9479
|
+
// B1(2026-11-07 登录慢):端口发现是否阻塞、shell 探测 TTL 缓存、状态组装都要能被单测钉住
|
|
9480
|
+
composeStatus as __composeStatus, launchdStatus as __launchdStatus,
|
|
9481
|
+
clearStatusProbeCache as __clearStatusProbeCache, upstreamDiscoverySettled as __upstreamDiscoverySettled,
|
|
9482
|
+
setUpstreamTestState as __setUpstreamTestState, resetUpstreamTestState as __resetUpstreamTestState,
|
|
9483
|
+
STATUS_PROBE_TTL_MS as __STATUS_PROBE_TTL_MS,
|
|
9028
9484
|
bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
|
|
9029
9485
|
bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
|
|
9030
9486
|
DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
|