dsh-remote-web 0.7.12-beta.1 → 0.7.12-beta.3
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 +500 -4
- package/lib/index.js +581 -27
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +1361 -0
- package/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +217 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +404 -90
- package/runtime/clients/dsh-remote/feishu-bot.mjs +1852 -0
- package/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/runtime/dsh-setup.mjs +1 -1
package/lib/index.js
CHANGED
|
@@ -6529,6 +6529,15 @@ const TELEMETRY_FAIL_CODES = new Set([
|
|
|
6529
6529
|
"npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding", "update_stalled",
|
|
6530
6530
|
"unknown",
|
|
6531
6531
|
]);
|
|
6532
|
+
/**
|
|
6533
|
+
* mirror_load 的 outcome 枚举(2026-11-08,与服务端 TELEMETRY_OUTCOME 逐字对齐):
|
|
6534
|
+
* ok = 就绪(真实活跃耗时)
|
|
6535
|
+
* slow = 20s 未就绪(删失样本)
|
|
6536
|
+
* suspended = 页面被系统挂起/冻结(旧客户端不会发,新客户端也暂不发;服务端按 hidden/active 派生)
|
|
6537
|
+
* abandoned = 用户关页/切走时仍未就绪
|
|
6538
|
+
* 缺省/未知一律发 ""(未知),**不再默认谎报 ok** —— 否则旧客户端的挂起样本会被记成正常。
|
|
6539
|
+
*/
|
|
6540
|
+
const TELEMETRY_OUTCOMES = new Set(["ok", "slow", "suspended", "abandoned"]);
|
|
6532
6541
|
/** node 半只发 source=plugin。 */
|
|
6533
6542
|
const TELEMETRY_SOURCE = "plugin";
|
|
6534
6543
|
const TELEMETRY_INSTALL_ID_FILE = ".telemetry-install-id";
|
|
@@ -6849,7 +6858,18 @@ function telemetryEventOf(name, extra = {}) {
|
|
|
6849
6858
|
if (name === "mirror_load") {
|
|
6850
6859
|
if (Number.isFinite(Number(extra.at)) && Number(extra.at) > 0) ev.at = Math.round(Number(extra.at));
|
|
6851
6860
|
if (Number.isFinite(Number(extra.ms))) ev.ms = Math.max(0, Math.min(600000, Math.round(Number(extra.ms))));
|
|
6852
|
-
|
|
6861
|
+
// 2026-11-08:outcome 四枚举透传;active_ms/hidden_ms/frozen_ms 是"活跃加载时间"口径。
|
|
6862
|
+
// 旧客户端不带这些字段 → outcome=""、数值字段缺省;旧服务端忽略未知字段,零破坏。
|
|
6863
|
+
const oc = String(extra.outcome || "");
|
|
6864
|
+
ev.outcome = TELEMETRY_OUTCOMES.has(oc) ? oc : "";
|
|
6865
|
+
for (const k of ["active_ms", "hidden_ms", "frozen_ms"]) {
|
|
6866
|
+
const v = Number(extra[k]);
|
|
6867
|
+
if (Number.isFinite(v) && v >= 0) ev[k] = Math.max(0, Math.min(600000, Math.round(v)));
|
|
6868
|
+
}
|
|
6869
|
+
// 防御:活跃耗时不可能超过墙钟耗时(取整/时钟差可能造出 1ms 级倒挂)
|
|
6870
|
+
if (Number.isFinite(Number(ev.active_ms)) && Number.isFinite(Number(ev.ms))) {
|
|
6871
|
+
ev.active_ms = Math.min(ev.active_ms, ev.ms);
|
|
6872
|
+
}
|
|
6853
6873
|
}
|
|
6854
6874
|
return ev;
|
|
6855
6875
|
}
|
|
@@ -7755,7 +7775,37 @@ function imBotsView(relayDir) {
|
|
|
7755
7775
|
sessionId: w.sessionId,
|
|
7756
7776
|
sessionTitle: w.sessionTitle,
|
|
7757
7777
|
},
|
|
7758
|
-
|
|
7778
|
+
// 钉钉 / 飞书:与 wecom 同一份文件里的两段(secret 永不回显,只给 hasSecret)
|
|
7779
|
+
dingtalk: {
|
|
7780
|
+
enabled: dingtalkCfgOf(bots).enabled,
|
|
7781
|
+
clientId: dingtalkCfgOf(bots).clientId,
|
|
7782
|
+
hasSecret: dingtalkCfgOf(bots).clientSecret.length > 0,
|
|
7783
|
+
robotCode: dingtalkCfgOf(bots).robotCode,
|
|
7784
|
+
replyMode: dingtalkCfgOf(bots).replyMode,
|
|
7785
|
+
sessionWebhook: dingtalkCfgOf(bots).sessionWebhook,
|
|
7786
|
+
boundChatId: dingtalkCfgOf(bots).boundChatId,
|
|
7787
|
+
allowFrom: dingtalkCfgOf(bots).allowFrom,
|
|
7788
|
+
cardTemplateId: dingtalkCfgOf(bots).cardTemplateId,
|
|
7789
|
+
streamUrl: dingtalkCfgOf(bots).streamUrl,
|
|
7790
|
+
credentialSource: dingtalkCfgOf(bots).credentialSource,
|
|
7791
|
+
},
|
|
7792
|
+
feishu: {
|
|
7793
|
+
enabled: feishuCfgOf(bots).enabled,
|
|
7794
|
+
appId: feishuCfgOf(bots).appId,
|
|
7795
|
+
hasSecret: feishuCfgOf(bots).appSecret.length > 0,
|
|
7796
|
+
domain: feishuCfgOf(bots).domain,
|
|
7797
|
+
receiveId: feishuCfgOf(bots).receiveId,
|
|
7798
|
+
receiveIdType: feishuCfgOf(bots).receiveIdType,
|
|
7799
|
+
boundChatId: feishuCfgOf(bots).boundChatId,
|
|
7800
|
+
allowFrom: feishuCfgOf(bots).allowFrom,
|
|
7801
|
+
cardApproval: feishuCfgOf(bots).cardApproval,
|
|
7802
|
+
sdkVersion: feishuCfgOf(bots).sdkVersion,
|
|
7803
|
+
},
|
|
7804
|
+
status: Object.assign({}, wecomStatusOf(stFile), {
|
|
7805
|
+
dingtalk: imPlatformStatusOf(stFile, "dingtalk"),
|
|
7806
|
+
feishu: imPlatformStatusOf(stFile, "feishu"),
|
|
7807
|
+
deps: { feishu: feishuDepsInfo(relayDir) },
|
|
7808
|
+
}),
|
|
7759
7809
|
updatedAt: typeof stFile.updatedAt === "number" ? stFile.updatedAt : 0,
|
|
7760
7810
|
};
|
|
7761
7811
|
}
|
|
@@ -7842,38 +7892,472 @@ function saveWecomConfig(relayDir, input) {
|
|
|
7842
7892
|
}
|
|
7843
7893
|
}
|
|
7844
7894
|
|
|
7895
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7896
|
+
// 钉钉 / 飞书通道(2026-11-08):与 wecom 同一套文件(im-bots.json / im-bot-status.json),
|
|
7897
|
+
// 但配置/状态字段按契约分开。**secret 永不返回浏览器**(只给 hasSecret)。
|
|
7898
|
+
// 飞书 SDK 走「按需下载」:~/.dsh-remote/im-deps 下 npm install 一个钉死版本,不随插件打包。
|
|
7899
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
7900
|
+
const IM_SECTION_MAX = 400; // 单个字符串字段上限(凭据/ID 都是短串)
|
|
7901
|
+
const IM_DEPS_DIR = "im-deps";
|
|
7902
|
+
const IM_DEPS_STATUS_FILE = "install-status.json";
|
|
7903
|
+
const FEISHU_SDK_PKG = "@larksuiteoapi/node-sdk";
|
|
7904
|
+
/** 钉死版本:升级要走发版流程(桥/插件对 SDK 行为有依赖,不能装 latest 漂移)。 */
|
|
7905
|
+
const FEISHU_SDK_PIN = "1.40.0";
|
|
7906
|
+
const FEISHU_SDK_SPEC = `${FEISHU_SDK_PKG}@${FEISHU_SDK_PIN}`;
|
|
7907
|
+
const IM_PLATFORMS = ["wecom", "dingtalk", "feishu"];
|
|
7908
|
+
const FEISHU_RECEIVE_ID_TYPES = ["open_id", "chat_id", "email", "user_id"];
|
|
7909
|
+
|
|
7910
|
+
const strOf = (v) => (typeof v === "string" ? v : "");
|
|
7911
|
+
const numOf = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
7912
|
+
const strListOf = (v) => (Array.isArray(v) ? v.slice(0, 50).map((x) => strOf(x).slice(0, IM_SECTION_MAX)).filter(Boolean) : []);
|
|
7913
|
+
|
|
7914
|
+
/** 归一化 dingtalk 配置(读/写共用;未知字段由调用方 ...bots 保留)。 */
|
|
7915
|
+
function dingtalkCfgOf(bots) {
|
|
7916
|
+
const d = bots && bots.dingtalk && typeof bots.dingtalk === "object" ? bots.dingtalk : {};
|
|
7917
|
+
return {
|
|
7918
|
+
enabled: !!d.enabled,
|
|
7919
|
+
clientId: strOf(d.clientId),
|
|
7920
|
+
clientSecret: strOf(d.clientSecret),
|
|
7921
|
+
robotCode: strOf(d.robotCode),
|
|
7922
|
+
replyMode: strOf(d.replyMode) || "stream",
|
|
7923
|
+
sessionWebhook: strOf(d.sessionWebhook),
|
|
7924
|
+
boundChatId: strOf(d.boundChatId),
|
|
7925
|
+
allowFrom: strListOf(d.allowFrom),
|
|
7926
|
+
cardTemplateId: strOf(d.cardTemplateId),
|
|
7927
|
+
streamUrl: strOf(d.streamUrl),
|
|
7928
|
+
// 凭据来源:scan=扫码一键创建;manual=用户手填(2026-11-08 面板显示用,非敏感)
|
|
7929
|
+
credentialSource: strOf(d.credentialSource),
|
|
7930
|
+
};
|
|
7931
|
+
}
|
|
7932
|
+
|
|
7933
|
+
/** 归一化 feishu 配置。 */
|
|
7934
|
+
function feishuCfgOf(bots) {
|
|
7935
|
+
const f = bots && bots.feishu && typeof bots.feishu === "object" ? bots.feishu : {};
|
|
7936
|
+
const type = strOf(f.receiveIdType);
|
|
7937
|
+
return {
|
|
7938
|
+
enabled: !!f.enabled,
|
|
7939
|
+
appId: strOf(f.appId),
|
|
7940
|
+
appSecret: strOf(f.appSecret),
|
|
7941
|
+
domain: strOf(f.domain) || "https://open.feishu.cn",
|
|
7942
|
+
receiveId: strOf(f.receiveId),
|
|
7943
|
+
receiveIdType: FEISHU_RECEIVE_ID_TYPES.includes(type) ? type : "open_id",
|
|
7944
|
+
boundChatId: strOf(f.boundChatId),
|
|
7945
|
+
allowFrom: strListOf(f.allowFrom),
|
|
7946
|
+
cardApproval: f.cardApproval !== false,
|
|
7947
|
+
sdkVersion: strOf(f.sdkVersion),
|
|
7948
|
+
};
|
|
7949
|
+
}
|
|
7950
|
+
|
|
7951
|
+
/** 状态文件里 dingtalk/feishu 段 → 契约形状(白名单;桥塞什么都不带出去)。 */
|
|
7952
|
+
function imPlatformStatusOf(status, platform) {
|
|
7953
|
+
const p = status && status[platform] && typeof status[platform] === "object" ? status[platform] : {};
|
|
7954
|
+
const connected = !!p.connected;
|
|
7955
|
+
const rawState = strOf(p.state);
|
|
7956
|
+
const states = ["idle", "connecting", "connected", "reconnecting", "failed"];
|
|
7957
|
+
const state = states.includes(rawState) ? rawState
|
|
7958
|
+
: connected ? "connected" : (p.enabled ? "connecting" : "idle");
|
|
7959
|
+
const t = strOf(p.tier);
|
|
7960
|
+
return {
|
|
7961
|
+
enabled: !!p.enabled,
|
|
7962
|
+
state,
|
|
7963
|
+
connected,
|
|
7964
|
+
authenticated: !!p.authenticated,
|
|
7965
|
+
lastError: strOf(p.lastError).slice(0, 500),
|
|
7966
|
+
boundChatId: strOf(p.boundChatId),
|
|
7967
|
+
// 飞书"接收群/私聊自动发现"(2026-11-08 业主:用户不需要填接收人)——
|
|
7968
|
+
// boundChatType:p2p/group;receiveIdSource:auto/manual/config(桥侧口径,只透传字符串)
|
|
7969
|
+
boundChatType: strOf(p.boundChatType).slice(0, 40),
|
|
7970
|
+
receiveIdSource: strOf(p.receiveIdSource).slice(0, 40),
|
|
7971
|
+
// 钉钉能力位(2026-11-08 桥侧新增):凭据来源 / 是否已配置 Robot Code / 能力开关。
|
|
7972
|
+
// 面板据 robotCodeConfigured 把「主动推送/卡片」置灰(缺 Robot Code 时能力不可用)。
|
|
7973
|
+
credentialSource: strOf(p.credentialSource).slice(0, 20),
|
|
7974
|
+
robotCodeConfigured: !!p.robotCodeConfigured,
|
|
7975
|
+
capabilities: (() => {
|
|
7976
|
+
const c = p.capabilities && typeof p.capabilities === "object" ? p.capabilities : null;
|
|
7977
|
+
return c ? { openapi: !!c.openapi, card: !!c.card } : null;
|
|
7978
|
+
})(),
|
|
7979
|
+
tier: (t === "free" || t === "pro" || t === "pro_max") ? t : "",
|
|
7980
|
+
caps: strListOf(p.caps).map((x) => x.slice(0, 80)),
|
|
7981
|
+
capsCount: numOf(p.capsCount),
|
|
7982
|
+
bindingGated: !!p.bindingGated,
|
|
7983
|
+
messagesPerMonth: numOf(p.messagesPerMonth),
|
|
7984
|
+
remainingMessages: typeof p.remainingMessages === "number" && Number.isFinite(p.remainingMessages) ? p.remainingMessages : null,
|
|
7985
|
+
usage: (() => {
|
|
7986
|
+
const u = p.usage && typeof p.usage === "object" ? p.usage : null;
|
|
7987
|
+
return u ? { month: strOf(u.month), messages: numOf(u.messages) } : null;
|
|
7988
|
+
})(),
|
|
7989
|
+
};
|
|
7990
|
+
}
|
|
7991
|
+
|
|
7992
|
+
function imDepsDirOf(relayDir) { return join(relayDir, IM_DEPS_DIR); }
|
|
7993
|
+
function imDepsStatusPathOf(relayDir) { return join(relayDir, IM_DEPS_DIR, IM_DEPS_STATUS_FILE); }
|
|
7994
|
+
|
|
7995
|
+
/** 飞书 SDK 依赖现状(GET /dsh-remote/im-deps 的数据源;phase 用于面板进度轮询)。 */
|
|
7996
|
+
function feishuDepsInfo(relayDir) {
|
|
7997
|
+
const base = imDepsDirOf(relayDir);
|
|
7998
|
+
const pkgDir = join(base, "node_modules", "@larksuiteoapi", "node-sdk");
|
|
7999
|
+
let installed = false;
|
|
8000
|
+
let version = "";
|
|
8001
|
+
try {
|
|
8002
|
+
const pj = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
|
|
8003
|
+
installed = true;
|
|
8004
|
+
version = strOf(pj.version);
|
|
8005
|
+
} catch { /* 未安装 */ }
|
|
8006
|
+
const st = readJsonFileSoft(imDepsStatusPathOf(relayDir));
|
|
8007
|
+
const f = st && st.feishu && typeof st.feishu === "object" ? st.feishu : {};
|
|
8008
|
+
const phases = ["idle", "downloading", "installing", "done", "failed"];
|
|
8009
|
+
let phase = phases.includes(strOf(f.phase)) ? strOf(f.phase) : (installed ? "done" : "idle");
|
|
8010
|
+
if (!installed && phase === "done") phase = "idle";
|
|
8011
|
+
return {
|
|
8012
|
+
installed,
|
|
8013
|
+
version: installed ? version : "",
|
|
8014
|
+
path: installed ? pkgDir : "",
|
|
8015
|
+
lastError: strOf(f.lastError).slice(0, 500),
|
|
8016
|
+
phase,
|
|
8017
|
+
};
|
|
8018
|
+
}
|
|
8019
|
+
|
|
8020
|
+
function writeFeishuDepsPhase(relayDir, patch) {
|
|
8021
|
+
try {
|
|
8022
|
+
const dir = imDepsDirOf(relayDir);
|
|
8023
|
+
mkdirSync(dir, { recursive: true });
|
|
8024
|
+
const file = imDepsStatusPathOf(relayDir);
|
|
8025
|
+
const cur = readJsonFileSoft(file);
|
|
8026
|
+
const next = { ...cur, feishu: { ...(cur.feishu && typeof cur.feishu === "object" ? cur.feishu : {}), ...patch } };
|
|
8027
|
+
const tmp = `${file}.tmp`;
|
|
8028
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
8029
|
+
try { chmodSync(tmp, 0o600); } catch { /* 非 POSIX */ }
|
|
8030
|
+
renameSync(tmp, file);
|
|
8031
|
+
try { chmodSync(file, 0o600); } catch { /* 忽略 */ }
|
|
8032
|
+
} catch { /* 进度记录失败不致命 */ }
|
|
8033
|
+
}
|
|
8034
|
+
|
|
8035
|
+
/** npm 安装失败的归因码(面板据此给人话 + 可重试建议)。 */
|
|
8036
|
+
function classifySdkInstallError(text) {
|
|
8037
|
+
const t = String(text || "");
|
|
8038
|
+
if (/ENOSPC|no space/i.test(t)) return { code: "disk_full", message: "磁盘空间不足:清出约 200MB 后重试" };
|
|
8039
|
+
if (/EACCES|EPERM|permission denied/i.test(t)) return { code: "permission_denied", message: "没有写入权限:检查 ~/.dsh-remote 目录权限后重试" };
|
|
8040
|
+
if (/ETIMEDOUT|ENOTFOUND|ECONN|EAI_AGAIN|network|socket hang up|registry/i.test(t)) return { code: "network", message: "网络不通:检查能否访问 npm registry(可换镜像后重试)" };
|
|
8041
|
+
return { code: "sdk_install_failed", message: `依赖安装失败:${t.split("\n").filter(Boolean).slice(-3).join(" ").slice(0, 300) || "未知原因"}` };
|
|
8042
|
+
}
|
|
8043
|
+
|
|
8044
|
+
const feishuInstallTasks = new Map(); // relayDir → { startedAt, running }
|
|
8045
|
+
|
|
8046
|
+
/**
|
|
8047
|
+
* 后台下载安装飞书 SDK(POST /dsh-remote/im-deps/feishu)。
|
|
8048
|
+
* 不阻塞请求:立刻回 taskId + 状态,进度由面板轮询 GET /dsh-remote/im-deps 的 phase。
|
|
8049
|
+
* 测试注入:opts.imDepsSpawn(cmd, args, opts) 可替换真实 npm(用例绝不允许真的联网装包)。
|
|
8050
|
+
*/
|
|
8051
|
+
function startFeishuInstall(relayDir, opts = {}) {
|
|
8052
|
+
const cur = feishuDepsInfo(relayDir);
|
|
8053
|
+
if (cur.installed) return { ok: true, started: false, alreadyInstalled: true, deps: { feishu: cur } };
|
|
8054
|
+
const running = feishuInstallTasks.get(relayDir);
|
|
8055
|
+
if (running && running.running) return { ok: true, started: false, running: true, taskId: running.taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8056
|
+
const taskId = `feishu-sdk-${Date.now().toString(36)}`;
|
|
8057
|
+
const task = { running: true, startedAt: Date.now(), taskId };
|
|
8058
|
+
feishuInstallTasks.set(relayDir, task);
|
|
8059
|
+
const finish = (patch) => {
|
|
8060
|
+
task.running = false;
|
|
8061
|
+
task.endedAt = Date.now();
|
|
8062
|
+
writeFeishuDepsPhase(relayDir, { ...patch, endedAt: task.endedAt, startedAt: task.startedAt, taskId });
|
|
8063
|
+
};
|
|
8064
|
+
writeFeishuDepsPhase(relayDir, { phase: "downloading", lastError: "", startedAt: task.startedAt });
|
|
8065
|
+
let child = null;
|
|
8066
|
+
try {
|
|
8067
|
+
const base = imDepsDirOf(relayDir);
|
|
8068
|
+
mkdirSync(base, { recursive: true });
|
|
8069
|
+
const args = ["install", "--prefix", base, "--no-audit", "--no-fund", "--loglevel=error", FEISHU_SDK_SPEC];
|
|
8070
|
+
const spawnImpl = typeof opts.imDepsSpawn === "function"
|
|
8071
|
+
? opts.imDepsSpawn
|
|
8072
|
+
: (cmd, a, o) => spawn(cmd, a, { ...o, windowsHide: true }); // Windows 不闪黑窗
|
|
8073
|
+
const isWin = isWindows();
|
|
8074
|
+
const cmd = isWin ? "npm.cmd" : "npm";
|
|
8075
|
+
child = spawnImpl(cmd, args, { cwd: base, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
8076
|
+
let output = "";
|
|
8077
|
+
const collect = (buf) => { try { output = (output + String(buf)).slice(-4000); } catch { /* 忽略 */ } };
|
|
8078
|
+
try { child.stdout?.on?.("data", collect); child.stderr?.on?.("data", collect); } catch { /* 忽略 */ }
|
|
8079
|
+
const done = (code, err) => {
|
|
8080
|
+
if (feishuInstallTasks.get(relayDir) !== task) return; // 已被新任务取代
|
|
8081
|
+
const after = feishuDepsInfo(relayDir);
|
|
8082
|
+
if (after.installed) return finish({ phase: "done", lastError: "" });
|
|
8083
|
+
const reason = classifySdkInstallError(err || output || `exit ${code}`);
|
|
8084
|
+
finish({ phase: "failed", lastError: reason.message, errorCode: reason.code });
|
|
8085
|
+
};
|
|
8086
|
+
try {
|
|
8087
|
+
child.on?.("exit", (code) => done(code, ""));
|
|
8088
|
+
child.on?.("error", (e) => done(-1, String(e?.message || e)));
|
|
8089
|
+
} catch { done(-1, "无法启动 npm"); }
|
|
8090
|
+
} catch (e) {
|
|
8091
|
+
const reason = classifySdkInstallError(String(e?.message || e));
|
|
8092
|
+
finish({ phase: "failed", lastError: reason.message, errorCode: reason.code });
|
|
8093
|
+
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8094
|
+
}
|
|
8095
|
+
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8096
|
+
}
|
|
8097
|
+
|
|
8098
|
+
// ── 钉钉「扫码一键创建机器人」(2026-11-08,官方 connector 同款 device-flow)─────────────
|
|
8099
|
+
// 流程(服务端代跑,device_code 只存内存、不下发浏览器):
|
|
8100
|
+
// POST .../app/registration/init {source:"DING_DWS_CLAW"} → {nonce}
|
|
8101
|
+
// POST .../app/registration/begin {nonce} → {device_code, verification_uri_complete, interval, expires_in}
|
|
8102
|
+
// POST .../app/registration/poll {device_code} → {status:"WAITING"|"SUCCESS", client_id, client_secret}
|
|
8103
|
+
// 安全:浏览器只拿 authId + 授权链接;clientSecret 只在 success 时直接写进 0600 的 im-bots.json,绝不回显。
|
|
8104
|
+
const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
|
|
8105
|
+
const DINGTALK_AUTH_TTL_MS = 2 * 60 * 1000; // 面板最长轮询 2 分钟
|
|
8106
|
+
const dingtalkAuthSessions = new Map(); // authId → {relayDir, deviceCode, url, interval, createdAt, expiresAt}
|
|
8107
|
+
const dingtalkAuthSuperseded = new Map(); // 被新授权作废的 authId → at(查询时回 cancelled)
|
|
8108
|
+
|
|
8109
|
+
function jsonPostForAuth(fetchImpl, url, payload) {
|
|
8110
|
+
return fetchImpl(url, {
|
|
8111
|
+
method: "POST",
|
|
8112
|
+
headers: { "content-type": "application/json" },
|
|
8113
|
+
body: JSON.stringify(payload || {}),
|
|
8114
|
+
signal: AbortSignal.timeout(8000),
|
|
8115
|
+
}).then(async (r) => {
|
|
8116
|
+
let body = null;
|
|
8117
|
+
try { body = await r.json(); } catch { body = null; }
|
|
8118
|
+
return { ok: !!r.ok, status: r.status, body: body || {} };
|
|
8119
|
+
});
|
|
8120
|
+
}
|
|
8121
|
+
|
|
8122
|
+
/**
|
|
8123
|
+
* 开始一次钉钉扫码授权(POST /dsh-remote/im-dingtalk-auth/begin)。
|
|
8124
|
+
* 同一 relayDir 同时只保留一个会话:begin 时把旧的作废(旧 authId 后续 poll 只会得到 expired)。
|
|
8125
|
+
*/
|
|
8126
|
+
async function beginDingtalkAuth(relayDir, opts = {}) {
|
|
8127
|
+
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8128
|
+
// 作废旧会话(同一时间只允许一个进行中的授权;旧 authId 后续查询回 cancelled)
|
|
8129
|
+
for (const [id, sess] of dingtalkAuthSessions) {
|
|
8130
|
+
if (sess.relayDir === relayDir) {
|
|
8131
|
+
dingtalkAuthSessions.delete(id);
|
|
8132
|
+
dingtalkAuthSuperseded.set(id, Date.now());
|
|
8133
|
+
}
|
|
8134
|
+
}
|
|
8135
|
+
// 有界:只留最近 20 个作废标记,避免长期运行内存增长
|
|
8136
|
+
while (dingtalkAuthSuperseded.size > 20) {
|
|
8137
|
+
const first = dingtalkAuthSuperseded.keys().next().value;
|
|
8138
|
+
dingtalkAuthSuperseded.delete(first);
|
|
8139
|
+
}
|
|
8140
|
+
let init;
|
|
8141
|
+
try {
|
|
8142
|
+
init = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" });
|
|
8143
|
+
} catch (e) {
|
|
8144
|
+
return { ok: false, code: "network_error", message: `网络不通:无法连接钉钉接口(${e?.message || e})` };
|
|
8145
|
+
}
|
|
8146
|
+
const nonce = init.ok && init.body && typeof init.body.nonce === "string" ? init.body.nonce : "";
|
|
8147
|
+
if (!nonce) {
|
|
8148
|
+
return { ok: false, code: "dingtalk_api_rejected", message: "钉钉接口拒绝了初始化请求(请稍后重试)" };
|
|
8149
|
+
}
|
|
8150
|
+
let begin;
|
|
8151
|
+
try {
|
|
8152
|
+
begin = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce });
|
|
8153
|
+
} catch (e) {
|
|
8154
|
+
return { ok: false, code: "network_error", message: `网络不通:无法连接钉钉接口(${e?.message || e})` };
|
|
8155
|
+
}
|
|
8156
|
+
const b = begin.body || {};
|
|
8157
|
+
const deviceCode = typeof b.device_code === "string" ? b.device_code : "";
|
|
8158
|
+
const url = typeof b.verification_uri_complete === "string" ? b.verification_uri_complete : "";
|
|
8159
|
+
if (!begin.ok || !deviceCode || !url) {
|
|
8160
|
+
return { ok: false, code: "dingtalk_api_rejected", message: "钉钉接口没有返回授权链接(请稍后重试)" };
|
|
8161
|
+
}
|
|
8162
|
+
const authId = `ding-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8163
|
+
const interval = Math.max(1, Math.min(10, Number(b.interval) || 2));
|
|
8164
|
+
const expiresIn = Math.max(10, Math.min(600, Number(b.expires_in) || 120));
|
|
8165
|
+
const now = Date.now();
|
|
8166
|
+
dingtalkAuthSessions.set(authId, {
|
|
8167
|
+
relayDir, deviceCode, url, interval,
|
|
8168
|
+
createdAt: now, expiresAt: Math.min(now + expiresIn * 1000, now + DINGTALK_AUTH_TTL_MS),
|
|
8169
|
+
});
|
|
8170
|
+
return { ok: true, authId, url, interval, expiresIn };
|
|
8171
|
+
}
|
|
8172
|
+
|
|
8173
|
+
/** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600 原子写;保留其它字段)。 */
|
|
8174
|
+
function saveDingtalkScannedCredentials(relayDir, clientId, clientSecret) {
|
|
8175
|
+
const bots = readImBots(relayDir);
|
|
8176
|
+
const cur = dingtalkCfgOf(bots);
|
|
8177
|
+
const next = {
|
|
8178
|
+
...cur,
|
|
8179
|
+
enabled: true,
|
|
8180
|
+
clientId: String(clientId || "").trim(),
|
|
8181
|
+
clientSecret: String(clientSecret || "").trim(),
|
|
8182
|
+
// 扫码流程只给 client_id/client_secret;robotCode 由桥连上后自动补齐/发现
|
|
8183
|
+
credentialSource: "scan",
|
|
8184
|
+
};
|
|
8185
|
+
try {
|
|
8186
|
+
writeImBots(relayDir, { ...bots, version: 1, dingtalk: next });
|
|
8187
|
+
return { ok: true };
|
|
8188
|
+
} catch (e) {
|
|
8189
|
+
return { ok: false, code: "write_failed", message: `保存扫码凭据失败:${e?.message || e}` };
|
|
8190
|
+
}
|
|
8191
|
+
}
|
|
8192
|
+
|
|
8193
|
+
/**
|
|
8194
|
+
* 查询一次扫码授权状态(GET /dsh-remote/im-dingtalk-auth/status?id=...)。
|
|
8195
|
+
* 返回 status: waiting | success | failed | expired;success 时**已**把凭据写盘,只回脱敏信息。
|
|
8196
|
+
*/
|
|
8197
|
+
async function dingtalkAuthStatus(authId, opts = {}) {
|
|
8198
|
+
const key = String(authId || "");
|
|
8199
|
+
const sess = dingtalkAuthSessions.get(key);
|
|
8200
|
+
if (!sess) {
|
|
8201
|
+
if (dingtalkAuthSuperseded.has(key)) {
|
|
8202
|
+
dingtalkAuthSuperseded.delete(key);
|
|
8203
|
+
return { ok: false, status: "failed", code: "cancelled", message: "已开始新的授权,这次已取消" };
|
|
8204
|
+
}
|
|
8205
|
+
return { ok: false, status: "expired", code: "expired", message: "授权会话不存在或已超时,请重新发起" };
|
|
8206
|
+
}
|
|
8207
|
+
const now = Date.now();
|
|
8208
|
+
if (now > sess.expiresAt) {
|
|
8209
|
+
dingtalkAuthSessions.delete(String(authId));
|
|
8210
|
+
return { ok: false, status: "expired", code: "expired", message: "授权已超时(2 分钟内未确认),请重新发起" };
|
|
8211
|
+
}
|
|
8212
|
+
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8213
|
+
let r;
|
|
8214
|
+
try {
|
|
8215
|
+
r = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/poll`, { device_code: sess.deviceCode });
|
|
8216
|
+
} catch (e) {
|
|
8217
|
+
return { ok: false, status: "failed", code: "network_error", message: `网络不通:查询授权状态失败(${e?.message || e})` };
|
|
8218
|
+
}
|
|
8219
|
+
const b = r.body || {};
|
|
8220
|
+
const st = String(b.status || "").toUpperCase();
|
|
8221
|
+
if (st === "WAITING" || st === "PENDING") return { ok: true, status: "waiting" };
|
|
8222
|
+
if (st === "SUCCESS" || (b.client_id && b.client_secret)) {
|
|
8223
|
+
const saved = saveDingtalkScannedCredentials(sess.relayDir, b.client_id, b.client_secret);
|
|
8224
|
+
dingtalkAuthSessions.delete(String(authId));
|
|
8225
|
+
if (!saved.ok) return { ok: false, status: "failed", code: saved.code, message: saved.message };
|
|
8226
|
+
const cid = String(b.client_id || "");
|
|
8227
|
+
return {
|
|
8228
|
+
ok: true, status: "success", credentialSource: "scan",
|
|
8229
|
+
clientIdTail: cid.length > 4 ? cid.slice(-4) : cid,
|
|
8230
|
+
hasSecret: true
|
|
8231
|
+
};
|
|
8232
|
+
}
|
|
8233
|
+
// 钉钉明确拒绝(创建被取消/参数过期等)
|
|
8234
|
+
return { ok: false, status: "failed", code: "dingtalk_api_rejected", message: String(b.message || b.errmsg || "钉钉授权被拒绝,请重试") };
|
|
8235
|
+
}
|
|
8236
|
+
|
|
8237
|
+
/** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
|
|
8238
|
+
function saveDingtalkConfig(relayDir, input) {
|
|
8239
|
+
const src = input && typeof input === "object" ? input : {};
|
|
8240
|
+
const bots = readImBots(relayDir);
|
|
8241
|
+
const cur = dingtalkCfgOf(bots);
|
|
8242
|
+
const next = { ...cur };
|
|
8243
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientId")) next.clientId = strOf(src.clientId).trim();
|
|
8244
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientSecret")) {
|
|
8245
|
+
const v = strOf(src.clientSecret).trim();
|
|
8246
|
+
if (v) next.clientSecret = v; // 空 = 不修改(面板不回显 secret)
|
|
8247
|
+
}
|
|
8248
|
+
if (Object.prototype.hasOwnProperty.call(src, "robotCode")) next.robotCode = strOf(src.robotCode).trim();
|
|
8249
|
+
for (const k of ["replyMode", "sessionWebhook", "boundChatId", "cardTemplateId", "streamUrl"]) {
|
|
8250
|
+
if (Object.prototype.hasOwnProperty.call(src, k)) next[k] = strOf(src[k]).trim();
|
|
8251
|
+
}
|
|
8252
|
+
if (Object.prototype.hasOwnProperty.call(src, "allowFrom")) next.allowFrom = strListOf(src.allowFrom);
|
|
8253
|
+
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
8254
|
+
else if (!next.enabled && next.clientId && next.clientSecret && next.robotCode) next.enabled = true;
|
|
8255
|
+
// 凭据来源标记(面板显示「扫码创建 / 手动填写」):手填过凭据就记 manual;扫码流程会写 scan。
|
|
8256
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientId") || Object.prototype.hasOwnProperty.call(src, "clientSecret")) {
|
|
8257
|
+
next.credentialSource = "manual";
|
|
8258
|
+
}
|
|
8259
|
+
if (!next.clientId) return { ok: false, code: "missing_client_id", message: "请填写 Client ID(= 钉钉应用的 AppKey)" };
|
|
8260
|
+
if (!next.clientSecret) return { ok: false, code: "missing_client_secret", message: "请填写 Client Secret(= 钉钉应用的 AppSecret,只保存在本机)" };
|
|
8261
|
+
if (!next.robotCode) return { ok: false, code: "missing_robot_code", message: "请填写 Robot Code(机器人编码,在钉钉开放平台机器人页复制)" };
|
|
8262
|
+
const out = { ...bots, version: 1, dingtalk: next };
|
|
8263
|
+
try {
|
|
8264
|
+
writeImBots(relayDir, out);
|
|
8265
|
+
return { ok: true };
|
|
8266
|
+
} catch (e) {
|
|
8267
|
+
return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
|
|
8268
|
+
}
|
|
8269
|
+
}
|
|
8270
|
+
|
|
8271
|
+
/** 保存 feishu 配置。 */
|
|
8272
|
+
function saveFeishuConfig(relayDir, input) {
|
|
8273
|
+
const src = input && typeof input === "object" ? input : {};
|
|
8274
|
+
const bots = readImBots(relayDir);
|
|
8275
|
+
const cur = feishuCfgOf(bots);
|
|
8276
|
+
const next = { ...cur };
|
|
8277
|
+
for (const k of ["appId", "domain", "receiveId", "boundChatId", "sdkVersion"]) {
|
|
8278
|
+
if (Object.prototype.hasOwnProperty.call(src, k)) next[k] = strOf(src[k]).trim();
|
|
8279
|
+
}
|
|
8280
|
+
if (Object.prototype.hasOwnProperty.call(src, "appSecret")) {
|
|
8281
|
+
const v = strOf(src.appSecret).trim();
|
|
8282
|
+
if (v) next.appSecret = v; // 空 = 不修改
|
|
8283
|
+
}
|
|
8284
|
+
if (Object.prototype.hasOwnProperty.call(src, "receiveIdType")) {
|
|
8285
|
+
const t = strOf(src.receiveIdType).trim();
|
|
8286
|
+
if (t && !FEISHU_RECEIVE_ID_TYPES.includes(t)) {
|
|
8287
|
+
return { ok: false, code: "invalid_receive_id_type", message: "接收对象类型只能是 open_id / chat_id / email / user_id" };
|
|
8288
|
+
}
|
|
8289
|
+
next.receiveIdType = t || "open_id";
|
|
8290
|
+
}
|
|
8291
|
+
if (Object.prototype.hasOwnProperty.call(src, "allowFrom")) next.allowFrom = strListOf(src.allowFrom);
|
|
8292
|
+
if (Object.prototype.hasOwnProperty.call(src, "cardApproval")) next.cardApproval = !!src.cardApproval;
|
|
8293
|
+
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
8294
|
+
else if (!next.enabled && next.appId && next.appSecret && next.receiveId) next.enabled = true;
|
|
8295
|
+
if (!next.appId) return { ok: false, code: "missing_app_id", message: "请填写 App ID(飞书开放平台应用的 App ID)" };
|
|
8296
|
+
if (!next.appSecret) return { ok: false, code: "missing_app_secret", message: "请填写 App Secret(只保存在本机,不会回显)" };
|
|
8297
|
+
// 接收人**不是必填**(2026-11-08 业主实测:App ID+Secret 连上后桥会自己发现你的群/私聊)。
|
|
8298
|
+
// 保留 receiveId/receiveIdType 字段是为了兼容桥侧手工配置/后续高级用法;面板不再要求填写。
|
|
8299
|
+
const out = { ...bots, version: 1, feishu: next };
|
|
8300
|
+
try {
|
|
8301
|
+
writeImBots(relayDir, out);
|
|
8302
|
+
return { ok: true };
|
|
8303
|
+
} catch (e) {
|
|
8304
|
+
return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
|
|
8305
|
+
}
|
|
8306
|
+
}
|
|
8307
|
+
|
|
8308
|
+
/** 按平台分发保存(PUT / POST test 共用)。 */
|
|
8309
|
+
function saveImPlatformConfig(relayDir, platform, input) {
|
|
8310
|
+
if (platform === "wecom") return saveWecomConfig(relayDir, input);
|
|
8311
|
+
if (platform === "dingtalk") return saveDingtalkConfig(relayDir, input);
|
|
8312
|
+
if (platform === "feishu") return saveFeishuConfig(relayDir, input);
|
|
8313
|
+
return { ok: false, code: "bad_platform", message: `不支持的平台:${platform}` };
|
|
8314
|
+
}
|
|
8315
|
+
|
|
8316
|
+
/** 各平台「是否已保存 secret」——决定测试前能不能直接连。 */
|
|
8317
|
+
function imPlatformHasSecret(relayDir, platform) {
|
|
8318
|
+
const bots = readImBots(relayDir);
|
|
8319
|
+
if (platform === "wecom") return !!wecomCfgOf(bots).secret;
|
|
8320
|
+
if (platform === "dingtalk") return !!dingtalkCfgOf(bots).clientSecret;
|
|
8321
|
+
if (platform === "feishu") return !!feishuCfgOf(bots).appSecret;
|
|
8322
|
+
return false;
|
|
8323
|
+
}
|
|
8324
|
+
|
|
8325
|
+
/** 状态文件里某平台的连接结论(测试轮询用)。 */
|
|
8326
|
+
function imPlatformStatusNow(relayDir, platform) {
|
|
8327
|
+
const st = readImBotStatus(relayDir);
|
|
8328
|
+
if (platform === "wecom") return wecomStatusOf(st);
|
|
8329
|
+
return imPlatformStatusOf(st, platform);
|
|
8330
|
+
}
|
|
8331
|
+
|
|
7845
8332
|
/**
|
|
7846
8333
|
* POST /dsh-remote/im-bots/test:保存(若带凭据)→ 轮询桥写的状态文件 ≤ waitMs。
|
|
7847
8334
|
* 返回面板直接可渲染的结论:连接中 / 已连接 / 失败原因(桥的 lastError 原文照登)。
|
|
7848
8335
|
*/
|
|
7849
|
-
async function imBotsTest(relayDir, waitMs, input) {
|
|
8336
|
+
async function imBotsTest(relayDir, waitMs, input, platform = "wecom") {
|
|
8337
|
+
const plat = IM_PLATFORMS.includes(platform) ? platform : "wecom";
|
|
7850
8338
|
const hasInput = input && typeof input === "object" && Object.keys(input).length > 0;
|
|
7851
8339
|
if (hasInput) {
|
|
7852
|
-
const saved =
|
|
8340
|
+
const saved = saveImPlatformConfig(relayDir, plat, input);
|
|
7853
8341
|
if (!saved.ok) return { httpCode: 400, body: { ok: false, error: saved.code, message: saved.message } };
|
|
7854
8342
|
}
|
|
7855
|
-
|
|
7856
|
-
if (!w.secret) {
|
|
8343
|
+
if (!imPlatformHasSecret(relayDir, plat)) {
|
|
7857
8344
|
return { httpCode: 400, body: { ok: false, error: "missing_secret", message: "还没有保存 Secret:请先填写并保存(Secret 只保存在本机)" } };
|
|
7858
8345
|
}
|
|
7859
8346
|
const startedAt = Date.now();
|
|
7860
8347
|
const total = Math.max(200, Number(waitMs) || IM_BOT_STATUS_WAIT_MS);
|
|
7861
|
-
|
|
8348
|
+
const okBody = (last) => ({ ok: true, platform: plat, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" });
|
|
8349
|
+
let last = imPlatformStatusNow(relayDir, plat);
|
|
7862
8350
|
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
|
-
}
|
|
8351
|
+
if (last.connected) return { httpCode: 200, body: okBody(last) };
|
|
7866
8352
|
await new Promise((r) => { const t = setTimeout(r, IM_BOT_STATUS_POLL_MS); try { t.unref?.(); } catch { /* 忽略 */ } });
|
|
7867
|
-
last =
|
|
8353
|
+
last = imPlatformStatusNow(relayDir, plat);
|
|
7868
8354
|
if (last.lastError && !last.connected) break; // 桥已经明确报错:不用傻等到超时
|
|
7869
8355
|
}
|
|
7870
|
-
if (last.connected) {
|
|
7871
|
-
return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
|
|
7872
|
-
}
|
|
8356
|
+
if (last.connected) return { httpCode: 200, body: okBody(last) };
|
|
7873
8357
|
if (last.lastError) {
|
|
7874
|
-
return { httpCode: 502, body: { ok: false, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
|
|
8358
|
+
return { httpCode: 502, body: { ok: false, platform: plat, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
|
|
7875
8359
|
}
|
|
7876
|
-
return { httpCode: 504, body: { ok: false, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
|
|
8360
|
+
return { httpCode: 504, body: { ok: false, platform: plat, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
|
|
7877
8361
|
}
|
|
7878
8362
|
|
|
7879
8363
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════════
|
|
@@ -8062,8 +8546,10 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8062
8546
|
},
|
|
8063
8547
|
},
|
|
8064
8548
|
// 镜像页就绪耗时(D1 在 clients/dsh-remote/mobile-adapter.mjs 里量,经此入队):
|
|
8065
|
-
// body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow"
|
|
8066
|
-
//
|
|
8549
|
+
// body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow"|"suspended"|"abandoned",
|
|
8550
|
+
// active_ms?, hidden_ms?, frozen_ms? }
|
|
8551
|
+
// 契约(926 迭代 §2 + 2026-11-08 活跃时间口径):事件名固定 mirror_load,ms 钳制 [0, 600000],
|
|
8552
|
+
// outcome 只认四枚举(非法/缺省发 "",**不再默认 ok**),三个时长字段可选、越界丢弃。
|
|
8067
8553
|
//
|
|
8068
8554
|
// 🔒 入参校验失败**静默丢弃**但仍回 200 {ok:true}:这条上报是手机镜像页加载路径上的
|
|
8069
8555
|
// fire-and-forget,任何 4xx 都可能被页面当成"功能坏了"而冒泡给用户;统计丢一条无所谓,
|
|
@@ -8081,11 +8567,17 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8081
8567
|
if (body && Number.isFinite(ms) && ms >= 0 && ms <= 600000) {
|
|
8082
8568
|
const atRaw = Number(body.at);
|
|
8083
8569
|
const at = Number.isFinite(atRaw) && atRaw > 0 ? Math.round(atRaw) : 0;
|
|
8084
|
-
|
|
8570
|
+
const oc = String(body.outcome || "");
|
|
8571
|
+
const rec = {
|
|
8085
8572
|
ms: Math.round(ms),
|
|
8086
8573
|
at,
|
|
8087
|
-
outcome:
|
|
8088
|
-
}
|
|
8574
|
+
outcome: TELEMETRY_OUTCOMES.has(oc) ? oc : "",
|
|
8575
|
+
};
|
|
8576
|
+
for (const k of ["active_ms", "hidden_ms", "frozen_ms"]) {
|
|
8577
|
+
const v = Number(body[k]);
|
|
8578
|
+
if (Number.isFinite(v) && v >= 0) rec[k] = Math.max(0, Math.min(600000, Math.round(v)));
|
|
8579
|
+
}
|
|
8580
|
+
telemetryRecord(relayDir, "mirror_load", rec);
|
|
8089
8581
|
}
|
|
8090
8582
|
} catch { /* 体读不到/超限:静默 */ }
|
|
8091
8583
|
sendJson(res, 200, { ok: true });
|
|
@@ -8713,13 +9205,64 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8713
9205
|
}
|
|
8714
9206
|
const body = await readJsonBody(req);
|
|
8715
9207
|
if (body.__parseError) return sendJson(res, 400, { ok: false, error: "bad_request", message: "请求体不是合法 JSON" });
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
9208
|
+
// 三种写法都认(向后兼容):
|
|
9209
|
+
// { wecom:{...} } / { dingtalk:{...} } / { feishu:{...} } —— 面板老写法
|
|
9210
|
+
// { platform:"dingtalk", config:{...} } —— 统一写法
|
|
9211
|
+
// 一次可传多个段;某段校验失败就整体 400(不写半截配置)。
|
|
9212
|
+
const platform = String((body && body.platform) || "");
|
|
9213
|
+
const cfg = body && body.config && typeof body.config === "object" ? body.config : null;
|
|
9214
|
+
const sections = IM_PLATFORMS.filter((k) => body && body[k] && typeof body[k] === "object");
|
|
9215
|
+
if (!sections.length && !(platform && cfg)) {
|
|
9216
|
+
return sendJson(res, 400, { ok: false, error: "bad_request", message: "缺少通道配置(wecom / dingtalk / feishu)" });
|
|
9217
|
+
}
|
|
9218
|
+
if (platform && cfg) {
|
|
9219
|
+
if (!IM_PLATFORMS.includes(platform)) return sendJson(res, 400, { ok: false, error: "bad_platform", message: `不支持的平台:${platform}` });
|
|
9220
|
+
const saved = saveImPlatformConfig(relayDir, platform, cfg);
|
|
9221
|
+
if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
|
|
9222
|
+
}
|
|
9223
|
+
for (const plat of sections) {
|
|
9224
|
+
const saved = saveImPlatformConfig(relayDir, plat, body[plat]);
|
|
9225
|
+
if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
|
|
9226
|
+
}
|
|
8720
9227
|
sendJson(res, 200, imBotsView(relayDir));
|
|
8721
9228
|
},
|
|
8722
9229
|
},
|
|
9230
|
+
{
|
|
9231
|
+
// 钉钉扫码一键创建机器人:开始授权(返回授权链接;device_code 只存服务端内存)
|
|
9232
|
+
method: "POST",
|
|
9233
|
+
path: "/dsh-remote/im-dingtalk-auth/begin",
|
|
9234
|
+
handler: async (_req, res) => {
|
|
9235
|
+
const r = await beginDingtalkAuth(relayDir, { imAuthFetch: opts.imAuthFetch });
|
|
9236
|
+
sendJson(res, r.ok ? 200 : 502, r);
|
|
9237
|
+
},
|
|
9238
|
+
},
|
|
9239
|
+
{
|
|
9240
|
+
// 轮询授权状态:waiting / success(已写盘,只回脱敏结果)/ failed / expired
|
|
9241
|
+
method: "GET",
|
|
9242
|
+
path: "/dsh-remote/im-dingtalk-auth/status",
|
|
9243
|
+
handler: async (req, res) => {
|
|
9244
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
9245
|
+
const r = await dingtalkAuthStatus(url.searchParams.get("id") || "", { imAuthFetch: opts.imAuthFetch });
|
|
9246
|
+
sendJson(res, 200, r);
|
|
9247
|
+
},
|
|
9248
|
+
},
|
|
9249
|
+
{
|
|
9250
|
+
// 飞书 SDK「按需下载」现状(面板据此显示未装/已装/下载中/失败)。
|
|
9251
|
+
method: "GET",
|
|
9252
|
+
path: "/dsh-remote/im-deps",
|
|
9253
|
+
handler: async (_req, res) => {
|
|
9254
|
+
sendJson(res, 200, { ok: true, feishu: feishuDepsInfo(relayDir) });
|
|
9255
|
+
},
|
|
9256
|
+
},
|
|
9257
|
+
{
|
|
9258
|
+
// 触发下载安装(后台执行,立刻返回;进度靠轮询 GET /dsh-remote/im-deps 的 phase)。
|
|
9259
|
+
method: "POST",
|
|
9260
|
+
path: "/dsh-remote/im-deps/feishu",
|
|
9261
|
+
handler: async (_req, res) => {
|
|
9262
|
+
const r = startFeishuInstall(relayDir, { imDepsSpawn: opts.imDepsSpawn });
|
|
9263
|
+
sendJson(res, 200, { ok: true, ...r });
|
|
9264
|
+
},
|
|
9265
|
+
},
|
|
8723
9266
|
{
|
|
8724
9267
|
method: "ALL",
|
|
8725
9268
|
path: "/dsh-remote/im-bots/test",
|
|
@@ -8731,8 +9274,15 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8731
9274
|
}
|
|
8732
9275
|
const body = await readJsonBody(req);
|
|
8733
9276
|
// body 可选:不传 = 只测**已保存**的配置;传 = 先保存再测(与面板「保存并连接」一次点完一致)
|
|
8734
|
-
|
|
8735
|
-
const
|
|
9277
|
+
// platform 默认 wecom(老前端不传时行为不变);config 支持统一写法与 {wecom} 老写法。
|
|
9278
|
+
const platform = String((body && !body.__parseError && body.platform) || "wecom");
|
|
9279
|
+
let input = null;
|
|
9280
|
+
if (body && !body.__parseError) {
|
|
9281
|
+
if (IM_PLATFORMS.includes(platform) && body[platform] && typeof body[platform] === "object") input = body[platform];
|
|
9282
|
+
else if (body.config && typeof body.config === "object") input = body.config;
|
|
9283
|
+
else if (body.wecom && typeof body.wecom === "object" && platform === "wecom") input = body.wecom;
|
|
9284
|
+
}
|
|
9285
|
+
const r = await imBotsTest(relayDir, opts.imBotsWaitMs, input, platform);
|
|
8736
9286
|
sendJson(res, r.httpCode, r.body);
|
|
8737
9287
|
},
|
|
8738
9288
|
},
|
|
@@ -9082,6 +9632,10 @@ export function apply(ctx, config = {}) {
|
|
|
9082
9632
|
ctx.effect(() => registerRoutes(ctx, relayDir, {
|
|
9083
9633
|
// 测试钩子:POST /dsh-remote/im-bots/test 的等待上限(默认 10s,见 imBotsTest 注释)。
|
|
9084
9634
|
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9635
|
+
// 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
|
|
9636
|
+
imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
|
|
9637
|
+
// 测试钩子:钉钉扫码授权的 fetch(用例注入假实现,**绝不真的打钉钉接口**)。
|
|
9638
|
+
imAuthFetch: typeof config.imAuthFetch === "function" ? config.imAuthFetch : undefined,
|
|
9085
9639
|
}), "dsh-remote-web: /dsh-remote routes");
|
|
9086
9640
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
9087
9641
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|