dsh-remote-web 0.7.12-beta.4 → 0.7.12-beta.6
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 +85 -48
- package/lib/index.js +62 -202
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-auth.mjs +631 -0
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +249 -32
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +35 -0
- package/runtime/clients/dsh-remote/feishu-bot.mjs +101 -0
- package/runtime/clients/dsh-remote/im-commands.mjs +302 -0
- package/runtime/clients/dsh-remote/wecom-bot.mjs +84 -2
- package/runtime/dsh-setup.mjs +1 -1
package/lib/client.js
CHANGED
|
@@ -3593,7 +3593,7 @@ window.__ModuleLoader__.load({
|
|
|
3593
3593
|
var imDingIdArr = useState(""); var imDingId = imDingIdArr[0]; var setImDingId = imDingIdArr[1];
|
|
3594
3594
|
var imDingSecretArr = useState(""); var imDingSecret = imDingSecretArr[0]; var setImDingSecret = imDingSecretArr[1];
|
|
3595
3595
|
var imDingRobotArr = useState(""); var imDingRobot = imDingRobotArr[0]; var setImDingRobot = imDingRobotArr[1];
|
|
3596
|
-
// 钉钉扫码一键创建(2026-11-08
|
|
3596
|
+
// 钉钉扫码一键创建(2026-11-08,委托桥侧持久化授权):本地只留等待态/链接/剩余时间;手动配置默认折叠
|
|
3597
3597
|
var imDingAuthArr = useState(null); var imDingAuth = imDingAuthArr[0]; var setImDingAuth = imDingAuthArr[1];
|
|
3598
3598
|
var imDingManualArr = useState(false); var imDingManual = imDingManualArr[0]; var setImDingManual = imDingManualArr[1];
|
|
3599
3599
|
var imFeishuAppIdArr = useState(""); var imFeishuAppId = imFeishuAppIdArr[0]; var setImFeishuAppId = imFeishuAppIdArr[1];
|
|
@@ -6027,6 +6027,16 @@ window.__ModuleLoader__.load({
|
|
|
6027
6027
|
var deps = b && b.status && b.status.deps && b.status.deps.feishu;
|
|
6028
6028
|
if (deps) setImDeps(deps);
|
|
6029
6029
|
loadImDeps();
|
|
6030
|
+
// 授权会话在桥里持久化:重开面板时若仍在等待,恢复展示(URL/剩余时间从状态文件读)
|
|
6031
|
+
api("/dsh-remote/im-dingtalk-auth/status").then(function (a) {
|
|
6032
|
+
if (a && a.state === "waiting") {
|
|
6033
|
+
setImDingAuth({
|
|
6034
|
+
phase: "waiting", url: String(a.url || ""), startedAt: Date.now(),
|
|
6035
|
+
expiresAt: Number(a.expiresAt) || (Date.now() + 15 * 60 * 1000),
|
|
6036
|
+
remainingSec: Number(a.remainingSec) || 0, error: ""
|
|
6037
|
+
});
|
|
6038
|
+
}
|
|
6039
|
+
}).catch(function () { /* 忽略 */ });
|
|
6030
6040
|
});
|
|
6031
6041
|
}
|
|
6032
6042
|
// 数据加载交给下面的 effect(按"弹层是否打开"触发):
|
|
@@ -6745,28 +6755,24 @@ window.__ModuleLoader__.load({
|
|
|
6745
6755
|
return IM_HUMAN_ERR[c] || "操作失败,请稍后重试";
|
|
6746
6756
|
}
|
|
6747
6757
|
|
|
6748
|
-
/**
|
|
6749
|
-
*
|
|
6758
|
+
/** 钉钉扫码授权(2026-11-08 架构修正:委托桥侧持久化授权)。
|
|
6759
|
+
* 面板只做三件事:写 begin 命令 → 轮询状态文件 → 展示。授权会话在桥那边落盘续轮询,
|
|
6760
|
+
* 关面板/宿主重启都不会丢(用户"钉钉说成功、我们没接住"的根因)。 */
|
|
6750
6761
|
function startDingAuth() {
|
|
6751
6762
|
if (imPlatBusy) return;
|
|
6752
6763
|
setImPlatBusy("auth");
|
|
6753
6764
|
setImPlatMsg(null);
|
|
6754
6765
|
setImPlatErr({});
|
|
6755
6766
|
post("/dsh-remote/im-dingtalk-auth/begin", {}).then(function (b) {
|
|
6756
|
-
if (!b || b.ok !== true
|
|
6767
|
+
if (!b || b.ok !== true) {
|
|
6757
6768
|
setImDingAuth(null);
|
|
6758
6769
|
setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(b && b.code, b && b.message) });
|
|
6759
6770
|
return;
|
|
6760
6771
|
}
|
|
6761
|
-
|
|
6772
|
+
// begin 只写命令文件;URL/剩余时间要等桥跑完写进状态文件 → 由轮询拿
|
|
6762
6773
|
setImDingAuth({
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
interval: Math.max(1, Math.min(10, Number(b.interval) || 3)),
|
|
6766
|
-
expiresAt: Date.now() + expiresIn * 1000,
|
|
6767
|
-
startedAt: Date.now(),
|
|
6768
|
-
phase: "waiting",
|
|
6769
|
-
error: ""
|
|
6774
|
+
phase: "waiting", url: "", startedAt: Date.now(),
|
|
6775
|
+
expiresAt: Date.now() + 15 * 60 * 1000, remainingSec: 0, error: ""
|
|
6770
6776
|
});
|
|
6771
6777
|
}).catch(function (e) {
|
|
6772
6778
|
setImDingAuth(null);
|
|
@@ -6774,13 +6780,13 @@ window.__ModuleLoader__.load({
|
|
|
6774
6780
|
setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(body.code || (e && e.code), body.message || (e && e.message)) });
|
|
6775
6781
|
}).finally(function () { setImPlatBusy(""); });
|
|
6776
6782
|
}
|
|
6777
|
-
/**
|
|
6783
|
+
/** 读一次桥写的授权进度(GET status → 状态文件;不自己 poll 钉钉)。 */
|
|
6778
6784
|
function pollDingAuth(manual) {
|
|
6779
6785
|
var a = imDingAuth;
|
|
6780
|
-
if (!a ||
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
if (
|
|
6786
|
+
if (!a || a.phase !== "waiting") return;
|
|
6787
|
+
api("/dsh-remote/im-dingtalk-auth/status").then(function (b) {
|
|
6788
|
+
var st2 = String((b && b.state) || "idle");
|
|
6789
|
+
if (st2 === "success") {
|
|
6784
6790
|
setImDingAuth(null); // 成功后自动收起授权块
|
|
6785
6791
|
setImPlatMsg({
|
|
6786
6792
|
kind: "ok",
|
|
@@ -6789,31 +6795,49 @@ window.__ModuleLoader__.load({
|
|
|
6789
6795
|
loadImBots();
|
|
6790
6796
|
return;
|
|
6791
6797
|
}
|
|
6792
|
-
if (
|
|
6793
|
-
setImDingAuth({
|
|
6798
|
+
if (st2 === "failed" || st2 === "expired") {
|
|
6799
|
+
setImDingAuth({
|
|
6800
|
+
phase: "failed", url: a.url, startedAt: a.startedAt, expiresAt: a.expiresAt,
|
|
6801
|
+
remainingSec: 0, error: imHumanErr(st2, b && b.message)
|
|
6802
|
+
});
|
|
6794
6803
|
return;
|
|
6795
6804
|
}
|
|
6796
|
-
// waiting
|
|
6797
|
-
setImDingAuth({
|
|
6805
|
+
// waiting / idle(桥还没开始):刷新 url 与剩余时间,继续等
|
|
6806
|
+
setImDingAuth({
|
|
6807
|
+
phase: "waiting",
|
|
6808
|
+
url: String((b && b.url) || a.url || ""),
|
|
6809
|
+
startedAt: a.startedAt || Date.now(),
|
|
6810
|
+
expiresAt: Number(b && b.expiresAt) || a.expiresAt || (Date.now() + 15 * 60 * 1000),
|
|
6811
|
+
remainingSec: Number(b && b.remainingSec) || 0,
|
|
6812
|
+
error: "", polledAt: Date.now()
|
|
6813
|
+
});
|
|
6798
6814
|
}).catch(function (e) {
|
|
6799
|
-
// 网络抖动:不打断等待(手动按钮仍在),仅在自动轮询里静默
|
|
6800
6815
|
if (manual) {
|
|
6801
6816
|
var body = (e && e.body) || {};
|
|
6802
6817
|
setImPlatMsg({ kind: "warn", text: "检查失败:" + imHumanErr(body.code, body.message || (e && e.message)) + "(可再试)" });
|
|
6803
6818
|
}
|
|
6804
6819
|
});
|
|
6805
6820
|
}
|
|
6806
|
-
/**
|
|
6821
|
+
/** 等待确认中:每 2s 读一次状态(桥后台在推进);**最多自动看 15 分钟后停止**,手动按钮仍在。 */
|
|
6807
6822
|
useEffect(function () {
|
|
6808
6823
|
if (iaModal !== "channel" || iaChStep !== "dingtalk") return undefined;
|
|
6809
6824
|
if (!imDingAuth || imDingAuth.phase !== "waiting") return undefined;
|
|
6810
6825
|
var waited = Date.now() - (imDingAuth.startedAt || Date.now());
|
|
6811
|
-
if (waited >
|
|
6826
|
+
if (waited > 15 * 60 * 1000) return undefined;
|
|
6812
6827
|
var t = null;
|
|
6813
|
-
try { t = setTimeout(function () { pollDingAuth(false); },
|
|
6828
|
+
try { t = setTimeout(function () { pollDingAuth(false); }, 2000); } catch (e) { /* 忽略 */ }
|
|
6814
6829
|
return function () { try { if (t) clearTimeout(t); } catch (e) { /* 忽略 */ } };
|
|
6815
6830
|
}, [iaModal, iaChStep, imDingAuth]);
|
|
6816
6831
|
|
|
6832
|
+
/** 取消授权:写 cancel 命令(桥停轮询 + 删持久化会话)。 */
|
|
6833
|
+
function cancelDingAuth() {
|
|
6834
|
+
post("/dsh-remote/im-dingtalk-auth/cancel", {}).catch(function () { /* 桥不在也不影响面板收起 */ })
|
|
6835
|
+
.finally(function () {
|
|
6836
|
+
setImDingAuth(null);
|
|
6837
|
+
setImPlatMsg({ kind: "ok", text: "已取消本次扫码授权" });
|
|
6838
|
+
});
|
|
6839
|
+
}
|
|
6840
|
+
|
|
6817
6841
|
/** 钉钉手动创建 7 步(2026-11-08 业主给的官方链接;每步一句 + 链接)。 */
|
|
6818
6842
|
var DINGTALK_STEPS = [
|
|
6819
6843
|
{ t: "用钉钉账号登录开发者后台", url: "https://open-dev.dingtalk.com/" },
|
|
@@ -6849,6 +6873,7 @@ window.__ModuleLoader__.load({
|
|
|
6849
6873
|
var st = (imBots && imBots.status && imBots.status.dingtalk) || {};
|
|
6850
6874
|
var err = imPlatErr || {};
|
|
6851
6875
|
var auth = imDingAuth;
|
|
6876
|
+
var authStatus = st.auth || {};
|
|
6852
6877
|
var sourceText = (st.credentialSource || d.credentialSource) === "scan" ? "扫码创建"
|
|
6853
6878
|
: (st.credentialSource || d.credentialSource) === "manual" ? "手动填写" : "";
|
|
6854
6879
|
var clientTail = String(d.clientId || "").length > 4 ? String(d.clientId).slice(-4) : String(d.clientId || "");
|
|
@@ -6881,6 +6906,13 @@ window.__ModuleLoader__.load({
|
|
|
6881
6906
|
// · 有凭据/已连接 → 「已连接 · Client ID 尾号 · 来源:…」
|
|
6882
6907
|
(function () {
|
|
6883
6908
|
var configured = !!d.enabled || !!d.hasSecret || !!String(d.credentialSource || "").trim();
|
|
6909
|
+
// 授权会话状态优先:failed/expired 是"授权"失败,不是"连接"失败(措辞不能混)
|
|
6910
|
+
if (authStatus.state === "failed") {
|
|
6911
|
+
return h("div", { className: "dru-ia-state is-err", role: "status" }, "授权失败:" + imHumanErr("", String(authStatus.message || "")));
|
|
6912
|
+
}
|
|
6913
|
+
if (authStatus.state === "expired") {
|
|
6914
|
+
return h("div", { className: "dru-ia-state is-warn", role: "status" }, "授权已过期:" + imHumanErr("expired", String(authStatus.message || "")) + "(点下方按钮可重新获取链接)");
|
|
6915
|
+
}
|
|
6884
6916
|
var failState = st.state === "failed" || st.state === "error";
|
|
6885
6917
|
if (!configured) return h("div", { className: "dru-ia-state" }, druIcon("chat"), "未配置:点下方按钮扫码创建机器人");
|
|
6886
6918
|
if (failState) {
|
|
@@ -6892,38 +6924,43 @@ window.__ModuleLoader__.load({
|
|
|
6892
6924
|
if (st.connected) return h("div", { className: "dru-ia-state is-ok" }, druIcon("check"), "已连接" + tailBit + srcBit);
|
|
6893
6925
|
return h("div", { className: "dru-ia-state" }, druIcon("chat"), "已保存凭据,等待连接…" + tailBit + srcBit);
|
|
6894
6926
|
})(),
|
|
6895
|
-
//
|
|
6927
|
+
// 扫码授权中(桥侧持久化:begin 只写命令,URL 等桥跑完写进状态 → 轮询拿)
|
|
6896
6928
|
(auth && auth.phase === "waiting")
|
|
6897
6929
|
? h("div", { className: "dru-ia-field" },
|
|
6898
6930
|
h("div", { className: "dru-ia-state", role: "status" }, h("span", { className: "dru-spin" }),
|
|
6899
|
-
|
|
6931
|
+
!auth.url
|
|
6932
|
+
? "正在生成授权链接…(桥正在创建,约需几秒)"
|
|
6933
|
+
: (auth.remainingSec > 0
|
|
6934
|
+
? "等待你在钉钉里确认…(剩余约 " + Math.max(1, Math.round(auth.remainingSec / 60)) + " 分钟;用手机钉钉扫码或打开链接)"
|
|
6935
|
+
: "等待你在钉钉里确认…(用手机钉钉扫码,或复制链接到手机打开)")),
|
|
6936
|
+
h("div", { className: "dru-ia-hint" }, "关掉面板也不影响:桥会继续等你在钉钉里确认,回来点「我已完成授权,检查一下」即可。"),
|
|
6900
6937
|
(function () {
|
|
6901
6938
|
var waitedMin = Math.floor((Date.now() - (auth.startedAt || Date.now())) / 60000);
|
|
6902
|
-
return
|
|
6903
|
-
waitedMin
|
|
6904
|
-
|
|
6905
|
-
: "授权链接 2 小时内有效;关掉面板也不影响,回来点「我已完成授权,检查一下」即可。");
|
|
6939
|
+
return waitedMin >= 15
|
|
6940
|
+
? h("div", { className: "dru-ia-hint" }, "已等待 " + waitedMin + " 分钟:自动刷新先停下(桥仍在继续),完成确认后点「我已完成授权,检查一下」。")
|
|
6941
|
+
: null;
|
|
6906
6942
|
})(),
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6943
|
+
auth.url
|
|
6944
|
+
? h("div", { className: "dru-copy-row" },
|
|
6945
|
+
h("div", { className: "dru-copy-box" },
|
|
6946
|
+
h("span", { className: "dru-link-val" }, auth.url),
|
|
6947
|
+
h("button", {
|
|
6948
|
+
type: "button", className: "dru-copy-btn", "aria-label": "复制授权链接",
|
|
6949
|
+
onClick: function () {
|
|
6950
|
+
copyText(auth.url).then(function (ok) { if (!ok) setImPlatMsg({ kind: "err", text: "复制失败:请手动选中链接复制" }); });
|
|
6951
|
+
}
|
|
6952
|
+
}, "复制授权链接")),
|
|
6953
|
+
h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
6954
|
+
h("a", { className: "dru-ia-btn is-primary", href: auth.url, target: "_blank", rel: "noopener" }, "在浏览器打开授权页"),
|
|
6955
|
+
h("button", { type: "button", className: "dru-ia-btn is-primary", onClick: function () { pollDingAuth(true); } }, "我已完成授权,检查一下"),
|
|
6956
|
+
h("button", { type: "button", className: "dru-ia-btn", onClick: cancelDingAuth }, "取消授权")))
|
|
6957
|
+
: h("div", { className: "dru-actions", style: { marginTop: 8 } },
|
|
6958
|
+
h("button", { type: "button", className: "dru-ia-btn is-primary", onClick: function () { pollDingAuth(true); } }, "我已完成授权,检查一下"),
|
|
6959
|
+
h("button", { type: "button", className: "dru-ia-btn", onClick: cancelDingAuth }, "取消授权")))
|
|
6923
6960
|
: null,
|
|
6924
6961
|
(auth && auth.phase === "failed")
|
|
6925
6962
|
? h("div", { className: "dru-ia-field" },
|
|
6926
|
-
h("div", { className: "dru-ia-state is-err", role: "alert" }, auth.error || "
|
|
6963
|
+
h("div", { className: "dru-ia-state is-err", role: "alert" }, "授权失败:" + (imHumanErr("", auth.error) || "请重试")),
|
|
6927
6964
|
h("button", { type: "button", className: "dru-ia-btn", onClick: function () { startDingAuth(); } }, "重试扫码授权"))
|
|
6928
6965
|
: null,
|
|
6929
6966
|
// 能力:主动推送 / 卡片(缺 Robot Code 时置灰,提示补 Robot Code)
|
package/lib/index.js
CHANGED
|
@@ -7802,7 +7802,7 @@ function imBotsView(relayDir) {
|
|
|
7802
7802
|
sdkVersion: feishuCfgOf(bots).sdkVersion,
|
|
7803
7803
|
},
|
|
7804
7804
|
status: Object.assign({}, wecomStatusOf(stFile), {
|
|
7805
|
-
dingtalk: imPlatformStatusOf(stFile, "dingtalk"),
|
|
7805
|
+
dingtalk: Object.assign(imPlatformStatusOf(stFile, "dingtalk"), { auth: imDingtalkAuthStatusOf(relayDir) }),
|
|
7806
7806
|
feishu: imPlatformStatusOf(stFile, "feishu"),
|
|
7807
7807
|
deps: { feishu: feishuDepsInfo(relayDir) },
|
|
7808
7808
|
}),
|
|
@@ -8095,211 +8095,61 @@ function startFeishuInstall(relayDir, opts = {}) {
|
|
|
8095
8095
|
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8096
8096
|
}
|
|
8097
8097
|
|
|
8098
|
-
// ──
|
|
8099
|
-
//
|
|
8100
|
-
//
|
|
8101
|
-
//
|
|
8102
|
-
//
|
|
8103
|
-
//
|
|
8104
|
-
//
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
|
|
8109
|
-
const DINGTALK_AUTH_TTL_MS = 7200 * 1000; // 兜底上限:钉钉 device-flow 的 expires_in(7200s)
|
|
8110
|
-
const DINGTALK_AUTH_POLL_MS = 3000; // 后台轮询间隔(测试可用 config.imAuthPollMs 覆盖)
|
|
8111
|
-
const dingtalkAuthSessions = new Map(); // authId → session(含后台 timer,绝不落盘)
|
|
8112
|
-
const dingtalkAuthSuperseded = new Map(); // 被新授权作废的 authId → at(查询时回 cancelled)
|
|
8113
|
-
|
|
8114
|
-
/** 面向用户的中文人话(错误里**绝不出现裸 code / 裸 ok**)。 */
|
|
8098
|
+
// ── 钉钉「扫码一键创建机器人」:**委托桥侧持久化授权**(2026-11-08 架构修正)────────────
|
|
8099
|
+
// 生产踩坑:授权会话曾经存在插件进程内存里 —— 宿主重启/面板关掉就丢,用户遇到
|
|
8100
|
+
// 「钉钉说连接成功、我们没接住」。现在插件**不碰钉钉接口、不存内存会话**:
|
|
8101
|
+
// · 插件写命令文件 <relayDir>/im-dingtalk-auth-cmd.json {action:"begin"|"cancel", at}
|
|
8102
|
+
// · 桥(clients/dsh-remote/dingtalk-auth.mjs)持久化 device_code 并断点续轮询,处理完删命令文件
|
|
8103
|
+
// · 桥把进度写进 im-bot-status.json 的 dingtalk.auth = {state,url,expiresAt,remainingSec,clientIdTail,message}
|
|
8104
|
+
// · 成功后桥**自己**写 im-bots.json 的 dingtalk 段并连上 —— 插件只读状态
|
|
8105
|
+
const IM_DINGTALK_AUTH_CMD_FILE = "im-dingtalk-auth-cmd.json";
|
|
8106
|
+
|
|
8107
|
+
/** 面向用户的中文人话(错误里**绝不出现裸 code / 裸 ok / errcode**)。 */
|
|
8115
8108
|
const DINGTALK_AUTH_MSG = {
|
|
8116
8109
|
network_error: "连不上钉钉接口,请检查网络后重试",
|
|
8117
8110
|
dingtalk_api_rejected: "钉钉拒绝了请求,请稍后重试",
|
|
8118
8111
|
expired: "授权已过期,请点「重试扫码」生成新的授权链接",
|
|
8119
|
-
cancelled: "
|
|
8112
|
+
cancelled: "这次授权已取消,请重新发起",
|
|
8120
8113
|
write_failed: "拿到的凭据保存失败(磁盘/权限问题),请检查后重试"
|
|
8121
8114
|
};
|
|
8122
8115
|
function dingTalkHumanMessage(code, fallbackMessage) {
|
|
8123
8116
|
const c = String(code || "");
|
|
8124
8117
|
const msg = String(fallbackMessage || "").trim();
|
|
8125
|
-
// 只接受"看起来是人话"的 message:含中文、且不是裸 code/ok/JSON 片段
|
|
8126
8118
|
const looksHuman = /[\u4e00-\u9fa5]/.test(msg) && !/^\s*[{[]/.test(msg);
|
|
8127
8119
|
if (looksHuman) return msg;
|
|
8128
8120
|
if (DINGTALK_AUTH_MSG[c]) return DINGTALK_AUTH_MSG[c];
|
|
8129
8121
|
return "操作失败,请稍后重试";
|
|
8130
8122
|
}
|
|
8131
8123
|
|
|
8132
|
-
|
|
8133
|
-
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
});
|
|
8143
|
-
}
|
|
8144
|
-
|
|
8145
|
-
/** 后台轮询一次(同一 session 不并发;成功后写盘并停表)。 */
|
|
8146
|
-
async function dingtalkAuthPollOnce(authId, opts = {}) {
|
|
8147
|
-
const sess = dingtalkAuthSessions.get(String(authId || ""));
|
|
8148
|
-
if (!sess || sess.inFlight) return sess;
|
|
8149
|
-
if (sess.state !== "waiting") return sess;
|
|
8150
|
-
if (Date.now() > sess.expiresAt) {
|
|
8151
|
-
sess.state = "expired";
|
|
8152
|
-
sess.message = dingTalkHumanMessage("expired");
|
|
8153
|
-
stopDingAuthTimer(sess);
|
|
8154
|
-
return sess;
|
|
8155
|
-
}
|
|
8156
|
-
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8157
|
-
sess.inFlight = true;
|
|
8158
|
-
try {
|
|
8159
|
-
const r = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/poll`, { device_code: sess.deviceCode });
|
|
8160
|
-
const b = r.body || {};
|
|
8161
|
-
const st = String(b.status || "").toUpperCase();
|
|
8162
|
-
if (st === "WAITING" || st === "PENDING") return sess;
|
|
8163
|
-
if (st === "SUCCESS" || (b.client_id && b.client_secret)) {
|
|
8164
|
-
const saved = saveDingtalkScannedCredentials(sess.relayDir, b.client_id, b.client_secret);
|
|
8165
|
-
if (!saved.ok) {
|
|
8166
|
-
sess.state = "failed";
|
|
8167
|
-
sess.message = dingTalkHumanMessage(saved.code, saved.message);
|
|
8168
|
-
} else {
|
|
8169
|
-
const cid = String(b.client_id || "");
|
|
8170
|
-
sess.state = "success";
|
|
8171
|
-
sess.clientIdTail = cid.length > 4 ? cid.slice(-4) : cid;
|
|
8172
|
-
sess.credentialSource = "scan";
|
|
8173
|
-
}
|
|
8174
|
-
stopDingAuthTimer(sess);
|
|
8175
|
-
return sess;
|
|
8176
|
-
}
|
|
8177
|
-
// 钉钉明确拒绝(创建被取消/参数过期等):用中文人话,绝不把 errmsg 裸值直接渲染
|
|
8178
|
-
sess.state = "failed";
|
|
8179
|
-
sess.message = dingTalkHumanMessage("dingtalk_api_rejected", b.message || b.errmsg);
|
|
8180
|
-
stopDingAuthTimer(sess);
|
|
8181
|
-
return sess;
|
|
8182
|
-
} catch (e) {
|
|
8183
|
-
// 网络抖动**不作废授权**(后台继续轮询直到 expires_in)—— 只有明确拒绝/过期才终止。
|
|
8184
|
-
return sess;
|
|
8185
|
-
} finally {
|
|
8186
|
-
sess.inFlight = false;
|
|
8187
|
-
}
|
|
8188
|
-
}
|
|
8189
|
-
|
|
8190
|
-
function stopDingAuthTimer(sess) {
|
|
8191
|
-
if (!sess) return;
|
|
8192
|
-
try { if (sess.timer) clearInterval(sess.timer); } catch { /* 忽略 */ }
|
|
8193
|
-
sess.timer = null;
|
|
8194
|
-
}
|
|
8195
|
-
|
|
8196
|
-
/**
|
|
8197
|
-
* 开始一次钉钉扫码授权(POST /dsh-remote/im-dingtalk-auth/begin)。
|
|
8198
|
-
* 同一 relayDir 同时只保留一个会话:begin 时把旧的作废(旧 authId 后续查询回 cancelled)。
|
|
8199
|
-
* TTL 按钉钉返回的 expires_in(生产实测 7200s),后台 setInterval 轮询,关闭面板不影响。
|
|
8200
|
-
*/
|
|
8201
|
-
async function beginDingtalkAuth(relayDir, opts = {}) {
|
|
8202
|
-
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8203
|
-
// 作废旧会话(同时停掉它们的后台轮询表)
|
|
8204
|
-
for (const [id, sess] of dingtalkAuthSessions) {
|
|
8205
|
-
if (sess.relayDir === relayDir) {
|
|
8206
|
-
stopDingAuthTimer(sess);
|
|
8207
|
-
dingtalkAuthSessions.delete(id);
|
|
8208
|
-
dingtalkAuthSuperseded.set(id, Date.now());
|
|
8209
|
-
}
|
|
8210
|
-
}
|
|
8211
|
-
while (dingtalkAuthSuperseded.size > 20) {
|
|
8212
|
-
const first = dingtalkAuthSuperseded.keys().next().value;
|
|
8213
|
-
dingtalkAuthSuperseded.delete(first);
|
|
8214
|
-
}
|
|
8215
|
-
let init;
|
|
8216
|
-
try {
|
|
8217
|
-
init = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" });
|
|
8218
|
-
} catch {
|
|
8219
|
-
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8220
|
-
}
|
|
8221
|
-
const nonce = init.ok && init.body && typeof init.body.nonce === "string" ? init.body.nonce : "";
|
|
8222
|
-
if (!nonce) return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8223
|
-
let begin;
|
|
8224
|
-
try {
|
|
8225
|
-
begin = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce });
|
|
8226
|
-
} catch {
|
|
8227
|
-
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8228
|
-
}
|
|
8229
|
-
const b = begin.body || {};
|
|
8230
|
-
const deviceCode = typeof b.device_code === "string" ? b.device_code : "";
|
|
8231
|
-
const url = typeof b.verification_uri_complete === "string" ? b.verification_uri_complete : "";
|
|
8232
|
-
if (!begin.ok || !deviceCode || !url) {
|
|
8233
|
-
return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8234
|
-
}
|
|
8235
|
-
const authId = `ding-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8236
|
-
const interval = Math.max(1, Math.min(10, Number(b.interval) || 3));
|
|
8237
|
-
// ★ TTL 用钉钉的 expires_in(生产 7200s),不再用以前写死的 2 分钟
|
|
8238
|
-
const expiresIn = Math.max(60, Math.min(7200, Number(b.expires_in) || 7200));
|
|
8239
|
-
const now = Date.now();
|
|
8240
|
-
const sess = {
|
|
8241
|
-
relayDir, deviceCode, url, interval,
|
|
8242
|
-
createdAt: now,
|
|
8243
|
-
expiresAt: Math.min(now + expiresIn * 1000, now + DINGTALK_AUTH_TTL_MS),
|
|
8244
|
-
state: "waiting", message: "", inFlight: false, timer: null, clientIdTail: "", credentialSource: ""
|
|
8245
|
-
};
|
|
8246
|
-
// 启动后台轮询:面板关掉/刷新都不影响,用户 2 小时内确认都能成功
|
|
8247
|
-
const pollMs = Math.max(10, Number(opts.imAuthPollMs) || DINGTALK_AUTH_POLL_MS);
|
|
8248
|
-
try {
|
|
8249
|
-
sess.timer = setInterval(() => { void dingtalkAuthPollOnce(authId, opts); }, pollMs);
|
|
8250
|
-
sess.timer.unref?.();
|
|
8251
|
-
} catch { sess.timer = null; }
|
|
8252
|
-
dingtalkAuthSessions.set(authId, sess);
|
|
8253
|
-
return { ok: true, authId, url, interval, expiresIn };
|
|
8124
|
+
/** 原子写命令文件(0600;桥是另一个进程,tmp+rename 防半写)。 */
|
|
8125
|
+
function writeDingtalkAuthCmd(relayDir, action) {
|
|
8126
|
+
const file = join(relayDir, IM_DINGTALK_AUTH_CMD_FILE);
|
|
8127
|
+
const tmp = `${file}.tmp`;
|
|
8128
|
+
mkdirSync(relayDir, { recursive: true });
|
|
8129
|
+
writeFileSync(tmp, JSON.stringify({ action, at: Date.now() }) + "\n", { mode: 0o600 });
|
|
8130
|
+
try { chmodSync(tmp, 0o600); } catch { /* 非 POSIX */ }
|
|
8131
|
+
renameSync(tmp, file);
|
|
8132
|
+
try { chmodSync(file, 0o600); } catch { /* 忽略 */ }
|
|
8133
|
+
return { ok: true };
|
|
8254
8134
|
}
|
|
8255
8135
|
|
|
8256
|
-
/**
|
|
8257
|
-
function
|
|
8258
|
-
const
|
|
8259
|
-
const
|
|
8260
|
-
const
|
|
8261
|
-
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8265
|
-
|
|
8266
|
-
|
|
8136
|
+
/** 读桥写的授权进度(im-bot-status.json 的 dingtalk.auth;白名单 + 人话兜底)。 */
|
|
8137
|
+
function imDingtalkAuthStatusOf(relayDir) {
|
|
8138
|
+
const st = readImBotStatus(relayDir);
|
|
8139
|
+
const d = st && st.dingtalk && typeof st.dingtalk === "object" ? st.dingtalk : {};
|
|
8140
|
+
const a = d.auth && typeof d.auth === "object" ? d.auth : {};
|
|
8141
|
+
const states = ["idle", "waiting", "success", "failed", "expired"];
|
|
8142
|
+
const state = states.includes(strOf(a.state)) ? strOf(a.state) : "idle";
|
|
8143
|
+
const url = strOf(a.url);
|
|
8144
|
+
return {
|
|
8145
|
+
state,
|
|
8146
|
+
// 只透出 https 链接(防止状态文件里塞 javascript:/data: 之类被面板当链接渲染)
|
|
8147
|
+
url: /^https:\/\/[^\s]+$/i.test(url) ? url.slice(0, 2000) : "",
|
|
8148
|
+
expiresAt: numOf(a.expiresAt),
|
|
8149
|
+
remainingSec: numOf(a.remainingSec) || numOf(a.remainingSeconds),
|
|
8150
|
+
clientIdTail: strOf(a.clientIdTail).slice(0, 20),
|
|
8151
|
+
message: state === "idle" ? "" : dingTalkHumanMessage(state === "success" ? "" : state, a.message)
|
|
8267
8152
|
};
|
|
8268
|
-
try {
|
|
8269
|
-
writeImBots(relayDir, { ...bots, version: 1, dingtalk: next });
|
|
8270
|
-
return { ok: true };
|
|
8271
|
-
} catch (e) {
|
|
8272
|
-
return { ok: false, code: "write_failed", message: dingTalkHumanMessage("write_failed", `保存扫码凭据失败:${e?.message || e}`) };
|
|
8273
|
-
}
|
|
8274
|
-
}
|
|
8275
|
-
|
|
8276
|
-
/**
|
|
8277
|
-
* 查询扫码授权状态(GET /dsh-remote/im-dingtalk-auth/status?id=...&check=1)。
|
|
8278
|
-
* 默认只读后台轮询的结论;check=1(面板「我已完成授权,检查一下」)会**立刻补轮一次**再返回。
|
|
8279
|
-
* success 时凭据已经写盘,响应只回脱敏信息(绝不回 secret)。
|
|
8280
|
-
*/
|
|
8281
|
-
async function dingtalkAuthStatus(authId, opts = {}) {
|
|
8282
|
-
const key = String(authId || "");
|
|
8283
|
-
const sess = dingtalkAuthSessions.get(key);
|
|
8284
|
-
if (!sess) {
|
|
8285
|
-
if (dingtalkAuthSuperseded.has(key)) {
|
|
8286
|
-
dingtalkAuthSuperseded.delete(key);
|
|
8287
|
-
return { ok: false, status: "failed", code: "cancelled", message: dingTalkHumanMessage("cancelled") };
|
|
8288
|
-
}
|
|
8289
|
-
return { ok: false, status: "expired", code: "expired", message: dingTalkHumanMessage("expired") };
|
|
8290
|
-
}
|
|
8291
|
-
if (opts.check === true && sess.state === "waiting") {
|
|
8292
|
-
await dingtalkAuthPollOnce(key, opts);
|
|
8293
|
-
}
|
|
8294
|
-
if (sess.state === "waiting") return { ok: true, status: "waiting" };
|
|
8295
|
-
if (sess.state === "success") {
|
|
8296
|
-
return {
|
|
8297
|
-
ok: true, status: "success", credentialSource: "scan",
|
|
8298
|
-
clientIdTail: sess.clientIdTail || "", hasSecret: true
|
|
8299
|
-
};
|
|
8300
|
-
}
|
|
8301
|
-
if (sess.state === "expired") return { ok: false, status: "expired", code: "expired", message: dingTalkHumanMessage("expired", sess.message) };
|
|
8302
|
-
return { ok: false, status: "failed", code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected", sess.message) };
|
|
8303
8153
|
}
|
|
8304
8154
|
|
|
8305
8155
|
/** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
|
|
@@ -9296,25 +9146,38 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
9296
9146
|
},
|
|
9297
9147
|
},
|
|
9298
9148
|
{
|
|
9299
|
-
//
|
|
9149
|
+
// 钉钉扫码一键创建:**只写命令文件**(桥持久化 device_code 并断点续轮询)。
|
|
9150
|
+
// 不返回 URL —— URL 由桥跑完写进状态文件,面板轮询 GET status 拿。
|
|
9300
9151
|
method: "POST",
|
|
9301
9152
|
path: "/dsh-remote/im-dingtalk-auth/begin",
|
|
9302
9153
|
handler: async (_req, res) => {
|
|
9303
|
-
|
|
9304
|
-
|
|
9154
|
+
try {
|
|
9155
|
+
writeDingtalkAuthCmd(relayDir, "begin");
|
|
9156
|
+
sendJson(res, 200, { ok: true });
|
|
9157
|
+
} catch (e) {
|
|
9158
|
+
sendJson(res, 500, { ok: false, code: "write_failed", message: dingTalkHumanMessage("write_failed", `写入授权命令失败:${e?.message || e}`) });
|
|
9159
|
+
}
|
|
9305
9160
|
},
|
|
9306
9161
|
},
|
|
9307
9162
|
{
|
|
9308
|
-
//
|
|
9163
|
+
// 取消授权:写 cancel 命令(桥停轮询 + 删 auth 持久文件)
|
|
9164
|
+
method: "POST",
|
|
9165
|
+
path: "/dsh-remote/im-dingtalk-auth/cancel",
|
|
9166
|
+
handler: async (_req, res) => {
|
|
9167
|
+
try {
|
|
9168
|
+
writeDingtalkAuthCmd(relayDir, "cancel");
|
|
9169
|
+
sendJson(res, 200, { ok: true });
|
|
9170
|
+
} catch (e) {
|
|
9171
|
+
sendJson(res, 500, { ok: false, code: "write_failed", message: dingTalkHumanMessage("write_failed", `写入取消命令失败:${e?.message || e}`) });
|
|
9172
|
+
}
|
|
9173
|
+
},
|
|
9174
|
+
},
|
|
9175
|
+
{
|
|
9176
|
+
// 授权进度:**直接读状态文件**(不自己 poll 钉钉)→ idle/waiting/success/failed/expired
|
|
9309
9177
|
method: "GET",
|
|
9310
9178
|
path: "/dsh-remote/im-dingtalk-auth/status",
|
|
9311
|
-
handler: async (
|
|
9312
|
-
|
|
9313
|
-
const r = await dingtalkAuthStatus(url.searchParams.get("id") || "", {
|
|
9314
|
-
imAuthFetch: opts.imAuthFetch,
|
|
9315
|
-
check: url.searchParams.get("check") === "1"
|
|
9316
|
-
});
|
|
9317
|
-
sendJson(res, 200, r);
|
|
9179
|
+
handler: async (_req, res) => {
|
|
9180
|
+
sendJson(res, 200, { ok: true, ...imDingtalkAuthStatusOf(relayDir) });
|
|
9318
9181
|
},
|
|
9319
9182
|
},
|
|
9320
9183
|
{
|
|
@@ -9705,9 +9568,6 @@ export function apply(ctx, config = {}) {
|
|
|
9705
9568
|
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9706
9569
|
// 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
|
|
9707
9570
|
imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
|
|
9708
|
-
// 测试钩子:钉钉扫码授权的 fetch(用例注入假实现,**绝不真的打钉钉接口**)+ 后台轮询间隔。
|
|
9709
|
-
imAuthFetch: typeof config.imAuthFetch === "function" ? config.imAuthFetch : undefined,
|
|
9710
|
-
imAuthPollMs: Number(config.imAuthPollMs) || undefined,
|
|
9711
9571
|
}), "dsh-remote-web: /dsh-remote routes");
|
|
9712
9572
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
9713
9573
|
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.
|
|
3
|
+
"version": "0.7.12-beta.6",
|
|
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",
|