dsh-remote-web 0.7.12-beta.1 → 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/index.js CHANGED
@@ -7755,7 +7755,36 @@ function imBotsView(relayDir) {
7755
7755
  sessionId: w.sessionId,
7756
7756
  sessionTitle: w.sessionTitle,
7757
7757
  },
7758
- status: wecomStatusOf(stFile),
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
+ }),
7759
7788
  updatedAt: typeof stFile.updatedAt === "number" ? stFile.updatedAt : 0,
7760
7789
  };
7761
7790
  }
@@ -7842,38 +7871,314 @@ function saveWecomConfig(relayDir, input) {
7842
7871
  }
7843
7872
  }
7844
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
+
7845
8153
  /**
7846
8154
  * POST /dsh-remote/im-bots/test:保存(若带凭据)→ 轮询桥写的状态文件 ≤ waitMs。
7847
8155
  * 返回面板直接可渲染的结论:连接中 / 已连接 / 失败原因(桥的 lastError 原文照登)。
7848
8156
  */
7849
- async function imBotsTest(relayDir, waitMs, input) {
8157
+ async function imBotsTest(relayDir, waitMs, input, platform = "wecom") {
8158
+ const plat = IM_PLATFORMS.includes(platform) ? platform : "wecom";
7850
8159
  const hasInput = input && typeof input === "object" && Object.keys(input).length > 0;
7851
8160
  if (hasInput) {
7852
- const saved = saveWecomConfig(relayDir, input);
8161
+ const saved = saveImPlatformConfig(relayDir, plat, input);
7853
8162
  if (!saved.ok) return { httpCode: 400, body: { ok: false, error: saved.code, message: saved.message } };
7854
8163
  }
7855
- const w = wecomCfgOf(readImBots(relayDir));
7856
- if (!w.secret) {
8164
+ if (!imPlatformHasSecret(relayDir, plat)) {
7857
8165
  return { httpCode: 400, body: { ok: false, error: "missing_secret", message: "还没有保存 Secret:请先填写并保存(Secret 只保存在本机)" } };
7858
8166
  }
7859
8167
  const startedAt = Date.now();
7860
8168
  const total = Math.max(200, Number(waitMs) || IM_BOT_STATUS_WAIT_MS);
7861
- let last = wecomStatusOf(readImBotStatus(relayDir));
8169
+ const okBody = (last) => ({ ok: true, platform: plat, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" });
8170
+ let last = imPlatformStatusNow(relayDir, plat);
7862
8171
  while (Date.now() - startedAt < total) {
7863
- if (last.connected) {
7864
- return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
7865
- }
8172
+ if (last.connected) return { httpCode: 200, body: okBody(last) };
7866
8173
  await new Promise((r) => { const t = setTimeout(r, IM_BOT_STATUS_POLL_MS); try { t.unref?.(); } catch { /* 忽略 */ } });
7867
- last = wecomStatusOf(readImBotStatus(relayDir));
8174
+ last = imPlatformStatusNow(relayDir, plat);
7868
8175
  if (last.lastError && !last.connected) break; // 桥已经明确报错:不用傻等到超时
7869
8176
  }
7870
- if (last.connected) {
7871
- return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
7872
- }
8177
+ if (last.connected) return { httpCode: 200, body: okBody(last) };
7873
8178
  if (last.lastError) {
7874
- 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}` } };
7875
8180
  }
7876
- 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 在运行,然后重试。" } };
7877
8182
  }
7878
8183
 
7879
8184
  // ═══════════════════════════════════════════════════════════════════════════════════════════════════
@@ -8713,13 +9018,45 @@ function registerRoutes(ctx, relayDir, opts = {}) {
8713
9018
  }
8714
9019
  const body = await readJsonBody(req);
8715
9020
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "bad_request", message: "请求体不是合法 JSON" });
8716
- const wecom = body && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
8717
- if (!wecom) return sendJson(res, 400, { ok: false, error: "bad_request", message: "缺少 wecom 配置" });
8718
- const saved = saveWecomConfig(relayDir, wecom);
8719
- if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
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
+ }
8720
9040
  sendJson(res, 200, imBotsView(relayDir));
8721
9041
  },
8722
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
+ },
8723
9060
  {
8724
9061
  method: "ALL",
8725
9062
  path: "/dsh-remote/im-bots/test",
@@ -8731,8 +9068,15 @@ function registerRoutes(ctx, relayDir, opts = {}) {
8731
9068
  }
8732
9069
  const body = await readJsonBody(req);
8733
9070
  // body 可选:不传 = 只测**已保存**的配置;传 = 先保存再测(与面板「保存并连接」一次点完一致)
8734
- const wecom = body && !body.__parseError && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
8735
- const r = await imBotsTest(relayDir, opts.imBotsWaitMs, wecom);
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);
8736
9080
  sendJson(res, r.httpCode, r.body);
8737
9081
  },
8738
9082
  },
@@ -9082,6 +9426,8 @@ export function apply(ctx, config = {}) {
9082
9426
  ctx.effect(() => registerRoutes(ctx, relayDir, {
9083
9427
  // 测试钩子:POST /dsh-remote/im-bots/test 的等待上限(默认 10s,见 imBotsTest 注释)。
9084
9428
  imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
9429
+ // 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
9430
+ imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
9085
9431
  }), "dsh-remote-web: /dsh-remote routes");
9086
9432
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
9087
9433
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.12-beta.1",
3
+ "version": "0.7.12-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",