dsh-remote-web 0.7.12-beta.3 → 0.7.12-beta.4
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 +79 -26
- package/lib/index.js +123 -51
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -6729,7 +6729,24 @@ window.__ModuleLoader__.load({
|
|
|
6729
6729
|
return null;
|
|
6730
6730
|
}
|
|
6731
6731
|
|
|
6732
|
-
/**
|
|
6732
|
+
/** 中文人话错误映射(客户端兜底:错误框里绝不出现裸 code/ok/英文原文)。 */
|
|
6733
|
+
var IM_HUMAN_ERR = {
|
|
6734
|
+
network_error: "连不上钉钉接口,请检查网络后重试",
|
|
6735
|
+
dingtalk_api_rejected: "钉钉拒绝了请求,请稍后重试",
|
|
6736
|
+
expired: "授权已过期,点「重试扫码」生成新的授权链接",
|
|
6737
|
+
cancelled: "这次授权已被新的授权取代,请用最新链接完成确认",
|
|
6738
|
+
write_failed: "凭据保存失败(磁盘/权限问题),请检查后重试"
|
|
6739
|
+
};
|
|
6740
|
+
function imHumanErr(code, message) {
|
|
6741
|
+
var c = String(code || "");
|
|
6742
|
+
var m = String(message || "").trim();
|
|
6743
|
+
var looksHuman = /[\u4e00-\u9fa5]/.test(m) && !/^\s*[{[]/.test(m);
|
|
6744
|
+
if (looksHuman) return m;
|
|
6745
|
+
return IM_HUMAN_ERR[c] || "操作失败,请稍后重试";
|
|
6746
|
+
}
|
|
6747
|
+
|
|
6748
|
+
/** 钉钉扫码授权:开始(POST begin)→ 拿授权链接;deviceCode 只在服务端,不落浏览器。
|
|
6749
|
+
* 轮询由**节点半后台**跑(关面板/刷新不影响),这里只查状态。 */
|
|
6733
6750
|
function startDingAuth() {
|
|
6734
6751
|
if (imPlatBusy) return;
|
|
6735
6752
|
setImPlatBusy("auth");
|
|
@@ -6738,52 +6755,62 @@ window.__ModuleLoader__.load({
|
|
|
6738
6755
|
post("/dsh-remote/im-dingtalk-auth/begin", {}).then(function (b) {
|
|
6739
6756
|
if (!b || b.ok !== true || !b.url) {
|
|
6740
6757
|
setImDingAuth(null);
|
|
6741
|
-
setImPlatMsg({ kind: "err", text: "发起授权失败:" + (
|
|
6758
|
+
setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(b && b.code, b && b.message) });
|
|
6742
6759
|
return;
|
|
6743
6760
|
}
|
|
6761
|
+
var expiresIn = Math.max(60, Math.min(7200, Number(b.expiresIn) || 7200));
|
|
6744
6762
|
setImDingAuth({
|
|
6745
6763
|
authId: String(b.authId || ""),
|
|
6746
6764
|
url: String(b.url || ""),
|
|
6747
|
-
interval: Math.max(1, Math.min(10, Number(b.interval) ||
|
|
6748
|
-
expiresAt: Date.now() +
|
|
6765
|
+
interval: Math.max(1, Math.min(10, Number(b.interval) || 3)),
|
|
6766
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
6767
|
+
startedAt: Date.now(),
|
|
6749
6768
|
phase: "waiting",
|
|
6750
6769
|
error: ""
|
|
6751
6770
|
});
|
|
6752
6771
|
}).catch(function (e) {
|
|
6753
6772
|
setImDingAuth(null);
|
|
6754
6773
|
var body = (e && e.body) || {};
|
|
6755
|
-
setImPlatMsg({ kind: "err", text: "发起授权失败:" + (body.
|
|
6774
|
+
setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(body.code || (e && e.code), body.message || (e && e.message)) });
|
|
6756
6775
|
}).finally(function () { setImPlatBusy(""); });
|
|
6757
6776
|
}
|
|
6758
|
-
/**
|
|
6759
|
-
function pollDingAuth() {
|
|
6777
|
+
/** 查询一次授权状态;manual=true = 用户点「我已完成授权,检查一下」(立刻让服务端补轮一次)。 */
|
|
6778
|
+
function pollDingAuth(manual) {
|
|
6760
6779
|
var a = imDingAuth;
|
|
6761
6780
|
if (!a || !a.authId || a.phase !== "waiting") return;
|
|
6762
|
-
|
|
6781
|
+
var qs = "?id=" + encodeURIComponent(a.authId) + (manual ? "&check=1" : "");
|
|
6782
|
+
api("/dsh-remote/im-dingtalk-auth/status" + qs).then(function (b) {
|
|
6763
6783
|
if (b && b.status === "success") {
|
|
6764
|
-
setImDingAuth(null);
|
|
6765
|
-
setImPlatMsg({
|
|
6784
|
+
setImDingAuth(null); // 成功后自动收起授权块
|
|
6785
|
+
setImPlatMsg({
|
|
6786
|
+
kind: "ok",
|
|
6787
|
+
text: "已连接" + (b.clientIdTail ? " · Client ID 尾号 " + b.clientIdTail : "") + " · 来源:扫码创建"
|
|
6788
|
+
});
|
|
6766
6789
|
loadImBots();
|
|
6767
6790
|
return;
|
|
6768
6791
|
}
|
|
6769
6792
|
if (b && (b.status === "failed" || b.status === "expired")) {
|
|
6770
|
-
setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, phase: "failed", error:
|
|
6793
|
+
setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, startedAt: a.startedAt, phase: "failed", error: imHumanErr(b.code, b.message) });
|
|
6771
6794
|
return;
|
|
6772
6795
|
}
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6796
|
+
// waiting:换新对象触发下一轮(服务端后台仍在推进,这里只负责刷新展示)
|
|
6797
|
+
setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, startedAt: a.startedAt, phase: "waiting", error: "", polledAt: Date.now() });
|
|
6798
|
+
}).catch(function (e) {
|
|
6799
|
+
// 网络抖动:不打断等待(手动按钮仍在),仅在自动轮询里静默
|
|
6800
|
+
if (manual) {
|
|
6801
|
+
var body = (e && e.body) || {};
|
|
6802
|
+
setImPlatMsg({ kind: "warn", text: "检查失败:" + imHumanErr(body.code, body.message || (e && e.message)) + "(可再试)" });
|
|
6776
6803
|
}
|
|
6777
|
-
|
|
6778
|
-
setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, phase: "waiting", error: "", polledAt: Date.now() });
|
|
6779
|
-
}).catch(function () { /* 网络抖动:下一轮再试(轮询期内不打断用户) */ });
|
|
6804
|
+
});
|
|
6780
6805
|
}
|
|
6781
|
-
/**
|
|
6806
|
+
/** 等待确认中:自动轮询 ~3s/次;**最多自动轮 10 分钟**,之后保留状态由用户手动检查。 */
|
|
6782
6807
|
useEffect(function () {
|
|
6783
6808
|
if (iaModal !== "channel" || iaChStep !== "dingtalk") return undefined;
|
|
6784
6809
|
if (!imDingAuth || imDingAuth.phase !== "waiting") return undefined;
|
|
6810
|
+
var waited = Date.now() - (imDingAuth.startedAt || Date.now());
|
|
6811
|
+
if (waited > 10 * 60 * 1000) return undefined; // 停止自动轮询(不再"2 分钟就判死")
|
|
6785
6812
|
var t = null;
|
|
6786
|
-
try { t = setTimeout(pollDingAuth, Math.max(1000, (imDingAuth.interval ||
|
|
6813
|
+
try { t = setTimeout(function () { pollDingAuth(false); }, Math.max(1000, (imDingAuth.interval || 3) * 1000)); } catch (e) { /* 忽略 */ }
|
|
6787
6814
|
return function () { try { if (t) clearTimeout(t); } catch (e) { /* 忽略 */ } };
|
|
6788
6815
|
}, [iaModal, iaChStep, imDingAuth]);
|
|
6789
6816
|
|
|
@@ -6848,17 +6875,39 @@ window.__ModuleLoader__.load({
|
|
|
6848
6875
|
h("a", { className: "dru-ia-tier-link", href: "https://open.dingtalk.com/", target: "_blank", rel: "noopener" }, "钉钉开放平台"),
|
|
6849
6876
|
" · ",
|
|
6850
6877
|
h("a", { className: "dru-ia-tier-link", href: "https://open-dev.dingtalk.com/", target: "_blank", rel: "noopener" }, "开发者后台")),
|
|
6851
|
-
//
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6878
|
+
// 状态行(2026-11-08 业主:"未配置"不许说成"连接失败"):
|
|
6879
|
+
// · 没有任何凭据 → 「未配置」(不是错误)
|
|
6880
|
+
// · 桥明确报 failed/error → 「连接失败:<中文人话>」
|
|
6881
|
+
// · 有凭据/已连接 → 「已连接 · Client ID 尾号 · 来源:…」
|
|
6882
|
+
(function () {
|
|
6883
|
+
var configured = !!d.enabled || !!d.hasSecret || !!String(d.credentialSource || "").trim();
|
|
6884
|
+
var failState = st.state === "failed" || st.state === "error";
|
|
6885
|
+
if (!configured) return h("div", { className: "dru-ia-state" }, druIcon("chat"), "未配置:点下方按钮扫码创建机器人");
|
|
6886
|
+
if (failState) {
|
|
6887
|
+
var human = imHumanErr("", String(st.lastError || ""));
|
|
6888
|
+
return h("div", { className: "dru-ia-state is-err", role: "status" }, "连接失败:" + human);
|
|
6889
|
+
}
|
|
6890
|
+
var tailBit = clientTail ? " · Client ID 尾号 " + clientTail : "";
|
|
6891
|
+
var srcBit = sourceText ? " · 来源:" + sourceText : "";
|
|
6892
|
+
if (st.connected) return h("div", { className: "dru-ia-state is-ok" }, druIcon("check"), "已连接" + tailBit + srcBit);
|
|
6893
|
+
return h("div", { className: "dru-ia-state" }, druIcon("chat"), "已保存凭据,等待连接…" + tailBit + srcBit);
|
|
6894
|
+
})(),
|
|
6857
6895
|
// 扫码授权中:链接 + 提示 + 复制/打开(无 QR 库 → 降级,明确告知用手机钉钉扫码)
|
|
6858
6896
|
(auth && auth.phase === "waiting")
|
|
6859
6897
|
? h("div", { className: "dru-ia-field" },
|
|
6860
6898
|
h("div", { className: "dru-ia-state", role: "status" }, h("span", { className: "dru-spin" }),
|
|
6861
6899
|
"等待你在钉钉里确认…(用手机钉钉扫下方链接,或复制到手机打开)"),
|
|
6900
|
+
(function () {
|
|
6901
|
+
var waitedMin = Math.floor((Date.now() - (auth.startedAt || Date.now())) / 60000);
|
|
6902
|
+
return h("div", { className: "dru-ia-hint" },
|
|
6903
|
+
waitedMin >= 10
|
|
6904
|
+
? "已等待 " + waitedMin + " 分钟:自动刷新先停下(后台仍在继续),完成授权后点「我已完成授权,检查一下」。"
|
|
6905
|
+
: "授权链接 2 小时内有效;关掉面板也不影响,回来点「我已完成授权,检查一下」即可。");
|
|
6906
|
+
})(),
|
|
6907
|
+
h("button", {
|
|
6908
|
+
type: "button", className: "dru-ia-btn is-primary", style: { alignSelf: "flex-start" },
|
|
6909
|
+
onClick: function () { pollDingAuth(true); }
|
|
6910
|
+
}, "我已完成授权,检查一下"),
|
|
6862
6911
|
h("div", { className: "dru-copy-row" },
|
|
6863
6912
|
h("div", { className: "dru-copy-box" },
|
|
6864
6913
|
h("span", { className: "dru-link-val" }, auth.url),
|
|
@@ -6925,7 +6974,11 @@ window.__ModuleLoader__.load({
|
|
|
6925
6974
|
h("button", { type: "button", className: "dru-ia-btn", disabled: imPlatBusy !== "", onClick: function () { saveImPlatform("dingtalk", "save"); } },
|
|
6926
6975
|
imPlatBusy === "save" ? "保存中…" : "仅保存")))
|
|
6927
6976
|
: null),
|
|
6928
|
-
|
|
6977
|
+
// 平台级消息(中文人话;绝不裸渲染 ok/code)
|
|
6978
|
+
imPlatMsg
|
|
6979
|
+
? h("div", { className: "dru-ia-state " + (imPlatMsg.kind === "ok" ? "is-ok" : imPlatMsg.kind === "warn" ? "is-warn" : "is-err"), role: "status" },
|
|
6980
|
+
imPlatMsg.text)
|
|
6981
|
+
: null
|
|
6929
6982
|
] }));
|
|
6930
6983
|
}
|
|
6931
6984
|
|
package/lib/index.js
CHANGED
|
@@ -8100,12 +8100,35 @@ function startFeishuInstall(relayDir, opts = {}) {
|
|
|
8100
8100
|
// POST .../app/registration/init {source:"DING_DWS_CLAW"} → {nonce}
|
|
8101
8101
|
// POST .../app/registration/begin {nonce} → {device_code, verification_uri_complete, interval, expires_in}
|
|
8102
8102
|
// POST .../app/registration/poll {device_code} → {status:"WAITING"|"SUCCESS", client_id, client_secret}
|
|
8103
|
-
//
|
|
8103
|
+
//
|
|
8104
|
+
// ★ 2026-11-08 生产实测修正(业主):**轮询必须在节点半后台跑**,面板只查状态。
|
|
8105
|
+
// 原实现让面板每 2s 轮询、还把 TTL 设成 2 分钟 —— 但钉钉 device-flow 的 expires_in 是 7200 秒,
|
|
8106
|
+
// 用户「点按钮→手机扫码→页面加载→点一键创建」很容易超过 2 分钟 → 被误判过期。
|
|
8107
|
+
// 现在:begin 即启动 setInterval(~3s) 后台轮询,关闭面板/刷新页面都不影响;TTL 按钉钉 expires_in。
|
|
8104
8108
|
const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
|
|
8105
|
-
const DINGTALK_AUTH_TTL_MS =
|
|
8106
|
-
const
|
|
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,绝不落盘)
|
|
8107
8112
|
const dingtalkAuthSuperseded = new Map(); // 被新授权作废的 authId → at(查询时回 cancelled)
|
|
8108
8113
|
|
|
8114
|
+
/** 面向用户的中文人话(错误里**绝不出现裸 code / 裸 ok**)。 */
|
|
8115
|
+
const DINGTALK_AUTH_MSG = {
|
|
8116
|
+
network_error: "连不上钉钉接口,请检查网络后重试",
|
|
8117
|
+
dingtalk_api_rejected: "钉钉拒绝了请求,请稍后重试",
|
|
8118
|
+
expired: "授权已过期,请点「重试扫码」生成新的授权链接",
|
|
8119
|
+
cancelled: "这次授权已被新的授权取代,请用最新链接完成确认",
|
|
8120
|
+
write_failed: "拿到的凭据保存失败(磁盘/权限问题),请检查后重试"
|
|
8121
|
+
};
|
|
8122
|
+
function dingTalkHumanMessage(code, fallbackMessage) {
|
|
8123
|
+
const c = String(code || "");
|
|
8124
|
+
const msg = String(fallbackMessage || "").trim();
|
|
8125
|
+
// 只接受"看起来是人话"的 message:含中文、且不是裸 code/ok/JSON 片段
|
|
8126
|
+
const looksHuman = /[\u4e00-\u9fa5]/.test(msg) && !/^\s*[{[]/.test(msg);
|
|
8127
|
+
if (looksHuman) return msg;
|
|
8128
|
+
if (DINGTALK_AUTH_MSG[c]) return DINGTALK_AUTH_MSG[c];
|
|
8129
|
+
return "操作失败,请稍后重试";
|
|
8130
|
+
}
|
|
8131
|
+
|
|
8109
8132
|
function jsonPostForAuth(fetchImpl, url, payload) {
|
|
8110
8133
|
return fetchImpl(url, {
|
|
8111
8134
|
method: "POST",
|
|
@@ -8119,20 +8142,72 @@ function jsonPostForAuth(fetchImpl, url, payload) {
|
|
|
8119
8142
|
});
|
|
8120
8143
|
}
|
|
8121
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
|
+
|
|
8122
8196
|
/**
|
|
8123
8197
|
* 开始一次钉钉扫码授权(POST /dsh-remote/im-dingtalk-auth/begin)。
|
|
8124
|
-
* 同一 relayDir 同时只保留一个会话:begin 时把旧的作废(旧 authId
|
|
8198
|
+
* 同一 relayDir 同时只保留一个会话:begin 时把旧的作废(旧 authId 后续查询回 cancelled)。
|
|
8199
|
+
* TTL 按钉钉返回的 expires_in(生产实测 7200s),后台 setInterval 轮询,关闭面板不影响。
|
|
8125
8200
|
*/
|
|
8126
8201
|
async function beginDingtalkAuth(relayDir, opts = {}) {
|
|
8127
8202
|
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8128
|
-
//
|
|
8203
|
+
// 作废旧会话(同时停掉它们的后台轮询表)
|
|
8129
8204
|
for (const [id, sess] of dingtalkAuthSessions) {
|
|
8130
8205
|
if (sess.relayDir === relayDir) {
|
|
8206
|
+
stopDingAuthTimer(sess);
|
|
8131
8207
|
dingtalkAuthSessions.delete(id);
|
|
8132
8208
|
dingtalkAuthSuperseded.set(id, Date.now());
|
|
8133
8209
|
}
|
|
8134
8210
|
}
|
|
8135
|
-
// 有界:只留最近 20 个作废标记,避免长期运行内存增长
|
|
8136
8211
|
while (dingtalkAuthSuperseded.size > 20) {
|
|
8137
8212
|
const first = dingtalkAuthSuperseded.keys().next().value;
|
|
8138
8213
|
dingtalkAuthSuperseded.delete(first);
|
|
@@ -8140,37 +8215,45 @@ async function beginDingtalkAuth(relayDir, opts = {}) {
|
|
|
8140
8215
|
let init;
|
|
8141
8216
|
try {
|
|
8142
8217
|
init = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" });
|
|
8143
|
-
} catch
|
|
8144
|
-
return { ok: false, code: "network_error", message:
|
|
8218
|
+
} catch {
|
|
8219
|
+
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8145
8220
|
}
|
|
8146
8221
|
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
|
-
}
|
|
8222
|
+
if (!nonce) return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8150
8223
|
let begin;
|
|
8151
8224
|
try {
|
|
8152
8225
|
begin = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce });
|
|
8153
|
-
} catch
|
|
8154
|
-
return { ok: false, code: "network_error", message:
|
|
8226
|
+
} catch {
|
|
8227
|
+
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8155
8228
|
}
|
|
8156
8229
|
const b = begin.body || {};
|
|
8157
8230
|
const deviceCode = typeof b.device_code === "string" ? b.device_code : "";
|
|
8158
8231
|
const url = typeof b.verification_uri_complete === "string" ? b.verification_uri_complete : "";
|
|
8159
8232
|
if (!begin.ok || !deviceCode || !url) {
|
|
8160
|
-
return { ok: false, code: "dingtalk_api_rejected", message: "
|
|
8233
|
+
return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8161
8234
|
}
|
|
8162
8235
|
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) ||
|
|
8164
|
-
|
|
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));
|
|
8165
8239
|
const now = Date.now();
|
|
8166
|
-
|
|
8240
|
+
const sess = {
|
|
8167
8241
|
relayDir, deviceCode, url, interval,
|
|
8168
|
-
createdAt: now,
|
|
8169
|
-
|
|
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);
|
|
8170
8253
|
return { ok: true, authId, url, interval, expiresIn };
|
|
8171
8254
|
}
|
|
8172
8255
|
|
|
8173
|
-
/** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600
|
|
8256
|
+
/** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600 原子写;保留其它字段,自动 enabled)。 */
|
|
8174
8257
|
function saveDingtalkScannedCredentials(relayDir, clientId, clientSecret) {
|
|
8175
8258
|
const bots = readImBots(relayDir);
|
|
8176
8259
|
const cur = dingtalkCfgOf(bots);
|
|
@@ -8186,13 +8269,14 @@ function saveDingtalkScannedCredentials(relayDir, clientId, clientSecret) {
|
|
|
8186
8269
|
writeImBots(relayDir, { ...bots, version: 1, dingtalk: next });
|
|
8187
8270
|
return { ok: true };
|
|
8188
8271
|
} catch (e) {
|
|
8189
|
-
return { ok: false, code: "write_failed", message: `保存扫码凭据失败:${e?.message || e}` };
|
|
8272
|
+
return { ok: false, code: "write_failed", message: dingTalkHumanMessage("write_failed", `保存扫码凭据失败:${e?.message || e}`) };
|
|
8190
8273
|
}
|
|
8191
8274
|
}
|
|
8192
8275
|
|
|
8193
8276
|
/**
|
|
8194
|
-
*
|
|
8195
|
-
*
|
|
8277
|
+
* 查询扫码授权状态(GET /dsh-remote/im-dingtalk-auth/status?id=...&check=1)。
|
|
8278
|
+
* 默认只读后台轮询的结论;check=1(面板「我已完成授权,检查一下」)会**立刻补轮一次**再返回。
|
|
8279
|
+
* success 时凭据已经写盘,响应只回脱敏信息(绝不回 secret)。
|
|
8196
8280
|
*/
|
|
8197
8281
|
async function dingtalkAuthStatus(authId, opts = {}) {
|
|
8198
8282
|
const key = String(authId || "");
|
|
@@ -8200,38 +8284,22 @@ async function dingtalkAuthStatus(authId, opts = {}) {
|
|
|
8200
8284
|
if (!sess) {
|
|
8201
8285
|
if (dingtalkAuthSuperseded.has(key)) {
|
|
8202
8286
|
dingtalkAuthSuperseded.delete(key);
|
|
8203
|
-
return { ok: false, status: "failed", code: "cancelled", message: "
|
|
8287
|
+
return { ok: false, status: "failed", code: "cancelled", message: dingTalkHumanMessage("cancelled") };
|
|
8204
8288
|
}
|
|
8205
|
-
return { ok: false, status: "expired", code: "expired", message: "
|
|
8289
|
+
return { ok: false, status: "expired", code: "expired", message: dingTalkHumanMessage("expired") };
|
|
8206
8290
|
}
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
dingtalkAuthSessions.delete(String(authId));
|
|
8210
|
-
return { ok: false, status: "expired", code: "expired", message: "授权已超时(2 分钟内未确认),请重新发起" };
|
|
8291
|
+
if (opts.check === true && sess.state === "waiting") {
|
|
8292
|
+
await dingtalkAuthPollOnce(key, opts);
|
|
8211
8293
|
}
|
|
8212
|
-
|
|
8213
|
-
|
|
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 || "");
|
|
8294
|
+
if (sess.state === "waiting") return { ok: true, status: "waiting" };
|
|
8295
|
+
if (sess.state === "success") {
|
|
8227
8296
|
return {
|
|
8228
8297
|
ok: true, status: "success", credentialSource: "scan",
|
|
8229
|
-
clientIdTail:
|
|
8230
|
-
hasSecret: true
|
|
8298
|
+
clientIdTail: sess.clientIdTail || "", hasSecret: true
|
|
8231
8299
|
};
|
|
8232
8300
|
}
|
|
8233
|
-
|
|
8234
|
-
return { ok: false, status: "failed", code: "dingtalk_api_rejected", message:
|
|
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) };
|
|
8235
8303
|
}
|
|
8236
8304
|
|
|
8237
8305
|
/** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
|
|
@@ -9232,7 +9300,7 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
9232
9300
|
method: "POST",
|
|
9233
9301
|
path: "/dsh-remote/im-dingtalk-auth/begin",
|
|
9234
9302
|
handler: async (_req, res) => {
|
|
9235
|
-
const r = await beginDingtalkAuth(relayDir, { imAuthFetch: opts.imAuthFetch });
|
|
9303
|
+
const r = await beginDingtalkAuth(relayDir, { imAuthFetch: opts.imAuthFetch, imAuthPollMs: opts.imAuthPollMs });
|
|
9236
9304
|
sendJson(res, r.ok ? 200 : 502, r);
|
|
9237
9305
|
},
|
|
9238
9306
|
},
|
|
@@ -9242,7 +9310,10 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
9242
9310
|
path: "/dsh-remote/im-dingtalk-auth/status",
|
|
9243
9311
|
handler: async (req, res) => {
|
|
9244
9312
|
const url = new URL(req.url ?? "/", "http://x");
|
|
9245
|
-
const r = await dingtalkAuthStatus(url.searchParams.get("id") || "", {
|
|
9313
|
+
const r = await dingtalkAuthStatus(url.searchParams.get("id") || "", {
|
|
9314
|
+
imAuthFetch: opts.imAuthFetch,
|
|
9315
|
+
check: url.searchParams.get("check") === "1"
|
|
9316
|
+
});
|
|
9246
9317
|
sendJson(res, 200, r);
|
|
9247
9318
|
},
|
|
9248
9319
|
},
|
|
@@ -9634,8 +9705,9 @@ export function apply(ctx, config = {}) {
|
|
|
9634
9705
|
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9635
9706
|
// 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
|
|
9636
9707
|
imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
|
|
9637
|
-
// 测试钩子:钉钉扫码授权的 fetch
|
|
9708
|
+
// 测试钩子:钉钉扫码授权的 fetch(用例注入假实现,**绝不真的打钉钉接口**)+ 后台轮询间隔。
|
|
9638
9709
|
imAuthFetch: typeof config.imAuthFetch === "function" ? config.imAuthFetch : undefined,
|
|
9710
|
+
imAuthPollMs: Number(config.imAuthPollMs) || undefined,
|
|
9639
9711
|
}), "dsh-remote-web: /dsh-remote routes");
|
|
9640
9712
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
9641
9713
|
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.4",
|
|
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",
|