@mrrisega/dsh-remote 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/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +79 -26
- package/packages/dsh-remote-web/lib/index.js +123 -51
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +49 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +56 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +197 -13
- package/packages/dsh-remote-web/test/im-bots.test.mjs +61 -14
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +66 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.12-beta.
|
|
3
|
+
"version": "0.7.12-beta.4",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -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
|
|
|
@@ -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");
|
|
@@ -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",
|
|
@@ -210,7 +210,11 @@ export function normalizeDingtalkConfig(raw) {
|
|
|
210
210
|
enabled: src.enabled === true,
|
|
211
211
|
clientId: str(src.clientId),
|
|
212
212
|
clientSecret: typeof src.clientSecret === "string" ? src.clientSecret : "",
|
|
213
|
+
// robotCode **可选**(扫码一键创建机器人可能不返回 robot_code):只影响 openapi 主动发送/卡片;
|
|
214
|
+
// 接收消息 + sessionWebhook 回复完全不需要它(capabilities 里会如实降级)。
|
|
213
215
|
robotCode: str(src.robotCode),
|
|
216
|
+
// 凭据来源(插件扫码授权流程写入 "scan",手工填默认 "manual")——仅用于状态排障/面板展示
|
|
217
|
+
credentialSource: str(src.credentialSource).toLowerCase() === "scan" ? "scan" : "manual",
|
|
214
218
|
replyMode,
|
|
215
219
|
sessionWebhook: str(src.sessionWebhook),
|
|
216
220
|
boundChatId: str(src.boundChatId),
|
|
@@ -239,7 +243,10 @@ export function readDingtalkConfig(relayDir) {
|
|
|
239
243
|
|
|
240
244
|
/**
|
|
241
245
|
* @param {{
|
|
242
|
-
* clientId: string, clientSecret: string,
|
|
246
|
+
* clientId: string, clientSecret: string,
|
|
247
|
+
* robotCode?: string, // 可选:只影响 openapi 主动发送/卡片;缺省时降级(接收+sessionWebhook 回复仍可用)
|
|
248
|
+
* cardTemplateId?: string, // 可选:卡片拍板模板(缺省 card 能力降级)
|
|
249
|
+
* credentialSource?: "scan"|"manual", // 凭据来源(状态透出;扫码授权写入 scan)
|
|
243
250
|
* replyMode?: "sessionWebhook"|"openapi",
|
|
244
251
|
* gatewayUrl?: string, // 覆盖 open 端点(私有化/测试)
|
|
245
252
|
* tokenUrl?: string, o2oSendUrl?: string, groupSendUrl?: string,
|
|
@@ -260,6 +267,8 @@ export function createDingtalkBot(options = {}) {
|
|
|
260
267
|
const clientId = String(options.clientId || "").trim();
|
|
261
268
|
const clientSecret = typeof options.clientSecret === "string" ? options.clientSecret : "";
|
|
262
269
|
const robotCode = String(options.robotCode || "").trim();
|
|
270
|
+
const cardTemplateId = String(options.cardTemplateId || "").trim();
|
|
271
|
+
const credentialSource = String(options.credentialSource || "").toLowerCase() === "scan" ? "scan" : "manual";
|
|
263
272
|
const replyMode = String(options.replyMode || "sessionWebhook").toLowerCase() === "openapi" ? "openapi" : "sessionWebhook";
|
|
264
273
|
const gatewayUrl = String(options.gatewayUrl || "").trim() || DINGTALK_OPEN_URL;
|
|
265
274
|
const tokenUrl = String(options.tokenUrl || "").trim() || DINGTALK_TOKEN_URL;
|
|
@@ -317,6 +326,17 @@ export function createDingtalkBot(options = {}) {
|
|
|
317
326
|
};
|
|
318
327
|
const membership = { tier: "", entitlements: null, checkedAt: 0, usage: { month: "", count: 0 } };
|
|
319
328
|
|
|
329
|
+
/**
|
|
330
|
+
* 能力降级表(写进状态,插件面板据此禁用按钮 + 提示去补配置):
|
|
331
|
+
* · openapi:主动发送需要 robotCode(扫码授权不一定返回 robot_code)→ 缺省 = false
|
|
332
|
+
* · card:卡片拍板需要 robotCode + 卡片平台模板 ID → 缺一即 false
|
|
333
|
+
* ⚠️ 接收消息 / sessionWebhook 回复**不依赖**这两项,所以机器人仍可正常用(产品主链路不受影响)。
|
|
334
|
+
*/
|
|
335
|
+
function capabilitiesNow() {
|
|
336
|
+
const openapi = Boolean(robotCode);
|
|
337
|
+
return { openapi, card: Boolean(openapi && cardTemplateId) };
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
let ws = null;
|
|
321
341
|
let stopped = true;
|
|
322
342
|
let reconnectTimer = null;
|
|
@@ -478,6 +498,10 @@ export function createDingtalkBot(options = {}) {
|
|
|
478
498
|
connectedAt: Number(state.lastConnectedAt) || 0,
|
|
479
499
|
openCount: Number(state.openCount) || 0,
|
|
480
500
|
reconnectAttempts: Number(state.reconnectAttempts) || 0,
|
|
501
|
+
// 凭据来源 + 能力降级(robotCode 可选:缺省时只降级 openapi/卡片)
|
|
502
|
+
credentialSource,
|
|
503
|
+
robotCodeConfigured: Boolean(robotCode),
|
|
504
|
+
capabilities: capabilitiesNow(),
|
|
481
505
|
// 会员 + 用量(与企微对齐)
|
|
482
506
|
tier: tierNow(),
|
|
483
507
|
caps: capsNow().slice(),
|
|
@@ -884,14 +908,23 @@ export function createDingtalkBot(options = {}) {
|
|
|
884
908
|
|
|
885
909
|
/** 主动发消息(openapi 模式;标 UNVERIFIED:msgKey/msgParam 形状来自官方文档,未经真机核验)。 */
|
|
886
910
|
async function sendOpenapi(text, { title } = {}) {
|
|
887
|
-
|
|
911
|
+
// ★ robotCode 可选:缺省时明确拒绝(不崩、不白打 token 请求),并说清"哪些能力不可用/怎么恢复"
|
|
912
|
+
if (!robotCode) {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
reason: "no_robot_code",
|
|
916
|
+
detail: "未配置 robotCode:openapi 主动发送与卡片拍板不可用(接收消息、sessionWebhook 回复不受影响)。"
|
|
917
|
+
+ "请在插件「钉钉机器人」里补 robotCode,或改用 sessionWebhook 接收回复。",
|
|
918
|
+
capabilities: capabilitiesNow()
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (!state.authenticated) return { ok: false, reason: "not_connected", capabilities: capabilitiesNow() };
|
|
888
922
|
const token = await getAccessToken();
|
|
889
|
-
if (!token) return { ok: false, reason: "no_access_token" };
|
|
923
|
+
if (!token) return { ok: false, reason: "no_access_token", capabilities: capabilitiesNow() };
|
|
890
924
|
const formatted = formatForChannel(formatChannel, { title, text: text == null ? "" : String(text) });
|
|
891
925
|
const msgParam = JSON.stringify({ title: truncateText(title || "DeepSeek Harness", 64), text: formatted });
|
|
892
926
|
const isGroup = state.boundConversationId && state.boundChatId === state.boundConversationId;
|
|
893
|
-
const useRobot = robotCode
|
|
894
|
-
if (!useRobot) return { ok: false, reason: "no_robot_code" };
|
|
927
|
+
const useRobot = robotCode;
|
|
895
928
|
const url = isGroup ? groupSendUrl : o2oSendUrl;
|
|
896
929
|
const body = isGroup
|
|
897
930
|
? { robotCode: useRobot, openConversationId: state.boundConversationId, msgKey: "sampleMarkdown", msgParam }
|
|
@@ -1094,6 +1127,10 @@ export function createDingtalkBot(options = {}) {
|
|
|
1094
1127
|
openCount: Number(state.openCount) || 0,
|
|
1095
1128
|
reconnectAttempts: Number(state.reconnectAttempts) || 0,
|
|
1096
1129
|
nextRetryInMs: state.nextRetryInMs,
|
|
1130
|
+
// 凭据来源 + 能力降级(robotCode 可选:缺省时只降级 openapi/卡片)
|
|
1131
|
+
credentialSource,
|
|
1132
|
+
robotCodeConfigured: Boolean(robotCode),
|
|
1133
|
+
capabilities: capabilitiesNow(),
|
|
1097
1134
|
subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
|
|
1098
1135
|
messages: state.messages,
|
|
1099
1136
|
gated: state.gated,
|
|
@@ -1121,6 +1158,10 @@ export function createDingtalkBot(options = {}) {
|
|
|
1121
1158
|
stopped = false;
|
|
1122
1159
|
state.running = true;
|
|
1123
1160
|
state.phase = "connecting";
|
|
1161
|
+
if (!robotCode) {
|
|
1162
|
+
log.info("未配置 robotCode:机器人仍可正常接收消息并用 sessionWebhook 回复;"
|
|
1163
|
+
+ "openapi 主动发送/卡片拍板降级为不可用(状态 capabilities 会如实标记)");
|
|
1164
|
+
}
|
|
1124
1165
|
writeStatus();
|
|
1125
1166
|
ensureSubscriber();
|
|
1126
1167
|
void connect().catch((e) => {
|
|
@@ -1199,7 +1240,7 @@ export function createDingtalkBotController(options = {}) {
|
|
|
1199
1240
|
function digestOf(cfg) {
|
|
1200
1241
|
if (!cfg) return "missing";
|
|
1201
1242
|
const secretHash = cfg.clientSecret ? crypto.createHash("sha1").update(String(cfg.clientSecret)).digest("hex") : "";
|
|
1202
|
-
return JSON.stringify([cfg.enabled, cfg.clientId || "", secretHash, cfg.robotCode || "", cfg.replyMode || "", cfg.sessionId || "", cfg.sessionTitle || "", cfg.allowFrom || [], notifyAllowed()]);
|
|
1243
|
+
return JSON.stringify([cfg.enabled, cfg.clientId || "", secretHash, cfg.robotCode || "", cfg.cardTemplateId || "", cfg.credentialSource || "", cfg.replyMode || "", cfg.sessionId || "", cfg.sessionTitle || "", cfg.allowFrom || [], notifyAllowed()]);
|
|
1203
1244
|
}
|
|
1204
1245
|
function writeDisabledStatus(reason) {
|
|
1205
1246
|
if (!relayDir) return;
|
|
@@ -1247,6 +1288,8 @@ export function createDingtalkBotController(options = {}) {
|
|
|
1247
1288
|
clientId: cfg.clientId,
|
|
1248
1289
|
clientSecret: cfg.clientSecret,
|
|
1249
1290
|
robotCode: cfg.robotCode,
|
|
1291
|
+
cardTemplateId: cfg.cardTemplateId,
|
|
1292
|
+
credentialSource: cfg.credentialSource,
|
|
1250
1293
|
replyMode: cfg.replyMode,
|
|
1251
1294
|
gatewayUrl: cfg.streamUrl || undefined,
|
|
1252
1295
|
relayDir,
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
飞书「企业自建应用」长连接机器人:**收消息 / 发通知 / 卡片拍板**,与企微、钉钉、微信并列的第四条通道;
|
|
10
10
|
**官方 SDK 不随安装包分发**(约 41MB),用户在面板点「下载依赖」后才懒加载;`package.json` 里**没有**这个依赖。
|
|
11
|
+
**接收人不需要用户填**:默认自动绑定(连上后用 `chat.list` 选机器人所在的群;或用户第一句话即绑定)。
|
|
11
12
|
|
|
12
13
|
---
|
|
13
14
|
|
|
@@ -39,9 +40,9 @@
|
|
|
39
40
|
"appId": "", // cli_ 开头;SDK 有严格正则(cli_+16 hex),不匹配会"保存成功但连不上"
|
|
40
41
|
"appSecret": "",
|
|
41
42
|
"domain": "feishu", // feishu=open.feishu.cn | lark=open.larksuite.com(国际版)
|
|
42
|
-
"receiveId": "", //
|
|
43
|
-
"receiveIdType": "", // "open_id" | "chat_id"
|
|
44
|
-
"boundChatId": "", //
|
|
43
|
+
"receiveId": "", // **可选/高级场景**:手工指定推送目标;空 = 自动绑定(推荐留空)
|
|
44
|
+
"receiveIdType": "", // **可选**:"open_id" | "chat_id";空 = 按 boundChatType 自动判断
|
|
45
|
+
"boundChatId": "", // 桥写回/可预填:自动绑定的目标(面板只读展示)
|
|
45
46
|
"allowFrom": [], // open_id 白名单(空 = 不限制;建议填写)
|
|
46
47
|
"cardApproval": true, // 审批用卡片按钮(false = 纯文本 + 回电脑拍板)
|
|
47
48
|
"sdkVersion": "", // 下载成功后回写(面板展示)
|
|
@@ -63,7 +64,13 @@
|
|
|
63
64
|
"feishu": {
|
|
64
65
|
"enabled": true, "state": "idle|connecting|connected|reconnecting|failed|sdk_missing",
|
|
65
66
|
"connected": true, "authenticated": true,
|
|
66
|
-
"lastError": "", "lastConnectedAt": 0, "lastMessageAt": 0,
|
|
67
|
+
"lastError": "", "lastConnectedAt": 0, "lastMessageAt": 0,
|
|
68
|
+
"boundChatId": "oc_xxx 或 ou_xxx", // 自动绑定的目标
|
|
69
|
+
"boundChatType": "chat_id|open_id", // 决定发送用的 receive_id_type
|
|
70
|
+
"boundChatName": "项目群", // 群名(面板显示「已自动绑定:<群名>」)
|
|
71
|
+
"chatCount": 1, // 机器人所在群数(chat.list;null=没权限/没查到)
|
|
72
|
+
"receiveIdSource": "auto_group|auto|config|none", // 接收人来源
|
|
73
|
+
"receiveIdConfigured": false, // 用户是否手工填了 receiveId
|
|
67
74
|
"appId": "cli_9f42***b1", "domain": "feishu", "receiveIdType": "open_id",
|
|
68
75
|
"cardApproval": true, "sdkVersion": "1.76.0",
|
|
69
76
|
"tier": "free", "caps": ["..."], "limits": { "messages_per_month": 20 },
|
|
@@ -122,13 +129,39 @@ import {
|
|
|
122
129
|
|
|
123
130
|
`allow` 只对应「允许一次」,不会变成永久放行;无决策来源时只 ACK 不生效(并记日志)。
|
|
124
131
|
|
|
132
|
+
### 4.2 接收人自动绑定(业主口径:不用用户填)
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
连接成功 ──▶ chat.list({page_size:20}) ← 需要可选权限 im:chat:readonly
|
|
136
|
+
├─ 1 个群 → 绑定它(receiveIdSource=auto_group,状态写 boundChatId/boundChatType=chat_id/boundChatName)
|
|
137
|
+
├─ 多个群 → 取第一个并计 chatCount(用户发消息即改绑;面板可提示"多个群")
|
|
138
|
+
├─ 空列表 → 不绑定,等首条消息
|
|
139
|
+
└─ 无权限/失败 → 只记日志(连接、发消息、消息绑定都不受影响)
|
|
140
|
+
首次收到消息 ──▶ 绑定该会话(单聊=sender.open_id;群=chat_id),覆盖 auto_group(receiveIdSource=auto)
|
|
141
|
+
配置里填了 receiveId ──▶ 永远优先(receiveIdSource=config;chat.list 只记录 chatCount,不覆盖)
|
|
142
|
+
未绑定 ──▶ 主动通知/审批卡跳过 + 记一条可读日志(不报错、不发请求);绑定成功后立即写状态
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- 发送时 `receive_id_type` 优先级:显式传参(内部回复)> **配置 `receiveId` 存在时**的 `receiveIdType` > 按 `boundChatType` 自动判断(`chat_id`/`open_id`)。
|
|
146
|
+
注意:`receiveId` 为空时,插件写的默认 `receiveIdType:"open_id"` **不生效**(否则会把群 `oc_…` 当 open_id 发)。
|
|
147
|
+
- chat.list 拿到群列表后,即使不覆盖绑定(配置优先/预填),也会按 `boundChatId` 补上 `boundChatName`(面板显示群名)。
|
|
148
|
+
- 配置里可预填 `boundChatId`(兼容旧配置/排障):`oc_` 开头按群、其余按 open_id;
|
|
149
|
+
**预填优先于 chat.list**(不会被"第一个群"覆盖,群数仍记录)。
|
|
150
|
+
|
|
125
151
|
---
|
|
126
152
|
|
|
127
|
-
## 5.
|
|
153
|
+
## 5. 配置页向导(插件半照抄 `FEISHU_SETUP_GUIDE`)
|
|
154
|
+
|
|
155
|
+
**主路径只有两件事**(展示在第一层):
|
|
156
|
+
1. 创建一个企业自建应用,复制 **App ID / App Secret**(接收人不用填)
|
|
157
|
+
2. 填进面板 → 下载依赖 → 在飞书里给机器人**发一句话**(或机器人已在群里则自动绑定)
|
|
128
158
|
|
|
129
|
-
|
|
159
|
+
官方链接(业主 2026-11-08 提供,一字不改):
|
|
160
|
+
- 创建企业自建应用:<https://open.feishu.cn/app?lang=zh-CN>
|
|
161
|
+
- 卡片交互机器人文档:<https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot>
|
|
130
162
|
- 使用长连接接收事件:<https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case>
|
|
131
|
-
|
|
163
|
+
|
|
164
|
+
其余后台步骤折叠在「**需要更细的步骤**」里(首次配置仍需做完):
|
|
132
165
|
|
|
133
166
|
| # | 步骤 |
|
|
134
167
|
|---|---|
|
|
@@ -147,18 +180,21 @@ import {
|
|
|
147
180
|
|
|
148
181
|
---
|
|
149
182
|
|
|
150
|
-
## 6.
|
|
183
|
+
## 6. 真实联调步骤(最小路径:只填 App ID/Secret)
|
|
151
184
|
|
|
152
|
-
|
|
185
|
+
> Lead 2026-11-08 用业主真实凭据实测:SDK 动态加载成功、`tenant_access_token` OK(781ms)、
|
|
186
|
+
> `WSClient` `state:"connected"`(**只要 App ID/Secret 就能连上,不需要额外配置**)、
|
|
187
|
+
> `client.im.v1.chat.list()` 能列出机器人所在群、`im.v1.message.create({receive_id_type:"chat_id"})` 发送成功。
|
|
153
188
|
|
|
154
|
-
1.
|
|
189
|
+
1. **建应用**(企业自建)→ 复制 **App ID / App Secret**;开机器人能力、申请收发消息权限、事件/回调切长连接、发版(按 §5 折叠的步骤)。
|
|
155
190
|
2. **本机下载依赖**(面板按钮等价命令):
|
|
156
191
|
`npm install --prefix ~/.dsh-remote/im-deps --no-save --no-audit --no-fund @larksuiteoapi/node-sdk`
|
|
157
|
-
3.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
3. **写配置(只需要两样)**:`im-bots.json` 的 `feishu.{enabled:true, appId, appSecret}`。
|
|
193
|
+
**`receiveId` / `receiveIdType` 留空** —— 不需要填。
|
|
194
|
+
4. **启动 bridge** → 状态 `feishu.state → connected`;随后自动绑定:
|
|
195
|
+
- 机器人已在群里 → `chat.list` 自动选中并写 `boundChatId`(`receiveIdSource:"auto_group"`,面板显示「已自动绑定:<群名>」);
|
|
196
|
+
- 机器人还没进群 → 状态 `boundChatId:""`,此时通知会跳过(只记日志),**在飞书里给机器人发一句话**即自动绑定(私聊记 `open_id`、群聊记 `chat_id`)。
|
|
197
|
+
5. **验收**:
|
|
162
198
|
- 出站:电脑上让 agent 跑一个任务 → 飞书收到「任务完成/出错」通知;
|
|
163
199
|
- 入站:飞书发一句话 → 电脑端当前会话收到(免费档每月 20 条,超出有升级提示);
|
|
164
200
|
- 拍板:触发一次需要审批的操作 → 飞书收到卡片 → 点「允许一次」→ 卡片原地变「已允许」+ 电脑端继续;点「拒绝」→ 电脑端拒绝;**不回** → 90s 后按拒绝处理。
|
|
@@ -170,8 +206,12 @@ import {
|
|
|
170
206
|
|
|
171
207
|
## 7. 已知限制 / 未核实
|
|
172
208
|
|
|
173
|
-
1. **本模块未做真机联调**:SDK/网络全部注入假实现单测(
|
|
209
|
+
1. **本模块未做真机联调**:SDK/网络全部注入假实现单测(51 条);真实建连、卡片回调、发版生效需按 §6 验一次。
|
|
210
|
+
(已做只读验证:本机真实 SDK 1.76.0 能被 `loadFeishuSdk` 动态加载,`Client/WSClient/EventDispatcher` 均存在。)
|
|
174
211
|
2. **卡片回调能否走长连接**:官方文档说可以;SDK README 有一句相反表述(调研 R8)。真机点一次按钮确认。
|
|
175
212
|
3. `patchCard`(原地更新)用 REST PATCH,官方权限/版本限制未核实;失败只记日志(不影响 ACK)。
|
|
176
213
|
4. 流式回复:飞书没有该能力(调研 §4.3),本模块不提供;通知/审批不需要。
|
|
177
214
|
5. 群聊只处理 @机器人 的消息由飞书侧决定(我们按收到的消息处理);多设备抢事件需「一个应用一台电脑」。
|
|
215
|
+
6. **自动选群**(`chat.list`)需要可选权限 `im:chat:readonly`:**没这个权限也能用**(回落到"首条消息绑定"),
|
|
216
|
+
只是不能"连上即绑定群";多个群时取第一个(用户发消息会改绑),面板可用 `chatCount` 提示。
|
|
217
|
+
7. 私聊绑定记的是 `sender.open_id`(不是 chat_id):用户换群/换会话不会发错地方;群绑定记 `chat_id`。
|
|
@@ -132,15 +132,32 @@ export const SDK_MISSING_HINT =
|
|
|
132
132
|
/** 长连接不支持代理(官方文档)→ 连接失败时给这条。 */
|
|
133
133
|
export const PROXY_HINT = "飞书长连接不支持代理:如果公司网络必须走代理,这条通道在当前网络下连不上(可改用手机远程)。";
|
|
134
134
|
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* 飞书官方文档 / 注册入口 + 配置向导(配置页直接渲染;本模块不画 UI)。
|
|
137
|
+
*
|
|
138
|
+
* 业主 2026-11-08 明确的产品口径:**接收人(open_id/chat_id)不让用户填** —— 用户本来也
|
|
139
|
+
* 不知道去哪拿,默认就是他/她本人。所以主路径只有两步(填 App ID/Secret + 发一句话自动绑定),
|
|
140
|
+
* 后台的 7 项配置折叠成「需要更细的步骤」。
|
|
141
|
+
*/
|
|
136
142
|
export const FEISHU_SETUP_GUIDE = Object.freeze({
|
|
137
143
|
docs: Object.freeze({
|
|
144
|
+
createApp: "https://open.feishu.cn/app?lang=zh-CN",
|
|
145
|
+
cardInteractiveBot: "https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot",
|
|
138
146
|
longConnectionEvent: "https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case",
|
|
139
147
|
longConnectionCallback: "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address",
|
|
140
148
|
sendMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/create",
|
|
141
149
|
receiveMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive",
|
|
142
150
|
registerApp: "https://open.feishu.cn/app"
|
|
143
151
|
}),
|
|
152
|
+
/** 主路径(展示在第一层,只要两件事)。 */
|
|
153
|
+
quickPath: Object.freeze([
|
|
154
|
+
"在飞书开放平台创建一个企业自建应用,复制 **App ID / App Secret**(只填这两样,接收人不用填)",
|
|
155
|
+
"填进本面板 → 下载依赖 → 在飞书里给机器人**发一句话**:接收人自动绑定,之后通知/拍板都发给你"
|
|
156
|
+
]),
|
|
157
|
+
quickPathHint: "应用能力/权限/事件与回调/发版这些后台设置仍需做一次(点开「需要更细的步骤」照着做)。",
|
|
158
|
+
detailsLabel: "需要更细的步骤",
|
|
159
|
+
/** 接收人自动绑定的口径(面板可展示成一句说明)。 */
|
|
160
|
+
bindingHint: "接收人不需要填:机器人收到你的第一条消息后自动记住这个会话;在群里 @ 它则记住那个群。",
|
|
144
161
|
steps: Object.freeze([
|
|
145
162
|
"打开飞书开放平台(open.feishu.cn/app)→ 创建**企业自建应用**(商店应用不支持长连接)",
|
|
146
163
|
"「凭证与基础信息」→ 复制 **App ID / App Secret**(App ID 形如 cli_ 开头的一串字符)",
|
|
@@ -149,7 +166,7 @@ export const FEISHU_SETUP_GUIDE = Object.freeze({
|
|
|
149
166
|
"「事件与回调 → 事件配置」→ 订阅方式选**使用长连接接收事件** → 添加「接收消息 v2.0」",
|
|
150
167
|
"「事件与回调 → 回调配置」→ 订阅方式选**使用长连接接收回调** → 添加 card.action.trigger(拍板卡片用)",
|
|
151
168
|
"「版本管理与发布」→ 创建版本并**发布**(不发布不生效);「可用范围」包含你自己/目标成员",
|
|
152
|
-
"回到本面板:填 App ID / Secret → 「下载依赖」→「测试连接」→
|
|
169
|
+
"回到本面板:填 App ID / Secret → 「下载依赖」→「测试连接」→ 在飞书里给机器人**发一句话**(接收人自动绑定,无需填写)"
|
|
153
170
|
]),
|
|
154
171
|
// 最容易被忽略的顺序坑:保存事件配置时,必须已经有一个长连接在线。
|
|
155
172
|
orderWarning: "顺序很重要:先在本机填好凭据让长连接在线,再去开发者后台保存「事件/回调」配置(否则保存会提示「应用未建立长连接」)。",
|
|
@@ -731,6 +748,10 @@ export function createFeishuBot({
|
|
|
731
748
|
receiveIdType = "",
|
|
732
749
|
allowFrom = [],
|
|
733
750
|
cardApproval = true,
|
|
751
|
+
/** 配置里预填的绑定点(可选;兼容旧配置/手工排障)。正常流程由 chat.list 或首条消息自动绑定。 */
|
|
752
|
+
boundChatId = "",
|
|
753
|
+
/** 连接成功后用 chat.list 自动选群:每页条数。 */
|
|
754
|
+
chatListPageSize = 20,
|
|
734
755
|
relayDir = "",
|
|
735
756
|
env = process.env,
|
|
736
757
|
logger,
|
|
@@ -755,6 +776,7 @@ export function createFeishuBot({
|
|
|
755
776
|
const seenCards = new Map();
|
|
756
777
|
let sender = null;
|
|
757
778
|
let wsClient = null;
|
|
779
|
+
let sdkRef = null; // 懒加载后的 SDK(chat.list 自动选群用)
|
|
758
780
|
let dispatcherHandlers = null;
|
|
759
781
|
let pollTimer = null;
|
|
760
782
|
let stopped = false;
|
|
@@ -768,6 +790,10 @@ export function createFeishuBot({
|
|
|
768
790
|
lastConnectedAt: 0,
|
|
769
791
|
lastMessageAt: 0,
|
|
770
792
|
boundChatId: "",
|
|
793
|
+
boundChatType: "", // "chat_id"(群,来自 chat.list/群消息)| "open_id"(单聊,来自消息)
|
|
794
|
+
boundChatName: "", // 群名(面板展示"已自动绑定:<群名或私聊>")
|
|
795
|
+
boundSource: "", // "auto_group"(chat.list 自动选群)| "auto"(首条消息/预填)
|
|
796
|
+
chatCount: null, // 机器人所在群数(chat.list 结果;null=没查到/没权限)
|
|
771
797
|
sdkVersion: "",
|
|
772
798
|
ackCount: 0,
|
|
773
799
|
decisionCount: 0
|
|
@@ -800,6 +826,7 @@ export function createFeishuBot({
|
|
|
800
826
|
state.lastConnectedAt = Number(clock()) || Date.now();
|
|
801
827
|
log.info("长连接已就绪");
|
|
802
828
|
try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
|
|
829
|
+
void autoBindFromChatList();
|
|
803
830
|
} else if (!connected) {
|
|
804
831
|
state.connected = false;
|
|
805
832
|
state.authenticated = false;
|
|
@@ -830,6 +857,7 @@ export function createFeishuBot({
|
|
|
830
857
|
state.lastConnectedAt = Number(clock()) || Date.now();
|
|
831
858
|
try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
|
|
832
859
|
emitStatus();
|
|
860
|
+
void autoBindFromChatList();
|
|
833
861
|
}
|
|
834
862
|
}
|
|
835
863
|
|
|
@@ -910,7 +938,17 @@ export function createFeishuBot({
|
|
|
910
938
|
void sender?.sendText?.(inbound.chatId, "你不在这个通道的白名单里,无法指挥这台电脑。", { receiveIdType: "chat_id" });
|
|
911
939
|
return;
|
|
912
940
|
}
|
|
913
|
-
|
|
941
|
+
/*
|
|
942
|
+
* 首次说话即绑定(与企微/钉钉一致):接收人不让用户填。
|
|
943
|
+
* · 群聊 → 记 chat_id(后续按 chat_id 推)
|
|
944
|
+
* · 单聊 → 记 sender.open_id(后续按 open_id 推,用户换群也不会发错地方)
|
|
945
|
+
* 绑定信息只放内存 + 状态文件(桥写),配置里不需要 receiveId。
|
|
946
|
+
*/
|
|
947
|
+
const isGroup = inbound.chatType === "group";
|
|
948
|
+
state.boundChatId = isGroup ? inbound.chatId : (inbound.openId || inbound.chatId);
|
|
949
|
+
state.boundChatType = isGroup ? "chat_id" : "open_id"; // 直接就是后续发送用的 receive_id_type
|
|
950
|
+
state.boundChatName = ""; // 消息里没有群名;面板显示「私聊/群」即可
|
|
951
|
+
state.boundSource = "auto"; // 用户发消息即改绑(覆盖 chat.list 的 auto_group)
|
|
914
952
|
state.lastMessageAt = Number(clock()) || Date.now();
|
|
915
953
|
emitStatus();
|
|
916
954
|
try { emitter.emit("message", inbound); } catch (e) { log.warn(`message 处理异常:${truncateText(e?.message || e, 100)}`); }
|
|
@@ -934,6 +972,133 @@ export function createFeishuBot({
|
|
|
934
972
|
return { dispatcher, handlers };
|
|
935
973
|
}
|
|
936
974
|
|
|
975
|
+
/**
|
|
976
|
+
* 接收人来源:
|
|
977
|
+
* · config —— 用户在高级场景里手工填了 receiveId(优先)
|
|
978
|
+
* · auto_group —— 连接成功后 chat.list 自动选中了机器人所在的群
|
|
979
|
+
* · auto —— 首条消息自动绑定(私聊 open_id / 群 chat_id),或配置预填
|
|
980
|
+
* · none —— 还没绑定(通知会跳过并记日志,不报错)
|
|
981
|
+
*/
|
|
982
|
+
function receiveIdSource() {
|
|
983
|
+
if (String(receiveId || "").trim()) return "config";
|
|
984
|
+
if (!state.boundChatId) return "none";
|
|
985
|
+
return state.boundSource === "auto_group" ? "auto_group" : "auto";
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** 自动绑定后的推送类型(直接就是 receive_id_type)。 */
|
|
989
|
+
function boundTargetType() {
|
|
990
|
+
if (state.boundChatType === "chat_id") return "chat_id";
|
|
991
|
+
if (state.boundChatType === "open_id") return "open_id";
|
|
992
|
+
return "";
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* **连接成功后自动选群**(业主/Lead 实测:`client.im.v1.chat.list()` 能列出机器人所在群):
|
|
997
|
+
* · 有群 → 记为接收人(多个群取第一个,用户发消息会改绑)→ receiveIdSource=auto_group
|
|
998
|
+
* · 空 → 不绑定,等首条消息
|
|
999
|
+
* · 无权限/失败 → 只记日志,**不影响连接与"发消息绑定"**
|
|
1000
|
+
* 配置里填了 receiveId 时只记录群数,不覆盖(配置优先)。
|
|
1001
|
+
*/
|
|
1002
|
+
async function autoBindFromChatList() {
|
|
1003
|
+
if (autoBindTried) return;
|
|
1004
|
+
autoBindTried = true;
|
|
1005
|
+
const cfgTo = String(receiveId || "").trim();
|
|
1006
|
+
let client = null;
|
|
1007
|
+
try {
|
|
1008
|
+
if (!sdkRef || typeof sdkRef.Client !== "function") {
|
|
1009
|
+
log.info("SDK 未提供 Client(跳过自动选群,等首条消息绑定)");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const opts = { appId, appSecret };
|
|
1013
|
+
if (normalizeDomain(domain) === "lark" && sdkRef?.Domain?.Lark) opts.domain = sdkRef.Domain.Lark;
|
|
1014
|
+
client = new sdkRef.Client(opts);
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
log.warn(`创建 Client 失败(跳过自动选群,不影响消息绑定):${truncateText(e?.message || e, 120)}`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const listFn = client?.im?.v1?.chat?.list;
|
|
1020
|
+
if (typeof listFn !== "function") {
|
|
1021
|
+
log.info("SDK 没有 chat.list(跳过自动选群,等首条消息绑定)");
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
let resp = null;
|
|
1025
|
+
try {
|
|
1026
|
+
// 官方 1.76 的调用形态是 { params: {…} };兼容直接传参数 / 无参(Lead 实测无参也能列出群)
|
|
1027
|
+
try {
|
|
1028
|
+
resp = await listFn.call(client.im.v1.chat, { params: { page_size: chatListPageSize } });
|
|
1029
|
+
} catch (e1) {
|
|
1030
|
+
try {
|
|
1031
|
+
resp = await listFn.call(client.im.v1.chat, { page_size: chatListPageSize });
|
|
1032
|
+
} catch (e2) {
|
|
1033
|
+
resp = await listFn.call(client.im.v1.chat);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
// 常见:应用没申请 im:chat:readonly → 只记日志(不影响连接/发消息/消息绑定)
|
|
1038
|
+
log.warn(`chat.list 失败(忽略,不影响消息绑定;如需自动选群请加 im:chat:readonly):${truncateText(e?.message || e, 120)}`);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const code = Number(resp?.code ?? resp?.data?.code);
|
|
1042
|
+
if (Number.isFinite(code) && code !== 0) {
|
|
1043
|
+
log.warn(`chat.list 返回错误码 ${code}(忽略;如需自动选群请加 im:chat:readonly);${feishuErrorHint(code, resp?.msg || "")}`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const items = Array.isArray(resp?.data?.items) ? resp.data.items : (Array.isArray(resp?.items) ? resp.items : []);
|
|
1047
|
+
state.chatCount = items.length;
|
|
1048
|
+
// 显式指定(receiveId)或配置预填 boundChatId 都算"用户已经定了目标"→ 只记录群数,不覆盖
|
|
1049
|
+
const prefilled = String(boundChatId || "").trim();
|
|
1050
|
+
if (cfgTo || prefilled) {
|
|
1051
|
+
// 不覆盖用户指定的目标,但尽量补上群名(面板显示「已自动绑定:<群名>」)
|
|
1052
|
+
const wanted = cfgTo || prefilled;
|
|
1053
|
+
const hit = items.find((it) => String(it?.chat_id || it?.chatId || "") === wanted);
|
|
1054
|
+
if (hit && !state.boundChatName) state.boundChatName = String(hit.name || "");
|
|
1055
|
+
log.info(`接收人已指定(${cfgTo ? "receiveId" : "配置预填 boundChatId"},优先),chat.list 仅记录机器人所在群数=${items.length}`);
|
|
1056
|
+
emitStatus();
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (!items.length) {
|
|
1060
|
+
log.info("机器人还没进任何群:等用户私聊或拉群后自动绑定");
|
|
1061
|
+
emitStatus();
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const first = items[0] || {};
|
|
1065
|
+
const chatId = String(first.chat_id || first.chatId || "");
|
|
1066
|
+
if (!chatId) {
|
|
1067
|
+
emitStatus();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
state.boundChatId = chatId;
|
|
1071
|
+
state.boundChatType = "chat_id";
|
|
1072
|
+
state.boundChatName = String(first.name || "");
|
|
1073
|
+
state.boundSource = "auto_group";
|
|
1074
|
+
log.info(`已自动绑定到群 chat=${chatIdHash(chatId)} 群数=${items.length}${items.length > 1 ? "(多个群取第一个;用户发消息会改绑)" : ""}`);
|
|
1075
|
+
emitStatus();
|
|
1076
|
+
}
|
|
1077
|
+
let autoBindTried = false;
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* 解析一次发送的 target 与 receive_id_type(产品口径:接收人默认自动绑定)。
|
|
1081
|
+
*
|
|
1082
|
+
* 优先级(任务书第 3 条):
|
|
1083
|
+
* ① 调用方显式传的 receiveIdType(内部回复用,最明确)
|
|
1084
|
+
* ② 配置里手工填的 receiveIdType(高级场景)
|
|
1085
|
+
* ③ 按 boundChatType 自动判断(群→chat_id / 单聊→open_id)
|
|
1086
|
+
* ④ 兜底 open_id(老行为)
|
|
1087
|
+
* target 优先级:显式 to > 配置 receiveId > 自动绑定的 boundChatId
|
|
1088
|
+
*/
|
|
1089
|
+
function resolveSendTarget(explicitTo, explicitType) {
|
|
1090
|
+
const cfgTo = String(receiveId || "").trim();
|
|
1091
|
+
const to = String(explicitTo || "").trim() || cfgTo || state.boundChatId || "";
|
|
1092
|
+
let type = normalizeReceiveIdType(explicitType || "", "");
|
|
1093
|
+
if (!type && cfgTo) {
|
|
1094
|
+
// 只有"用户显式填了 receiveId"时,receiveIdType 才有意义(插件的默认 open_id 不能套到群上)
|
|
1095
|
+
type = normalizeReceiveIdType(receiveIdType, "") || DEFAULT_RECEIVE_ID_TYPE;
|
|
1096
|
+
}
|
|
1097
|
+
// 显式 to(内部回复,未传 type)沿用老行为 open_id;自动绑定则按 boundChatType 判断
|
|
1098
|
+
if (!type) type = explicitTo ? DEFAULT_RECEIVE_ID_TYPE : (boundTargetType() || DEFAULT_RECEIVE_ID_TYPE);
|
|
1099
|
+
return { to, type };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
937
1102
|
function status() {
|
|
938
1103
|
return {
|
|
939
1104
|
enabled: started && !stopped,
|
|
@@ -944,6 +1109,12 @@ export function createFeishuBot({
|
|
|
944
1109
|
lastConnectedAt: state.lastConnectedAt,
|
|
945
1110
|
lastMessageAt: state.lastMessageAt,
|
|
946
1111
|
boundChatId: state.boundChatId,
|
|
1112
|
+
boundChatType: state.boundChatType,
|
|
1113
|
+
boundChatName: state.boundChatName,
|
|
1114
|
+
chatCount: state.chatCount,
|
|
1115
|
+
// 接收人来源:config / auto_group(chat.list 自动选群)/ auto(首条消息)/ none
|
|
1116
|
+
receiveIdSource: receiveIdSource(),
|
|
1117
|
+
receiveIdConfigured: Boolean(String(receiveId || "").trim()),
|
|
947
1118
|
appId: maskAppId(appId),
|
|
948
1119
|
domain: normalizeDomain(domain),
|
|
949
1120
|
receiveIdType: normalizeReceiveIdType(receiveIdType, ""),
|
|
@@ -984,7 +1155,15 @@ export function createFeishuBot({
|
|
|
984
1155
|
return { ok: false, reason: "sdk_missing", status: status() };
|
|
985
1156
|
}
|
|
986
1157
|
const sdk = loaded.sdk || {};
|
|
1158
|
+
sdkRef = sdk;
|
|
987
1159
|
state.sdkVersion = String(loaded.status?.version || "");
|
|
1160
|
+
// 配置里预填的绑定点(可选):oc_ 开头视为群 chat_id,其余视为 open_id
|
|
1161
|
+
const seeded = String(boundChatId || "").trim();
|
|
1162
|
+
if (seeded && !state.boundChatId) {
|
|
1163
|
+
state.boundChatId = seeded;
|
|
1164
|
+
state.boundChatType = /^oc_/i.test(seeded) ? "chat_id" : "open_id";
|
|
1165
|
+
state.boundSource = "auto";
|
|
1166
|
+
}
|
|
988
1167
|
|
|
989
1168
|
// ③ 发送器(REST)
|
|
990
1169
|
sender = createFeishuSender({
|
|
@@ -1041,13 +1220,12 @@ export function createFeishuBot({
|
|
|
1041
1220
|
return { ok: true };
|
|
1042
1221
|
},
|
|
1043
1222
|
|
|
1044
|
-
/**
|
|
1223
|
+
/** 发文本(自动按飞书通道格式化;接收人默认自动绑定)。 */
|
|
1045
1224
|
async sendText(to, text, opts = {}) {
|
|
1046
|
-
const target = String(to || receiveId || state.boundChatId || "");
|
|
1047
1225
|
if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
});
|
|
1226
|
+
const { to: target, type } = resolveSendTarget(to, opts.receiveIdType);
|
|
1227
|
+
if (!target) return { ok: false, reason: "还没绑定接收人:先在飞书里给机器人发一句话" };
|
|
1228
|
+
const r = await sender.sendText(target, text, { receiveIdType: type });
|
|
1051
1229
|
if (r.ok) {
|
|
1052
1230
|
state.lastMessageAt = Number(clock()) || Date.now();
|
|
1053
1231
|
emitStatus();
|
|
@@ -1057,11 +1235,10 @@ export function createFeishuBot({
|
|
|
1057
1235
|
|
|
1058
1236
|
/** 发审批卡片(返回值带 messageId,供回执后原地更新)。 */
|
|
1059
1237
|
async sendCard(to, card, opts = {}) {
|
|
1060
|
-
const target = String(to || receiveId || state.boundChatId || "");
|
|
1061
1238
|
if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
});
|
|
1239
|
+
const { to: target, type } = resolveSendTarget(to, opts.receiveIdType);
|
|
1240
|
+
if (!target) return { ok: false, reason: "还没绑定接收人:先在飞书里给机器人发一句话" };
|
|
1241
|
+
return sender.sendCard(target, card, { receiveIdType: type });
|
|
1065
1242
|
},
|
|
1066
1243
|
|
|
1067
1244
|
/** 更新卡片(best-effort)。 */
|
|
@@ -1254,6 +1431,12 @@ export function createFeishuBotController(options = {}) {
|
|
|
1254
1431
|
lastConnectedAt: bs?.lastConnectedAt || 0,
|
|
1255
1432
|
lastMessageAt: bs?.lastMessageAt || 0,
|
|
1256
1433
|
boundChatId: (bs && bs.boundChatId) || "",
|
|
1434
|
+
boundChatType: (bs && bs.boundChatType) || "",
|
|
1435
|
+
boundChatName: (bs && bs.boundChatName) || "",
|
|
1436
|
+
chatCount: Number.isInteger(bs?.chatCount) ? bs.chatCount : null,
|
|
1437
|
+
// 面板据此显示「接收人:自动绑定(已/未)/ 手动配置」;receiveId 用户不用填
|
|
1438
|
+
receiveIdSource: (bs && bs.receiveIdSource) || (configNow?.receiveId ? "config" : "none"),
|
|
1439
|
+
receiveIdConfigured: Boolean(configNow?.receiveId),
|
|
1257
1440
|
appId: bs?.appId || maskAppId(""),
|
|
1258
1441
|
domain: bs?.domain || "feishu",
|
|
1259
1442
|
receiveIdType: bs?.receiveIdType || "",
|
|
@@ -1342,7 +1525,7 @@ export function createFeishuBotController(options = {}) {
|
|
|
1342
1525
|
if (!notifyEnabled || !bot) return;
|
|
1343
1526
|
const target = targetOf();
|
|
1344
1527
|
if (!target) {
|
|
1345
|
-
log.info("
|
|
1528
|
+
log.info("有事件要推,但还没绑定接收方:不用填接收人 —— 在飞书里给机器人发一句话,就会自动绑定到你这个会话");
|
|
1346
1529
|
return;
|
|
1347
1530
|
}
|
|
1348
1531
|
if (!notifyRateOk()) {
|
|
@@ -1570,6 +1753,7 @@ export function createFeishuBotController(options = {}) {
|
|
|
1570
1753
|
receiveId: cfg.receiveId,
|
|
1571
1754
|
receiveIdType: cfg.receiveIdType,
|
|
1572
1755
|
allowFrom: cfg.allowFrom,
|
|
1756
|
+
boundChatId: cfg.boundChatId,
|
|
1573
1757
|
cardApproval: cardApprovalAllowed && cfg.cardApproval,
|
|
1574
1758
|
relayDir,
|
|
1575
1759
|
env,
|
|
@@ -648,7 +648,7 @@ function makeAuthFetch(plan = {}) {
|
|
|
648
648
|
}
|
|
649
649
|
if (u.endsWith("/begin")) {
|
|
650
650
|
if (plan.beginRejected) return resp({ errmsg: "rejected" }, false, 400);
|
|
651
|
-
return resp(plan.begin || { device_code: "dc-secret", verification_uri_complete: "https://oapi.dingtalk.com/auth?x=1", interval: 2, expires_in:
|
|
651
|
+
return resp(plan.begin || { device_code: "dc-secret", verification_uri_complete: "https://oapi.dingtalk.com/auth?x=1", interval: 2, expires_in: 7200 });
|
|
652
652
|
}
|
|
653
653
|
if (u.endsWith("/poll")) {
|
|
654
654
|
if (plan.pollThrows) throw new Error("ETIMEDOUT");
|
|
@@ -670,7 +670,7 @@ test("POST /im-dingtalk-auth/begin:返回 authId/url/interval/expiresIn;devi
|
|
|
670
670
|
assert.ok(r.body.authId, "必须给浏览器一个 authId 句柄");
|
|
671
671
|
assert.equal(r.body.url, "https://oapi.dingtalk.com/auth?x=1");
|
|
672
672
|
assert.equal(r.body.interval, 2);
|
|
673
|
-
assert.equal(r.body.expiresIn,
|
|
673
|
+
assert.equal(r.body.expiresIn, 7200, "★ TTL 必须按钉钉 expires_in(7200s),不能再是写死的 2 分钟");
|
|
674
674
|
const raw = JSON.stringify(r.body);
|
|
675
675
|
assert.ok(!raw.includes("dc-secret"), "★ device_code 不能下发给浏览器");
|
|
676
676
|
});
|
|
@@ -679,7 +679,7 @@ test("POST /im-dingtalk-auth/begin:返回 authId/url/interval/expiresIn;devi
|
|
|
679
679
|
test("POST begin 限流:同一时间只允许一个授权,旧 authId 查询回 cancelled", async () => {
|
|
680
680
|
const relayDir = await tmpRelay();
|
|
681
681
|
const imAuthFetch = makeAuthFetch();
|
|
682
|
-
await withHost(relayDir, { imAuthFetch }, async (base) => {
|
|
682
|
+
await withHost(relayDir, { imAuthFetch, imAuthPollMs: 60000 }, async (base) => {
|
|
683
683
|
const a = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
684
684
|
const b = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
685
685
|
assert.ok(a.body.authId && b.body.authId && a.body.authId !== b.body.authId);
|
|
@@ -699,12 +699,12 @@ test("GET status:waiting → success 时把凭据写入 im-bots.json(0600、
|
|
|
699
699
|
{ status: "SUCCESS", client_id: "ding-client-8899", client_secret: "DING-SCAN-SECRET" },
|
|
700
700
|
],
|
|
701
701
|
});
|
|
702
|
-
await withHost(relayDir, { imAuthFetch }, async (base) => {
|
|
702
|
+
await withHost(relayDir, { imAuthFetch, imAuthPollMs: 60000 }, async (base) => {
|
|
703
703
|
const begin = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
704
704
|
const id = encodeURIComponent(begin.body.authId);
|
|
705
|
-
let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}`);
|
|
705
|
+
let st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}&check=1`);
|
|
706
706
|
assert.equal(st.body.status, "waiting", "用户还没确认 → waiting");
|
|
707
|
-
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}`);
|
|
707
|
+
st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${id}&check=1`);
|
|
708
708
|
assert.equal(st.body.status, "success");
|
|
709
709
|
assert.equal(st.body.credentialSource, "scan");
|
|
710
710
|
assert.equal(st.body.hasSecret, true);
|
|
@@ -737,22 +737,21 @@ test("GET status:未知/过期 authId → expired;钉钉拒绝 → failed(di
|
|
|
737
737
|
// 钉钉拒绝 poll
|
|
738
738
|
const rejDir = await tmpRelay();
|
|
739
739
|
const rejFetch = makeAuthFetch({ polls: [{ status: "DENIED", message: "用户取消了创建" }] });
|
|
740
|
-
await withHost(rejDir, { imAuthFetch: rejFetch }, async (base2) => {
|
|
740
|
+
await withHost(rejDir, { imAuthFetch: rejFetch, imAuthPollMs: 60000 }, async (base2) => {
|
|
741
741
|
const b = await post(base2, `${base2}/dsh-remote/im-dingtalk-auth/begin`);
|
|
742
|
-
const st = await json(`${base2}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
|
|
742
|
+
const st = await json(`${base2}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}&check=1`);
|
|
743
743
|
assert.equal(st.body.status, "failed");
|
|
744
744
|
assert.equal(st.body.code, "dingtalk_api_rejected");
|
|
745
745
|
assert.ok(st.body.message.includes("用户取消"), "钉钉原文原因要带给用户");
|
|
746
746
|
});
|
|
747
747
|
|
|
748
|
-
//
|
|
748
|
+
// 网络抖动的 poll:**不放失败**(后台继续重试直到 expires_in),状态仍 waiting
|
|
749
749
|
const netDir = await tmpRelay();
|
|
750
750
|
const netFetch = makeAuthFetch({ pollThrows: true });
|
|
751
|
-
await withHost(netDir, { imAuthFetch: netFetch }, async (base3) => {
|
|
751
|
+
await withHost(netDir, { imAuthFetch: netFetch, imAuthPollMs: 60000 }, async (base3) => {
|
|
752
752
|
const b = await post(base3, `${base3}/dsh-remote/im-dingtalk-auth/begin`);
|
|
753
|
-
const st = await json(`${base3}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}`);
|
|
754
|
-
assert.equal(st.body.status, "
|
|
755
|
-
assert.equal(st.body.code, "network_error");
|
|
753
|
+
const st = await json(`${base3}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(b.body.authId)}&check=1`);
|
|
754
|
+
assert.equal(st.body.status, "waiting", "网络抖动不该判死授权(用户还在扫码)");
|
|
756
755
|
});
|
|
757
756
|
});
|
|
758
757
|
});
|
|
@@ -764,7 +763,7 @@ test("POST begin:钉钉接口不可用 → network_error / dingtalk_api_reject
|
|
|
764
763
|
assert.equal(r.status, 502);
|
|
765
764
|
assert.equal(r.body.ok, false);
|
|
766
765
|
assert.equal(r.body.code, "network_error");
|
|
767
|
-
assert.match(r.body.message,
|
|
766
|
+
assert.match(r.body.message, /连不上钉钉接口/, "错误 message 必须是中文人话");
|
|
768
767
|
});
|
|
769
768
|
const dir2 = await tmpRelay();
|
|
770
769
|
await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
|
|
@@ -772,3 +771,51 @@ test("POST begin:钉钉接口不可用 → network_error / dingtalk_api_reject
|
|
|
772
771
|
assert.equal(r.body.code, "dingtalk_api_rejected");
|
|
773
772
|
});
|
|
774
773
|
});
|
|
774
|
+
|
|
775
|
+
test("钉钉授权:后台轮询在**无人查询**时也继续前进(关面板/刷新不影响),TTL 按 expires_in=7200s", async () => {
|
|
776
|
+
const relayDir = await tmpRelay();
|
|
777
|
+
const imAuthFetch = makeAuthFetch({
|
|
778
|
+
polls: [{ status: "WAITING" }, { status: "SUCCESS", client_id: "ding-bg-1", client_secret: "BG-SECRET" }],
|
|
779
|
+
});
|
|
780
|
+
// 后台轮询间隔压到 20ms(生产 3s),验证"不靠面板/HTTP 查询也会成功"
|
|
781
|
+
await withHost(relayDir, { imAuthFetch, imAuthPollMs: 20 }, async (base) => {
|
|
782
|
+
const begin = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
783
|
+
assert.equal(begin.body.expiresIn, 7200, "★ TTL 必须是钉钉的 expires_in(7200s),不是 2 分钟");
|
|
784
|
+
// 不调用任何 status 接口,等后台轮询把 SUCCESS 落盘
|
|
785
|
+
let cfg = null;
|
|
786
|
+
for (let i = 0; i < 100; i++) {
|
|
787
|
+
try { cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8")); } catch { cfg = null; }
|
|
788
|
+
if (cfg && cfg.dingtalk && cfg.dingtalk.clientId === "ding-bg-1") break;
|
|
789
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
790
|
+
}
|
|
791
|
+
assert.ok(cfg && cfg.dingtalk.clientId === "ding-bg-1", "★ 后台轮询必须在没有面板查询时也把凭据写盘");
|
|
792
|
+
assert.equal(cfg.dingtalk.credentialSource, "scan");
|
|
793
|
+
assert.equal(cfg.dingtalk.enabled, true, "扫码成功自动 enabled");
|
|
794
|
+
// 之后查询直接拿到 success(脱敏)
|
|
795
|
+
const st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=${encodeURIComponent(begin.body.authId)}`);
|
|
796
|
+
assert.equal(st.body.status, "success");
|
|
797
|
+
assert.ok(!JSON.stringify(st.body).includes("BG-SECRET"));
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("钉钉授权错误:message 永远是中文人话(绝不裸 code / ok)", async () => {
|
|
802
|
+
const dir1 = await tmpRelay();
|
|
803
|
+
await withHost(dir1, { imAuthFetch: makeAuthFetch({ initThrows: true }) }, async (base) => {
|
|
804
|
+
const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
805
|
+
assert.equal(r.body.code, "network_error");
|
|
806
|
+
assert.notEqual(r.body.message, r.body.code, "message 不能等于裸 code");
|
|
807
|
+
assert.match(r.body.message, /[\u4e00-\u9fa5]/, "message 必须含中文");
|
|
808
|
+
});
|
|
809
|
+
const dir2 = await tmpRelay();
|
|
810
|
+
await withHost(dir2, { imAuthFetch: makeAuthFetch({ beginRejected: true }) }, async (base) => {
|
|
811
|
+
const r = await post(base, `${base}/dsh-remote/im-dingtalk-auth/begin`);
|
|
812
|
+
assert.match(r.body.message, /[\u4e00-\u9fa5]/);
|
|
813
|
+
assert.ok(!/^\s*(ok|error|https?:)/i.test(r.body.message), "不得裸渲染 ok/errcode/URL");
|
|
814
|
+
});
|
|
815
|
+
const dir3 = await tmpRelay();
|
|
816
|
+
await withHost(dir3, { imAuthFetch: makeAuthFetch() }, async (base) => {
|
|
817
|
+
const st = await json(`${base}/dsh-remote/im-dingtalk-auth/status?id=nope`);
|
|
818
|
+
assert.equal(st.body.code, "expired");
|
|
819
|
+
assert.match(st.body.message, /授权已过期/, "过期也要中文人话");
|
|
820
|
+
});
|
|
821
|
+
});
|
|
@@ -759,7 +759,7 @@ test("钉钉主路径:扫码一键创建(begin→等待→轮询→自动成
|
|
|
759
759
|
await flush(); await flush();
|
|
760
760
|
tree = await plugin.settle(4);
|
|
761
761
|
assert.ok(plugin.dingAuthCalls().filter((c) => c.kind === "status").length >= 1, "要轮询授权状态");
|
|
762
|
-
assert.ok(textOf(tree).includes("
|
|
762
|
+
assert.ok(textOf(tree).includes("来源:扫码创建"), "成功后自动切到已连接状态(显示来源)");
|
|
763
763
|
assert.ok(textOf(tree).includes("8899"), "成功提示要带 Client ID 尾号");
|
|
764
764
|
dlg = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
765
765
|
assert.ok(dlg, "成功后弹层仍开着(用户能直接看到结果/去钉钉试)");
|
|
@@ -1288,3 +1288,68 @@ test("飞书依赖下载:点下载 → POST 安装 → 轮询 /im-deps,装
|
|
|
1288
1288
|
assert.ok(textOf(tree).includes("1.40.0"), "装好后面板直接显示版本(用户不需要重开弹层)");
|
|
1289
1289
|
assert.ok(find(tree, (n) => n.props?.id === "dru-fs-appid"), "装好后应出现飞书配置表单");
|
|
1290
1290
|
});
|
|
1291
|
+
|
|
1292
|
+
// ─────────── ⑫ 钉钉授权修正(2026-11-08 生产踩坑:TTL/人话/未配置措辞) ───────────
|
|
1293
|
+
|
|
1294
|
+
test("钉钉等待中:「我已完成授权,检查一下」手动触发 status?check=1(不再 2 分钟判死)", async () => {
|
|
1295
|
+
const plugin = loadPlugin();
|
|
1296
|
+
plugin.setImBotsGet({
|
|
1297
|
+
ok: true, wecom: {}, feishu: {}, dingtalk: {},
|
|
1298
|
+
status: { dingtalk: { connected: false }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1299
|
+
});
|
|
1300
|
+
// 用户点「检查」时服务端后台已经推进到 success(手动检查只是立刻取一次结论)
|
|
1301
|
+
plugin.setDingAuthStatus([{ status: "success", clientIdTail: "0001", credentialSource: "scan", hasSecret: true }]);
|
|
1302
|
+
let tree = await plugin.settle();
|
|
1303
|
+
openEntry(tree, "channel");
|
|
1304
|
+
tree = await plugin.settle();
|
|
1305
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1306
|
+
tree = await plugin.settle();
|
|
1307
|
+
btnWith(tree, "用钉钉扫码,一键创建机器人").props.onClick();
|
|
1308
|
+
await flush(); await flush();
|
|
1309
|
+
tree = await plugin.settle(3);
|
|
1310
|
+
const checkBtn = btnWith(tree, "我已完成授权,检查一下");
|
|
1311
|
+
assert.ok(checkBtn, "等待中要有「我已完成授权,检查一下」按钮");
|
|
1312
|
+
checkBtn.props.onClick();
|
|
1313
|
+
await flush(); await flush();
|
|
1314
|
+
tree = await plugin.settle(3);
|
|
1315
|
+
const calls = plugin.dingAuthCalls().filter((c) => c.kind === "status");
|
|
1316
|
+
assert.ok(calls.some((c) => String(c.path).includes("check=1")), "★ 手动检查必须触发一次 status?check=1(让服务端立刻补轮)");
|
|
1317
|
+
assert.ok(textOf(tree).includes("来源:扫码创建"), "手动检查成功后自动切已连接并收起授权块");
|
|
1318
|
+
assert.equal(btnWith(tree, "我已完成授权,检查一下"), undefined, "成功后授权块自动收起");
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
test("钉钉未配置措辞:enabled=false 显示「未配置」,不是「连接失败」", async () => {
|
|
1322
|
+
const plugin = loadPlugin();
|
|
1323
|
+
plugin.setImBotsGet({
|
|
1324
|
+
ok: true, wecom: {}, feishu: {},
|
|
1325
|
+
dingtalk: { enabled: false, clientId: "", hasSecret: false, robotCode: "", credentialSource: "" },
|
|
1326
|
+
status: { dingtalk: { connected: false, enabled: false, state: "idle", lastError: "未启用(配置里 dingtalk.enabled=false)" }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1327
|
+
});
|
|
1328
|
+
let tree = await plugin.settle();
|
|
1329
|
+
openEntry(tree, "channel");
|
|
1330
|
+
tree = await plugin.settle();
|
|
1331
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1332
|
+
tree = await plugin.settle();
|
|
1333
|
+
const t = textOf(tree);
|
|
1334
|
+
assert.ok(t.includes("未配置"), "没有任何凭据时显示「未配置」");
|
|
1335
|
+
assert.ok(!t.includes("连接失败"), "未配置 ≠ 连接失败(业主明确要求)");
|
|
1336
|
+
assert.ok(!t.includes("未启用(配置里 dingtalk.enabled=false)"), "不得把底层 enabled=false 原文透给用户");
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("钉钉连接失败:只有 state=failed 才显示「连接失败:<中文人话>」,绝不裸渲染 code/ok", async () => {
|
|
1340
|
+
const plugin = loadPlugin();
|
|
1341
|
+
plugin.setImBotsGet({
|
|
1342
|
+
ok: true, wecom: {}, feishu: {},
|
|
1343
|
+
dingtalk: { enabled: true, clientId: "ding-x", hasSecret: true, robotCode: "rc", credentialSource: "scan" },
|
|
1344
|
+
status: { dingtalk: { connected: false, enabled: true, state: "failed", lastError: "ok" }, feishu: {}, deps: { feishu: { installed: false } } },
|
|
1345
|
+
});
|
|
1346
|
+
let tree = await plugin.settle();
|
|
1347
|
+
openEntry(tree, "channel");
|
|
1348
|
+
tree = await plugin.settle();
|
|
1349
|
+
clickChannelRow(tree, "钉钉机器人");
|
|
1350
|
+
tree = await plugin.settle();
|
|
1351
|
+
const t = textOf(tree);
|
|
1352
|
+
assert.ok(t.includes("连接失败"), "state=failed 时才显示连接失败");
|
|
1353
|
+
assert.ok(t.includes("操作失败,请稍后重试"), "后端给了裸 ok 时要兜底成中文人话");
|
|
1354
|
+
assert.ok(!/连接失败:\s*ok\s*$/m.test(t), "绝不把裸 ok 渲染出来");
|
|
1355
|
+
});
|