dsh-remote-web 0.7.12-beta.3 → 0.7.12-beta.5

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 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):authId/授权链接/等待态;手动配置默认折叠
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(按"弹层是否打开"触发):
@@ -6729,64 +6739,105 @@ window.__ModuleLoader__.load({
6729
6739
  return null;
6730
6740
  }
6731
6741
 
6732
- /** 钉钉扫码授权:开始(POST begin)→ 拿授权链接;deviceCode 只在服务端,不落浏览器。 */
6742
+ /** 中文人话错误映射(客户端兜底:错误框里绝不出现裸 code/ok/英文原文)。 */
6743
+ var IM_HUMAN_ERR = {
6744
+ network_error: "连不上钉钉接口,请检查网络后重试",
6745
+ dingtalk_api_rejected: "钉钉拒绝了请求,请稍后重试",
6746
+ expired: "授权已过期,点「重试扫码」生成新的授权链接",
6747
+ cancelled: "这次授权已被新的授权取代,请用最新链接完成确认",
6748
+ write_failed: "凭据保存失败(磁盘/权限问题),请检查后重试"
6749
+ };
6750
+ function imHumanErr(code, message) {
6751
+ var c = String(code || "");
6752
+ var m = String(message || "").trim();
6753
+ var looksHuman = /[\u4e00-\u9fa5]/.test(m) && !/^\s*[{[]/.test(m);
6754
+ if (looksHuman) return m;
6755
+ return IM_HUMAN_ERR[c] || "操作失败,请稍后重试";
6756
+ }
6757
+
6758
+ /** 钉钉扫码授权(2026-11-08 架构修正:委托桥侧持久化授权)。
6759
+ * 面板只做三件事:写 begin 命令 → 轮询状态文件 → 展示。授权会话在桥那边落盘续轮询,
6760
+ * 关面板/宿主重启都不会丢(用户"钉钉说成功、我们没接住"的根因)。 */
6733
6761
  function startDingAuth() {
6734
6762
  if (imPlatBusy) return;
6735
6763
  setImPlatBusy("auth");
6736
6764
  setImPlatMsg(null);
6737
6765
  setImPlatErr({});
6738
6766
  post("/dsh-remote/im-dingtalk-auth/begin", {}).then(function (b) {
6739
- if (!b || b.ok !== true || !b.url) {
6767
+ if (!b || b.ok !== true) {
6740
6768
  setImDingAuth(null);
6741
- setImPlatMsg({ kind: "err", text: "发起授权失败:" + ((b && b.message) || "请稍后重试") });
6769
+ setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(b && b.code, b && b.message) });
6742
6770
  return;
6743
6771
  }
6772
+ // begin 只写命令文件;URL/剩余时间要等桥跑完写进状态文件 → 由轮询拿
6744
6773
  setImDingAuth({
6745
- authId: String(b.authId || ""),
6746
- url: String(b.url || ""),
6747
- interval: Math.max(1, Math.min(10, Number(b.interval) || 2)),
6748
- expiresAt: Date.now() + Math.max(10, Math.min(600, Number(b.expiresIn) || 120)) * 1000,
6749
- phase: "waiting",
6750
- error: ""
6774
+ phase: "waiting", url: "", startedAt: Date.now(),
6775
+ expiresAt: Date.now() + 15 * 60 * 1000, remainingSec: 0, error: ""
6751
6776
  });
6752
6777
  }).catch(function (e) {
6753
6778
  setImDingAuth(null);
6754
6779
  var body = (e && e.body) || {};
6755
- setImPlatMsg({ kind: "err", text: "发起授权失败:" + (body.message || (e && e.message) || "网络错误") });
6780
+ setImPlatMsg({ kind: "err", text: "发起授权失败:" + imHumanErr(body.code || (e && e.code), body.message || (e && e.message)) });
6756
6781
  }).finally(function () { setImPlatBusy(""); });
6757
6782
  }
6758
- /** 查询一次授权状态;成功后凭据已由服务端写盘 → 刷新面板即"已连接"。 */
6759
- function pollDingAuth() {
6783
+ /** 读一次桥写的授权进度(GET status → 状态文件;不自己 poll 钉钉)。 */
6784
+ function pollDingAuth(manual) {
6760
6785
  var a = imDingAuth;
6761
- if (!a || !a.authId || a.phase !== "waiting") return;
6762
- api("/dsh-remote/im-dingtalk-auth/status?id=" + encodeURIComponent(a.authId)).then(function (b) {
6763
- if (b && b.status === "success") {
6764
- setImDingAuth(null);
6765
- setImPlatMsg({ kind: "ok", text: "已创建机器人并建立连接" + (b.clientIdTail ? "(Client ID 尾号 " + b.clientIdTail + ")" : "") + ",去钉钉里发条消息试试" });
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") {
6790
+ setImDingAuth(null); // 成功后自动收起授权块
6791
+ setImPlatMsg({
6792
+ kind: "ok",
6793
+ text: "已连接" + (b.clientIdTail ? " · Client ID 尾号 " + b.clientIdTail : "") + " · 来源:扫码创建"
6794
+ });
6766
6795
  loadImBots();
6767
6796
  return;
6768
6797
  }
6769
- 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: String(b.message || "授权失败,请重试") });
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
+ });
6771
6803
  return;
6772
6804
  }
6773
- if (Date.now() > a.expiresAt) {
6774
- setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, phase: "failed", error: "授权已超时(2 分钟内未确认),请重新发起" });
6775
- return;
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
+ });
6814
+ }).catch(function (e) {
6815
+ if (manual) {
6816
+ var body = (e && e.body) || {};
6817
+ setImPlatMsg({ kind: "warn", text: "检查失败:" + imHumanErr(body.code, body.message || (e && e.message)) + "(可再试)" });
6776
6818
  }
6777
- // waiting:**换一个新对象** → 轮询 effect 依赖变化 → 继续排下一次(否则只会轮询一次)。
6778
- setImDingAuth({ authId: a.authId, url: a.url, interval: a.interval, expiresAt: a.expiresAt, phase: "waiting", error: "", polledAt: Date.now() });
6779
- }).catch(function () { /* 网络抖动:下一轮再试(轮询期内不打断用户) */ });
6819
+ });
6780
6820
  }
6781
- /** 等待确认中:每 ~2s 轮询一次 status(最长到 expiresAt)。 */
6821
+ /** 等待确认中:每 2s 读一次状态(桥后台在推进);**最多自动看 15 分钟后停止**,手动按钮仍在。 */
6782
6822
  useEffect(function () {
6783
6823
  if (iaModal !== "channel" || iaChStep !== "dingtalk") return undefined;
6784
6824
  if (!imDingAuth || imDingAuth.phase !== "waiting") return undefined;
6825
+ var waited = Date.now() - (imDingAuth.startedAt || Date.now());
6826
+ if (waited > 15 * 60 * 1000) return undefined;
6785
6827
  var t = null;
6786
- try { t = setTimeout(pollDingAuth, Math.max(1000, (imDingAuth.interval || 2) * 1000)); } catch (e) { /* 忽略 */ }
6828
+ try { t = setTimeout(function () { pollDingAuth(false); }, 2000); } catch (e) { /* 忽略 */ }
6787
6829
  return function () { try { if (t) clearTimeout(t); } catch (e) { /* 忽略 */ } };
6788
6830
  }, [iaModal, iaChStep, imDingAuth]);
6789
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
+
6790
6841
  /** 钉钉手动创建 7 步(2026-11-08 业主给的官方链接;每步一句 + 链接)。 */
6791
6842
  var DINGTALK_STEPS = [
6792
6843
  { t: "用钉钉账号登录开发者后台", url: "https://open-dev.dingtalk.com/" },
@@ -6822,6 +6873,7 @@ window.__ModuleLoader__.load({
6822
6873
  var st = (imBots && imBots.status && imBots.status.dingtalk) || {};
6823
6874
  var err = imPlatErr || {};
6824
6875
  var auth = imDingAuth;
6876
+ var authStatus = st.auth || {};
6825
6877
  var sourceText = (st.credentialSource || d.credentialSource) === "scan" ? "扫码创建"
6826
6878
  : (st.credentialSource || d.credentialSource) === "manual" ? "手动填写" : "";
6827
6879
  var clientTail = String(d.clientId || "").length > 4 ? String(d.clientId).slice(-4) : String(d.clientId || "");
@@ -6848,33 +6900,67 @@ window.__ModuleLoader__.load({
6848
6900
  h("a", { className: "dru-ia-tier-link", href: "https://open.dingtalk.com/", target: "_blank", rel: "noopener" }, "钉钉开放平台"),
6849
6901
  " · ",
6850
6902
  h("a", { className: "dru-ia-tier-link", href: "https://open-dev.dingtalk.com/", target: "_blank", rel: "noopener" }, "开发者后台")),
6851
- // 已连接状态:clientId 尾号 + 来源
6852
- (st.connected || d.hasSecret)
6853
- ? h("div", { className: "dru-ia-state is-ok" }, druIcon("check"),
6854
- (st.connected ? "已连接" : "已保存凭据") + (clientTail ? "(Client ID 尾号 " + clientTail + ")" : "")
6855
- + (sourceText ? " · 来源:" + sourceText : ""))
6856
- : h("div", { className: "dru-ia-state" }, druIcon("chat"), "还没有连接:点下方按钮扫码创建机器人"),
6857
- // 扫码授权中:链接 + 提示 + 复制/打开(无 QR 库 → 降级,明确告知用手机钉钉扫码)
6903
+ // 状态行(2026-11-08 业主:"未配置"不许说成"连接失败"):
6904
+ // · 没有任何凭据 → 「未配置」(不是错误)
6905
+ // · 桥明确报 failed/error → 「连接失败:<中文人话>」
6906
+ // · 有凭据/已连接 → 「已连接 · Client ID 尾号 · 来源:…」
6907
+ (function () {
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
+ }
6916
+ var failState = st.state === "failed" || st.state === "error";
6917
+ if (!configured) return h("div", { className: "dru-ia-state" }, druIcon("chat"), "未配置:点下方按钮扫码创建机器人");
6918
+ if (failState) {
6919
+ var human = imHumanErr("", String(st.lastError || ""));
6920
+ return h("div", { className: "dru-ia-state is-err", role: "status" }, "连接失败:" + human);
6921
+ }
6922
+ var tailBit = clientTail ? " · Client ID 尾号 " + clientTail : "";
6923
+ var srcBit = sourceText ? " · 来源:" + sourceText : "";
6924
+ if (st.connected) return h("div", { className: "dru-ia-state is-ok" }, druIcon("check"), "已连接" + tailBit + srcBit);
6925
+ return h("div", { className: "dru-ia-state" }, druIcon("chat"), "已保存凭据,等待连接…" + tailBit + srcBit);
6926
+ })(),
6927
+ // 扫码授权中(桥侧持久化:begin 只写命令,URL 等桥跑完写进状态 → 轮询拿)
6858
6928
  (auth && auth.phase === "waiting")
6859
6929
  ? h("div", { className: "dru-ia-field" },
6860
6930
  h("div", { className: "dru-ia-state", role: "status" }, h("span", { className: "dru-spin" }),
6861
- "等待你在钉钉里确认…(用手机钉钉扫下方链接,或复制到手机打开)"),
6862
- h("div", { className: "dru-copy-row" },
6863
- h("div", { className: "dru-copy-box" },
6864
- h("span", { className: "dru-link-val" }, auth.url),
6865
- h("button", {
6866
- type: "button", className: "dru-copy-btn", "aria-label": "复制授权链接",
6867
- onClick: function () {
6868
- copyText(auth.url).then(function (ok) { if (!ok) setImPlatMsg({ kind: "err", text: "复制失败:请手动选中链接复制" }); });
6869
- }
6870
- }, "复制授权链接")),
6871
- h("div", { className: "dru-actions", style: { marginTop: 8 } },
6872
- h("a", { className: "dru-ia-btn is-primary", href: auth.url, target: "_blank", rel: "noopener" }, "在浏览器打开授权页"),
6873
- h("button", { type: "button", className: "dru-ia-btn", onClick: function () { setImDingAuth(null); setImPlatMsg({ kind: "ok", text: "已取消本次扫码授权" }); } }, "取消授权"))))
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" }, "关掉面板也不影响:桥会继续等你在钉钉里确认,回来点「我已完成授权,检查一下」即可。"),
6937
+ (function () {
6938
+ var waitedMin = Math.floor((Date.now() - (auth.startedAt || Date.now())) / 60000);
6939
+ return waitedMin >= 15
6940
+ ? h("div", { className: "dru-ia-hint" }, "已等待 " + waitedMin + " 分钟:自动刷新先停下(桥仍在继续),完成确认后点「我已完成授权,检查一下」。")
6941
+ : null;
6942
+ })(),
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 }, "取消授权")))
6874
6960
  : null,
6875
6961
  (auth && auth.phase === "failed")
6876
6962
  ? h("div", { className: "dru-ia-field" },
6877
- 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) || "请重试")),
6878
6964
  h("button", { type: "button", className: "dru-ia-btn", onClick: function () { startDingAuth(); } }, "重试扫码授权"))
6879
6965
  : null,
6880
6966
  // 能力:主动推送 / 卡片(缺 Robot Code 时置灰,提示补 Robot Code)
@@ -6925,7 +7011,11 @@ window.__ModuleLoader__.load({
6925
7011
  h("button", { type: "button", className: "dru-ia-btn", disabled: imPlatBusy !== "", onClick: function () { saveImPlatform("dingtalk", "save"); } },
6926
7012
  imPlatBusy === "save" ? "保存中…" : "仅保存")))
6927
7013
  : null),
6928
- imPlatStateLine(st)
7014
+ // 平台级消息(中文人话;绝不裸渲染 ok/code)
7015
+ imPlatMsg
7016
+ ? h("div", { className: "dru-ia-state " + (imPlatMsg.kind === "ok" ? "is-ok" : imPlatMsg.kind === "warn" ? "is-warn" : "is-err"), role: "status" },
7017
+ imPlatMsg.text)
7018
+ : null
6929
7019
  ] }));
6930
7020
  }
6931
7021
 
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,143 +8095,61 @@ function startFeishuInstall(relayDir, opts = {}) {
8095
8095
  return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
8096
8096
  }
8097
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 };
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**)。 */
8108
+ const DINGTALK_AUTH_MSG = {
8109
+ network_error: "连不上钉钉接口,请检查网络后重试",
8110
+ dingtalk_api_rejected: "钉钉拒绝了请求,请稍后重试",
8111
+ expired: "授权已过期,请点「重试扫码」生成新的授权链接",
8112
+ cancelled: "这次授权已取消,请重新发起",
8113
+ write_failed: "拿到的凭据保存失败(磁盘/权限问题),请检查后重试"
8114
+ };
8115
+ function dingTalkHumanMessage(code, fallbackMessage) {
8116
+ const c = String(code || "");
8117
+ const msg = String(fallbackMessage || "").trim();
8118
+ const looksHuman = /[\u4e00-\u9fa5]/.test(msg) && !/^\s*[{[]/.test(msg);
8119
+ if (looksHuman) return msg;
8120
+ if (DINGTALK_AUTH_MSG[c]) return DINGTALK_AUTH_MSG[c];
8121
+ return "操作失败,请稍后重试";
8122
+ }
8123
+
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 };
8171
8134
  }
8172
8135
 
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",
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)
8184
8152
  };
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
8153
  }
8236
8154
 
8237
8155
  /** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
@@ -9228,22 +9146,38 @@ function registerRoutes(ctx, relayDir, opts = {}) {
9228
9146
  },
9229
9147
  },
9230
9148
  {
9231
- // 钉钉扫码一键创建机器人:开始授权(返回授权链接;device_code 只存服务端内存)
9149
+ // 钉钉扫码一键创建:**只写命令文件**(桥持久化 device_code 并断点续轮询)。
9150
+ // 不返回 URL —— URL 由桥跑完写进状态文件,面板轮询 GET status 拿。
9232
9151
  method: "POST",
9233
9152
  path: "/dsh-remote/im-dingtalk-auth/begin",
9234
9153
  handler: async (_req, res) => {
9235
- const r = await beginDingtalkAuth(relayDir, { imAuthFetch: opts.imAuthFetch });
9236
- sendJson(res, r.ok ? 200 : 502, r);
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
+ }
9160
+ },
9161
+ },
9162
+ {
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
+ }
9237
9173
  },
9238
9174
  },
9239
9175
  {
9240
- // 轮询授权状态:waiting / success(已写盘,只回脱敏结果)/ failed / expired
9176
+ // 授权进度:**直接读状态文件**(不自己 poll 钉钉)→ idle/waiting/success/failed/expired
9241
9177
  method: "GET",
9242
9178
  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);
9179
+ handler: async (_req, res) => {
9180
+ sendJson(res, 200, { ok: true, ...imDingtalkAuthStatusOf(relayDir) });
9247
9181
  },
9248
9182
  },
9249
9183
  {
@@ -9634,8 +9568,6 @@ export function apply(ctx, config = {}) {
9634
9568
  imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
9635
9569
  // 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
9636
9570
  imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
9637
- // 测试钩子:钉钉扫码授权的 fetch(用例注入假实现,**绝不真的打钉钉接口**)。
9638
- imAuthFetch: typeof config.imAuthFetch === "function" ? config.imAuthFetch : undefined,
9639
9571
  }), "dsh-remote-web: /dsh-remote routes");
9640
9572
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
9641
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",
3
+ "version": "0.7.12-beta.5",
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",
@@ -0,0 +1,631 @@
1
+ /**
2
+ * dingtalk-auth — 钉钉「扫码一键创建机器人」授权会话的**持久化 + 断点续轮询**(2026-11-08)。
3
+ *
4
+ * 要解决的根因
5
+ * -----------------------------------------------------------------
6
+ * 官方 connector 的授权是标准 device-flow:
7
+ * POST /app/registration/init {source:"DING_DWS_CLAW"} → {nonce}
8
+ * POST /app/registration/begin {nonce} → {device_code, verification_uri_complete, interval, expires_in}
9
+ * POST /app/registration/poll {device_code} → {status:"WAITING"|"SUCCESS", client_id, client_secret}
10
+ *
11
+ * 凭据**只在 poll 返回 SUCCESS 的那一次**给出。如果 device_code 只存在进程内存里(插件进程/面板关掉/宿主重启),
12
+ * 授权会话就丢了 —— 钉钉侧显示「连接成功」、机器人也建好了,但我们永远接不住凭据,用户只能去控制台重新复制。
13
+ *
14
+ * 本模块因此把授权会话**原子落盘**到 `<relayDir>/im-dingtalk-auth.json`(0600),
15
+ * bridge 启动时 `resumeDingtalkAuth()` 继续按 interval 轮询;拿到凭据后写 `im-bots.json` 的 dingtalk 段、
16
+ * 删除 auth 文件、写状态 `dingtalk.state="connected"`,随后现有 controller 的热更新会真正把机器人连上。
17
+ *
18
+ * 安全
19
+ * -----------------------------------------------------------------
20
+ * · auth 文件含 device_code(等价于"授权票据")→ 0600,且**不写状态文件**、不进日志。
21
+ * · 拿到的 clientSecret 只写 im-bots.json(0600 原子写),日志只打 clientId 尾号。
22
+ *
23
+ * 对插件暴露的接口(插件侧改为委托调用,2026-11-08 Lead 确认):
24
+ * beginDingtalkAuth({relayDir, fetchImpl}) → {ok, url, expiresIn, interval, expiresAt}
25
+ * authStatus({relayDir}) → {ok, status:"idle|waiting|success|failed|expired", remainingSeconds, url?, message?, clientIdTail?}
26
+ * cancelDingtalkAuth({relayDir}) → {ok:true}(停轮询 + 删 auth 文件)
27
+ * resumeDingtalkAuth({relayDir, fetchImpl, onSuccess}) → bridge 启动时调用(fire-and-forget 也安全)
28
+ * stopDingtalkAuthPolling() → 只停定时器(**保留** auth 文件,供下次启动续轮询)
29
+ */
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+
34
+ import {
35
+ IM_BOTS_FILE,
36
+ IM_BOT_STATUS_FILE,
37
+ atomicWriteJson,
38
+ createScopedLogger,
39
+ readJsonFile,
40
+ truncateText
41
+ } from "./wecom-bot.mjs";
42
+
43
+ /** 授权会话文件(相对 relayDir;0600;含 device_code,绝不进状态/日志)。 */
44
+ export const DINGTALK_AUTH_FILE = "im-dingtalk-auth.json";
45
+ /**
46
+ * 控制命令文件(插件写、桥读,0600):`{"action":"begin"|"cancel","at":<ts>}`。
47
+ * 桥用 `fs.watchFile` 盯它(1s),处理完删除文件;同一 `at` 不重复处理。
48
+ * 这样插件不需要点对点 HTTP 端点,也不需要持有 device_code —— 只发"开始/取消"意图。
49
+ */
50
+ export const DINGTALK_AUTH_CMD_FILE = "im-dingtalk-auth-cmd.json";
51
+ /** 官方 connector 用的注册端点(与插件侧 lib/index.js 的 DINGTALK_REG_BASE 保持一致)。 */
52
+ export const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
53
+ /** 兜底 TTL 上限:钉钉 device-flow 的 expires_in 是 7200s(2 小时)。 */
54
+ export const DINGTALK_AUTH_TTL_MS = 7200 * 1000;
55
+ /** 默认轮询间隔(秒);钉钉 begin 会返回 interval(实测 3s 左右)。 */
56
+ const DEFAULT_POLL_SECONDS = 3;
57
+ /** 单次 HTTP 超时。 */
58
+ const REQUEST_TIMEOUT_MS = 8_000;
59
+ /** auth 成功后 lastResult 保留多久(供插件轮询读结果)。 */
60
+ const LAST_RESULT_TTL_MS = 10 * 60 * 1000;
61
+
62
+ /** 面向用户的中文错误(绝不把裸 code / 钉钉 errmsg 直接渲染给用户)。 */
63
+ const HUMAN = Object.freeze({
64
+ network_error: "连不上钉钉接口,请检查网络后重试",
65
+ rejected: "钉钉拒绝了这次授权请求,请重新扫码",
66
+ expired: "授权已过期,请重新扫码生成新的链接",
67
+ cancelled: "这次授权已被取消",
68
+ write_failed: "拿到的凭据保存失败(磁盘/权限问题),请检查后重试",
69
+ bad_credentials: "钉钉返回的凭据不完整,请重新扫码",
70
+ no_relay_dir: "缺少 relay 目录,无法保存授权会话",
71
+ no_session: "没有进行中的扫码授权",
72
+ success: "授权成功,机器人凭据已保存(正在自动连接)"
73
+ });
74
+
75
+ /** relayDir → 后台轮询会话(只存内存;device_code 同时持久化在 auth 文件里)。 */
76
+ const sessions = new Map();
77
+ /** relayDir → 最近一次结论(success/failed/expired/cancelled;供 authStatus 在文件删除后仍能读到)。 */
78
+ const lastResults = new Map();
79
+ let generationSeq = 0;
80
+
81
+ function human(code, fallback) {
82
+ const text = String(fallback || "").trim();
83
+ if (/[\u4e00-\u9fa5]/.test(text) && !/^\s*[{[]/.test(text)) return truncateText(text, 120);
84
+ return HUMAN[code] || "操作失败,请稍后重试";
85
+ }
86
+
87
+ function nowOf(options) {
88
+ return typeof options.clock === "function" ? Number(options.clock()) || Date.now() : Date.now();
89
+ }
90
+
91
+ function authFileOf(relayDir) {
92
+ return path.join(String(relayDir || ""), DINGTALK_AUTH_FILE);
93
+ }
94
+
95
+ /** 读取授权会话文件(不存在/坏 JSON → null,绝不抛)。 */
96
+ export function readDingtalkAuthSession(relayDir) {
97
+ if (!relayDir) return null;
98
+ const raw = readJsonFile(authFileOf(relayDir));
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
100
+ const deviceCode = typeof raw.deviceCode === "string" ? raw.deviceCode : "";
101
+ if (!deviceCode) return null;
102
+ return {
103
+ deviceCode,
104
+ interval: Math.max(1, Math.min(10, Number(raw.interval) || DEFAULT_POLL_SECONDS)),
105
+ expiresAt: Number(raw.expiresAt) || 0,
106
+ url: typeof raw.url === "string" ? raw.url : "",
107
+ createdAt: Number(raw.createdAt) || 0,
108
+ credentialSource: "scan"
109
+ };
110
+ }
111
+
112
+ function removeAuthFile(relayDir) {
113
+ try { fs.rmSync(authFileOf(relayDir), { force: true }); } catch { /* 忽略 */ }
114
+ }
115
+
116
+ /** 合并写状态文件的 dingtalk 段(保留其它段/字段;绝不含 secret)。 */
117
+ function patchDingtalkStatus(relayDir, patch, onWarn) {
118
+ if (!relayDir) return;
119
+ try {
120
+ const file = path.join(String(relayDir), IM_BOT_STATUS_FILE);
121
+ const prev = readJsonFile(file);
122
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
123
+ const cur = base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {};
124
+ atomicWriteJson(file, {
125
+ ...base,
126
+ version: 1,
127
+ updatedAt: Date.now(),
128
+ dingtalk: { ...cur, ...patch }
129
+ }, { onWarn });
130
+ } catch { /* 状态写失败不影响授权本身 */ }
131
+ }
132
+
133
+ /** relayDir → 上次写入的 dingtalk.auth 快照(JSON;内容没变就不重复写文件)。 */
134
+ const lastAuthSync = new Map();
135
+
136
+ /** 把 authStatus() 的结果同步进状态文件 `dingtalk.auth`(**绝不含 deviceCode/secret**)。 */
137
+ function syncAuthStatus(relayDir, onWarn) {
138
+ if (!relayDir) return null;
139
+ const st = authStatus({ relayDir });
140
+ const snapshot = {
141
+ state: String(st.status || "idle"),
142
+ url: st.status === "waiting" && st.url ? String(st.url) : "",
143
+ expiresAt: Number(st.expiresAt) || 0,
144
+ remainingSec: Math.max(0, Number(st.remainingSeconds) || 0),
145
+ clientIdTail: String(st.clientIdTail || ""),
146
+ message: String(st.message || "")
147
+ };
148
+ const sig = JSON.stringify(snapshot);
149
+ if (lastAuthSync.get(relayDir) === sig) return snapshot;
150
+ lastAuthSync.set(relayDir, sig);
151
+ patchDingtalkStatus(relayDir, { auth: snapshot }, onWarn);
152
+ return snapshot;
153
+ }
154
+
155
+ // ---------- 控制命令文件(插件 → 桥) ----------
156
+
157
+ /** relayDir → { watcher 状态/去重信息 }(只存内存;命令文件处理完即删)。 */
158
+ const cmdWatchers = new Map();
159
+
160
+ function readCommandFile(relayDir) {
161
+ const file = path.join(String(relayDir || ""), DINGTALK_AUTH_CMD_FILE);
162
+ const raw = readJsonFile(file);
163
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
164
+ const action = String(raw.action || "").toLowerCase();
165
+ if (action !== "begin" && action !== "cancel") return null;
166
+ return { action, at: Number(raw.at) || 0 };
167
+ }
168
+
169
+ function removeCommandFile(relayDir) {
170
+ try { fs.rmSync(path.join(String(relayDir || ""), DINGTALK_AUTH_CMD_FILE), { force: true }); } catch { /* 忽略 */ }
171
+ }
172
+
173
+ /**
174
+ * 处理命令文件(幂等):读 → 去重(at) → 执行 → 删除文件 → 同步状态。
175
+ * 返回处理结果(测试/排障用):`{ok, action?, skipped?}`。
176
+ */
177
+ export async function handleDingtalkAuthCommand(options = {}) {
178
+ const relayDir = String(options.relayDir || "");
179
+ if (!relayDir) return { ok: false, code: "no_relay_dir" };
180
+ const state = cmdWatchers.get(relayDir) || { lastHandledAt: 0, handling: false };
181
+ if (state.handling) return { ok: false, code: "busy" };
182
+ const cmd = readCommandFile(relayDir);
183
+ if (!cmd) {
184
+ // 坏文件/未知 action:删掉,避免每 1s 刷日志(只记一次 debug)
185
+ if (fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE))) {
186
+ removeCommandFile(relayDir);
187
+ if (options.logger) options.logger("debug", "[dingtalk-auth] 忽略无法识别的授权命令文件(已删除)");
188
+ }
189
+ return { ok: false, code: "no_command" };
190
+ }
191
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
192
+ if (cmd.at && cmd.at <= state.lastHandledAt) {
193
+ removeCommandFile(relayDir);
194
+ log.debug(`忽略重复的授权命令(at=${cmd.at} ≤ lastHandledAt=${state.lastHandledAt})`);
195
+ return { ok: true, action: cmd.action, skipped: true, at: cmd.at };
196
+ }
197
+ state.handling = true;
198
+ cmdWatchers.set(relayDir, state);
199
+ try {
200
+ if (cmd.action === "begin") {
201
+ log.info("收到面板「开始扫码授权」命令 → 生成授权链接并持久化会话");
202
+ const r = await beginDingtalkAuth({
203
+ relayDir,
204
+ fetchImpl: options.fetchImpl,
205
+ logger: options.logger,
206
+ pollMs: options.pollMs,
207
+ onSuccess: options.onSuccess,
208
+ onWarn: options.onWarn
209
+ });
210
+ state.lastHandledAt = cmd.at || Date.now();
211
+ log.info(r.ok ? "已生成扫码授权链接(等待用户手机确认)" : `扫码授权发起失败:${r.message || r.code || "?"}`);
212
+ return { ok: r.ok, action: "begin", at: cmd.at, result: r };
213
+ }
214
+ log.info("收到面板「取消扫码授权」命令");
215
+ cancelDingtalkAuth({ relayDir, onWarn: options.onWarn });
216
+ state.lastHandledAt = cmd.at || Date.now();
217
+ return { ok: true, action: "cancel", at: cmd.at };
218
+ } finally {
219
+ removeCommandFile(relayDir);
220
+ state.handling = false;
221
+ cmdWatchers.set(relayDir, state);
222
+ syncAuthStatus(relayDir, options.onWarn);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 启动命令文件监听(bridge 启动时调用;幂等)。
228
+ * 同时每秒刷新一次 `dingtalk.auth`(remainingSec 会随时间减少),桥退出时 stop 掉。
229
+ * @param {{relayDir:string, fetchImpl?:Function, logger?:Function, onSuccess?:Function, onWarn?:Function,
230
+ * intervalMs?:number, pollMs?:number}} options
231
+ */
232
+ export function startDingtalkAuthCommandWatcher(options = {}) {
233
+ const relayDir = String(options.relayDir || "");
234
+ if (!relayDir) return { ok: false, code: "no_relay_dir" };
235
+ const existing = cmdWatchers.get(relayDir);
236
+ if (existing && existing.timer) return { ok: true, started: false };
237
+ const file = path.join(relayDir, DINGTALK_AUTH_CMD_FILE);
238
+ const intervalMs = Math.max(20, Number(options.intervalMs) || 1000);
239
+ const state = { lastHandledAt: 0, handling: false, timer: null, listener: null };
240
+ // ⚠️ 必须**先把 state 放进 map 再触发首次 handle**:否则启动时恰有命令文件的情况下,
241
+ // handleDingtalkAuthCommand 会用自己的临时 state 覆盖 map,导致 stop 时拿不到 listener
242
+ // → fs.watchFile 解不掉(测试进程/桥退出时泄漏)。
243
+ cmdWatchers.set(relayDir, state);
244
+ const tick = () => {
245
+ // 先处理命令(有就执行),再刷新状态(remainingSec 随时间变化)
246
+ void handleDingtalkAuthCommand(options)
247
+ .catch(() => { /* 内部已兜住 */ })
248
+ .finally(() => { syncAuthStatus(relayDir, options.onWarn); });
249
+ };
250
+ state.listener = () => { void handleDingtalkAuthCommand(options).catch(() => {}); };
251
+ try {
252
+ fs.watchFile(file, { interval: intervalMs }, state.listener);
253
+ } catch (e) {
254
+ if (options.logger) options.logger("warn", `[dingtalk-auth] 监听授权命令文件失败:${e && e.message ? e.message : e}`);
255
+ }
256
+ state.timer = setInterval(tick, intervalMs);
257
+ if (typeof state.timer.unref === "function") state.timer.unref();
258
+ // 启动时先检查一次(上次退出可能留下未处理命令),之后 interval 心跳兜底
259
+ void handleDingtalkAuthCommand(options).catch(() => {});
260
+ syncAuthStatus(relayDir, options.onWarn);
261
+ return { ok: true, started: true, intervalMs };
262
+ }
263
+
264
+ /** 停止命令文件监听(bridge 退出时调用;**保留** auth 文件,下次启动续轮询)。 */
265
+ export function stopDingtalkAuthCommandWatcher(relayDir) {
266
+ const dirs = relayDir ? [String(relayDir)] : [...cmdWatchers.keys()];
267
+ for (const dir of dirs) {
268
+ const state = cmdWatchers.get(dir);
269
+ if (!state) continue;
270
+ try { if (state.timer) clearInterval(state.timer); } catch { /* 忽略 */ }
271
+ state.timer = null;
272
+ try {
273
+ if (state.listener) fs.unwatchFile(path.join(dir, DINGTALK_AUTH_CMD_FILE), state.listener);
274
+ } catch { /* 忽略 */ }
275
+ state.listener = null;
276
+ cmdWatchers.delete(dir);
277
+ }
278
+ }
279
+
280
+ /** 测试用:清空命令监听内存态(不动文件)。 */
281
+ export function __resetDingtalkAuthCmdForTest() {
282
+ stopDingtalkAuthCommandWatcher();
283
+ }
284
+
285
+ function stopSession(relayDir) {
286
+ const sess = sessions.get(relayDir);
287
+ if (sess) {
288
+ try { if (sess.timer) clearInterval(sess.timer); } catch { /* 忽略 */ }
289
+ sess.timer = null;
290
+ sessions.delete(relayDir);
291
+ }
292
+ }
293
+
294
+ function setLastResult(relayDir, result) {
295
+ lastResults.set(relayDir, { ...result, at: Date.now() });
296
+ while (lastResults.size > 50) {
297
+ const first = lastResults.keys().next();
298
+ if (first.done) break;
299
+ lastResults.delete(first.value);
300
+ }
301
+ }
302
+
303
+ async function jsonPost(fetchImpl, url, payload) {
304
+ const res = await fetchImpl(url, {
305
+ method: "POST",
306
+ headers: { "content-type": "application/json", accept: "application/json" },
307
+ body: JSON.stringify(payload || {}),
308
+ signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
309
+ ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) : undefined
310
+ });
311
+ let body = null;
312
+ try { body = res && typeof res.json === "function" ? await res.json() : null; } catch { body = null; }
313
+ return { ok: Boolean(res && res.ok), status: Number(res && res.status) || 0, body: body && typeof body === "object" ? body : {} };
314
+ }
315
+
316
+ /** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600 原子写;保留其它段/字段,自动 enabled)。 */
317
+ function saveScannedCredentials(relayDir, clientId, clientSecret, onWarn) {
318
+ const cid = String(clientId || "").trim();
319
+ const csec = String(clientSecret || "").trim();
320
+ if (!cid || !csec) return { ok: false, code: "bad_credentials", message: human("bad_credentials") };
321
+ try {
322
+ const file = path.join(String(relayDir), IM_BOTS_FILE);
323
+ const prev = readJsonFile(file);
324
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
325
+ const cur = base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {};
326
+ const dingtalk = {
327
+ ...cur,
328
+ enabled: true,
329
+ clientId: cid,
330
+ clientSecret: csec,
331
+ credentialSource: "scan"
332
+ };
333
+ // 扫码流程只给 client_id/client_secret:robotCode 缺省用 clientId(Lead 2026-11-08 约定;
334
+ // 用户后续可在面板里改成正真的机器人编码 —— openapi/卡片能力由 dingtalk-bot 的 capabilities 表达)。
335
+ if (!String(dingtalk.robotCode || "").trim()) dingtalk.robotCode = cid;
336
+ atomicWriteJson(file, { ...base, version: 1, dingtalk }, { onWarn });
337
+ return { ok: true, clientIdTail: cid.length > 4 ? cid.slice(-4) : cid };
338
+ } catch (e) {
339
+ return { ok: false, code: "write_failed", message: human("write_failed", e && e.message) };
340
+ }
341
+ }
342
+
343
+ function expireAuth(relayDir, sess) {
344
+ stopSession(relayDir);
345
+ removeAuthFile(relayDir);
346
+ const message = human("expired");
347
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
348
+ setLastResult(relayDir, { status: "expired", message, code: "expired" });
349
+ syncAuthStatus(relayDir, sess && sess.onWarn);
350
+ if (sess) sess.log.info(`扫码授权会话已过期(device_code 清理,可在面板重新发起)`);
351
+ }
352
+
353
+ function finishFailure(relayDir, sess, { code, message }) {
354
+ stopSession(relayDir);
355
+ removeAuthFile(relayDir);
356
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
357
+ setLastResult(relayDir, { status: "failed", code, message });
358
+ syncAuthStatus(relayDir, sess && sess.onWarn);
359
+ sess.log.warn(`扫码授权失败:${code || "?"} — ${message}`);
360
+ }
361
+
362
+ function finishSuccess(relayDir, sess, { clientId, clientSecret }) {
363
+ const saved = saveScannedCredentials(relayDir, clientId, clientSecret, sess && sess.onWarn);
364
+ stopSession(relayDir);
365
+ if (!saved.ok) {
366
+ removeAuthFile(relayDir);
367
+ const message = saved.message || human("write_failed");
368
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
369
+ setLastResult(relayDir, { status: "failed", code: saved.code || "write_failed", message });
370
+ syncAuthStatus(relayDir, sess && sess.onWarn);
371
+ sess.log.warn(`扫码凭据保存失败:${message}`);
372
+ return;
373
+ }
374
+ // 先删 auth 文件(凭据已落盘;device_code 不再需要)再写状态
375
+ removeAuthFile(relayDir);
376
+ patchDingtalkStatus(relayDir, { state: "connected", lastError: "", credentialSource: "scan" }, sess && sess.onWarn);
377
+ setLastResult(relayDir, { status: "success", credentialSource: "scan", clientIdTail: saved.clientIdTail, message: human("success") });
378
+ syncAuthStatus(relayDir, sess && sess.onWarn);
379
+ sess.log.info(`扫码授权成功:机器人凭据已写入 im-bots.json(clientId 尾号 ${saved.clientIdTail},credentialSource=scan)`);
380
+ if (typeof sess.onSuccess === "function") {
381
+ try { sess.onSuccess({ clientIdTail: saved.clientIdTail, credentialSource: "scan" }); } catch { /* 忽略 */ }
382
+ }
383
+ }
384
+
385
+ async function pollOnce(relayDir, sess) {
386
+ if (sessions.get(relayDir) !== sess) return; // 已被新会话取代
387
+ if (sess.inFlight) return;
388
+ const at = sess.clock();
389
+ if (at >= sess.expiresAt) { expireAuth(relayDir, sess); return; }
390
+ sess.inFlight = true;
391
+ let r;
392
+ try {
393
+ r = await jsonPost(sess.fetchImpl, `${DINGTALK_REG_BASE}/poll`, { device_code: sess.deviceCode });
394
+ } catch {
395
+ sess.networkErrors += 1;
396
+ if (sess.networkErrors === 1) sess.log.warn("轮询钉钉接口网络异常:继续重试直到授权过期(不会丢会话)");
397
+ return;
398
+ } finally {
399
+ sess.inFlight = false;
400
+ }
401
+ if (sessions.get(relayDir) !== sess) return; // 期间被 begin 覆盖
402
+ const body = r.body || {};
403
+ const status = String(body.status || "").toUpperCase();
404
+ const clientId = body.client_id || body.clientId || "";
405
+ const clientSecret = body.client_secret || body.clientSecret || "";
406
+ if (status === "SUCCESS" || (clientId && clientSecret)) {
407
+ finishSuccess(relayDir, sess, { clientId, clientSecret });
408
+ return;
409
+ }
410
+ if (status === "FAIL" || status === "EXPIRED" || status === "REJECTED") {
411
+ finishFailure(relayDir, sess, {
412
+ code: status === "EXPIRED" ? "expired" : "rejected",
413
+ message: human(status === "EXPIRED" ? "expired" : "rejected", body.message || body.errmsg)
414
+ });
415
+ return;
416
+ }
417
+ if (status === "WAITING" || status === "PENDING" || status === "") {
418
+ sess.waits += 1;
419
+ syncAuthStatus(relayDir, sess.onWarn); // 顺带刷新 dingtalk.auth(remainingSec 随时间减少)
420
+ return; // 用户还没在手机钉钉里点「一键创建」
421
+ }
422
+ // 未知/异常响应:宽容地继续等(直到过期),避免一次怪响应把会话作废
423
+ sess.waits += 1;
424
+ }
425
+
426
+ function startPolling(relayDir, sess) {
427
+ stopSession(relayDir);
428
+ sessions.set(relayDir, sess);
429
+ const tick = () => { void pollOnce(relayDir, sess).catch(() => { /* 轮询内部已兜住 */ }); };
430
+ sess.timer = setInterval(tick, sess.pollMs);
431
+ if (typeof sess.timer.unref === "function") sess.timer.unref();
432
+ // 立刻先轮一次:用户在 begin 之前就点完确认也不耽误
433
+ void pollOnce(relayDir, sess).catch(() => { /* 忽略 */ });
434
+ }
435
+
436
+ function makeSession({ relayDir, session, fetchImpl, logger, onSuccess, clock, pollMs, onWarn }) {
437
+ const log = createScopedLogger({ sink: logger, prefix: "[dingtalk-auth]" });
438
+ const now = clock();
439
+ const gen = ++generationSeq;
440
+ return {
441
+ gen,
442
+ relayDir,
443
+ deviceCode: session.deviceCode,
444
+ expiresAt: session.expiresAt,
445
+ url: session.url,
446
+ fetchImpl,
447
+ logger,
448
+ onSuccess,
449
+ onWarn,
450
+ clock,
451
+ log,
452
+ pollMs: Math.max(10, Number(pollMs) || Math.max(1000, Number(session.interval || DEFAULT_POLL_SECONDS) * 1000)),
453
+ inFlight: false,
454
+ waits: 0,
455
+ networkErrors: 0
456
+ };
457
+ }
458
+
459
+ /**
460
+ * 开始一次扫码授权:init → begin → **把会话原子落盘** → 后台按 interval 轮询。
461
+ *
462
+ * 同一 relayDir 同时只保留一个会话:新的 begin 会作废旧会话(旧 auth 文件被覆盖/删除)。
463
+ * @returns {Promise<{ok:boolean, url?:string, expiresIn?:number, interval?:number, expiresAt?:number, code?:string, message?:string}>}
464
+ */
465
+ export async function beginDingtalkAuth(options = {}) {
466
+ const relayDir = String(options.relayDir || "");
467
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
468
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
469
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
470
+ if (typeof fetchImpl !== "function") return { ok: false, code: "network_error", message: human("network_error") };
471
+ // 覆盖旧会话(并发 begin):先停表、清旧文件,再发新请求 —— 旧链接从此失效
472
+ stopSession(relayDir);
473
+ removeAuthFile(relayDir);
474
+
475
+ let init;
476
+ try { init = await jsonPost(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" }); }
477
+ catch { return { ok: false, code: "network_error", message: human("network_error") }; }
478
+ const nonce = init.ok && init.body && typeof init.body.nonce === "string" ? init.body.nonce : "";
479
+ if (!nonce) {
480
+ const message = human("rejected", init.body && (init.body.message || init.body.errmsg));
481
+ setLastResult(relayDir, { status: "failed", code: "rejected", message });
482
+ return { ok: false, code: "rejected", message };
483
+ }
484
+ let begin;
485
+ try { begin = await jsonPost(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce }); }
486
+ catch { return { ok: false, code: "network_error", message: human("network_error") }; }
487
+ const body = begin.body || {};
488
+ const deviceCode = typeof body.device_code === "string" ? body.device_code : "";
489
+ const url = typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : "";
490
+ if (!begin.ok || !deviceCode || !url) {
491
+ const message = human("rejected", body.message || body.errmsg);
492
+ setLastResult(relayDir, { status: "failed", code: "rejected", message });
493
+ return { ok: false, code: "rejected", message };
494
+ }
495
+ const interval = Math.max(1, Math.min(10, Number(body.interval) || DEFAULT_POLL_SECONDS));
496
+ const expiresIn = Math.max(60, Math.min(7200, Number(body.expires_in) || DINGTALK_AUTH_TTL_MS / 1000));
497
+ const at = nowOf(options);
498
+ const expiresAt = at + Math.min(expiresIn * 1000, DINGTALK_AUTH_TTL_MS);
499
+ const persisted = { deviceCode, interval, expiresAt, url, createdAt: at, credentialSource: "scan" };
500
+ try {
501
+ atomicWriteJson(authFileOf(relayDir), { version: 1, ...persisted }, { onWarn: options.onWarn });
502
+ } catch (e) {
503
+ return { ok: false, code: "write_failed", message: human("write_failed", e && e.message) };
504
+ }
505
+ const sess = makeSession({
506
+ relayDir,
507
+ session: persisted,
508
+ fetchImpl,
509
+ logger: options.logger,
510
+ onSuccess: options.onSuccess,
511
+ clock: () => nowOf(options),
512
+ pollMs: options.pollMs,
513
+ onWarn: options.onWarn
514
+ });
515
+ startPolling(relayDir, sess);
516
+ setLastResult(relayDir, { status: "waiting", url, expiresAt });
517
+ syncAuthStatus(relayDir, options.onWarn);
518
+ log.info(`已生成扫码授权链接(device_code 已持久化,会话有效期 ${Math.round((expiresAt - at) / 1000)}s;关面板/重启桥都不丢)`);
519
+ return { ok: true, url, expiresIn: Math.round((expiresAt - at) / 1000), interval, expiresAt };
520
+ }
521
+
522
+ /**
523
+ * 断点续轮询:bridge 启动时调用。存在未过期会话 → 继续按 interval poll;
524
+ * 成功后写 `im-bots.json`(credentialSource:"scan")、删 auth 文件、写状态 `state="connected"`。
525
+ * 不存在/已过期 → 返回 `{ok:false, code:"no_session"|"expired"}`(过期会顺手清理文件与状态)。
526
+ */
527
+ export async function resumeDingtalkAuth(options = {}) {
528
+ const relayDir = String(options.relayDir || "");
529
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
530
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
531
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
532
+ const at = nowOf(options);
533
+ const session = readDingtalkAuthSession(relayDir);
534
+ if (!session) return { ok: false, code: "no_session", message: human("no_session") };
535
+ if (!session.expiresAt || at >= session.expiresAt) {
536
+ removeAuthFile(relayDir);
537
+ const message = human("expired");
538
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
539
+ setLastResult(relayDir, { status: "expired", code: "expired", message });
540
+ log.info("发现已过期的扫码授权会话,已清理(可在面板重新发起)");
541
+ return { ok: false, code: "expired", message };
542
+ }
543
+ if (sessions.has(relayDir)) {
544
+ const cur = sessions.get(relayDir);
545
+ return {
546
+ ok: true,
547
+ status: "waiting",
548
+ url: session.url,
549
+ remainingSeconds: Math.max(0, Math.ceil((session.expiresAt - at) / 1000)),
550
+ pollMs: cur.pollMs
551
+ };
552
+ }
553
+ const sess = makeSession({
554
+ relayDir,
555
+ session,
556
+ fetchImpl,
557
+ logger: options.logger,
558
+ onSuccess: options.onSuccess,
559
+ clock: () => nowOf(options),
560
+ pollMs: options.pollMs,
561
+ onWarn: options.onWarn
562
+ });
563
+ startPolling(relayDir, sess);
564
+ setLastResult(relayDir, { status: "waiting", url: session.url, expiresAt: session.expiresAt });
565
+ syncAuthStatus(relayDir, options.onWarn);
566
+ log.info(`续接上次未完成的扫码授权(剩余 ${Math.round((session.expiresAt - at) / 1000)}s,device_code 来自磁盘)`);
567
+ return { ok: true, status: "waiting", url: session.url, remainingSeconds: Math.ceil((session.expiresAt - at) / 1000), interval: session.interval, pollMs: sess.pollMs };
568
+ }
569
+
570
+ /**
571
+ * 查询授权状态(给插件读):waiting/success/failed/expired/idle + 剩余秒数。
572
+ * 只读,不会重启已停止的轮询(bridge 重启后由 resumeDingtalkAuth 负责续上)。
573
+ */
574
+ export function authStatus(options = {}) {
575
+ const relayDir = String(options.relayDir || "");
576
+ const at = nowOf(options);
577
+ if (!relayDir) return { ok: false, status: "idle", remainingSeconds: 0, message: human("no_relay_dir") };
578
+ const session = readDingtalkAuthSession(relayDir);
579
+ if (session) {
580
+ if (at < session.expiresAt) {
581
+ return {
582
+ ok: true,
583
+ status: "waiting",
584
+ remainingSeconds: Math.max(0, Math.ceil((session.expiresAt - at) / 1000)),
585
+ url: session.url,
586
+ interval: session.interval,
587
+ expiresAt: session.expiresAt,
588
+ hasSecret: false
589
+ };
590
+ }
591
+ // 过期:顺手清理(读接口也允许做这种幂等清理;结果仍如实返回)
592
+ stopSession(relayDir);
593
+ removeAuthFile(relayDir);
594
+ const message = human("expired");
595
+ setLastResult(relayDir, { status: "expired", code: "expired", message });
596
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
597
+ return { ok: false, status: "expired", remainingSeconds: 0, message };
598
+ }
599
+ const last = lastResults.get(relayDir);
600
+ if (last && at - last.at < LAST_RESULT_TTL_MS) {
601
+ return { ...last, ok: last.status === "success", status: last.status, remainingSeconds: 0 };
602
+ }
603
+ return { ok: false, status: "idle", remainingSeconds: 0 };
604
+ }
605
+
606
+ /** 用户显式取消:停轮询 + 删 auth 文件 + 状态标记取消。 */
607
+ export function cancelDingtalkAuth(options = {}) {
608
+ const relayDir = String(options.relayDir || "");
609
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
610
+ stopSession(relayDir);
611
+ removeAuthFile(relayDir);
612
+ const message = human("cancelled");
613
+ setLastResult(relayDir, { status: "failed", code: "cancelled", message });
614
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
615
+ syncAuthStatus(relayDir, options.onWarn);
616
+ return { ok: true };
617
+ }
618
+
619
+ /** 只停定时器(**保留** auth 文件):bridge 正常退出/配置变化时调用,下次启动可续轮询。 */
620
+ export function stopDingtalkAuthPolling() {
621
+ for (const relayDir of [...sessions.keys()]) stopSession(relayDir);
622
+ }
623
+
624
+ /** 测试用:清空内存态(定时器 + 最近结论),不动文件。 */
625
+ export function __resetDingtalkAuthForTest() {
626
+ stopDingtalkAuthPolling();
627
+ stopDingtalkAuthCommandWatcher();
628
+ lastResults.clear();
629
+ lastAuthSync.clear();
630
+ generationSeq = 0;
631
+ }
@@ -124,6 +124,15 @@ import { createAgentRuntime } from "./agent/index.mjs";
124
124
  import { createWecomBotController } from "./wecom-bot.mjs";
125
125
  // 钉钉 Stream 模式长连接(2026-11-08):与企微同构、零新增依赖;默认关闭,配置在 im-bots.json 的 dingtalk 段。
126
126
  import { createDingtalkBotController } from "./dingtalk-bot.mjs";
127
+ // 钉钉「扫码一键创建机器人」授权会话:**持久化 + 断点续轮询**(2026-11-08)。
128
+ // 根因:device_code 只存在插件进程内存 → 面板关/宿主重启就丢,而凭据只在 poll SUCCESS 那次返回。
129
+ // 现在授权会话落盘在 <relayDir>/im-dingtalk-auth.json,bridge 启动即续轮询,桥在跑就能接住。
130
+ import {
131
+ resumeDingtalkAuth,
132
+ startDingtalkAuthCommandWatcher,
133
+ stopDingtalkAuthCommandWatcher,
134
+ stopDingtalkAuthPolling
135
+ } from "./dingtalk-auth.mjs";
127
136
  // 飞书通道:官方 SDK 按需下载(41MB,不随包分发)、懒加载;未启用时零副作用
128
137
  import { createFeishuBotController } from "./feishu-bot.mjs";
129
138
 
@@ -3358,6 +3367,29 @@ function imBotLog(level, message) {
3358
3367
  }
3359
3368
 
3360
3369
  function startImBots() {
3370
+ // ⓪ 钉钉扫码授权断点续轮询(fire-and-forget,绝不阻塞隧道/连接主链路):
3371
+ // 上次没等到用户确认就重启/关面板也没关系 —— 会话在磁盘上,桥一跑就继续 poll,
3372
+ // 成功后自动写 im-bots.json(credentialSource:"scan")→ controller 热更新把机器人连上。
3373
+ try {
3374
+ void resumeDingtalkAuth({
3375
+ relayDir: RELAY_DIR,
3376
+ logger: imBotLog,
3377
+ onWarn: (m) => imBotLog("warn", m),
3378
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3379
+ }).catch((e) => {
3380
+ console.warn(`[bridge] 钉钉扫码授权续轮询失败(不影响远程访问): ${redactText(e)}`);
3381
+ });
3382
+ // 控制命令文件:插件写 <relayDir>/im-dingtalk-auth-cmd.json(begin/cancel),桥读并执行。
3383
+ // 插件不需要 device_code,也不需要 HTTP 端点 —— 只表达意图;桥在跑就能接住。
3384
+ startDingtalkAuthCommandWatcher({
3385
+ relayDir: RELAY_DIR,
3386
+ logger: imBotLog,
3387
+ onWarn: (m) => imBotLog("warn", m),
3388
+ onSuccess: () => { try { startImBots(); } catch { /* 忽略 */ } }
3389
+ });
3390
+ } catch (e) {
3391
+ console.warn(`[bridge] 钉钉扫码授权续轮询初始化失败(忽略): ${redactText(e)}`);
3392
+ }
3361
3393
  // ① 企业微信(现有)
3362
3394
  try {
3363
3395
  if (!imBots) {
@@ -3437,6 +3469,9 @@ function startImBots() {
3437
3469
 
3438
3470
  /** 优雅退出 IM 机器人通道(企微 + 钉钉,互不影响)。失败只记日志。 */
3439
3471
  async function stopImBots() {
3472
+ // 先停扫码授权轮询/命令监听(**保留** auth 文件:下次启动能续轮询;用户显式取消才删文件)
3473
+ try { stopDingtalkAuthCommandWatcher(); } catch { /* 忽略 */ }
3474
+ try { stopDingtalkAuthPolling(); } catch { /* 忽略 */ }
3440
3475
  const wecom = imBots;
3441
3476
  const dingtalk = imBotsDingtalk;
3442
3477
  const feishu = imBotsFeishu;
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
149
149
  * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
150
150
  * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
151
151
  */
152
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
152
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。