dsh-remote-web 0.7.12-beta.4 → 0.7.12-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * dingtalk-auth — 钉钉「扫码一键创建机器人」授权会话的**持久化 + 断点续轮询**(2026-11-08)。
3
+ *
4
+ * 要解决的根因
5
+ * -----------------------------------------------------------------
6
+ * 官方 connector 的授权是标准 device-flow:
7
+ * POST /app/registration/init {source:"DING_DWS_CLAW"} → {nonce}
8
+ * POST /app/registration/begin {nonce} → {device_code, verification_uri_complete, interval, expires_in}
9
+ * POST /app/registration/poll {device_code} → {status:"WAITING"|"SUCCESS", client_id, client_secret}
10
+ *
11
+ * 凭据**只在 poll 返回 SUCCESS 的那一次**给出。如果 device_code 只存在进程内存里(插件进程/面板关掉/宿主重启),
12
+ * 授权会话就丢了 —— 钉钉侧显示「连接成功」、机器人也建好了,但我们永远接不住凭据,用户只能去控制台重新复制。
13
+ *
14
+ * 本模块因此把授权会话**原子落盘**到 `<relayDir>/im-dingtalk-auth.json`(0600),
15
+ * bridge 启动时 `resumeDingtalkAuth()` 继续按 interval 轮询;拿到凭据后写 `im-bots.json` 的 dingtalk 段、
16
+ * 删除 auth 文件、写状态 `dingtalk.state="connected"`,随后现有 controller 的热更新会真正把机器人连上。
17
+ *
18
+ * 安全
19
+ * -----------------------------------------------------------------
20
+ * · auth 文件含 device_code(等价于"授权票据")→ 0600,且**不写状态文件**、不进日志。
21
+ * · 拿到的 clientSecret 只写 im-bots.json(0600 原子写),日志只打 clientId 尾号。
22
+ *
23
+ * 对插件暴露的接口(插件侧改为委托调用,2026-11-08 Lead 确认):
24
+ * beginDingtalkAuth({relayDir, fetchImpl}) → {ok, url, expiresIn, interval, expiresAt}
25
+ * authStatus({relayDir}) → {ok, status:"idle|waiting|success|failed|expired", remainingSeconds, url?, message?, clientIdTail?}
26
+ * cancelDingtalkAuth({relayDir}) → {ok:true}(停轮询 + 删 auth 文件)
27
+ * resumeDingtalkAuth({relayDir, fetchImpl, onSuccess}) → bridge 启动时调用(fire-and-forget 也安全)
28
+ * stopDingtalkAuthPolling() → 只停定时器(**保留** auth 文件,供下次启动续轮询)
29
+ */
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+
34
+ import {
35
+ IM_BOTS_FILE,
36
+ IM_BOT_STATUS_FILE,
37
+ atomicWriteJson,
38
+ createScopedLogger,
39
+ readJsonFile,
40
+ truncateText
41
+ } from "./wecom-bot.mjs";
42
+
43
+ /** 授权会话文件(相对 relayDir;0600;含 device_code,绝不进状态/日志)。 */
44
+ export const DINGTALK_AUTH_FILE = "im-dingtalk-auth.json";
45
+ /**
46
+ * 控制命令文件(插件写、桥读,0600):`{"action":"begin"|"cancel","at":<ts>}`。
47
+ * 桥用 `fs.watchFile` 盯它(1s),处理完删除文件;同一 `at` 不重复处理。
48
+ * 这样插件不需要点对点 HTTP 端点,也不需要持有 device_code —— 只发"开始/取消"意图。
49
+ */
50
+ export const DINGTALK_AUTH_CMD_FILE = "im-dingtalk-auth-cmd.json";
51
+ /** 官方 connector 用的注册端点(与插件侧 lib/index.js 的 DINGTALK_REG_BASE 保持一致)。 */
52
+ export const DINGTALK_REG_BASE = "https://oapi.dingtalk.com/app/registration";
53
+ /** 兜底 TTL 上限:钉钉 device-flow 的 expires_in 是 7200s(2 小时)。 */
54
+ export const DINGTALK_AUTH_TTL_MS = 7200 * 1000;
55
+ /** 默认轮询间隔(秒);钉钉 begin 会返回 interval(实测 3s 左右)。 */
56
+ const DEFAULT_POLL_SECONDS = 3;
57
+ /** 单次 HTTP 超时。 */
58
+ const REQUEST_TIMEOUT_MS = 8_000;
59
+ /** auth 成功后 lastResult 保留多久(供插件轮询读结果)。 */
60
+ const LAST_RESULT_TTL_MS = 10 * 60 * 1000;
61
+
62
+ /** 面向用户的中文错误(绝不把裸 code / 钉钉 errmsg 直接渲染给用户)。 */
63
+ const HUMAN = Object.freeze({
64
+ network_error: "连不上钉钉接口,请检查网络后重试",
65
+ rejected: "钉钉拒绝了这次授权请求,请重新扫码",
66
+ expired: "授权已过期,请重新扫码生成新的链接",
67
+ cancelled: "这次授权已被取消",
68
+ write_failed: "拿到的凭据保存失败(磁盘/权限问题),请检查后重试",
69
+ bad_credentials: "钉钉返回的凭据不完整,请重新扫码",
70
+ no_relay_dir: "缺少 relay 目录,无法保存授权会话",
71
+ no_session: "没有进行中的扫码授权",
72
+ success: "授权成功,机器人凭据已保存(正在自动连接)"
73
+ });
74
+
75
+ /** relayDir → 后台轮询会话(只存内存;device_code 同时持久化在 auth 文件里)。 */
76
+ const sessions = new Map();
77
+ /** relayDir → 最近一次结论(success/failed/expired/cancelled;供 authStatus 在文件删除后仍能读到)。 */
78
+ const lastResults = new Map();
79
+ let generationSeq = 0;
80
+
81
+ function human(code, fallback) {
82
+ const text = String(fallback || "").trim();
83
+ if (/[\u4e00-\u9fa5]/.test(text) && !/^\s*[{[]/.test(text)) return truncateText(text, 120);
84
+ return HUMAN[code] || "操作失败,请稍后重试";
85
+ }
86
+
87
+ function nowOf(options) {
88
+ return typeof options.clock === "function" ? Number(options.clock()) || Date.now() : Date.now();
89
+ }
90
+
91
+ function authFileOf(relayDir) {
92
+ return path.join(String(relayDir || ""), DINGTALK_AUTH_FILE);
93
+ }
94
+
95
+ /** 读取授权会话文件(不存在/坏 JSON → null,绝不抛)。 */
96
+ export function readDingtalkAuthSession(relayDir) {
97
+ if (!relayDir) return null;
98
+ const raw = readJsonFile(authFileOf(relayDir));
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
100
+ const deviceCode = typeof raw.deviceCode === "string" ? raw.deviceCode : "";
101
+ if (!deviceCode) return null;
102
+ return {
103
+ deviceCode,
104
+ interval: Math.max(1, Math.min(10, Number(raw.interval) || DEFAULT_POLL_SECONDS)),
105
+ expiresAt: Number(raw.expiresAt) || 0,
106
+ url: typeof raw.url === "string" ? raw.url : "",
107
+ createdAt: Number(raw.createdAt) || 0,
108
+ credentialSource: "scan"
109
+ };
110
+ }
111
+
112
+ function removeAuthFile(relayDir) {
113
+ try { fs.rmSync(authFileOf(relayDir), { force: true }); } catch { /* 忽略 */ }
114
+ }
115
+
116
+ /** 合并写状态文件的 dingtalk 段(保留其它段/字段;绝不含 secret)。 */
117
+ function patchDingtalkStatus(relayDir, patch, onWarn) {
118
+ if (!relayDir) return;
119
+ try {
120
+ const file = path.join(String(relayDir), IM_BOT_STATUS_FILE);
121
+ const prev = readJsonFile(file);
122
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
123
+ const cur = base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {};
124
+ atomicWriteJson(file, {
125
+ ...base,
126
+ version: 1,
127
+ updatedAt: Date.now(),
128
+ dingtalk: { ...cur, ...patch }
129
+ }, { onWarn });
130
+ } catch { /* 状态写失败不影响授权本身 */ }
131
+ }
132
+
133
+ /** relayDir → 上次写入的 dingtalk.auth 快照(JSON;内容没变就不重复写文件)。 */
134
+ const lastAuthSync = new Map();
135
+
136
+ /** 把 authStatus() 的结果同步进状态文件 `dingtalk.auth`(**绝不含 deviceCode/secret**)。 */
137
+ function syncAuthStatus(relayDir, onWarn) {
138
+ if (!relayDir) return null;
139
+ const st = authStatus({ relayDir });
140
+ const snapshot = {
141
+ state: String(st.status || "idle"),
142
+ url: st.status === "waiting" && st.url ? String(st.url) : "",
143
+ expiresAt: Number(st.expiresAt) || 0,
144
+ remainingSec: Math.max(0, Number(st.remainingSeconds) || 0),
145
+ clientIdTail: String(st.clientIdTail || ""),
146
+ message: String(st.message || "")
147
+ };
148
+ const sig = JSON.stringify(snapshot);
149
+ if (lastAuthSync.get(relayDir) === sig) return snapshot;
150
+ lastAuthSync.set(relayDir, sig);
151
+ patchDingtalkStatus(relayDir, { auth: snapshot }, onWarn);
152
+ return snapshot;
153
+ }
154
+
155
+ // ---------- 控制命令文件(插件 → 桥) ----------
156
+
157
+ /** relayDir → { watcher 状态/去重信息 }(只存内存;命令文件处理完即删)。 */
158
+ const cmdWatchers = new Map();
159
+
160
+ function readCommandFile(relayDir) {
161
+ const file = path.join(String(relayDir || ""), DINGTALK_AUTH_CMD_FILE);
162
+ const raw = readJsonFile(file);
163
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
164
+ const action = String(raw.action || "").toLowerCase();
165
+ if (action !== "begin" && action !== "cancel") return null;
166
+ return { action, at: Number(raw.at) || 0 };
167
+ }
168
+
169
+ function removeCommandFile(relayDir) {
170
+ try { fs.rmSync(path.join(String(relayDir || ""), DINGTALK_AUTH_CMD_FILE), { force: true }); } catch { /* 忽略 */ }
171
+ }
172
+
173
+ /**
174
+ * 处理命令文件(幂等):读 → 去重(at) → 执行 → 删除文件 → 同步状态。
175
+ * 返回处理结果(测试/排障用):`{ok, action?, skipped?}`。
176
+ */
177
+ export async function handleDingtalkAuthCommand(options = {}) {
178
+ const relayDir = String(options.relayDir || "");
179
+ if (!relayDir) return { ok: false, code: "no_relay_dir" };
180
+ const state = cmdWatchers.get(relayDir) || { lastHandledAt: 0, handling: false };
181
+ if (state.handling) return { ok: false, code: "busy" };
182
+ const cmd = readCommandFile(relayDir);
183
+ if (!cmd) {
184
+ // 坏文件/未知 action:删掉,避免每 1s 刷日志(只记一次 debug)
185
+ if (fs.existsSync(path.join(relayDir, DINGTALK_AUTH_CMD_FILE))) {
186
+ removeCommandFile(relayDir);
187
+ if (options.logger) options.logger("debug", "[dingtalk-auth] 忽略无法识别的授权命令文件(已删除)");
188
+ }
189
+ return { ok: false, code: "no_command" };
190
+ }
191
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
192
+ if (cmd.at && cmd.at <= state.lastHandledAt) {
193
+ removeCommandFile(relayDir);
194
+ log.debug(`忽略重复的授权命令(at=${cmd.at} ≤ lastHandledAt=${state.lastHandledAt})`);
195
+ return { ok: true, action: cmd.action, skipped: true, at: cmd.at };
196
+ }
197
+ state.handling = true;
198
+ cmdWatchers.set(relayDir, state);
199
+ try {
200
+ if (cmd.action === "begin") {
201
+ log.info("收到面板「开始扫码授权」命令 → 生成授权链接并持久化会话");
202
+ const r = await beginDingtalkAuth({
203
+ relayDir,
204
+ fetchImpl: options.fetchImpl,
205
+ logger: options.logger,
206
+ pollMs: options.pollMs,
207
+ onSuccess: options.onSuccess,
208
+ onWarn: options.onWarn
209
+ });
210
+ state.lastHandledAt = cmd.at || Date.now();
211
+ log.info(r.ok ? "已生成扫码授权链接(等待用户手机确认)" : `扫码授权发起失败:${r.message || r.code || "?"}`);
212
+ return { ok: r.ok, action: "begin", at: cmd.at, result: r };
213
+ }
214
+ log.info("收到面板「取消扫码授权」命令");
215
+ cancelDingtalkAuth({ relayDir, onWarn: options.onWarn });
216
+ state.lastHandledAt = cmd.at || Date.now();
217
+ return { ok: true, action: "cancel", at: cmd.at };
218
+ } finally {
219
+ removeCommandFile(relayDir);
220
+ state.handling = false;
221
+ cmdWatchers.set(relayDir, state);
222
+ syncAuthStatus(relayDir, options.onWarn);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 启动命令文件监听(bridge 启动时调用;幂等)。
228
+ * 同时每秒刷新一次 `dingtalk.auth`(remainingSec 会随时间减少),桥退出时 stop 掉。
229
+ * @param {{relayDir:string, fetchImpl?:Function, logger?:Function, onSuccess?:Function, onWarn?:Function,
230
+ * intervalMs?:number, pollMs?:number}} options
231
+ */
232
+ export function startDingtalkAuthCommandWatcher(options = {}) {
233
+ const relayDir = String(options.relayDir || "");
234
+ if (!relayDir) return { ok: false, code: "no_relay_dir" };
235
+ const existing = cmdWatchers.get(relayDir);
236
+ if (existing && existing.timer) return { ok: true, started: false };
237
+ const file = path.join(relayDir, DINGTALK_AUTH_CMD_FILE);
238
+ const intervalMs = Math.max(20, Number(options.intervalMs) || 1000);
239
+ const state = { lastHandledAt: 0, handling: false, timer: null, listener: null };
240
+ // ⚠️ 必须**先把 state 放进 map 再触发首次 handle**:否则启动时恰有命令文件的情况下,
241
+ // handleDingtalkAuthCommand 会用自己的临时 state 覆盖 map,导致 stop 时拿不到 listener
242
+ // → fs.watchFile 解不掉(测试进程/桥退出时泄漏)。
243
+ cmdWatchers.set(relayDir, state);
244
+ const tick = () => {
245
+ // 先处理命令(有就执行),再刷新状态(remainingSec 随时间变化)
246
+ void handleDingtalkAuthCommand(options)
247
+ .catch(() => { /* 内部已兜住 */ })
248
+ .finally(() => { syncAuthStatus(relayDir, options.onWarn); });
249
+ };
250
+ state.listener = () => { void handleDingtalkAuthCommand(options).catch(() => {}); };
251
+ try {
252
+ fs.watchFile(file, { interval: intervalMs }, state.listener);
253
+ } catch (e) {
254
+ if (options.logger) options.logger("warn", `[dingtalk-auth] 监听授权命令文件失败:${e && e.message ? e.message : e}`);
255
+ }
256
+ state.timer = setInterval(tick, intervalMs);
257
+ if (typeof state.timer.unref === "function") state.timer.unref();
258
+ // 启动时先检查一次(上次退出可能留下未处理命令),之后 interval 心跳兜底
259
+ void handleDingtalkAuthCommand(options).catch(() => {});
260
+ syncAuthStatus(relayDir, options.onWarn);
261
+ return { ok: true, started: true, intervalMs };
262
+ }
263
+
264
+ /** 停止命令文件监听(bridge 退出时调用;**保留** auth 文件,下次启动续轮询)。 */
265
+ export function stopDingtalkAuthCommandWatcher(relayDir) {
266
+ const dirs = relayDir ? [String(relayDir)] : [...cmdWatchers.keys()];
267
+ for (const dir of dirs) {
268
+ const state = cmdWatchers.get(dir);
269
+ if (!state) continue;
270
+ try { if (state.timer) clearInterval(state.timer); } catch { /* 忽略 */ }
271
+ state.timer = null;
272
+ try {
273
+ if (state.listener) fs.unwatchFile(path.join(dir, DINGTALK_AUTH_CMD_FILE), state.listener);
274
+ } catch { /* 忽略 */ }
275
+ state.listener = null;
276
+ cmdWatchers.delete(dir);
277
+ }
278
+ }
279
+
280
+ /** 测试用:清空命令监听内存态(不动文件)。 */
281
+ export function __resetDingtalkAuthCmdForTest() {
282
+ stopDingtalkAuthCommandWatcher();
283
+ }
284
+
285
+ function stopSession(relayDir) {
286
+ const sess = sessions.get(relayDir);
287
+ if (sess) {
288
+ try { if (sess.timer) clearInterval(sess.timer); } catch { /* 忽略 */ }
289
+ sess.timer = null;
290
+ sessions.delete(relayDir);
291
+ }
292
+ }
293
+
294
+ function setLastResult(relayDir, result) {
295
+ lastResults.set(relayDir, { ...result, at: Date.now() });
296
+ while (lastResults.size > 50) {
297
+ const first = lastResults.keys().next();
298
+ if (first.done) break;
299
+ lastResults.delete(first.value);
300
+ }
301
+ }
302
+
303
+ async function jsonPost(fetchImpl, url, payload) {
304
+ const res = await fetchImpl(url, {
305
+ method: "POST",
306
+ headers: { "content-type": "application/json", accept: "application/json" },
307
+ body: JSON.stringify(payload || {}),
308
+ signal: typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
309
+ ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) : undefined
310
+ });
311
+ let body = null;
312
+ try { body = res && typeof res.json === "function" ? await res.json() : null; } catch { body = null; }
313
+ return { ok: Boolean(res && res.ok), status: Number(res && res.status) || 0, body: body && typeof body === "object" ? body : {} };
314
+ }
315
+
316
+ /** 把扫码拿到的凭据写进 im-bots.json 的 dingtalk 段(0600 原子写;保留其它段/字段,自动 enabled)。 */
317
+ function saveScannedCredentials(relayDir, clientId, clientSecret, onWarn) {
318
+ const cid = String(clientId || "").trim();
319
+ const csec = String(clientSecret || "").trim();
320
+ if (!cid || !csec) return { ok: false, code: "bad_credentials", message: human("bad_credentials") };
321
+ try {
322
+ const file = path.join(String(relayDir), IM_BOTS_FILE);
323
+ const prev = readJsonFile(file);
324
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
325
+ const cur = base.dingtalk && typeof base.dingtalk === "object" ? base.dingtalk : {};
326
+ const dingtalk = {
327
+ ...cur,
328
+ enabled: true,
329
+ clientId: cid,
330
+ clientSecret: csec,
331
+ credentialSource: "scan"
332
+ };
333
+ // 扫码流程只给 client_id/client_secret:robotCode 缺省用 clientId(Lead 2026-11-08 约定;
334
+ // 用户后续可在面板里改成正真的机器人编码 —— openapi/卡片能力由 dingtalk-bot 的 capabilities 表达)。
335
+ if (!String(dingtalk.robotCode || "").trim()) dingtalk.robotCode = cid;
336
+ atomicWriteJson(file, { ...base, version: 1, dingtalk }, { onWarn });
337
+ return { ok: true, clientIdTail: cid.length > 4 ? cid.slice(-4) : cid };
338
+ } catch (e) {
339
+ return { ok: false, code: "write_failed", message: human("write_failed", e && e.message) };
340
+ }
341
+ }
342
+
343
+ function expireAuth(relayDir, sess) {
344
+ stopSession(relayDir);
345
+ removeAuthFile(relayDir);
346
+ const message = human("expired");
347
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
348
+ setLastResult(relayDir, { status: "expired", message, code: "expired" });
349
+ syncAuthStatus(relayDir, sess && sess.onWarn);
350
+ if (sess) sess.log.info(`扫码授权会话已过期(device_code 清理,可在面板重新发起)`);
351
+ }
352
+
353
+ function finishFailure(relayDir, sess, { code, message }) {
354
+ stopSession(relayDir);
355
+ removeAuthFile(relayDir);
356
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
357
+ setLastResult(relayDir, { status: "failed", code, message });
358
+ syncAuthStatus(relayDir, sess && sess.onWarn);
359
+ sess.log.warn(`扫码授权失败:${code || "?"} — ${message}`);
360
+ }
361
+
362
+ function finishSuccess(relayDir, sess, { clientId, clientSecret }) {
363
+ const saved = saveScannedCredentials(relayDir, clientId, clientSecret, sess && sess.onWarn);
364
+ stopSession(relayDir);
365
+ if (!saved.ok) {
366
+ removeAuthFile(relayDir);
367
+ const message = saved.message || human("write_failed");
368
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, sess && sess.onWarn);
369
+ setLastResult(relayDir, { status: "failed", code: saved.code || "write_failed", message });
370
+ syncAuthStatus(relayDir, sess && sess.onWarn);
371
+ sess.log.warn(`扫码凭据保存失败:${message}`);
372
+ return;
373
+ }
374
+ // 先删 auth 文件(凭据已落盘;device_code 不再需要)再写状态
375
+ removeAuthFile(relayDir);
376
+ patchDingtalkStatus(relayDir, { state: "connected", lastError: "", credentialSource: "scan" }, sess && sess.onWarn);
377
+ setLastResult(relayDir, { status: "success", credentialSource: "scan", clientIdTail: saved.clientIdTail, message: human("success") });
378
+ syncAuthStatus(relayDir, sess && sess.onWarn);
379
+ sess.log.info(`扫码授权成功:机器人凭据已写入 im-bots.json(clientId 尾号 ${saved.clientIdTail},credentialSource=scan)`);
380
+ if (typeof sess.onSuccess === "function") {
381
+ try { sess.onSuccess({ clientIdTail: saved.clientIdTail, credentialSource: "scan" }); } catch { /* 忽略 */ }
382
+ }
383
+ }
384
+
385
+ async function pollOnce(relayDir, sess) {
386
+ if (sessions.get(relayDir) !== sess) return; // 已被新会话取代
387
+ if (sess.inFlight) return;
388
+ const at = sess.clock();
389
+ if (at >= sess.expiresAt) { expireAuth(relayDir, sess); return; }
390
+ sess.inFlight = true;
391
+ let r;
392
+ try {
393
+ r = await jsonPost(sess.fetchImpl, `${DINGTALK_REG_BASE}/poll`, { device_code: sess.deviceCode });
394
+ } catch {
395
+ sess.networkErrors += 1;
396
+ if (sess.networkErrors === 1) sess.log.warn("轮询钉钉接口网络异常:继续重试直到授权过期(不会丢会话)");
397
+ return;
398
+ } finally {
399
+ sess.inFlight = false;
400
+ }
401
+ if (sessions.get(relayDir) !== sess) return; // 期间被 begin 覆盖
402
+ const body = r.body || {};
403
+ const status = String(body.status || "").toUpperCase();
404
+ const clientId = body.client_id || body.clientId || "";
405
+ const clientSecret = body.client_secret || body.clientSecret || "";
406
+ if (status === "SUCCESS" || (clientId && clientSecret)) {
407
+ finishSuccess(relayDir, sess, { clientId, clientSecret });
408
+ return;
409
+ }
410
+ if (status === "FAIL" || status === "EXPIRED" || status === "REJECTED") {
411
+ finishFailure(relayDir, sess, {
412
+ code: status === "EXPIRED" ? "expired" : "rejected",
413
+ message: human(status === "EXPIRED" ? "expired" : "rejected", body.message || body.errmsg)
414
+ });
415
+ return;
416
+ }
417
+ if (status === "WAITING" || status === "PENDING" || status === "") {
418
+ sess.waits += 1;
419
+ syncAuthStatus(relayDir, sess.onWarn); // 顺带刷新 dingtalk.auth(remainingSec 随时间减少)
420
+ return; // 用户还没在手机钉钉里点「一键创建」
421
+ }
422
+ // 未知/异常响应:宽容地继续等(直到过期),避免一次怪响应把会话作废
423
+ sess.waits += 1;
424
+ }
425
+
426
+ function startPolling(relayDir, sess) {
427
+ stopSession(relayDir);
428
+ sessions.set(relayDir, sess);
429
+ const tick = () => { void pollOnce(relayDir, sess).catch(() => { /* 轮询内部已兜住 */ }); };
430
+ sess.timer = setInterval(tick, sess.pollMs);
431
+ if (typeof sess.timer.unref === "function") sess.timer.unref();
432
+ // 立刻先轮一次:用户在 begin 之前就点完确认也不耽误
433
+ void pollOnce(relayDir, sess).catch(() => { /* 忽略 */ });
434
+ }
435
+
436
+ function makeSession({ relayDir, session, fetchImpl, logger, onSuccess, clock, pollMs, onWarn }) {
437
+ const log = createScopedLogger({ sink: logger, prefix: "[dingtalk-auth]" });
438
+ const now = clock();
439
+ const gen = ++generationSeq;
440
+ return {
441
+ gen,
442
+ relayDir,
443
+ deviceCode: session.deviceCode,
444
+ expiresAt: session.expiresAt,
445
+ url: session.url,
446
+ fetchImpl,
447
+ logger,
448
+ onSuccess,
449
+ onWarn,
450
+ clock,
451
+ log,
452
+ pollMs: Math.max(10, Number(pollMs) || Math.max(1000, Number(session.interval || DEFAULT_POLL_SECONDS) * 1000)),
453
+ inFlight: false,
454
+ waits: 0,
455
+ networkErrors: 0
456
+ };
457
+ }
458
+
459
+ /**
460
+ * 开始一次扫码授权:init → begin → **把会话原子落盘** → 后台按 interval 轮询。
461
+ *
462
+ * 同一 relayDir 同时只保留一个会话:新的 begin 会作废旧会话(旧 auth 文件被覆盖/删除)。
463
+ * @returns {Promise<{ok:boolean, url?:string, expiresIn?:number, interval?:number, expiresAt?:number, code?:string, message?:string}>}
464
+ */
465
+ export async function beginDingtalkAuth(options = {}) {
466
+ const relayDir = String(options.relayDir || "");
467
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
468
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
469
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
470
+ if (typeof fetchImpl !== "function") return { ok: false, code: "network_error", message: human("network_error") };
471
+ // 覆盖旧会话(并发 begin):先停表、清旧文件,再发新请求 —— 旧链接从此失效
472
+ stopSession(relayDir);
473
+ removeAuthFile(relayDir);
474
+
475
+ let init;
476
+ try { init = await jsonPost(fetchImpl, `${DINGTALK_REG_BASE}/init`, { source: "DING_DWS_CLAW" }); }
477
+ catch { return { ok: false, code: "network_error", message: human("network_error") }; }
478
+ const nonce = init.ok && init.body && typeof init.body.nonce === "string" ? init.body.nonce : "";
479
+ if (!nonce) {
480
+ const message = human("rejected", init.body && (init.body.message || init.body.errmsg));
481
+ setLastResult(relayDir, { status: "failed", code: "rejected", message });
482
+ return { ok: false, code: "rejected", message };
483
+ }
484
+ let begin;
485
+ try { begin = await jsonPost(fetchImpl, `${DINGTALK_REG_BASE}/begin`, { nonce }); }
486
+ catch { return { ok: false, code: "network_error", message: human("network_error") }; }
487
+ const body = begin.body || {};
488
+ const deviceCode = typeof body.device_code === "string" ? body.device_code : "";
489
+ const url = typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : "";
490
+ if (!begin.ok || !deviceCode || !url) {
491
+ const message = human("rejected", body.message || body.errmsg);
492
+ setLastResult(relayDir, { status: "failed", code: "rejected", message });
493
+ return { ok: false, code: "rejected", message };
494
+ }
495
+ const interval = Math.max(1, Math.min(10, Number(body.interval) || DEFAULT_POLL_SECONDS));
496
+ const expiresIn = Math.max(60, Math.min(7200, Number(body.expires_in) || DINGTALK_AUTH_TTL_MS / 1000));
497
+ const at = nowOf(options);
498
+ const expiresAt = at + Math.min(expiresIn * 1000, DINGTALK_AUTH_TTL_MS);
499
+ const persisted = { deviceCode, interval, expiresAt, url, createdAt: at, credentialSource: "scan" };
500
+ try {
501
+ atomicWriteJson(authFileOf(relayDir), { version: 1, ...persisted }, { onWarn: options.onWarn });
502
+ } catch (e) {
503
+ return { ok: false, code: "write_failed", message: human("write_failed", e && e.message) };
504
+ }
505
+ const sess = makeSession({
506
+ relayDir,
507
+ session: persisted,
508
+ fetchImpl,
509
+ logger: options.logger,
510
+ onSuccess: options.onSuccess,
511
+ clock: () => nowOf(options),
512
+ pollMs: options.pollMs,
513
+ onWarn: options.onWarn
514
+ });
515
+ startPolling(relayDir, sess);
516
+ setLastResult(relayDir, { status: "waiting", url, expiresAt });
517
+ syncAuthStatus(relayDir, options.onWarn);
518
+ log.info(`已生成扫码授权链接(device_code 已持久化,会话有效期 ${Math.round((expiresAt - at) / 1000)}s;关面板/重启桥都不丢)`);
519
+ return { ok: true, url, expiresIn: Math.round((expiresAt - at) / 1000), interval, expiresAt };
520
+ }
521
+
522
+ /**
523
+ * 断点续轮询:bridge 启动时调用。存在未过期会话 → 继续按 interval poll;
524
+ * 成功后写 `im-bots.json`(credentialSource:"scan")、删 auth 文件、写状态 `state="connected"`。
525
+ * 不存在/已过期 → 返回 `{ok:false, code:"no_session"|"expired"}`(过期会顺手清理文件与状态)。
526
+ */
527
+ export async function resumeDingtalkAuth(options = {}) {
528
+ const relayDir = String(options.relayDir || "");
529
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
530
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
531
+ const log = createScopedLogger({ sink: options.logger, prefix: "[dingtalk-auth]" });
532
+ const at = nowOf(options);
533
+ const session = readDingtalkAuthSession(relayDir);
534
+ if (!session) return { ok: false, code: "no_session", message: human("no_session") };
535
+ if (!session.expiresAt || at >= session.expiresAt) {
536
+ removeAuthFile(relayDir);
537
+ const message = human("expired");
538
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
539
+ setLastResult(relayDir, { status: "expired", code: "expired", message });
540
+ log.info("发现已过期的扫码授权会话,已清理(可在面板重新发起)");
541
+ return { ok: false, code: "expired", message };
542
+ }
543
+ if (sessions.has(relayDir)) {
544
+ const cur = sessions.get(relayDir);
545
+ return {
546
+ ok: true,
547
+ status: "waiting",
548
+ url: session.url,
549
+ remainingSeconds: Math.max(0, Math.ceil((session.expiresAt - at) / 1000)),
550
+ pollMs: cur.pollMs
551
+ };
552
+ }
553
+ const sess = makeSession({
554
+ relayDir,
555
+ session,
556
+ fetchImpl,
557
+ logger: options.logger,
558
+ onSuccess: options.onSuccess,
559
+ clock: () => nowOf(options),
560
+ pollMs: options.pollMs,
561
+ onWarn: options.onWarn
562
+ });
563
+ startPolling(relayDir, sess);
564
+ setLastResult(relayDir, { status: "waiting", url: session.url, expiresAt: session.expiresAt });
565
+ syncAuthStatus(relayDir, options.onWarn);
566
+ log.info(`续接上次未完成的扫码授权(剩余 ${Math.round((session.expiresAt - at) / 1000)}s,device_code 来自磁盘)`);
567
+ return { ok: true, status: "waiting", url: session.url, remainingSeconds: Math.ceil((session.expiresAt - at) / 1000), interval: session.interval, pollMs: sess.pollMs };
568
+ }
569
+
570
+ /**
571
+ * 查询授权状态(给插件读):waiting/success/failed/expired/idle + 剩余秒数。
572
+ * 只读,不会重启已停止的轮询(bridge 重启后由 resumeDingtalkAuth 负责续上)。
573
+ */
574
+ export function authStatus(options = {}) {
575
+ const relayDir = String(options.relayDir || "");
576
+ const at = nowOf(options);
577
+ if (!relayDir) return { ok: false, status: "idle", remainingSeconds: 0, message: human("no_relay_dir") };
578
+ const session = readDingtalkAuthSession(relayDir);
579
+ if (session) {
580
+ if (at < session.expiresAt) {
581
+ return {
582
+ ok: true,
583
+ status: "waiting",
584
+ remainingSeconds: Math.max(0, Math.ceil((session.expiresAt - at) / 1000)),
585
+ url: session.url,
586
+ interval: session.interval,
587
+ expiresAt: session.expiresAt,
588
+ hasSecret: false
589
+ };
590
+ }
591
+ // 过期:顺手清理(读接口也允许做这种幂等清理;结果仍如实返回)
592
+ stopSession(relayDir);
593
+ removeAuthFile(relayDir);
594
+ const message = human("expired");
595
+ setLastResult(relayDir, { status: "expired", code: "expired", message });
596
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
597
+ return { ok: false, status: "expired", remainingSeconds: 0, message };
598
+ }
599
+ const last = lastResults.get(relayDir);
600
+ if (last && at - last.at < LAST_RESULT_TTL_MS) {
601
+ return { ...last, ok: last.status === "success", status: last.status, remainingSeconds: 0 };
602
+ }
603
+ return { ok: false, status: "idle", remainingSeconds: 0 };
604
+ }
605
+
606
+ /** 用户显式取消:停轮询 + 删 auth 文件 + 状态标记取消。 */
607
+ export function cancelDingtalkAuth(options = {}) {
608
+ const relayDir = String(options.relayDir || "");
609
+ if (!relayDir) return { ok: false, code: "no_relay_dir", message: human("no_relay_dir") };
610
+ stopSession(relayDir);
611
+ removeAuthFile(relayDir);
612
+ const message = human("cancelled");
613
+ setLastResult(relayDir, { status: "failed", code: "cancelled", message });
614
+ patchDingtalkStatus(relayDir, { state: "failed", lastError: message, credentialSource: "scan" }, options.onWarn);
615
+ syncAuthStatus(relayDir, options.onWarn);
616
+ return { ok: true };
617
+ }
618
+
619
+ /** 只停定时器(**保留** auth 文件):bridge 正常退出/配置变化时调用,下次启动可续轮询。 */
620
+ export function stopDingtalkAuthPolling() {
621
+ for (const relayDir of [...sessions.keys()]) stopSession(relayDir);
622
+ }
623
+
624
+ /** 测试用:清空内存态(定时器 + 最近结论),不动文件。 */
625
+ export function __resetDingtalkAuthForTest() {
626
+ stopDingtalkAuthPolling();
627
+ stopDingtalkAuthCommandWatcher();
628
+ lastResults.clear();
629
+ lastAuthSync.clear();
630
+ generationSeq = 0;
631
+ }