dsh-remote-web 0.7.12-beta.2 → 0.7.12-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +291 -70
- package/lib/index.js +287 -7
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +49 -6
- package/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +56 -16
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +6 -4
- package/runtime/clients/dsh-remote/feishu-bot.mjs +197 -13
package/lib/index.js
CHANGED
|
@@ -6529,6 +6529,15 @@ const TELEMETRY_FAIL_CODES = new Set([
|
|
|
6529
6529
|
"npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding", "update_stalled",
|
|
6530
6530
|
"unknown",
|
|
6531
6531
|
]);
|
|
6532
|
+
/**
|
|
6533
|
+
* mirror_load 的 outcome 枚举(2026-11-08,与服务端 TELEMETRY_OUTCOME 逐字对齐):
|
|
6534
|
+
* ok = 就绪(真实活跃耗时)
|
|
6535
|
+
* slow = 20s 未就绪(删失样本)
|
|
6536
|
+
* suspended = 页面被系统挂起/冻结(旧客户端不会发,新客户端也暂不发;服务端按 hidden/active 派生)
|
|
6537
|
+
* abandoned = 用户关页/切走时仍未就绪
|
|
6538
|
+
* 缺省/未知一律发 ""(未知),**不再默认谎报 ok** —— 否则旧客户端的挂起样本会被记成正常。
|
|
6539
|
+
*/
|
|
6540
|
+
const TELEMETRY_OUTCOMES = new Set(["ok", "slow", "suspended", "abandoned"]);
|
|
6532
6541
|
/** node 半只发 source=plugin。 */
|
|
6533
6542
|
const TELEMETRY_SOURCE = "plugin";
|
|
6534
6543
|
const TELEMETRY_INSTALL_ID_FILE = ".telemetry-install-id";
|
|
@@ -6849,7 +6858,18 @@ function telemetryEventOf(name, extra = {}) {
|
|
|
6849
6858
|
if (name === "mirror_load") {
|
|
6850
6859
|
if (Number.isFinite(Number(extra.at)) && Number(extra.at) > 0) ev.at = Math.round(Number(extra.at));
|
|
6851
6860
|
if (Number.isFinite(Number(extra.ms))) ev.ms = Math.max(0, Math.min(600000, Math.round(Number(extra.ms))));
|
|
6852
|
-
|
|
6861
|
+
// 2026-11-08:outcome 四枚举透传;active_ms/hidden_ms/frozen_ms 是"活跃加载时间"口径。
|
|
6862
|
+
// 旧客户端不带这些字段 → outcome=""、数值字段缺省;旧服务端忽略未知字段,零破坏。
|
|
6863
|
+
const oc = String(extra.outcome || "");
|
|
6864
|
+
ev.outcome = TELEMETRY_OUTCOMES.has(oc) ? oc : "";
|
|
6865
|
+
for (const k of ["active_ms", "hidden_ms", "frozen_ms"]) {
|
|
6866
|
+
const v = Number(extra[k]);
|
|
6867
|
+
if (Number.isFinite(v) && v >= 0) ev[k] = Math.max(0, Math.min(600000, Math.round(v)));
|
|
6868
|
+
}
|
|
6869
|
+
// 防御:活跃耗时不可能超过墙钟耗时(取整/时钟差可能造出 1ms 级倒挂)
|
|
6870
|
+
if (Number.isFinite(Number(ev.active_ms)) && Number.isFinite(Number(ev.ms))) {
|
|
6871
|
+
ev.active_ms = Math.min(ev.active_ms, ev.ms);
|
|
6872
|
+
}
|
|
6853
6873
|
}
|
|
6854
6874
|
return ev;
|
|
6855
6875
|
}
|
|
@@ -7767,6 +7787,7 @@ function imBotsView(relayDir) {
|
|
|
7767
7787
|
allowFrom: dingtalkCfgOf(bots).allowFrom,
|
|
7768
7788
|
cardTemplateId: dingtalkCfgOf(bots).cardTemplateId,
|
|
7769
7789
|
streamUrl: dingtalkCfgOf(bots).streamUrl,
|
|
7790
|
+
credentialSource: dingtalkCfgOf(bots).credentialSource,
|
|
7770
7791
|
},
|
|
7771
7792
|
feishu: {
|
|
7772
7793
|
enabled: feishuCfgOf(bots).enabled,
|
|
@@ -7904,6 +7925,8 @@ function dingtalkCfgOf(bots) {
|
|
|
7904
7925
|
allowFrom: strListOf(d.allowFrom),
|
|
7905
7926
|
cardTemplateId: strOf(d.cardTemplateId),
|
|
7906
7927
|
streamUrl: strOf(d.streamUrl),
|
|
7928
|
+
// 凭据来源:scan=扫码一键创建;manual=用户手填(2026-11-08 面板显示用,非敏感)
|
|
7929
|
+
credentialSource: strOf(d.credentialSource),
|
|
7907
7930
|
};
|
|
7908
7931
|
}
|
|
7909
7932
|
|
|
@@ -7941,6 +7964,18 @@ function imPlatformStatusOf(status, platform) {
|
|
|
7941
7964
|
authenticated: !!p.authenticated,
|
|
7942
7965
|
lastError: strOf(p.lastError).slice(0, 500),
|
|
7943
7966
|
boundChatId: strOf(p.boundChatId),
|
|
7967
|
+
// 飞书"接收群/私聊自动发现"(2026-11-08 业主:用户不需要填接收人)——
|
|
7968
|
+
// boundChatType:p2p/group;receiveIdSource:auto/manual/config(桥侧口径,只透传字符串)
|
|
7969
|
+
boundChatType: strOf(p.boundChatType).slice(0, 40),
|
|
7970
|
+
receiveIdSource: strOf(p.receiveIdSource).slice(0, 40),
|
|
7971
|
+
// 钉钉能力位(2026-11-08 桥侧新增):凭据来源 / 是否已配置 Robot Code / 能力开关。
|
|
7972
|
+
// 面板据 robotCodeConfigured 把「主动推送/卡片」置灰(缺 Robot Code 时能力不可用)。
|
|
7973
|
+
credentialSource: strOf(p.credentialSource).slice(0, 20),
|
|
7974
|
+
robotCodeConfigured: !!p.robotCodeConfigured,
|
|
7975
|
+
capabilities: (() => {
|
|
7976
|
+
const c = p.capabilities && typeof p.capabilities === "object" ? p.capabilities : null;
|
|
7977
|
+
return c ? { openapi: !!c.openapi, card: !!c.card } : null;
|
|
7978
|
+
})(),
|
|
7944
7979
|
tier: (t === "free" || t === "pro" || t === "pro_max") ? t : "",
|
|
7945
7980
|
caps: strListOf(p.caps).map((x) => x.slice(0, 80)),
|
|
7946
7981
|
capsCount: numOf(p.capsCount),
|
|
@@ -8060,6 +8095,213 @@ function startFeishuInstall(relayDir, opts = {}) {
|
|
|
8060
8095
|
return { ok: true, started: true, taskId, deps: { feishu: feishuDepsInfo(relayDir) } };
|
|
8061
8096
|
}
|
|
8062
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
|
+
//
|
|
8104
|
+
// ★ 2026-11-08 生产实测修正(业主):**轮询必须在节点半后台跑**,面板只查状态。
|
|
8105
|
+
// 原实现让面板每 2s 轮询、还把 TTL 设成 2 分钟 —— 但钉钉 device-flow 的 expires_in 是 7200 秒,
|
|
8106
|
+
// 用户「点按钮→手机扫码→页面加载→点一键创建」很容易超过 2 分钟 → 被误判过期。
|
|
8107
|
+
// 现在:begin 即启动 setInterval(~3s) 后台轮询,关闭面板/刷新页面都不影响;TTL 按钉钉 expires_in。
|
|
8108
|
+
const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
|
|
8109
|
+
const DINGTALK_AUTH_TTL_MS = 7200 * 1000; // 兜底上限:钉钉 device-flow 的 expires_in(7200s)
|
|
8110
|
+
const DINGTALK_AUTH_POLL_MS = 3000; // 后台轮询间隔(测试可用 config.imAuthPollMs 覆盖)
|
|
8111
|
+
const dingtalkAuthSessions = new Map(); // authId → session(含后台 timer,绝不落盘)
|
|
8112
|
+
const dingtalkAuthSuperseded = new Map(); // 被新授权作废的 authId → at(查询时回 cancelled)
|
|
8113
|
+
|
|
8114
|
+
/** 面向用户的中文人话(错误里**绝不出现裸 code / 裸 ok**)。 */
|
|
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
|
+
|
|
8132
|
+
function jsonPostForAuth(fetchImpl, url, payload) {
|
|
8133
|
+
return fetchImpl(url, {
|
|
8134
|
+
method: "POST",
|
|
8135
|
+
headers: { "content-type": "application/json" },
|
|
8136
|
+
body: JSON.stringify(payload || {}),
|
|
8137
|
+
signal: AbortSignal.timeout(8000),
|
|
8138
|
+
}).then(async (r) => {
|
|
8139
|
+
let body = null;
|
|
8140
|
+
try { body = await r.json(); } catch { body = null; }
|
|
8141
|
+
return { ok: !!r.ok, status: r.status, body: body || {} };
|
|
8142
|
+
});
|
|
8143
|
+
}
|
|
8144
|
+
|
|
8145
|
+
/** 后台轮询一次(同一 session 不并发;成功后写盘并停表)。 */
|
|
8146
|
+
async function dingtalkAuthPollOnce(authId, opts = {}) {
|
|
8147
|
+
const sess = dingtalkAuthSessions.get(String(authId || ""));
|
|
8148
|
+
if (!sess || sess.inFlight) return sess;
|
|
8149
|
+
if (sess.state !== "waiting") return sess;
|
|
8150
|
+
if (Date.now() > sess.expiresAt) {
|
|
8151
|
+
sess.state = "expired";
|
|
8152
|
+
sess.message = dingTalkHumanMessage("expired");
|
|
8153
|
+
stopDingAuthTimer(sess);
|
|
8154
|
+
return sess;
|
|
8155
|
+
}
|
|
8156
|
+
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8157
|
+
sess.inFlight = true;
|
|
8158
|
+
try {
|
|
8159
|
+
const r = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/poll`, { device_code: sess.deviceCode });
|
|
8160
|
+
const b = r.body || {};
|
|
8161
|
+
const st = String(b.status || "").toUpperCase();
|
|
8162
|
+
if (st === "WAITING" || st === "PENDING") return sess;
|
|
8163
|
+
if (st === "SUCCESS" || (b.client_id && b.client_secret)) {
|
|
8164
|
+
const saved = saveDingtalkScannedCredentials(sess.relayDir, b.client_id, b.client_secret);
|
|
8165
|
+
if (!saved.ok) {
|
|
8166
|
+
sess.state = "failed";
|
|
8167
|
+
sess.message = dingTalkHumanMessage(saved.code, saved.message);
|
|
8168
|
+
} else {
|
|
8169
|
+
const cid = String(b.client_id || "");
|
|
8170
|
+
sess.state = "success";
|
|
8171
|
+
sess.clientIdTail = cid.length > 4 ? cid.slice(-4) : cid;
|
|
8172
|
+
sess.credentialSource = "scan";
|
|
8173
|
+
}
|
|
8174
|
+
stopDingAuthTimer(sess);
|
|
8175
|
+
return sess;
|
|
8176
|
+
}
|
|
8177
|
+
// 钉钉明确拒绝(创建被取消/参数过期等):用中文人话,绝不把 errmsg 裸值直接渲染
|
|
8178
|
+
sess.state = "failed";
|
|
8179
|
+
sess.message = dingTalkHumanMessage("dingtalk_api_rejected", b.message || b.errmsg);
|
|
8180
|
+
stopDingAuthTimer(sess);
|
|
8181
|
+
return sess;
|
|
8182
|
+
} catch (e) {
|
|
8183
|
+
// 网络抖动**不作废授权**(后台继续轮询直到 expires_in)—— 只有明确拒绝/过期才终止。
|
|
8184
|
+
return sess;
|
|
8185
|
+
} finally {
|
|
8186
|
+
sess.inFlight = false;
|
|
8187
|
+
}
|
|
8188
|
+
}
|
|
8189
|
+
|
|
8190
|
+
function stopDingAuthTimer(sess) {
|
|
8191
|
+
if (!sess) return;
|
|
8192
|
+
try { if (sess.timer) clearInterval(sess.timer); } catch { /* 忽略 */ }
|
|
8193
|
+
sess.timer = null;
|
|
8194
|
+
}
|
|
8195
|
+
|
|
8196
|
+
/**
|
|
8197
|
+
* 开始一次钉钉扫码授权(POST /dsh-remote/im-dingtalk-auth/begin)。
|
|
8198
|
+
* 同一 relayDir 同时只保留一个会话:begin 时把旧的作废(旧 authId 后续查询回 cancelled)。
|
|
8199
|
+
* TTL 按钉钉返回的 expires_in(生产实测 7200s),后台 setInterval 轮询,关闭面板不影响。
|
|
8200
|
+
*/
|
|
8201
|
+
async function beginDingtalkAuth(relayDir, opts = {}) {
|
|
8202
|
+
const fetchImpl = typeof opts.imAuthFetch === "function" ? opts.imAuthFetch : fetch;
|
|
8203
|
+
// 作废旧会话(同时停掉它们的后台轮询表)
|
|
8204
|
+
for (const [id, sess] of dingtalkAuthSessions) {
|
|
8205
|
+
if (sess.relayDir === relayDir) {
|
|
8206
|
+
stopDingAuthTimer(sess);
|
|
8207
|
+
dingtalkAuthSessions.delete(id);
|
|
8208
|
+
dingtalkAuthSuperseded.set(id, Date.now());
|
|
8209
|
+
}
|
|
8210
|
+
}
|
|
8211
|
+
while (dingtalkAuthSuperseded.size > 20) {
|
|
8212
|
+
const first = dingtalkAuthSuperseded.keys().next().value;
|
|
8213
|
+
dingtalkAuthSuperseded.delete(first);
|
|
8214
|
+
}
|
|
8215
|
+
let init;
|
|
8216
|
+
try {
|
|
8217
|
+
init = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" });
|
|
8218
|
+
} catch {
|
|
8219
|
+
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8220
|
+
}
|
|
8221
|
+
const nonce = init.ok && init.body && typeof init.body.nonce === "string" ? init.body.nonce : "";
|
|
8222
|
+
if (!nonce) return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8223
|
+
let begin;
|
|
8224
|
+
try {
|
|
8225
|
+
begin = await jsonPostForAuth(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce });
|
|
8226
|
+
} catch {
|
|
8227
|
+
return { ok: false, code: "network_error", message: dingTalkHumanMessage("network_error") };
|
|
8228
|
+
}
|
|
8229
|
+
const b = begin.body || {};
|
|
8230
|
+
const deviceCode = typeof b.device_code === "string" ? b.device_code : "";
|
|
8231
|
+
const url = typeof b.verification_uri_complete === "string" ? b.verification_uri_complete : "";
|
|
8232
|
+
if (!begin.ok || !deviceCode || !url) {
|
|
8233
|
+
return { ok: false, code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected") };
|
|
8234
|
+
}
|
|
8235
|
+
const authId = `ding-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8236
|
+
const interval = Math.max(1, Math.min(10, Number(b.interval) || 3));
|
|
8237
|
+
// ★ TTL 用钉钉的 expires_in(生产 7200s),不再用以前写死的 2 分钟
|
|
8238
|
+
const expiresIn = Math.max(60, Math.min(7200, Number(b.expires_in) || 7200));
|
|
8239
|
+
const now = Date.now();
|
|
8240
|
+
const sess = {
|
|
8241
|
+
relayDir, deviceCode, url, interval,
|
|
8242
|
+
createdAt: now,
|
|
8243
|
+
expiresAt: Math.min(now + expiresIn * 1000, now + DINGTALK_AUTH_TTL_MS),
|
|
8244
|
+
state: "waiting", message: "", inFlight: false, timer: null, clientIdTail: "", credentialSource: ""
|
|
8245
|
+
};
|
|
8246
|
+
// 启动后台轮询:面板关掉/刷新都不影响,用户 2 小时内确认都能成功
|
|
8247
|
+
const pollMs = Math.max(10, Number(opts.imAuthPollMs) || DINGTALK_AUTH_POLL_MS);
|
|
8248
|
+
try {
|
|
8249
|
+
sess.timer = setInterval(() => { void dingtalkAuthPollOnce(authId, opts); }, pollMs);
|
|
8250
|
+
sess.timer.unref?.();
|
|
8251
|
+
} catch { sess.timer = null; }
|
|
8252
|
+
dingtalkAuthSessions.set(authId, sess);
|
|
8253
|
+
return { ok: true, authId, url, interval, expiresIn };
|
|
8254
|
+
}
|
|
8255
|
+
|
|
8256
|
+
/** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600 原子写;保留其它字段,自动 enabled)。 */
|
|
8257
|
+
function saveDingtalkScannedCredentials(relayDir, clientId, clientSecret) {
|
|
8258
|
+
const bots = readImBots(relayDir);
|
|
8259
|
+
const cur = dingtalkCfgOf(bots);
|
|
8260
|
+
const next = {
|
|
8261
|
+
...cur,
|
|
8262
|
+
enabled: true,
|
|
8263
|
+
clientId: String(clientId || "").trim(),
|
|
8264
|
+
clientSecret: String(clientSecret || "").trim(),
|
|
8265
|
+
// 扫码流程只给 client_id/client_secret;robotCode 由桥连上后自动补齐/发现
|
|
8266
|
+
credentialSource: "scan",
|
|
8267
|
+
};
|
|
8268
|
+
try {
|
|
8269
|
+
writeImBots(relayDir, { ...bots, version: 1, dingtalk: next });
|
|
8270
|
+
return { ok: true };
|
|
8271
|
+
} catch (e) {
|
|
8272
|
+
return { ok: false, code: "write_failed", message: dingTalkHumanMessage("write_failed", `保存扫码凭据失败:${e?.message || e}`) };
|
|
8273
|
+
}
|
|
8274
|
+
}
|
|
8275
|
+
|
|
8276
|
+
/**
|
|
8277
|
+
* 查询扫码授权状态(GET /dsh-remote/im-dingtalk-auth/status?id=...&check=1)。
|
|
8278
|
+
* 默认只读后台轮询的结论;check=1(面板「我已完成授权,检查一下」)会**立刻补轮一次**再返回。
|
|
8279
|
+
* success 时凭据已经写盘,响应只回脱敏信息(绝不回 secret)。
|
|
8280
|
+
*/
|
|
8281
|
+
async function dingtalkAuthStatus(authId, opts = {}) {
|
|
8282
|
+
const key = String(authId || "");
|
|
8283
|
+
const sess = dingtalkAuthSessions.get(key);
|
|
8284
|
+
if (!sess) {
|
|
8285
|
+
if (dingtalkAuthSuperseded.has(key)) {
|
|
8286
|
+
dingtalkAuthSuperseded.delete(key);
|
|
8287
|
+
return { ok: false, status: "failed", code: "cancelled", message: dingTalkHumanMessage("cancelled") };
|
|
8288
|
+
}
|
|
8289
|
+
return { ok: false, status: "expired", code: "expired", message: dingTalkHumanMessage("expired") };
|
|
8290
|
+
}
|
|
8291
|
+
if (opts.check === true && sess.state === "waiting") {
|
|
8292
|
+
await dingtalkAuthPollOnce(key, opts);
|
|
8293
|
+
}
|
|
8294
|
+
if (sess.state === "waiting") return { ok: true, status: "waiting" };
|
|
8295
|
+
if (sess.state === "success") {
|
|
8296
|
+
return {
|
|
8297
|
+
ok: true, status: "success", credentialSource: "scan",
|
|
8298
|
+
clientIdTail: sess.clientIdTail || "", hasSecret: true
|
|
8299
|
+
};
|
|
8300
|
+
}
|
|
8301
|
+
if (sess.state === "expired") return { ok: false, status: "expired", code: "expired", message: dingTalkHumanMessage("expired", sess.message) };
|
|
8302
|
+
return { ok: false, status: "failed", code: "dingtalk_api_rejected", message: dingTalkHumanMessage("dingtalk_api_rejected", sess.message) };
|
|
8303
|
+
}
|
|
8304
|
+
|
|
8063
8305
|
/** 保存 dingtalk 配置(空 secret = 不修改;凭据完整性在返回里说清)。 */
|
|
8064
8306
|
function saveDingtalkConfig(relayDir, input) {
|
|
8065
8307
|
const src = input && typeof input === "object" ? input : {};
|
|
@@ -8078,6 +8320,10 @@ function saveDingtalkConfig(relayDir, input) {
|
|
|
8078
8320
|
if (Object.prototype.hasOwnProperty.call(src, "allowFrom")) next.allowFrom = strListOf(src.allowFrom);
|
|
8079
8321
|
if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
|
|
8080
8322
|
else if (!next.enabled && next.clientId && next.clientSecret && next.robotCode) next.enabled = true;
|
|
8323
|
+
// 凭据来源标记(面板显示「扫码创建 / 手动填写」):手填过凭据就记 manual;扫码流程会写 scan。
|
|
8324
|
+
if (Object.prototype.hasOwnProperty.call(src, "clientId") || Object.prototype.hasOwnProperty.call(src, "clientSecret")) {
|
|
8325
|
+
next.credentialSource = "manual";
|
|
8326
|
+
}
|
|
8081
8327
|
if (!next.clientId) return { ok: false, code: "missing_client_id", message: "请填写 Client ID(= 钉钉应用的 AppKey)" };
|
|
8082
8328
|
if (!next.clientSecret) return { ok: false, code: "missing_client_secret", message: "请填写 Client Secret(= 钉钉应用的 AppSecret,只保存在本机)" };
|
|
8083
8329
|
if (!next.robotCode) return { ok: false, code: "missing_robot_code", message: "请填写 Robot Code(机器人编码,在钉钉开放平台机器人页复制)" };
|
|
@@ -8116,7 +8362,8 @@ function saveFeishuConfig(relayDir, input) {
|
|
|
8116
8362
|
else if (!next.enabled && next.appId && next.appSecret && next.receiveId) next.enabled = true;
|
|
8117
8363
|
if (!next.appId) return { ok: false, code: "missing_app_id", message: "请填写 App ID(飞书开放平台应用的 App ID)" };
|
|
8118
8364
|
if (!next.appSecret) return { ok: false, code: "missing_app_secret", message: "请填写 App Secret(只保存在本机,不会回显)" };
|
|
8119
|
-
|
|
8365
|
+
// 接收人**不是必填**(2026-11-08 业主实测:App ID+Secret 连上后桥会自己发现你的群/私聊)。
|
|
8366
|
+
// 保留 receiveId/receiveIdType 字段是为了兼容桥侧手工配置/后续高级用法;面板不再要求填写。
|
|
8120
8367
|
const out = { ...bots, version: 1, feishu: next };
|
|
8121
8368
|
try {
|
|
8122
8369
|
writeImBots(relayDir, out);
|
|
@@ -8367,8 +8614,10 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8367
8614
|
},
|
|
8368
8615
|
},
|
|
8369
8616
|
// 镜像页就绪耗时(D1 在 clients/dsh-remote/mobile-adapter.mjs 里量,经此入队):
|
|
8370
|
-
// body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow"
|
|
8371
|
-
//
|
|
8617
|
+
// body = { ms: 耗时毫秒, at: 事件时刻 epoch ms, outcome: "ok"|"slow"|"suspended"|"abandoned",
|
|
8618
|
+
// active_ms?, hidden_ms?, frozen_ms? }
|
|
8619
|
+
// 契约(926 迭代 §2 + 2026-11-08 活跃时间口径):事件名固定 mirror_load,ms 钳制 [0, 600000],
|
|
8620
|
+
// outcome 只认四枚举(非法/缺省发 "",**不再默认 ok**),三个时长字段可选、越界丢弃。
|
|
8372
8621
|
//
|
|
8373
8622
|
// 🔒 入参校验失败**静默丢弃**但仍回 200 {ok:true}:这条上报是手机镜像页加载路径上的
|
|
8374
8623
|
// fire-and-forget,任何 4xx 都可能被页面当成"功能坏了"而冒泡给用户;统计丢一条无所谓,
|
|
@@ -8386,11 +8635,17 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8386
8635
|
if (body && Number.isFinite(ms) && ms >= 0 && ms <= 600000) {
|
|
8387
8636
|
const atRaw = Number(body.at);
|
|
8388
8637
|
const at = Number.isFinite(atRaw) && atRaw > 0 ? Math.round(atRaw) : 0;
|
|
8389
|
-
|
|
8638
|
+
const oc = String(body.outcome || "");
|
|
8639
|
+
const rec = {
|
|
8390
8640
|
ms: Math.round(ms),
|
|
8391
8641
|
at,
|
|
8392
|
-
outcome:
|
|
8393
|
-
}
|
|
8642
|
+
outcome: TELEMETRY_OUTCOMES.has(oc) ? oc : "",
|
|
8643
|
+
};
|
|
8644
|
+
for (const k of ["active_ms", "hidden_ms", "frozen_ms"]) {
|
|
8645
|
+
const v = Number(body[k]);
|
|
8646
|
+
if (Number.isFinite(v) && v >= 0) rec[k] = Math.max(0, Math.min(600000, Math.round(v)));
|
|
8647
|
+
}
|
|
8648
|
+
telemetryRecord(relayDir, "mirror_load", rec);
|
|
8394
8649
|
}
|
|
8395
8650
|
} catch { /* 体读不到/超限:静默 */ }
|
|
8396
8651
|
sendJson(res, 200, { ok: true });
|
|
@@ -9040,6 +9295,28 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
9040
9295
|
sendJson(res, 200, imBotsView(relayDir));
|
|
9041
9296
|
},
|
|
9042
9297
|
},
|
|
9298
|
+
{
|
|
9299
|
+
// 钉钉扫码一键创建机器人:开始授权(返回授权链接;device_code 只存服务端内存)
|
|
9300
|
+
method: "POST",
|
|
9301
|
+
path: "/dsh-remote/im-dingtalk-auth/begin",
|
|
9302
|
+
handler: async (_req, res) => {
|
|
9303
|
+
const r = await beginDingtalkAuth(relayDir, { imAuthFetch: opts.imAuthFetch, imAuthPollMs: opts.imAuthPollMs });
|
|
9304
|
+
sendJson(res, r.ok ? 200 : 502, r);
|
|
9305
|
+
},
|
|
9306
|
+
},
|
|
9307
|
+
{
|
|
9308
|
+
// 轮询授权状态:waiting / success(已写盘,只回脱敏结果)/ failed / expired
|
|
9309
|
+
method: "GET",
|
|
9310
|
+
path: "/dsh-remote/im-dingtalk-auth/status",
|
|
9311
|
+
handler: async (req, res) => {
|
|
9312
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
9313
|
+
const r = await dingtalkAuthStatus(url.searchParams.get("id") || "", {
|
|
9314
|
+
imAuthFetch: opts.imAuthFetch,
|
|
9315
|
+
check: url.searchParams.get("check") === "1"
|
|
9316
|
+
});
|
|
9317
|
+
sendJson(res, 200, r);
|
|
9318
|
+
},
|
|
9319
|
+
},
|
|
9043
9320
|
{
|
|
9044
9321
|
// 飞书 SDK「按需下载」现状(面板据此显示未装/已装/下载中/失败)。
|
|
9045
9322
|
method: "GET",
|
|
@@ -9428,6 +9705,9 @@ export function apply(ctx, config = {}) {
|
|
|
9428
9705
|
imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
|
|
9429
9706
|
// 测试钩子:飞书 SDK 安装的 npm spawn(用例注入假实现,**绝不真的联网装包**)。
|
|
9430
9707
|
imDepsSpawn: typeof config.imDepsSpawn === "function" ? config.imDepsSpawn : undefined,
|
|
9708
|
+
// 测试钩子:钉钉扫码授权的 fetch(用例注入假实现,**绝不真的打钉钉接口**)+ 后台轮询间隔。
|
|
9709
|
+
imAuthFetch: typeof config.imAuthFetch === "function" ? config.imAuthFetch : undefined,
|
|
9710
|
+
imAuthPollMs: Number(config.imAuthPollMs) || undefined,
|
|
9431
9711
|
}), "dsh-remote-web: /dsh-remote routes");
|
|
9432
9712
|
// 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
|
|
9433
9713
|
ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.12-beta.
|
|
3
|
+
"version": "0.7.12-beta.4",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -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`。
|