@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.12-beta.1

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.
Files changed (74) hide show
  1. package/clients/dsh-remote/agent/README.md +412 -0
  2. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  3. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  4. package/clients/dsh-remote/agent/codex.mjs +736 -0
  5. package/clients/dsh-remote/agent/detect.mjs +399 -0
  6. package/clients/dsh-remote/agent/events.mjs +511 -0
  7. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  8. package/clients/dsh-remote/agent/index.mjs +339 -0
  9. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  10. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  11. package/clients/dsh-remote/agent/proc.mjs +179 -0
  12. package/clients/dsh-remote/agent/version.mjs +148 -0
  13. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  14. package/clients/dsh-remote/decision-poller.mjs +235 -0
  15. package/clients/dsh-remote/dsh-bridge.mjs +220 -1
  16. package/clients/dsh-remote/im-format.mjs +372 -0
  17. package/clients/dsh-remote/push-reporter.mjs +54 -0
  18. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  19. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  20. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  21. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  22. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  23. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  24. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  25. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  26. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  27. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  28. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  29. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  30. package/clients/dsh-remote/test/im-format.test.mjs +229 -0
  31. package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
  32. package/clients/dsh-remote/test/wecom-bot.test.mjs +1718 -0
  33. package/clients/dsh-remote/upstream-discovery.mjs +495 -43
  34. package/clients/dsh-remote/wecom-bot.mjs +2670 -0
  35. package/dsh-setup.mjs +95 -9
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +1377 -292
  38. package/packages/dsh-remote-web/lib/index.js +419 -12
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +18 -3
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
  59. package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
  60. package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
  61. package/packages/dsh-remote-web/test/im-bots.test.mjs +400 -0
  62. package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
  63. package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
  64. package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +17 -6
  65. package/packages/dsh-remote-web/test/panel-ia.test.mjs +996 -0
  66. package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
  67. package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
  68. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +14 -5
  69. package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +69 -119
  70. package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
  71. package/packages/dsh-remote-web/test/settings-entry.test.mjs +42 -25
  72. package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
  73. package/packages/dsh-remote-web/test/telemetry.test.mjs +6 -4
  74. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
@@ -0,0 +1,2444 @@
1
+ /**
2
+ * wecom-bot — 企业微信「智能机器人」WebSocket 长连接通道(bridge 侧,2026-11-07 / task-21)。
3
+ *
4
+ * 为什么是长连接而不是 webhook 群机器人(业主 2026-11-07 纠正方向)
5
+ * -----------------------------------------------------------------
6
+ * 群机器人 webhook 只能「发」不能「收」,而且收消息要公网回调地址;企业微信「智能机器人」
7
+ * 支持 Bot ID + Secret 直连官方 WebSocket,电脑在线就能双向联动(收消息 / 被动回复 / 主动推送)。
8
+ *
9
+ * 协议事实(Lead 已用真实凭据实测通过,此处照抄,勿凭记忆改)
10
+ * -----------------------------------------------------------------
11
+ * 端点 wss://openws.work.weixin.qq.com(私有部署企业不同 → `url` 可配置)
12
+ * 认证 → {cmd:"aibot_subscribe", headers:{req_id}, body:{secret, bot_id}}
13
+ * ← {headers:{req_id}, errcode:0, errmsg:"ok"} errcode≠0 = 凭据错(不可重试)
14
+ * 心跳 → {cmd:"ping", headers:{req_id}},间隔 30s;连续 2 次无 ack 判死重连
15
+ * 入站消息 ← {cmd:"aibot_msg_callback", headers:{req_id}, body:{msgid, msgtype, text:{content}, …}}
16
+ * 入站事件 ← {cmd:"aibot_event_callback", …}(enter_chat / template_card_event / feedback_event …)
17
+ * 被动回复 → {cmd:"aibot_respond_msg", headers:{req_id: 入站帧的 req_id},
18
+ * body:{msgtype:"stream", stream:{id, finish, content}}}
19
+ * 主动推送 → {cmd:"aibot_send_msg", headers:{req_id}, body:{chatid, msgtype:"markdown", markdown:{content}}}
20
+ * · 单聊 chatid = 对方 userid;群聊 chatid = 群 chatid
21
+ *
22
+ * ⚠️ 必须 `import WebSocket from "ws"`,**不要用 Node 全局 WebSocket**(本机实测 1006 失败)。
23
+ * `ws` 已是本项目运行时依赖(`clients/dsh-remote/package.json` + 插件自带 runtime 都带)。
24
+ *
25
+ * 设计边界(每一条都重要)
26
+ * -----------------------------------------------------------------
27
+ * 1. **默认关闭**:没有 `im-bots.json` / 未启用 / 缺 botId+secret → 什么都不起(零副作用)。
28
+ * 2. **凭据错 ≠ 网络断**:errcode≠0 → 立即停止并记明确错误,**绝不无限重连**(否则会被官方限流/封禁);
29
+ * 网络断/心跳超时 → 指数退避 1s→2s→…→30s 重连。
30
+ * 3. **secret 绝不进日志**:本模块只打印 cmd/errcode/chatid 哈希;日志器再做一层字面量脱敏兜底。
31
+ * 4. **不阻塞主链路**:所有失败都吞掉只记日志;通知发送 fire-and-forget,绝不影响隧道。
32
+ * 5. **文件按契约**:`im-bots.json`(插件写,桥只读)/ `im-bot-status.json`(桥写,原子替换 + 0600)。
33
+ *
34
+ * 对外 API(task-21 规格)
35
+ * -----------------------------------------------------------------
36
+ * createWecomBot({botId, secret, url?, logger?, clock?, wsFactory?, ...})
37
+ * → {start, stop, sendMarkdown(chatid, text), replyStream(frame, id, content, finish), status(), on(evt)}
38
+ * createWecomBotController({relayDir, ...})
39
+ * → {start, stop, status()} // 读配置 + fs.watchFile(2s) 热更新;bridge 只需要它
40
+ * 纯函数(便于单测,不依赖网络):normalizeImBotsConfig / readImBotsConfig /
41
+ * normalizeAllowList / extractInboundText / pickInboundIdentity / toMarkdownNotification /
42
+ * atomicWriteJson / hardenFile / maskSecrets
43
+ */
44
+
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import crypto from "node:crypto";
48
+ import { spawnSync } from "node:child_process";
49
+ import { EventEmitter } from "node:events";
50
+
51
+ import WebSocket from "ws";
52
+
53
+ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
+
55
+ // ===========================================================================
56
+ // 1. 常量 / 契约文件名
57
+ // ===========================================================================
58
+
59
+ /** 官方智能机器人长连接地址(企业私有部署可能不同 → 配置里可覆盖)。 */
60
+ export const DEFAULT_WECOM_WS_URL = "wss://openws.work.weixin.qq.com";
61
+ /** 配置文件(**插件写、桥只读**;内含明文 secret,必须 0600)。 */
62
+ export const IM_BOTS_FILE = "im-bots.json";
63
+ /** 状态文件(**桥写、插件读**;原子替换 + 0600,绝不含 secret)。 */
64
+ export const IM_BOT_STATUS_FILE = "im-bot-status.json";
65
+
66
+ /** 心跳间隔 / 连续未 ack 判死次数(协议事实:30s,连续 2 次)。 */
67
+ export const HEARTBEAT_MS = 30_000;
68
+ export const HEARTBEAT_MAX_MISS = 2;
69
+ /** 认证应答超时:超时按「网络问题」重连(不按凭据错)。 */
70
+ export const AUTH_TIMEOUT_MS = 10_000;
71
+ /** 重连退避:1s 起、翻倍、30s 封顶。 */
72
+ export const RECONNECT_BASE_MS = 1_000;
73
+ export const RECONNECT_MAX_MS = 30_000;
74
+ /** 配置热更新轮询(fs.watchFile interval)。 */
75
+ export const CONFIG_POLL_MS = 2_000;
76
+ /** 主动推送等 ack 的超时(只用于记账/日志,不阻塞)。 */
77
+ export const SEND_ACK_TIMEOUT_MS = 15_000;
78
+ /** 通知正文上限(企业微信 markdown 单条有长度限制,这里先截)。 */
79
+ export const NOTIFY_MAX_CHARS = 600;
80
+ /** 「回答回传」:入站后多久内到达的 turn/end 才回传正文(超时视为另一次对话 → 回到标题通知)。 */
81
+ export const ANSWER_RELAY_TTL_MS = 10 * 60 * 1000;
82
+ /** 「回答回传」正文上限;超出截断并注明。 */
83
+ export const ANSWER_MAX_CHARS = 1500;
84
+ /** 截断提示(用户能看懂、也知道去哪看全文)。 */
85
+ export const ANSWER_TRUNCATED_NOTE = "…(已截断,完整内容看电脑)";
86
+ /**
87
+ * 取「最终回答」的重试间隔(ms)。
88
+ * 为什么要有:turn/end 与"最终消息已落进会话历史"之间可能有极短竞态 —— 第一次 pageSession
89
+ * 拿到的是空/上一条。只在「ok 但没有正文」时补一次(400ms),拿到就发;这是唯一会多等的场景。
90
+ */
91
+ export const ANSWER_FETCH_RETRY_DELAYS_MS = Object.freeze([0, 400]);
92
+
93
+ /* ── 审批卡片(task-24)────────────────────────────────────────────────────────
94
+ * 协议来自官方 @wecom/aibot-node-sdk 类型定义(Lead 已核实):
95
+ * 发卡:{cmd:"aibot_send_msg", headers:{req_id}, body:{chatid, msgtype:"template_card", template_card:{...}}}
96
+ * 点按钮 → 入站 {cmd:"aibot_event_callback", body:{event:{eventtype:"template_card_event", event_key, task_id}}}
97
+ * 更新:{cmd:"aibot_respond_update_msg", headers:{req_id:事件帧的req_id},
98
+ * body:{response_type:"update_template_card", template_card:{...改后的卡...}}}
99
+ */
100
+ /** 审批卡 TTL(与系统一致 90s):过期后再点按「拒绝」处理(fail-closed)。 */
101
+ export const APPROVAL_CARD_TTL_MS = 90_000;
102
+ /** 卡片摘要上限(官方 sub_title_text 限 112 字)。 */
103
+ export const APPROVAL_CARD_SUMMARY_MAX = 112;
104
+ /** task_id 上限(官方:字母数字-_@,≤128)。 */
105
+ export const APPROVAL_CARD_TASK_ID_MAX = 128;
106
+ /** 审批卡在内存里最多保留多少张(超出丢最旧;只影响「点旧卡」时的幂等/过期判定)。 */
107
+ export const APPROVAL_CARD_MAX = 50;
108
+ /** 状态文件 `sessions[]` 上限(task-24:≤10,只含 id/title/updatedAt)。 */
109
+ export const SESSION_LIST_MAX = 10;
110
+ /** 预览卡片(自检/演示):内存里最多记住几张(独立于真实审批,绝不进幂等表)。 */
111
+ export const PREVIEW_CARD_MAX = 10;
112
+ /** 会话列表(含标题/updatedAt)的刷新节流;会话事件很密,避免每来一条就查一次 DSH。 */
113
+ export const SESSION_LIST_REFRESH_MS = 30_000;
114
+ /** 入站 msgid 去重表上限 / TTL(重连后官方可能补发同一条)。 */
115
+ export const SEEN_MSG_MAX = 200;
116
+ export const SEEN_MSG_TTL_MS = 10 * 60 * 1000;
117
+ /** 日志行上限(内存里留最近 N 行,供测试断言「secret 从未出现」)。 */
118
+ export const LOG_MAX_LINES = 200;
119
+
120
+ const WS_OPEN = 1;
121
+
122
+ // ===========================================================================
123
+ // 2. 小工具:脱敏 / 日志 / 文件
124
+ // ===========================================================================
125
+
126
+ /** 类似命令行的敏感字段(`secret=xxx` / `"secret":"xxx"` / `bot_id=xxx`)一律抹掉。 */
127
+ const SECRET_FIELD_RE = /("?(?:secret|bot_id|botid|app_secret|appsecret|token|password)"?\s*[:=]\s*)("?)([^",\s}]+)\2/gi;
128
+
129
+ /**
130
+ * 把已知密钥字面量与「像密钥的字段」都抹成 ***(日志的最后一道防线)。
131
+ * @param {string} text
132
+ * @param {string[]} [knownSecrets]
133
+ * @returns {string}
134
+ */
135
+ export function maskSecrets(text, knownSecrets = []) {
136
+ let out = String(text == null ? "" : text);
137
+ for (const s of knownSecrets) {
138
+ const v = String(s || "");
139
+ if (v.length < 4) continue;
140
+ out = out.split(v).join("***");
141
+ }
142
+ return out.replace(SECRET_FIELD_RE, (_m, head) => `${head}"***"`).replace(/(secret|bot_id|botid)=\S+/gi, (_m, k) => `${k}=***`);
143
+ }
144
+
145
+ /**
146
+ * chatid 的短哈希:日志里只打这个(chatid 是 userid/群 id,属于可识别信息)。
147
+ * @param {string} chatid
148
+ * @returns {string} 空串或 8 位十六进制
149
+ */
150
+ export function chatIdHash(chatid) {
151
+ const v = String(chatid || "").trim();
152
+ if (!v) return "";
153
+ try {
154
+ return crypto.createHash("sha1").update(v).digest("hex").slice(0, 8);
155
+ } catch {
156
+ return "????????";
157
+ }
158
+ }
159
+
160
+ /** 截断(单行化),用于错误/标题。 */
161
+ export function truncateText(value, max = 120) {
162
+ const s = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
163
+ if (s.length <= max) return s;
164
+ return s.slice(0, Math.max(0, max - 1)) + "…";
165
+ }
166
+
167
+ /**
168
+ * 极简作用域日志器(与 wechat-channel 的 createLogger 同思路:内存留痕 + 全量脱敏)。
169
+ *
170
+ * 绝不打印完整帧:本模块自己只传 cmd/errcode/chatid 哈希/短原因;日志器再兜一层字面量脱敏,
171
+ * 所以即使调用方手滑把 secret 拼进消息,也不会落盘(`logger.lines` 供测试钉死这一点)。
172
+ *
173
+ * @param {{sink?: Function|object, secrets?: string[], prefix?: string, maxLines?: number}} opts
174
+ * @returns {{info:Function,warn:Function,error:Function,debug:Function,lines:string[]}}
175
+ */
176
+ export function createScopedLogger(opts = {}) {
177
+ const prefix = String(opts.prefix || "[wecom]");
178
+ const maxLines = Number.isFinite(opts.maxLines) ? Math.max(1, opts.maxLines) : LOG_MAX_LINES;
179
+ const known = Array.isArray(opts.secrets) ? opts.secrets.filter((s) => typeof s === "string" && s.length >= 4) : [];
180
+ const sink = opts.sink;
181
+ const lines = [];
182
+ const push = (level, message) => {
183
+ let line;
184
+ try {
185
+ line = `${prefix} ${level} ${maskSecrets(message, known)}`;
186
+ } catch {
187
+ line = `${prefix} ${level} <log-failed>`;
188
+ }
189
+ lines.push(line);
190
+ if (lines.length > maxLines) lines.splice(0, lines.length - maxLines);
191
+ try {
192
+ if (typeof sink === "function") sink(level, line);
193
+ else if (sink && typeof sink[level] === "function") sink[level](line);
194
+ } catch {
195
+ /* 日志失败绝不影响业务 */
196
+ }
197
+ };
198
+ return {
199
+ info: (m) => push("info", m),
200
+ warn: (m) => push("warn", m),
201
+ error: (m) => push("warn", m), // bridge 日志里 error 也落 warn(保持与其它模块一致)
202
+ debug: (m) => push("debug", m),
203
+ lines
204
+ };
205
+ }
206
+
207
+ /**
208
+ * 把文件权限收紧到「只有本人可读写」。
209
+ *
210
+ * ⚠️ Windows 上 `{mode:0o600}` 是空操作(Windows 用 ACL):实测默认拿到的是用户目录继承 ACL,
211
+ * 备份/同步盘/支持包都会连 secret 一起带走。这里显式断继承、只授当前用户;失败只告警一次。
212
+ * (与 wechat-channel/dsh-bridge 里的同名实现等价 —— 本模块必须零内部依赖、可独立单测。)
213
+ *
214
+ * @param {string} file
215
+ * @param {(msg:string)=>void} [onWarn]
216
+ */
217
+ let hardenWarned = false;
218
+ export function hardenFile(file, onWarn) {
219
+ try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
220
+ if (process.platform !== "win32") return;
221
+ const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
222
+ if (!who) {
223
+ if (!hardenWarned) {
224
+ hardenWarned = true;
225
+ const msg = `无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`;
226
+ if (typeof onWarn === "function") onWarn(msg); else console.warn(`⚠️ ${msg}`);
227
+ }
228
+ return;
229
+ }
230
+ let r;
231
+ try {
232
+ r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
233
+ { windowsHide: true, encoding: "utf8", timeout: 8000 });
234
+ } catch (e) { r = { status: -1, stderr: e.message }; }
235
+ if (r.status !== 0 && !hardenWarned) {
236
+ hardenWarned = true;
237
+ const msg = `收紧文件权限失败(${who}):${String(r.stderr || "").trim() || `icacls 退出码 ${r.status}`}`;
238
+ if (typeof onWarn === "function") onWarn(msg); else console.warn(`⚠️ ${msg}`);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * 原子写 JSON:同目录临时文件 → rename(同文件系统 rename 是原子的)。
244
+ *
245
+ * 为什么必须原子:插件每 2s 左右读一次状态文件渲染 UI;直接 writeFileSync 有「读到写了一半的
246
+ * 半个 JSON」的窗口,而那份文件是面板判断「机器人是否在线」的唯一依据。
247
+ * 失败时清理临时文件,**绝不留下半成品**。
248
+ *
249
+ * @param {string} file
250
+ * @param {unknown} data
251
+ * @param {{onWarn?:(m:string)=>void}} [opts]
252
+ */
253
+ export function atomicWriteJson(file, data, opts = {}) {
254
+ const dir = path.dirname(file);
255
+ fs.mkdirSync(dir, { recursive: true });
256
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
257
+ try {
258
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
259
+ hardenFile(tmp, opts.onWarn);
260
+ fs.renameSync(tmp, file); // 原子替换
261
+ hardenFile(file, opts.onWarn);
262
+ } catch (e) {
263
+ try { fs.rmSync(tmp, { force: true }); } catch { /* 忽略 */ }
264
+ throw e;
265
+ }
266
+ }
267
+
268
+ /** 读 JSON 文件(不存在/坏 JSON → null,绝不抛)。 */
269
+ export function readJsonFile(file) {
270
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
271
+ }
272
+
273
+ /** 读文件的 POSIX 权限位(Windows 上不可信,测试里按平台跳过断言)。 */
274
+ export function fileMode(file) {
275
+ try { return fs.statSync(file).mode & 0o777; } catch { return null; }
276
+ }
277
+
278
+ // ===========================================================================
279
+ // 3. 配置契约:im-bots.json(插件写 / 桥读)
280
+ // ===========================================================================
281
+
282
+ /**
283
+ * `im-bots.json` 的规范形状(缺字段一律补默认;非法值当未配置,**绝不抛**):
284
+ *
285
+ * ```json
286
+ * {
287
+ * "version": 1,
288
+ * "wecom": {
289
+ * "enabled": true,
290
+ * "botId": "",
291
+ * "secret": "",
292
+ * "url": "", // 可选:私有部署企业填自己的长连接地址
293
+ * "targetChatId": "", // 可选:主动推送的兜底接收方(userid 或群 chatid)
294
+ * "allowFrom": [], // 可选:入站白名单(userid / chatid),空 = 首次发送者自动绑定
295
+ * "sessionId": "", // 可选:**固定转发目标会话**(空 = 跟随当前活跃会话)
296
+ * "sessionTitle": "", // 可选:上面那个会话的显示名(只用于状态文件/面板展示)
297
+ * "previewCardAt": 0 // 可选:**发送预览卡片**的触发时间戳(ms;插件写、桥读)
298
+ * },
299
+ * "feishu": { "enabled": false, "appId": "", "appSecret": "" }
300
+ * }
301
+ * ```
302
+ *
303
+ * @param {any} raw
304
+ * @returns {{version:number, wecom:{enabled:boolean,botId:string,secret:string,url:string,targetChatId:string,allowFrom:string[],sessionId:string,sessionTitle:string,previewCardAt:number}, feishu:{enabled:boolean,appId:string,appSecret:string}}}
305
+ */
306
+ export function normalizeImBotsConfig(raw) {
307
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
308
+ const w = src.wecom && typeof src.wecom === "object" && !Array.isArray(src.wecom) ? src.wecom : {};
309
+ const f = src.feishu && typeof src.feishu === "object" && !Array.isArray(src.feishu) ? src.feishu : {};
310
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
311
+ return {
312
+ version: Number.isFinite(Number(src.version)) ? Number(src.version) : 1,
313
+ wecom: {
314
+ enabled: w.enabled === true,
315
+ botId: str(w.botId),
316
+ secret: typeof w.secret === "string" ? w.secret : "",
317
+ url: str(w.url),
318
+ targetChatId: str(w.targetChatId),
319
+ allowFrom: normalizeAllowList(w.allowFrom),
320
+ // 会话可选绑定(task-24):空 = 跟随「最近活跃会话」
321
+ sessionId: str(w.sessionId),
322
+ sessionTitle: str(w.sessionTitle),
323
+ // 预览卡片触发时间戳(ms):插件每点一次「发送预览」就写一个新值
324
+ previewCardAt: Number.isFinite(Number(w.previewCardAt)) && Number(w.previewCardAt) > 0 ? Number(w.previewCardAt) : 0
325
+ },
326
+ feishu: {
327
+ enabled: f.enabled === true,
328
+ appId: str(f.appId),
329
+ appSecret: typeof f.appSecret === "string" ? f.appSecret : ""
330
+ }
331
+ };
332
+ }
333
+
334
+ /**
335
+ * 入站白名单规范化:只接受字符串数组,去空去重、最多 50 个(防止配置写坏成对象/巨数组)。
336
+ * @param {any} raw
337
+ * @returns {string[]}
338
+ */
339
+ export function normalizeAllowList(raw) {
340
+ if (!Array.isArray(raw)) return [];
341
+ const out = [];
342
+ const seen = new Set();
343
+ for (const item of raw) {
344
+ if (typeof item !== "string") continue;
345
+ const v = item.trim();
346
+ if (!v || seen.has(v)) continue;
347
+ seen.add(v);
348
+ out.push(v);
349
+ if (out.length >= 50) break;
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /**
355
+ * 从 `relayDir/im-bots.json` 读配置。
356
+ * @param {string} relayDir
357
+ * @returns {ReturnType<typeof normalizeImBotsConfig>|null} 文件不存在 → null;解析失败 → null
358
+ */
359
+ export function readImBotsConfig(relayDir) {
360
+ if (!relayDir) return null;
361
+ const raw = readJsonFile(path.join(String(relayDir), IM_BOTS_FILE));
362
+ if (raw === null) return null;
363
+ return normalizeImBotsConfig(raw);
364
+ }
365
+
366
+ // ===========================================================================
367
+ // 4. 入站解析(纯函数,便于钉住字段形状)
368
+ // ===========================================================================
369
+
370
+ /**
371
+ * 从入站 body 里抽文本(尽力而为,不认识就返回空串)。
372
+ *
373
+ * 已知形状:`{msgtype:"text", text:{content:"…"}}`;`mixed` 可能是
374
+ * `{mixed:{msg_item:[{type:"text", text:{content}}]}}` 或 `{mixed:[…]}`;
375
+ * `voice` 可能带 `voice.content`(语音转写文本,若有)。**绝不猜图片/文件内容**。
376
+ *
377
+ * @param {any} body
378
+ * @returns {string}
379
+ */
380
+ export function extractInboundText(body) {
381
+ if (!body || typeof body !== "object") return "";
382
+ const direct = body.text && typeof body.text === "object" ? body.text.content : undefined;
383
+ if (typeof direct === "string" && direct.trim()) return direct.trim();
384
+ if (typeof body.content === "string" && body.content.trim()) return body.content.trim();
385
+ const mixed = body.mixed;
386
+ const items = mixed && typeof mixed === "object"
387
+ ? (Array.isArray(mixed.msg_item) ? mixed.msg_item : Array.isArray(mixed.items) ? mixed.items : Array.isArray(mixed) ? mixed : [])
388
+ : [];
389
+ const parts = [];
390
+ for (const it of items) {
391
+ if (!it || typeof it !== "object") continue;
392
+ const type = String(it.type || it.msgtype || "");
393
+ if (type && type !== "text") continue;
394
+ const c = it.text && typeof it.text === "object" ? it.text.content : undefined;
395
+ if (typeof c === "string" && c.trim()) parts.push(c.trim());
396
+ }
397
+ if (parts.length) return parts.join("\n");
398
+ const voice = body.voice && typeof body.voice === "object" ? body.voice : null;
399
+ if (voice && typeof voice.content === "string" && voice.content.trim()) return voice.content.trim();
400
+ if (typeof body.msgtype === "string" && body.msgtype !== "text" && body.msgtype !== "mixed") {
401
+ // 图片/文件/语音无转写:只回一句提示由上层决定,不当作文本转发
402
+ return "";
403
+ }
404
+ return "";
405
+ }
406
+
407
+ /**
408
+ * 从入站 body 里抽「谁发的 / 哪个会话」。
409
+ *
410
+ * 单聊 chatid = 对方 userid;群聊 = 群 chatid(优先用 body.chatid,取不到再退回 userid)。
411
+ * 各字段名在不同回调形状里略有差异,这里按**可信度**依次取,取不到给空串。
412
+ *
413
+ * @param {any} body
414
+ * @returns {{fromUser:string, chatid:string, isGroup:boolean}}
415
+ */
416
+ export function pickInboundIdentity(body) {
417
+ const b = body && typeof body === "object" ? body : {};
418
+ const from = b.from && typeof b.from === "object" ? b.from : {};
419
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
420
+ const fromUser = str(from.userid) || str(from.user_id) || str(from.userId)
421
+ || str(b.fromuserid) || str(b.from_userid) || str(b.userid) || str(b.user_id);
422
+ const explicitChat = str(b.chatid) || str(b.chat_id) || str(from.chatid) || str(from.chat_id);
423
+ const chattype = str(b.chattype) || str(b.chat_type) || str(b.type) || "";
424
+ const isGroup = /group|chatroom|群/i.test(chattype);
425
+ return { fromUser, chatid: explicitChat || (isGroup ? "" : fromUser), isGroup };
426
+ }
427
+
428
+ /**
429
+ * 出站通知文案(Markdown;企业微信 markdown 支持粗体、emoji、换行)。
430
+ *
431
+ * 只映射「用户需要知道的三类」:任务完成 / 出错 / 需要拍板(含 agent 提问与计划审批)。
432
+ * 其余节点(gap/fault/日报/额度…)**不在这里发** —— 微信通道与 push-reporter 已各自负责,
433
+ * 重复推送是骚扰。
434
+ *
435
+ * @param {object} node dsh-events 的分类节点
436
+ * @param {{sessionTitle?:(sessionId:string)=>string, maxChars?:number}} [opts]
437
+ * @returns {string|null} 不需要推送 → null
438
+ */
439
+ export function toMarkdownNotification(node, opts = {}) {
440
+ if (!node || typeof node !== "object") return null;
441
+ const sessionId = typeof node.sessionId === "string" ? node.sessionId : "";
442
+ let title = "";
443
+ try {
444
+ if (typeof opts.sessionTitle === "function") title = String(opts.sessionTitle(sessionId) || "");
445
+ } catch { title = ""; }
446
+ title = truncateText(title || shortSession(sessionId), 40);
447
+ const suffix = title ? `|${title}` : "";
448
+ const maxChars = Number.isFinite(opts.maxChars) ? Math.max(20, opts.maxChars) : NOTIFY_MAX_CHARS;
449
+
450
+ let text = null;
451
+ switch (node.kind) {
452
+ case NODE_KINDS.TURN_END: {
453
+ const reason = String(node.reason || "unknown");
454
+ if (reason === "error" || reason === "max-tokens") {
455
+ text = `❌ 出错了${suffix}`
456
+ + (reason === "max-tokens" ? "\n输出被截断,可能需要你再给一句「继续」。" : "\n到电脑上看一眼错误详情。");
457
+ } else if (reason === "blocked") {
458
+ text = `⛔ 被拦下了${suffix}`;
459
+ } else if (reason === "aborted" || reason === "interrupted") {
460
+ text = `⏹ 已停止${suffix}`;
461
+ } else if (reason === "completed") {
462
+ text = `✅ 任务完成${suffix}`;
463
+ } else {
464
+ text = `ℹ️ 任务结束${suffix}(${truncateText(reason, 30)})`;
465
+ }
466
+ break;
467
+ }
468
+ case NODE_KINDS.SESSION_ERROR:
469
+ text = `❌ 出错了${suffix}${node.message ? "\n" + truncateText(node.message, 160) : ""}`;
470
+ break;
471
+ case NODE_KINDS.APPROVAL_REQUEST:
472
+ text = `🛂 需要你在电脑上拍板|${truncateText(node.toolName || node.reason || "一步操作", 60)}`
473
+ + "\n(远程通道只读通知;请在电脑上确认)";
474
+ break;
475
+ case NODE_KINDS.USER_QUESTION:
476
+ text = `🛂 需要你回答${suffix}\n(agent 在等你选一个选项,请到电脑上回答)`;
477
+ break;
478
+ case NODE_KINDS.PLAN_REVIEW:
479
+ text = `🛂 等你批准计划${suffix}\n(请到电脑上确认计划)`;
480
+ break;
481
+ default:
482
+ return null;
483
+ }
484
+ return truncateText(text, maxChars);
485
+ }
486
+
487
+ /** 会话 id 的短形式(日志/标题用;不泄露完整 id)。 */
488
+ function shortSession(sessionId) {
489
+ const s = String(sessionId || "").replace(/^session-/, "");
490
+ if (!s) return "";
491
+ return s.length <= 12 ? s : s.slice(0, 8) + "…";
492
+ }
493
+
494
+ // ===========================================================================
495
+ // 4b. 审批卡片(纯函数,便于单测钉住报文形状)
496
+ // ===========================================================================
497
+
498
+ /** 安全哈希(字母数字):task_id 只允许 [A-Za-z0-9-_@],且 ≤128。 */
499
+ export function approvalTaskId(requestId) {
500
+ const raw = String(requestId || "");
501
+ let hash = "";
502
+ try {
503
+ hash = crypto.createHash("sha1").update(raw).digest("hex").slice(0, 32);
504
+ } catch {
505
+ hash = "x" + raw.replace(/[^A-Za-z0-9]/g, "").slice(0, 31);
506
+ }
507
+ const id = `apr_${hash}`;
508
+ return id.length > APPROVAL_CARD_TASK_ID_MAX ? id.slice(0, APPROVAL_CARD_TASK_ID_MAX) : id;
509
+ }
510
+
511
+ /**
512
+ * 解析按钮回调的 `event_key`(`allow:<requestId>` / `deny:<requestId>`)。
513
+ * requestId 本身可能含 `:`(DSH eventId 形态不定),所以**只按第一个冒号切**。
514
+ * @param {string} eventKey
515
+ * @returns {{decision:"allow"|"deny", requestId:string}|null}
516
+ */
517
+ export function parseApprovalEventKey(eventKey) {
518
+ const raw = String(eventKey || "").trim();
519
+ const idx = raw.indexOf(":");
520
+ if (idx <= 0) return null;
521
+ const kind = raw.slice(0, idx).trim().toLowerCase();
522
+ const requestId = raw.slice(idx + 1).trim();
523
+ if (!requestId) return null;
524
+ if (kind === "allow" || kind === "allowed" || kind === "approve") return { decision: "allow", requestId };
525
+ if (kind === "deny" || kind === "denied" || kind === "reject") return { decision: "deny", requestId };
526
+ return null;
527
+ }
528
+
529
+ /**
530
+ * 审批卡(button_interaction)报文。
531
+ * @param {{requestId:string, title?:string, summary?:string, toolName?:string}} info
532
+ */
533
+ export function buildApprovalCard(info = {}) {
534
+ const requestId = String(info.requestId || "");
535
+ const toolName = truncateText(info.toolName || "", 40);
536
+ const title = truncateText(info.title || "需要你在电脑上拍板", 30);
537
+ const summary = truncateText(info.summary || "", APPROVAL_CARD_SUMMARY_MAX);
538
+ return {
539
+ card_type: "button_interaction",
540
+ task_id: approvalTaskId(requestId),
541
+ main_title: {
542
+ title,
543
+ desc: toolName ? `工具:${toolName}` : "DeepSeek Harness 在等你允许一步操作"
544
+ },
545
+ // 官方限制 112 字;摘要只放"在等什么",不放完整命令(隐私边界与 push-reporter 一致)
546
+ sub_title_text: summary,
547
+ button_list: [
548
+ { text: "允许一次", key: `allow:${requestId}`, style: 1 },
549
+ { text: "拒绝", key: `deny:${requestId}`, style: 2 }
550
+ ]
551
+ };
552
+ }
553
+
554
+ /** 卡片更新后的文案(决定/过期/失败)—— 用 text_notice(按钮消失,结果一目了然)。 */
555
+ export function buildDecidedCard(info = {}) {
556
+ const state = String(info.state || "handled");
557
+ const requestId = String(info.requestId || "");
558
+ const who = truncateText(info.who || "", 40);
559
+ const whenText = typeof info.at === "number" && info.at > 0 ? fmtClockFromMs(info.at) : "";
560
+ const map = {
561
+ allowed: { title: "✅ 已允许一次", desc: who ? `操作人:${who}` : "已允许一次" },
562
+ denied: { title: "❌ 已拒绝", desc: who ? `操作人:${who}` : "已拒绝" },
563
+ expired: { title: "⌛ 已过期(按拒绝处理)", desc: "超过 90 秒未处理,已 fail-closed 按拒绝执行" },
564
+ failed: { title: "⚠️ 没能送达电脑端", desc: truncateText(info.reason || "请到电脑上确认", 60) },
565
+ handled: { title: "ℹ️ 这条审批已经处理过了", desc: who ? `操作人:${who}` : "" },
566
+ };
567
+ const hit = map[state] || map.handled;
568
+ return {
569
+ card_type: "text_notice",
570
+ task_id: approvalTaskId(requestId),
571
+ main_title: { title: hit.title, desc: hit.desc },
572
+ sub_title_text: whenText ? `处理时间:${whenText}` : ""
573
+ };
574
+ }
575
+
576
+ /**
577
+ * 预览审批卡(button_interaction):让业主不用等真实审批就能看到卡片长什么样、按钮能不能点。
578
+ * task_id / 按钮 key 都带 `preview` 且以时间戳结尾 —— 点击时能一眼识别,**绝不会走进真实审批**。
579
+ * @param {number} ts 触发时间戳(ms;来自 im-bots.json 的 previewCardAt)
580
+ */
581
+ export function buildPreviewCard(ts) {
582
+ const stamp = String(Number(ts) || 0).slice(-10);
583
+ return {
584
+ card_type: "button_interaction",
585
+ task_id: `apr_preview_${stamp}`,
586
+ main_title: {
587
+ title: "预览:需要你在电脑上拍板",
588
+ desc: "这是一张预览卡片,不会真的执行操作"
589
+ },
590
+ sub_title_text: "点一下下面的按钮试试:只会更新这张卡片,不会执行任何操作",
591
+ button_list: [
592
+ { text: "允许一次", key: `allow:preview:${stamp}`, style: 1 },
593
+ { text: "拒绝", key: `deny:preview:${stamp}`, style: 2 }
594
+ ]
595
+ };
596
+ }
597
+
598
+ /** 预览卡点击后的结果卡(text_notice;按钮消失、说明这是演示)。 */
599
+ export function buildPreviewDecidedCard(info = {}) {
600
+ const decision = info.decision === "allow" ? "允许" : "拒绝";
601
+ const who = truncateText(info.who || "", 40);
602
+ const whenText = typeof info.at === "number" && info.at > 0 ? fmtClockFromMs(info.at) : "";
603
+ const parts = [];
604
+ if (who) parts.push(`操作人:${who}`);
605
+ if (whenText) parts.push(`时间:${whenText}`);
606
+ return {
607
+ card_type: "text_notice",
608
+ task_id: typeof info.taskId === "string" && info.taskId ? info.taskId : `apr_preview_${String(Number(info.ts) || 0).slice(-10)}`,
609
+ main_title: {
610
+ title: `预览卡片 · 已收到你的选择(${decision})`,
611
+ desc: "这是预览卡片,没有对应的真实操作"
612
+ },
613
+ sub_title_text: parts.join(" | ")
614
+ };
615
+ }
616
+
617
+ /** 是否是预览卡的按钮(`allow:preview:<ts>` / `deny:preview:<ts>` 或 task_id 前缀)。 */
618
+ export function isPreviewCardEvent(requestId, taskId) {
619
+ if (/^preview:/.test(String(requestId || ""))) return true;
620
+ if (/^apr_preview_/.test(String(taskId || ""))) return true;
621
+ return false;
622
+ }
623
+
624
+ /** 毫秒时间戳 → HH:MM:SS(本地时区;只用于卡片确认文案)。 */
625
+ function fmtClockFromMs(ms) {
626
+ try {
627
+ const d = new Date(Number(ms));
628
+ if (Number.isNaN(d.getTime())) return "";
629
+ const p = (n) => String(n).padStart(2, "0");
630
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
631
+ } catch { return ""; }
632
+ }
633
+
634
+ /**
635
+ * 状态文件里的 `sessions[]`(≤10,只含 id/title/updatedAt;**绝不含消息正文**)。
636
+ * @param {Array<{id:string,title?:string,updatedAt?:number}>} list
637
+ */
638
+ export function normalizeSessionStatusList(list) {
639
+ const out = [];
640
+ const seen = new Set();
641
+ const arr = Array.isArray(list) ? list : [];
642
+ for (const it of arr) {
643
+ if (!it || typeof it !== "object") continue;
644
+ const id = typeof it.id === "string" ? it.id.trim() : "";
645
+ if (!id || seen.has(id)) continue;
646
+ seen.add(id);
647
+ out.push({
648
+ id,
649
+ title: truncateText(it.title || "", 60),
650
+ updatedAt: Number.isFinite(Number(it.updatedAt)) ? Number(it.updatedAt) : 0
651
+ });
652
+ if (out.length >= SESSION_LIST_MAX) break;
653
+ }
654
+ return out;
655
+ }
656
+
657
+ // ===========================================================================
658
+ // 5. WebSocket 长连接客户端
659
+ // ===========================================================================
660
+
661
+ /**
662
+ * 创建企业微信智能机器人长连接客户端。
663
+ *
664
+ * @param {{
665
+ * botId: string, secret: string, url?: string,
666
+ * relayDir?: string, // 有值时写 im-bot-status.json(桥的场景)
667
+ * targetChatId?: string, allowFrom?: string[],
668
+ * notify?: boolean, // 出站通知总开关(默认 true,还要过 env)
669
+ * logger?: Function|{info?:Function,warn?:Function,error?:Function,debug?:Function},
670
+ * clock?: () => number,
671
+ * wsFactory?: (url:string)=>any, // 仅测试:注入假 WS;默认 new WebSocket(url)
672
+ * subscriber?: any, // 注入既有订阅器(bridge 可选复用)
673
+ * createSubscriber?: () => any, // 注入工厂
674
+ * upstream?: string|(()=>string), // 默认订阅器的上游(让端口漂移能跟上)
675
+ * cookie?: string|(()=>string),
676
+ * sessionTitle?: (sessionId:string)=>string,
677
+ * sessionIdProvider?: () => string, // 入站转发目标会话(bridge 可注入)
678
+ * heartbeatMs?: number, heartbeatMaxMiss?: number, authTimeoutMs?: number,
679
+ * reconnectBaseMs?: number, reconnectMaxMs?: number, sendAckTimeoutMs?: number,
680
+ * maxNotifyPerMinute?: number // 通知限速(默认 10,防事件风暴刷屏)
681
+ * }} options
682
+ */
683
+ export function createWecomBot(options = {}) {
684
+ const botId = String(options.botId || "").trim();
685
+ const secret = typeof options.secret === "string" ? options.secret : "";
686
+ const url = String(options.url || "").trim() || DEFAULT_WECOM_WS_URL;
687
+ const relayDir = options.relayDir ? String(options.relayDir) : "";
688
+ const targetChatId = String(options.targetChatId || "").trim();
689
+ const allowFrom = normalizeAllowList(options.allowFrom);
690
+ const now = typeof options.clock === "function" ? options.clock : () => Date.now();
691
+ const heartbeatMs = positiveNumber(options.heartbeatMs, HEARTBEAT_MS);
692
+ const heartbeatMaxMiss = Math.max(1, Math.floor(positiveNumber(options.heartbeatMaxMiss, HEARTBEAT_MAX_MISS)));
693
+ const authTimeoutMs = positiveNumber(options.authTimeoutMs, AUTH_TIMEOUT_MS);
694
+ const reconnectBaseMs = positiveNumber(options.reconnectBaseMs, RECONNECT_BASE_MS);
695
+ const reconnectMaxMs = Math.max(reconnectBaseMs, positiveNumber(options.reconnectMaxMs, RECONNECT_MAX_MS));
696
+ const sendAckTimeoutMs = positiveNumber(options.sendAckTimeoutMs, SEND_ACK_TIMEOUT_MS);
697
+ const notifyAllowed = options.notify !== false;
698
+ const maxNotifyPerMinute = Math.max(1, Math.floor(positiveNumber(options.maxNotifyPerMinute, 10)));
699
+ // 「回答回传」(入站 → agent 回答 → 原路发回):默认开;TTL/上限可注入(测试用)
700
+ const answerRelayEnabled = options.answerRelay !== false;
701
+ const answerRelayTtlMs = positiveNumber(options.answerRelayTtlMs, ANSWER_RELAY_TTL_MS);
702
+ const answerMaxChars = Math.max(20, Math.floor(positiveNumber(options.answerMaxChars, ANSWER_MAX_CHARS)));
703
+ // 审批卡片(task-24):默认开;TTL 可注入(测试用);决策回调由 bridge 注入
704
+ const approvalCardsEnabled = options.approvalCards !== false;
705
+ const approvalCardTtlMs = positiveNumber(options.approvalCardTtlMs, APPROVAL_CARD_TTL_MS);
706
+ const onApprovalDecision = typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : null;
707
+ // 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话)
708
+ const configuredSessionId = String(options.configuredSessionId || "").trim();
709
+ const configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
710
+ const log = createScopedLogger({ sink: options.logger, secrets: [secret], prefix: "[wecom]" });
711
+
712
+ const statusFile = relayDir ? path.join(relayDir, IM_BOT_STATUS_FILE) : "";
713
+ const emitter = new EventEmitter();
714
+ emitter.setMaxListeners(20);
715
+
716
+ /** 状态机(`status()` 的底层;不含任何密钥)。 */
717
+ const state = {
718
+ running: false,
719
+ connected: false,
720
+ authenticated: false,
721
+ authFailed: false,
722
+ lastError: "",
723
+ lastConnectedAt: 0,
724
+ lastMessageAt: 0,
725
+ boundChatId: "",
726
+ targetChatId,
727
+ reconnectAttempts: 0,
728
+ nextRetryInMs: 0,
729
+ lastPingAt: 0,
730
+ lastPongAt: 0,
731
+ inbound: 0,
732
+ rejected: 0,
733
+ notified: 0,
734
+ sent: 0,
735
+ sendFailed: 0,
736
+ answers: 0,
737
+ cards: 0,
738
+ approvalDecisions: 0,
739
+ previews: 0,
740
+ previewClicks: 0
741
+ };
742
+
743
+ let ws = null;
744
+ let stopped = true;
745
+ let reqSeq = 0;
746
+ /** 连接代次:每新建一次连接 +1。旧连接遗留的定时器(认证/发送 ack)据此自我作废 ——
747
+ * 否则一次「认证超时」的定时器会在重连成功 15s 后把**新连接**误杀(真机上会表现为周期性掉线)。 */
748
+ let epoch = 0;
749
+ let authTimer = null;
750
+ let heartbeatTimer = null;
751
+ let reconnectTimer = null;
752
+ let pingOutstanding = 0;
753
+ let subscriber = null;
754
+ let ownsSubscriber = false;
755
+ let wired = false;
756
+ let lastNoTargetLogAt = 0;
757
+ /**
758
+ * 最近一次**入站转发成功**的会话:{ sessionId, at }。
759
+ * 用途:收到这个会话的 turn/end 时,把 agent 的最终回答正文回传给同一个企微会话
760
+ * (否则业主只能看到「已转给桌面端会话 ✓」,看不到回答)。消费一次即清空。
761
+ */
762
+ let lastInbound = { sessionId: "", at: 0 };
763
+ /**
764
+ * 会话可选绑定(task-24):
765
+ * · sessionList — 状态文件里的 `sessions[]`(≤10,只含 id/title/updatedAt)
766
+ * · activeSession — 最近活跃会话(来自 session-state running / control-activity / 事件)
767
+ * · boundSessionId — 当前**实际**转发目标(config.sessionId → active → sessions[0])
768
+ */
769
+ let sessionList = [];
770
+ let activeSession = { id: "", title: "" };
771
+ let boundSessionId = "";
772
+ const sessionRegistry = new Map(); // id -> {id,title,updatedAt}
773
+ const sessionCache = { at: 0, items: [], inFlight: false };
774
+ let sessionRefreshTimer = null;
775
+ /** 审批卡片:内存登记(task_id → 记录 / requestId → 记录),用于幂等与 TTL 判定。 */
776
+ const cardsByTask = new Map();
777
+ const cardsByRequest = new Map();
778
+ /**
779
+ * 预览卡**独立集合**:只用于识别点击来源与重放结果卡。
780
+ * ⚠️ 绝不进 cardsByTask/cardsByRequest —— 避免污染真实审批的幂等表与 90s TTL 语义。
781
+ */
782
+ const previewCards = new Map(); // taskId -> {ts, chatid, sentAt}
783
+ const pending = new Map(); // reqId -> {kind, timer, onAck}
784
+ const seenMsgs = new Map(); // msgid -> at
785
+ const notifiedKeys = new Map(); // dedupe key -> at
786
+ const notifyTimes = []; // 限速窗口
787
+
788
+ // 重启后沿用上次绑定的会话(status 文件里那一份;不是密钥,泄露风险低)
789
+ if (relayDir) {
790
+ try {
791
+ const prev = readJsonFile(statusFile);
792
+ const bid = prev && prev.wecom && typeof prev.wecom.boundChatId === "string" ? prev.wecom.boundChatId : "";
793
+ if (bid) state.boundChatId = bid;
794
+ } catch { /* 忽略 */ }
795
+ }
796
+
797
+ // ---------- 状态文件(契约) ----------
798
+
799
+ /** 契约里的 wecom 段(**字段名冻结**,插件按它渲染;不含 secret)。 */
800
+ function statusWecom() {
801
+ return {
802
+ // enabled = **配置意图**(有凭据就算启用),不因桥退出/断线翻成 false ——
803
+ // 否则 bridge 优雅退出时写下的最后一版状态会让面板显示「已被禁用」,
804
+ // 与用户实际配置相反。运行时状态由 connected/authenticated 表达。
805
+ enabled: Boolean(botId && secret),
806
+ connected: state.connected === true,
807
+ authenticated: state.authenticated === true,
808
+ lastError: truncateText(maskSecrets(state.lastError, [secret]), 160),
809
+ lastConnectedAt: Number(state.lastConnectedAt) || 0,
810
+ lastMessageAt: Number(state.lastMessageAt) || 0,
811
+ boundChatId: state.boundChatId || "",
812
+ targetChatId: targetChatId || "",
813
+ // 会话可选绑定(task-24):只读元数据(id/标题/更新时间),绝不含消息正文
814
+ sessions: normalizeSessionStatusList(sessionList),
815
+ activeSessionId: activeSession.id || "",
816
+ activeSessionTitle: activeSession.title || "",
817
+ boundSessionId: boundSessionId || ""
818
+ };
819
+ }
820
+
821
+ /** 写状态文件:合并既有其它段(feishu 等由别的模块写,绝不整份覆盖)。 */
822
+ function writeStatus() {
823
+ if (!statusFile) return;
824
+ try {
825
+ const prev = readJsonFile(statusFile);
826
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
827
+ const next = {
828
+ ...base,
829
+ version: 1,
830
+ updatedAt: Number(now()) || Date.now(),
831
+ wecom: { ...(base.wecom && typeof base.wecom === "object" ? base.wecom : {}), ...statusWecom() }
832
+ };
833
+ atomicWriteJson(statusFile, next, { onWarn: (m) => log.warn(m) });
834
+ } catch (e) {
835
+ log.warn(`状态文件写入失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
836
+ }
837
+ }
838
+
839
+ // ---------- 帧收发 ----------
840
+
841
+ function nextReqId(kind) {
842
+ reqSeq += 1;
843
+ // 官方只要求 req_id 可回带;用「类型-序号-时间」便于日志定位,且不含任何机密
844
+ return `dsh-${kind}-${reqSeq}-${Number(now()) || Date.now()}`;
845
+ }
846
+
847
+ function rawSend(obj) {
848
+ const sock = ws;
849
+ if (!sock || sock.readyState !== WS_OPEN) return false;
850
+ try {
851
+ sock.send(JSON.stringify(obj));
852
+ return true;
853
+ } catch (e) {
854
+ log.warn(`发送失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
855
+ return false;
856
+ }
857
+ }
858
+
859
+ /** 发一帧并登记 ack 期望(超时只记账,不阻塞)。 */
860
+ function sendWithAck(kind, cmd, body, onAck) {
861
+ const reqId = nextReqId(kind);
862
+ const myEpoch = epoch;
863
+ const timer = setTimeout(() => {
864
+ pending.delete(reqId);
865
+ if (myEpoch !== epoch) return; // 旧连接的遗留定时器:不得影响新连接
866
+ if (kind === "ping") return; // 心跳由 pingOutstanding 计数判死,这里不重复处理
867
+ try { if (onAck) onAck({ ok: false, timeout: true, errcode: null }); } catch { /* 忽略 */ }
868
+ }, sendAckTimeoutMs);
869
+ if (typeof timer.unref === "function") timer.unref();
870
+ pending.set(reqId, { kind, timer, onAck });
871
+ const frame = { cmd, headers: { req_id: reqId } };
872
+ if (body !== undefined) frame.body = body;
873
+ if (!rawSend(frame)) {
874
+ clearTimeout(timer);
875
+ pending.delete(reqId);
876
+ return { ok: false, reqId, reason: "socket_closed" };
877
+ }
878
+ return { ok: true, reqId };
879
+ }
880
+
881
+ /** 处理服务端 ack(按 req_id 匹配)。 */
882
+ function handleAck(reqId, frame) {
883
+ const entry = pending.get(reqId);
884
+ if (!entry) return false;
885
+ pending.delete(reqId);
886
+ try { if (entry.timer) clearTimeout(entry.timer); } catch { /* 忽略 */ }
887
+ const errcode = frame.errcode !== undefined ? Number(frame.errcode) : 0;
888
+ const errmsg = typeof frame.errmsg === "string" ? frame.errmsg : "";
889
+ try {
890
+ if (entry.onAck) entry.onAck({ ok: errcode === 0, errcode, errmsg });
891
+ } catch { /* 忽略 */ }
892
+ return true;
893
+ }
894
+
895
+ // ---------- 连接 / 认证 / 心跳 / 重连 ----------
896
+
897
+ function clearAuthTimer() {
898
+ if (authTimer) { try { clearTimeout(authTimer); } catch { /* 忽略 */ } authTimer = null; }
899
+ }
900
+ function stopHeartbeat() {
901
+ if (heartbeatTimer) { try { clearInterval(heartbeatTimer); } catch { /* 忽略 */ } heartbeatTimer = null; }
902
+ pingOutstanding = 0;
903
+ }
904
+ function dropSocket() {
905
+ const sock = ws;
906
+ ws = null;
907
+ if (!sock) return;
908
+ try {
909
+ sock.removeAllListeners();
910
+ sock.on("error", () => { /* 关闭途中的错误不该冒泡 */ });
911
+ if (typeof sock.terminate === "function" && sock.readyState === WS_OPEN) sock.terminate();
912
+ else if (typeof sock.close === "function") sock.close();
913
+ } catch { /* 忽略 */ }
914
+ }
915
+
916
+ function connect() {
917
+ if (stopped || state.authFailed) return;
918
+ let sock;
919
+ try {
920
+ sock = typeof options.wsFactory === "function" ? options.wsFactory(url) : new WebSocket(url);
921
+ } catch (e) {
922
+ scheduleReconnect(`ws_init:${truncateText(e && e.message ? e.message : e, 80)}`);
923
+ return;
924
+ }
925
+ ws = sock;
926
+ epoch += 1;
927
+ try {
928
+ sock.on("open", () => onOpen());
929
+ sock.on("message", (raw) => onRawMessage(raw));
930
+ sock.on("error", (e) => {
931
+ // 只记短原因(不断言"凭据错":错误事件 ≠ 认证结果)
932
+ log.warn(`WebSocket 异常:${truncateText(e && e.message ? e.message : e, 120)}`);
933
+ });
934
+ sock.on("close", (code, reason) => onClose(code, reason));
935
+ } catch (e) {
936
+ log.warn(`挂监听失败:${truncateText(e && e.message ? e.message : e, 120)}`);
937
+ }
938
+ }
939
+
940
+ function onOpen() {
941
+ if (stopped) return;
942
+ state.connected = true;
943
+ state.authenticated = false;
944
+ state.lastConnectedAt = now();
945
+ state.nextRetryInMs = 0;
946
+ log.info("已连上企业微信长连接,正在认证…");
947
+ writeStatus();
948
+ emitStatus();
949
+ // 认证帧(协议事实见文件头)
950
+ sendWithAck("auth", "aibot_subscribe", { secret, bot_id: botId }, (ack) => {
951
+ clearAuthTimer();
952
+ if (ack && ack.ok) {
953
+ state.authenticated = true;
954
+ state.authFailed = false;
955
+ state.reconnectAttempts = 0;
956
+ state.lastError = "";
957
+ log.info("认证成功(aibot_subscribe ok)");
958
+ writeStatus();
959
+ emitStatus();
960
+ startHeartbeat();
961
+ emitter.emit("authenticated", { at: now() });
962
+ return;
963
+ }
964
+ if (ack && ack.timeout) {
965
+ // 认证没应答:按网络问题重连(不判凭据错)
966
+ log.warn("认证应答超时,按网络问题重连");
967
+ closeAndReconnect("auth_timeout");
968
+ return;
969
+ }
970
+ failAuth(ack && ack.errcode, ack && ack.errmsg);
971
+ });
972
+ // 兜底:连 auth ack 都没等到
973
+ clearAuthTimer();
974
+ authTimer = setTimeout(() => {
975
+ authTimer = null;
976
+ if (stopped || state.authenticated || state.authFailed) return;
977
+ log.warn("等待认证应答超时,按网络问题重连");
978
+ closeAndReconnect("auth_timeout");
979
+ }, authTimeoutMs);
980
+ if (typeof authTimer.unref === "function") authTimer.unref();
981
+ }
982
+
983
+ /**
984
+ * 认证失败 = **凭据/权限问题**,立即停止,不重连(否则会被官方限流甚至封禁)。
985
+ * @param {any} errcode
986
+ * @param {any} errmsg
987
+ */
988
+ function failAuth(errcode, errmsg) {
989
+ state.authFailed = true;
990
+ state.authenticated = false;
991
+ state.running = false;
992
+ const code = Number.isFinite(Number(errcode)) ? Number(errcode) : null;
993
+ // 服务端 errmsg 原样回显 secret 是最坏情况(实测不会,但状态/日志绝不能因此泄露)
994
+ const msg = truncateText(maskSecrets(errmsg || "认证被拒绝", [secret]), 120);
995
+ state.lastError = code === null ? `认证被拒绝:${msg}` : `认证被拒绝(errcode=${code}):${msg}`;
996
+ log.warn(`${state.lastError};凭据问题不重连,请检查 Bot ID / Secret`);
997
+ stopHeartbeat();
998
+ clearAuthTimer();
999
+ try { for (const [, p] of pending) { try { clearTimeout(p.timer); } catch { /* 忽略 */ } } } catch { /* 忽略 */ }
1000
+ pending.clear();
1001
+ dropSocket();
1002
+ state.connected = false;
1003
+ state.nextRetryInMs = 0;
1004
+ writeStatus();
1005
+ emitStatus();
1006
+ emitter.emit("auth-failed", { errcode: code, message: msg, at: now() });
1007
+ }
1008
+
1009
+ function startHeartbeat() {
1010
+ stopHeartbeat();
1011
+ heartbeatTimer = setInterval(() => {
1012
+ if (stopped || !state.authenticated) return;
1013
+ if (pingOutstanding >= heartbeatMaxMiss) {
1014
+ log.warn(`心跳连续 ${pingOutstanding} 次未应答,判定连接已死,准备重连`);
1015
+ closeAndReconnect("heartbeat_timeout");
1016
+ return;
1017
+ }
1018
+ pingOutstanding += 1;
1019
+ state.lastPingAt = now();
1020
+ sendWithAck("ping", "ping", undefined, (ack) => {
1021
+ if (ack && ack.ok) {
1022
+ pingOutstanding = 0;
1023
+ state.lastPongAt = now();
1024
+ } else if (!ack || !ack.timeout) {
1025
+ log.warn(`心跳应答异常(errcode=${ack && ack.errcode})`);
1026
+ }
1027
+ });
1028
+ }, heartbeatMs);
1029
+ if (typeof heartbeatTimer.unref === "function") heartbeatTimer.unref();
1030
+ }
1031
+
1032
+ function closeAndReconnect(reason) {
1033
+ if (stopped || state.authFailed) return;
1034
+ dropSocket();
1035
+ state.connected = false;
1036
+ state.authenticated = false;
1037
+ stopHeartbeat();
1038
+ clearAuthTimer();
1039
+ scheduleReconnect(reason);
1040
+ }
1041
+
1042
+ function scheduleReconnect(reason) {
1043
+ if (stopped || state.authFailed) return;
1044
+ if (reconnectTimer) return; // 单飞:一次只排一个重连
1045
+ const attempt = state.reconnectAttempts;
1046
+ const delay = Math.min(reconnectMaxMs, Math.round(reconnectBaseMs * Math.pow(2, attempt)));
1047
+ state.reconnectAttempts = attempt + 1;
1048
+ state.nextRetryInMs = delay;
1049
+ state.lastError = state.lastError || `连接断开:${truncateText(reason, 80)}`;
1050
+ log.warn(`连接断开(${reason}),${delay}ms 后重连(第 ${state.reconnectAttempts} 次)`);
1051
+ writeStatus();
1052
+ emitStatus();
1053
+ reconnectTimer = setTimeout(() => {
1054
+ reconnectTimer = null;
1055
+ state.nextRetryInMs = 0;
1056
+ connect();
1057
+ }, delay);
1058
+ if (typeof reconnectTimer.unref === "function") reconnectTimer.unref();
1059
+ }
1060
+
1061
+ function onClose(code, reason) {
1062
+ if (stopped) return;
1063
+ const why = `close ${code === undefined ? "?" : code}${reason ? ` ${truncateText(String(reason), 60)}` : ""}`;
1064
+ state.connected = false;
1065
+ state.authenticated = false;
1066
+ stopHeartbeat();
1067
+ clearAuthTimer();
1068
+ if (state.authFailed) return;
1069
+ scheduleReconnect(why);
1070
+ }
1071
+
1072
+ // ---------- 帧分发 ----------
1073
+
1074
+ function onRawMessage(raw) {
1075
+ let frame = null;
1076
+ try {
1077
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
1078
+ frame = JSON.parse(text);
1079
+ } catch {
1080
+ log.warn("收到无法解析的帧(已忽略)");
1081
+ return;
1082
+ }
1083
+ if (!frame || typeof frame !== "object") return;
1084
+ const headers = frame.headers && typeof frame.headers === "object" ? frame.headers : {};
1085
+ const reqId = typeof headers.req_id === "string" ? headers.req_id : typeof headers.reqId === "string" ? headers.reqId : "";
1086
+ // ① ack(按 req_id 匹配)—— 只打 cmd/errcode,绝不整帧落日志
1087
+ if (reqId && handleAck(reqId, frame)) return;
1088
+ // ② 无 req_id 的错误帧:未认证时的非 0 errcode 被视为认证失败(保守:凭据问题不能重连风暴)
1089
+ if (frame.errcode !== undefined && Number(frame.errcode) !== 0 && !state.authenticated) {
1090
+ failAuth(frame.errcode, frame.errmsg);
1091
+ return;
1092
+ }
1093
+ const cmd = typeof frame.cmd === "string" ? frame.cmd : "";
1094
+ if (cmd === "aibot_msg_callback") {
1095
+ void handleInboundMessage(frame);
1096
+ return;
1097
+ }
1098
+ if (cmd === "aibot_event_callback") {
1099
+ void handleInboundEvent(frame);
1100
+ return;
1101
+ }
1102
+ if (cmd === "ping") {
1103
+ // 少数服务端实现会主动 ping:回一个同形状的 pong(不影响官方 ack 流程)
1104
+ rawSend({ cmd: "pong", headers: reqId ? { req_id: reqId } : {} });
1105
+ return;
1106
+ }
1107
+ log.debug(`未处理的帧 cmd=${cmd || "(ack)"}${frame.errcode !== undefined ? ` errcode=${frame.errcode}` : ""}`);
1108
+ }
1109
+
1110
+ async function handleInboundMessage(frame) {
1111
+ const body = frame.body && typeof frame.body === "object" ? frame.body : {};
1112
+ const msgid = String(body.msgid || body.msg_id || "").trim();
1113
+ const text = extractInboundText(body);
1114
+ const { fromUser, chatid, isGroup } = pickInboundIdentity(body);
1115
+
1116
+ // 去重(官方重连后可能补发;同一 msgid 只处理一次)
1117
+ if (msgid) {
1118
+ pruneSeen();
1119
+ if (seenMsgs.has(msgid)) {
1120
+ log.debug(`重复消息已忽略(msgid 命中)`);
1121
+ return;
1122
+ }
1123
+ seenMsgs.set(msgid, now());
1124
+ }
1125
+ state.inbound += 1;
1126
+ state.lastMessageAt = now();
1127
+
1128
+ const info = { msgid, text, chatid, fromUser, isGroup, frame, at: now() };
1129
+
1130
+ // 安全:allowFrom 白名单(可选)。非空时只接受白名单内的 userid/chatid;空 = 首次发送者自动绑定。
1131
+ if (allowFrom.length && !allowFrom.includes(fromUser) && !allowFrom.includes(chatid)) {
1132
+ state.rejected += 1;
1133
+ log.warn(`拒绝非白名单发送者(from=${chatIdHash(fromUser) || "?"} chat=${chatIdHash(chatid) || "?"})`);
1134
+ writeStatus();
1135
+ replyText(frame, "你暂时没有这台电脑的远程通道权限。请让电脑那边把你的企业微信 userid 加进白名单。");
1136
+ emitter.emit("inbound-rejected", info);
1137
+ return;
1138
+ }
1139
+ if (!text) {
1140
+ log.info(`收到非文本消息(msgtype=${truncateText(body.msgtype || "?", 20)}),未转发`);
1141
+ writeStatus();
1142
+ replyText(frame, "目前只支持文本消息(图片/文件/语音暂不转发)。");
1143
+ emitter.emit("inbound-ignored", info);
1144
+ return;
1145
+ }
1146
+ // 绑定:最近一次跟机器人说话的会话就是主动推送的接收方
1147
+ if (chatid && chatid !== state.boundChatId) {
1148
+ state.boundChatId = chatid;
1149
+ log.info(`已绑定推送会话 chat=${chatIdHash(chatid)}${isGroup ? "(群)" : ""}`);
1150
+ }
1151
+ writeStatus();
1152
+ emitStatus();
1153
+ emitter.emit("message", info);
1154
+ // 便捷回调(规格里写的 onMessage 形态):与事件并行,异常绝不影响转发
1155
+ try { if (typeof options.onMessage === "function") options.onMessage(info); } catch { /* 忽略 */ }
1156
+ await dispatchInbound(frame, info);
1157
+ }
1158
+
1159
+ async function handleInboundEvent(frame) {
1160
+ const body = frame.body && typeof frame.body === "object" ? frame.body : {};
1161
+ const ev = body.event && typeof body.event === "object" ? body.event : {};
1162
+ // 卡片事件的事件类型在 `body.event.eventtype` 里(body.event 是对象,不能直接 String())
1163
+ const eventType = String(ev.eventtype || ev.event_type || body.event_type || body.eventType || "").trim();
1164
+ const { fromUser, chatid, isGroup } = pickInboundIdentity(body);
1165
+ log.info(`收到机器人事件 event=${truncateText(eventType || "?", 40)}${fromUser ? ` from=${chatIdHash(fromUser)}` : ""}`);
1166
+ state.lastMessageAt = now();
1167
+ writeStatus();
1168
+ emitter.emit("bot-event", { eventType, fromUser, chatid, isGroup, frame, at: now() });
1169
+ // 审批卡片按钮点击 → 走真实审批回执(task-24)
1170
+ if (eventType === "template_card_event" || (ev.event_key && ev.task_id)) {
1171
+ await handleApprovalCardEvent(frame);
1172
+ }
1173
+ }
1174
+
1175
+ /** 把入站文本转给 DSH 会话(尽力,失败给用户一句人话)。 */
1176
+ async function dispatchInbound(frame, info) {
1177
+ const sub = subscriber;
1178
+ const sessionId = await resolveSessionId(sub);
1179
+ if (!sub || typeof sub.promptSession !== "function") {
1180
+ log.warn("入站消息无法转发:事件订阅器未就绪(详见启动日志)");
1181
+ replyText(frame, "已收到你的消息,但后台还没连上 DeepSeek Harness 的事件通道,暂时转不过去。请确认电脑端 dsh web 正在运行。");
1182
+ emitter.emit("inbound-failed", { ...info, reason: "no_subscriber" });
1183
+ return;
1184
+ }
1185
+ if (!sessionId) {
1186
+ log.warn("入站消息无法转发:还没有可接管的会话");
1187
+ replyText(frame, "已收到,但还没在桌面端选择要接管的会话。请先在电脑的 DeepSeek Harness 里打开一个会话,再发一次。");
1188
+ emitter.emit("inbound-failed", { ...info, reason: "no_session" });
1189
+ return;
1190
+ }
1191
+ let result;
1192
+ try {
1193
+ result = await sub.promptSession({ sessionId, text: info.text });
1194
+ } catch (e) {
1195
+ result = { ok: false, message: e && e.message ? e.message : String(e) };
1196
+ }
1197
+ if (result && result.ok) {
1198
+ // 记下"这次入站交给了哪个会话":该会话下一次 turn/end 要把回答正文回传给企微
1199
+ lastInbound = { sessionId, at: now() };
1200
+ // 状态文件对外表达"当前实际转发目标"(面板据此显示绑定关系)
1201
+ if (boundSessionId !== sessionId) { boundSessionId = sessionId; writeStatus(); }
1202
+ log.info(`入站文本已转给会话 session=${shortSession(sessionId)}`);
1203
+ replyText(frame, `已转给桌面端会话 ✓(${shortSession(sessionId)})`);
1204
+ emitter.emit("inbound-routed", { ...info, sessionId });
1205
+ return;
1206
+ }
1207
+ const why = truncateText((result && (result.code || result.message)) || "未知错误", 80);
1208
+ log.warn(`入站文本转发失败:${why}`);
1209
+ replyText(frame, `转发失败:${why}。请到电脑上确认会话还在运行。`);
1210
+ emitter.emit("inbound-failed", { ...info, sessionId, reason: why });
1211
+ }
1212
+
1213
+ // ---------- 会话可选绑定(task-24) ----------
1214
+
1215
+ /** subscriber 当前 follow 着的会话 id 列表(只读;拿不到算空)。 */
1216
+ function followedSessionIds() {
1217
+ const sub = subscriber;
1218
+ if (!sub || typeof sub.sessions !== "function") return [];
1219
+ try {
1220
+ const ids = sub.sessions();
1221
+ return Array.isArray(ids) ? ids.filter((x) => typeof x === "string" && x).slice(0, 100) : [];
1222
+ } catch { return []; }
1223
+ }
1224
+
1225
+ /** 会话标题:注入的 sessionTitle() > DSH 列表里的标题 > 配置里给固定会话起的名字。 */
1226
+ function titleOfSession(id, fallback) {
1227
+ if (typeof options.sessionTitle === "function") {
1228
+ try {
1229
+ const t = String(options.sessionTitle(id) || "").trim();
1230
+ if (t) return truncateText(t, 60);
1231
+ } catch { /* 忽略 */ }
1232
+ }
1233
+ if (fallback) return truncateText(fallback, 60);
1234
+ if (id === configuredSessionId && configuredSessionTitle) return configuredSessionTitle;
1235
+ const cached = sessionRegistry.get(id);
1236
+ return cached && cached.title ? cached.title : "";
1237
+ }
1238
+
1239
+ /** 转发目标优先级(task-24):配置固定 → 最近活跃 → 会话列表第一个。 */
1240
+ function computeBoundSessionId() {
1241
+ if (configuredSessionId) return configuredSessionId;
1242
+ if (activeSession.id) return activeSession.id;
1243
+ return sessionList.length ? sessionList[0].id : "";
1244
+ }
1245
+
1246
+ /** 同步 active/bound 派生状态 + 把「配置固定的会话」补进列表首位(用户明确选过)。 */
1247
+ function syncSessionState() {
1248
+ if (activeSession.id) activeSession.title = titleOfSession(activeSession.id, activeSession.title);
1249
+ boundSessionId = computeBoundSessionId();
1250
+ if (configuredSessionId && !sessionList.some((s) => s.id === configuredSessionId)) {
1251
+ sessionList = normalizeSessionStatusList([
1252
+ { id: configuredSessionId, title: configuredSessionTitle, updatedAt: 0 },
1253
+ ...sessionList
1254
+ ]);
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * 刷新「会话列表 + 标题 + updatedAt」并写状态文件。
1260
+ *
1261
+ * 来源(task-24):`subscriber.sessions()`(= 当前 follow 中的会话 id)∩ `listSessions()` 的元数据。
1262
+ * 只写元数据(id/title/updatedAt),**绝不含消息正文**;`listSessions` 有 30s 缓存,
1263
+ * 会话事件再密也不会把 DSH 打成筛子。
1264
+ */
1265
+ async function refreshSessions(opts = {}) {
1266
+ const sub = subscriber;
1267
+ if (!sub) return;
1268
+ const at = now();
1269
+ const ids = followedSessionIds();
1270
+ const needDetail = opts.force === true || at - sessionCache.at > SESSION_LIST_REFRESH_MS;
1271
+ let detail = sessionCache.items;
1272
+ if (needDetail && !sessionCache.inFlight && typeof sub.listSessions === "function") {
1273
+ sessionCache.inFlight = true;
1274
+ try {
1275
+ const r = await sub.listSessions();
1276
+ if (r && r.ok && Array.isArray(r.sessions)) {
1277
+ detail = r.sessions;
1278
+ sessionCache.at = now();
1279
+ sessionCache.items = detail;
1280
+ }
1281
+ } catch { /* 忽略:拿不到标题不影响转发 */ } finally {
1282
+ sessionCache.inFlight = false;
1283
+ }
1284
+ }
1285
+ const byId = new Map();
1286
+ for (const it of Array.isArray(detail) ? detail : []) {
1287
+ if (it && typeof it.sessionId === "string" && it.sessionId) byId.set(it.sessionId, it);
1288
+ }
1289
+ const rows = [];
1290
+ for (const id of ids) {
1291
+ const d = byId.get(id) || {};
1292
+ const title = titleOfSession(id, d.title);
1293
+ const updatedAt = Number(d.updatedAt) || (sessionRegistry.get(id) && sessionRegistry.get(id).updatedAt) || at;
1294
+ sessionRegistry.set(id, { id, title, updatedAt });
1295
+ rows.push({ id, title, updatedAt });
1296
+ }
1297
+ rows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
1298
+ sessionList = normalizeSessionStatusList(rows);
1299
+ syncSessionState();
1300
+ writeStatus();
1301
+ }
1302
+
1303
+ /** 会话事件的刷新合并(80ms);`force` 时立刻拉一次详情(如 ready / 配置变更后)。 */
1304
+ function scheduleSessionRefresh(force = false) {
1305
+ if (stopped) return;
1306
+ if (sessionRefreshTimer) return;
1307
+ sessionRefreshTimer = setTimeout(() => {
1308
+ sessionRefreshTimer = null;
1309
+ void refreshSessions({ force }).catch(() => {});
1310
+ }, force ? 0 : 80);
1311
+ if (typeof sessionRefreshTimer.unref === "function") sessionRefreshTimer.unref();
1312
+ }
1313
+
1314
+ /**
1315
+ * 入站转发目标会话(task-24 优先级):
1316
+ * ① `im-bots.json` 的 `wecom.sessionId`(配置固定;用户明确选的)
1317
+ * ② `activeSessionId`(最近活跃/最近事件)
1318
+ * ③ `sessions[0]`(follow 中按 updatedAt 最新)
1319
+ * ④ 兜底维持旧行为:`listSessions()` 里 running / 第一个
1320
+ */
1321
+ async function resolveSessionId(sub) {
1322
+ // ⓪ bridge 注入的显式取值器(历史钩子,最高优先;bridge 当前未使用)
1323
+ try {
1324
+ if (typeof options.sessionIdProvider === "function") {
1325
+ const got = String(options.sessionIdProvider() || "");
1326
+ if (got) return got;
1327
+ }
1328
+ } catch { /* 忽略 */ }
1329
+ if (configuredSessionId) return configuredSessionId;
1330
+ if (activeSession.id) return activeSession.id;
1331
+ if (sessionList.length === 0) {
1332
+ try { await refreshSessions({ force: true }); } catch { /* 忽略 */ }
1333
+ }
1334
+ if (sessionList.length) return sessionList[0].id;
1335
+ if (!sub || typeof sub.listSessions !== "function") return "";
1336
+ try {
1337
+ const r = await sub.listSessions();
1338
+ const list = r && r.ok && Array.isArray(r.sessions) ? r.sessions : [];
1339
+ const pick = list.find((s) => s && s.running) || list[0];
1340
+ return pick && typeof pick.sessionId === "string" ? pick.sessionId : "";
1341
+ } catch {
1342
+ return "";
1343
+ }
1344
+ }
1345
+
1346
+ // ---------- 被动回复 / 主动推送 ----------
1347
+
1348
+ /** 被动回复(同一条入站消息的 req_id 上回一个 stream 帧)。 */
1349
+ function replyStream(frame, id, content, finish = true) {
1350
+ const headers = frame && frame.headers && typeof frame.headers === "object" ? frame.headers : {};
1351
+ const inboundReqId = typeof headers.req_id === "string" ? headers.req_id : typeof headers.reqId === "string" ? headers.reqId : "";
1352
+ if (!inboundReqId) return { ok: false, reason: "no_req_id" };
1353
+ const ok = rawSend({
1354
+ cmd: "aibot_respond_msg",
1355
+ headers: { req_id: inboundReqId },
1356
+ body: { msgtype: "stream", stream: { id: String(id || `reply-${nextReqId("reply")}`), finish: finish === true, content: String(content == null ? "" : content) } }
1357
+ });
1358
+ return ok ? { ok: true } : { ok: false, reason: "socket_closed" };
1359
+ }
1360
+
1361
+ /** 回复一句人话:优先被动回复(不需要绑定),拿不到入站 req_id 时退回主动推送。 */
1362
+ function replyText(frame, text) {
1363
+ const content = truncateText(text, 500);
1364
+ const r = replyStream(frame, "", content, true);
1365
+ if (r.ok) return r;
1366
+ const target = state.boundChatId || targetChatId;
1367
+ if (target) return sendMarkdown(target, content);
1368
+ return { ok: false, reason: r.reason || "no_target" };
1369
+ }
1370
+
1371
+ /**
1372
+ * 主动推送 Markdown。
1373
+ * @param {string} chatid 单聊 = userid;群聊 = 群 chatid
1374
+ * @param {string} text
1375
+ * @returns {{ok:boolean, reqId?:string, reason?:string}}
1376
+ */
1377
+ function sendMarkdown(chatid, text) {
1378
+ const to = String(chatid || "").trim();
1379
+ const content = truncateText(text == null ? "" : text, NOTIFY_MAX_CHARS);
1380
+ if (!to) return { ok: false, reason: "no_chatid" };
1381
+ if (!content) return { ok: false, reason: "empty_text" };
1382
+ if (!state.authenticated) return { ok: false, reason: "not_authenticated" };
1383
+ const r = sendWithAck("send", "aibot_send_msg", {
1384
+ chatid: to,
1385
+ msgtype: "markdown",
1386
+ markdown: { content }
1387
+ }, (ack) => {
1388
+ // 官方契约只保证认证/心跳有 ack;推送帧**以发出为准**(没有 ack 不视为失败,不刷告警)。
1389
+ // 但若服务端明确回了非 0 errcode,必须记账 + 告警(例如机器人被禁用/被移出群)。
1390
+ if (ack && ack.ok !== true && ack.timeout !== true) {
1391
+ state.sendFailed += 1;
1392
+ log.warn(`主动推送被拒绝(errcode=${ack.errcode})`);
1393
+ }
1394
+ });
1395
+ if (r.ok) {
1396
+ state.sent += 1;
1397
+ log.debug(`主动推送已发出 chat=${chatIdHash(to)} len=${content.length}`);
1398
+ }
1399
+ return r;
1400
+ }
1401
+
1402
+ // ---------- 审批卡片(task-24) ----------
1403
+
1404
+ /** 卡片记录表清理(只影响「点旧卡」时的幂等/TTL 判定;上限 50 张)。 */
1405
+ function pruneApprovalCards() {
1406
+ const at = now();
1407
+ const cutoff = at - 10 * 60 * 1000;
1408
+ for (const [taskId, rec] of cardsByTask) {
1409
+ if (rec.sentAt < cutoff || rec.decidedAt && rec.decidedAt < cutoff) cardsByTask.delete(taskId);
1410
+ }
1411
+ for (const [rid, rec] of cardsByRequest) {
1412
+ if (!cardsByTask.has(rec.taskId)) cardsByRequest.delete(rid);
1413
+ }
1414
+ while (cardsByTask.size > APPROVAL_CARD_MAX) {
1415
+ const first = cardsByTask.keys().next();
1416
+ if (first.done) break;
1417
+ const rec = cardsByTask.get(first.value);
1418
+ cardsByTask.delete(first.value);
1419
+ if (rec) cardsByRequest.delete(rec.requestId);
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * 主动发一张**审批卡片**(button_interaction:允许一次 / 拒绝)。
1425
+ *
1426
+ * 与 markdown 通知的区别:卡片点按钮会回一个 `template_card_event`,据此走真实审批回执;
1427
+ * markdown 只能"通知你去电脑上点"。所以审批类事件优先发卡片(发不出去再退回 markdown)。
1428
+ *
1429
+ * @param {string} chatid 单聊 = userid;群聊 = 群 chatid
1430
+ * @param {{requestId:string, title?:string, summary?:string, toolName?:string}} info
1431
+ * @returns {{ok:boolean, taskId?:string, reqId?:string, reason?:string}}
1432
+ */
1433
+ function sendApprovalCard(chatid, info = {}) {
1434
+ const to = String(chatid || "").trim();
1435
+ const requestId = String(info.requestId || "").trim();
1436
+ if (!to) return { ok: false, reason: "no_chatid" };
1437
+ if (!requestId) return { ok: false, reason: "no_request_id" };
1438
+ if (!state.authenticated) return { ok: false, reason: "not_authenticated" };
1439
+ pruneApprovalCards();
1440
+ const card = buildApprovalCard({ requestId, title: info.title, summary: info.summary, toolName: info.toolName });
1441
+ const record = {
1442
+ taskId: card.task_id,
1443
+ requestId,
1444
+ chatid: to,
1445
+ sentAt: now(),
1446
+ decided: false,
1447
+ decidedAt: 0,
1448
+ decidedBy: "",
1449
+ decidedState: "pending",
1450
+ title: card.main_title && card.main_title.title,
1451
+ summary: card.sub_title_text,
1452
+ };
1453
+ cardsByTask.set(record.taskId, record);
1454
+ cardsByRequest.set(requestId, record);
1455
+ const r = sendWithAck("send", "aibot_send_msg", {
1456
+ chatid: to,
1457
+ msgtype: "template_card",
1458
+ template_card: card
1459
+ }, (ack) => {
1460
+ if (ack && ack.ok !== true && ack.timeout !== true) {
1461
+ state.sendFailed += 1;
1462
+ log.warn(`审批卡片被拒绝(errcode=${ack.errcode})`);
1463
+ }
1464
+ });
1465
+ if (!r.ok) {
1466
+ cardsByTask.delete(record.taskId);
1467
+ cardsByRequest.delete(requestId);
1468
+ return r;
1469
+ }
1470
+ state.sent += 1;
1471
+ state.cards += 1;
1472
+ // 只打长度/短 task_id:请求 id 与工具名都不进日志
1473
+ log.info(`审批卡片已发出 chat=${chatIdHash(to)} task=${record.taskId} 摘要长度=${(record.summary || "").length}`);
1474
+ return { ok: true, taskId: record.taskId, reqId: r.reqId };
1475
+ }
1476
+
1477
+ /**
1478
+ * 发一张**预览审批卡**(自检/演示):业主不用等真实审批就能看到卡片样式、验证按钮能不能点。
1479
+ *
1480
+ * 与 sendApprovalCard 的区别:按钮 key 带 `preview:`,点击时只更新卡片、**不调 onApprovalDecision**,
1481
+ * 也不进真实审批的幂等表/TTL 集合(独立 previewCards)。
1482
+ *
1483
+ * @param {string} chatid
1484
+ * @param {number} ts 触发时间戳(来自 im-bots.json 的 previewCardAt)
1485
+ */
1486
+ function sendPreviewCard(chatid, ts) {
1487
+ const to = String(chatid || "").trim();
1488
+ const stamp = Number(ts) || 0;
1489
+ if (!to) return { ok: false, reason: "no_chatid" };
1490
+ if (!stamp) return { ok: false, reason: "no_ts" };
1491
+ if (!state.authenticated) return { ok: false, reason: "not_authenticated" };
1492
+ const card = buildPreviewCard(stamp);
1493
+ const r = sendWithAck("send", "aibot_send_msg", {
1494
+ chatid: to,
1495
+ msgtype: "template_card",
1496
+ template_card: card
1497
+ }, (ack) => {
1498
+ if (ack && ack.ok !== true && ack.timeout !== true) {
1499
+ state.sendFailed += 1;
1500
+ log.warn(`预览卡片被拒绝(errcode=${ack.errcode})`);
1501
+ }
1502
+ });
1503
+ if (!r.ok) return r;
1504
+ state.sent += 1;
1505
+ state.previews += 1;
1506
+ previewCards.set(card.task_id, { ts: stamp, chatid: to, sentAt: now() });
1507
+ while (previewCards.size > PREVIEW_CARD_MAX) {
1508
+ const first = previewCards.keys().next();
1509
+ if (first.done) break;
1510
+ previewCards.delete(first.value);
1511
+ }
1512
+ log.info(`预览卡片已发出 chat=${chatIdHash(to)} ts=${String(stamp).slice(-10)}`);
1513
+ return { ok: true, taskId: card.task_id, reqId: r.reqId };
1514
+ }
1515
+
1516
+ /** 预览卡点击:只更新这张卡 + 回一句说明;**绝不调 onApprovalDecision**(没有真实操作)。 */
1517
+ function handlePreviewCardClick(frame, parsed, info = {}) {
1518
+ const who = info.fromUser || info.chatid || "企微用户";
1519
+ const taskId = String(info.taskId || "");
1520
+ const ts = Number((parsed.requestId.match(/^preview:(\d+)$/) || [])[1]) || 0;
1521
+ const card = buildPreviewDecidedCard({ decision: parsed.decision, who, at: now(), taskId, ts });
1522
+ const headers = frame && frame.headers && typeof frame.headers === "object" ? frame.headers : {};
1523
+ const reqId = typeof headers.req_id === "string" ? headers.req_id : typeof headers.reqId === "string" ? headers.reqId : "";
1524
+ if (reqId) {
1525
+ rawSend({
1526
+ cmd: "aibot_respond_update_msg",
1527
+ headers: { req_id: reqId },
1528
+ body: { response_type: "update_template_card", template_card: card }
1529
+ });
1530
+ }
1531
+ const replyChat = info.chatid || state.boundChatId || targetChatId;
1532
+ if (replyChat) sendMarkdown(replyChat, "这是预览卡片,没有对应的真实操作。");
1533
+ state.previewClicks += 1;
1534
+ log.info(`预览卡片点击 decision=${parsed.decision} from=${chatIdHash(who) || "?"}`);
1535
+ emitter.emit("preview-clicked", { decision: parsed.decision, taskId, fromUser: info.fromUser || "", at: now() });
1536
+ return { ok: true };
1537
+ }
1538
+
1539
+ /** 用事件帧的 req_id 更新卡片(按钮消失 → text_notice 结果卡)。 */
1540
+ function updateApprovalCard(frame, record, stateOverride) {
1541
+ const headers = frame && frame.headers && typeof frame.headers === "object" ? frame.headers : {};
1542
+ const reqId = typeof headers.req_id === "string" ? headers.req_id : typeof headers.reqId === "string" ? headers.reqId : "";
1543
+ if (!reqId) return { ok: false, reason: "no_req_id" };
1544
+ const card = buildDecidedCard({
1545
+ requestId: record.requestId,
1546
+ state: stateOverride || record.decidedState,
1547
+ who: record.decidedBy,
1548
+ at: record.decidedAt,
1549
+ reason: record.failReason
1550
+ });
1551
+ const ok = rawSend({
1552
+ cmd: "aibot_respond_update_msg",
1553
+ headers: { req_id: reqId },
1554
+ body: { response_type: "update_template_card", template_card: card }
1555
+ });
1556
+ return ok ? { ok: true } : { ok: false, reason: "socket_closed" };
1557
+ }
1558
+
1559
+ /**
1560
+ * 处理「用户点了审批卡片按钮」(template_card_event)。
1561
+ *
1562
+ * 幂等:同一张卡只消费一次——重复点击不再调回调,只把结果卡重放一遍 + 回一句"已处理"。
1563
+ * TTL:90s(与系统一致)之后点 = 过期 → **按拒绝处理**(fail-closed)并回调 deny。
1564
+ * 安全:`allowFrom` 白名单同样约束按钮点击(陌生人不能替业主拍板)。
1565
+ */
1566
+ async function handleApprovalCardEvent(frame) {
1567
+ const body = frame && frame.body && typeof frame.body === "object" ? frame.body : {};
1568
+ const ev = body.event && typeof body.event === "object" ? body.event : (body.event_type || body.eventtype ? body : {});
1569
+ const eventType = String(ev.eventtype || ev.event_type || body.eventtype || body.event_type || "");
1570
+ if (eventType && eventType !== "template_card_event") {
1571
+ log.debug(`未处理的卡片事件类型 event=${truncateText(eventType, 30)}`);
1572
+ return;
1573
+ }
1574
+ const eventKey = String(ev.event_key || ev.eventKey || "");
1575
+ const taskId = String(ev.task_id || ev.taskId || "");
1576
+ const parsed = parseApprovalEventKey(eventKey);
1577
+ const { fromUser, chatid, isGroup } = pickInboundIdentity(body);
1578
+ const senderId = fromUser || chatid;
1579
+ const replyChat = chatid || state.boundChatId || targetChatId;
1580
+
1581
+ state.lastMessageAt = now();
1582
+ writeStatus();
1583
+ // 只打 hash:企业微信 userid 是可识别信息
1584
+ log.info(`审批卡片点击 task=${taskId || "?"} decision=${parsed ? parsed.decision : "?"} from=${chatIdHash(senderId) || "?"}`);
1585
+
1586
+ if (allowFrom.length && !allowFrom.includes(fromUser) && !allowFrom.includes(chatid)) {
1587
+ state.rejected += 1;
1588
+ log.warn(`拒绝非白名单的审批点击 from=${chatIdHash(senderId) || "?"}`);
1589
+ if (replyChat) sendMarkdown(replyChat, "你没有这台电脑的审批权限。");
1590
+ emitter.emit("approval-rejected", { taskId, eventKey, fromUser, chatid, at: now() });
1591
+ return;
1592
+ }
1593
+ if (!parsed) {
1594
+ log.warn("审批卡片按钮无法识别(event_key 不合法或卡片已失效)");
1595
+ if (replyChat) sendMarkdown(replyChat, "⚠️ 这个按钮已经失效了(审批请求可能已结束)。请到电脑上确认。");
1596
+ return;
1597
+ }
1598
+ const requestId = parsed.requestId;
1599
+ // 预览卡:独立分支(不回调、不进幂等表、不受 90s TTL 约束;可反复点着看)
1600
+ if (isPreviewCardEvent(requestId, taskId) || previewCards.has(taskId)) {
1601
+ handlePreviewCardClick(frame, parsed, { taskId, fromUser, chatid, isGroup });
1602
+ return;
1603
+ }
1604
+ const record = (taskId && cardsByTask.get(taskId)) || cardsByRequest.get(requestId) || null;
1605
+ const who = fromUser || chatid || "企微用户";
1606
+
1607
+ // ① 幂等:同一张卡只处理一次
1608
+ if (record && record.decided) {
1609
+ updateApprovalCard(frame, record);
1610
+ const label = record.decidedState === "allowed" ? "已允许" : record.decidedState === "denied" ? "已拒绝" : record.decidedState === "expired" ? "已过期" : "已处理";
1611
+ if (replyChat) sendMarkdown(replyChat, `ℹ️ 这条审批已经处理过了(${label}),重复点击不会再执行一次。`);
1612
+ emitter.emit("approval-duplicate", { requestId, taskId: record.taskId, fromUser, chatid, at: now() });
1613
+ return;
1614
+ }
1615
+
1616
+ // ② TTL:过期(或记录已丢失 = 无法证明未过期)→ 按拒绝处理(fail-closed)
1617
+ const expired = !record || (now() - record.sentAt > approvalCardTtlMs);
1618
+ const target = record || {
1619
+ taskId: taskId || approvalTaskId(requestId),
1620
+ requestId,
1621
+ chatid: replyChat || "",
1622
+ sentAt: 0,
1623
+ decided: false,
1624
+ decidedAt: 0,
1625
+ decidedBy: "",
1626
+ decidedState: "pending",
1627
+ title: "",
1628
+ summary: ""
1629
+ };
1630
+ const decision = expired ? "deny" : parsed.decision;
1631
+ target.decided = true;
1632
+ target.decidedAt = now();
1633
+ target.decidedBy = who;
1634
+ target.decidedState = expired ? "expired" : decision === "allow" ? "allowed" : "denied";
1635
+ cardsByTask.set(target.taskId, target);
1636
+ cardsByRequest.set(requestId, target);
1637
+
1638
+ // ③ 调注入的决策回调(bridge 内部路由到 approvalCoordinator / pushReporter,与手机拍板同一条路)
1639
+ let result = { ok: false, reason: "审批通道未就绪" };
1640
+ if (onApprovalDecision) {
1641
+ try {
1642
+ const r = await Promise.resolve(onApprovalDecision(requestId, decision, {
1643
+ source: "wecom-card",
1644
+ taskId: target.taskId,
1645
+ chatid: target.chatid,
1646
+ fromUser,
1647
+ isGroup,
1648
+ expired,
1649
+ at: now()
1650
+ }));
1651
+ result = r && typeof r === "object" ? r : { ok: r !== false };
1652
+ } catch (e) {
1653
+ result = { ok: false, reason: truncateText(e && e.message ? e.message : e, 80) };
1654
+ }
1655
+ }
1656
+
1657
+ if (expired) {
1658
+ // 过期也要求"回调 deny"(幂等:DSH 侧若已按未批准处理,回执会失败,但卡片必须显示真相)
1659
+ updateApprovalCard(frame, target);
1660
+ if (replyChat) sendMarkdown(replyChat, `⌛ 这条审批已过期(超过 ${Math.round(approvalCardTtlMs / 1000)} 秒),已按「拒绝」处理。请到电脑上重新发起。`);
1661
+ emitter.emit("approval-expired", { requestId, taskId: target.taskId, fromUser, chatid, decision: "deny", result, at: now() });
1662
+ return;
1663
+ }
1664
+
1665
+ if (result && result.ok) {
1666
+ state.approvalDecisions += 1;
1667
+ updateApprovalCard(frame, target);
1668
+ const timeText = fmtClockFromMs(target.decidedAt);
1669
+ if (replyChat) {
1670
+ sendMarkdown(replyChat, decision === "allow"
1671
+ ? `✅ 已允许一次(${who} · ${timeText})`
1672
+ : `❌ 已拒绝(${who} · ${timeText})`);
1673
+ }
1674
+ log.info(`审批卡片处理完成 decision=${decision} task=${target.taskId}`);
1675
+ emitter.emit("approval-decided", { requestId, taskId: target.taskId, decision, fromUser, chatid, via: result.via, at: now() });
1676
+ return;
1677
+ }
1678
+
1679
+ // ④ 没送达电脑端(例如手机上已经拍过板 / DSH 侧已过期):如实说,卡片显示失败
1680
+ target.decidedState = "failed";
1681
+ target.failReason = truncateText((result && result.reason) || "没有可用的审批通道", 60);
1682
+ updateApprovalCard(frame, target);
1683
+ if (replyChat) sendMarkdown(replyChat, `⚠️ 没能送达电脑端:${target.failReason}。请到电脑上确认。`);
1684
+ log.warn(`审批卡片未能送达电脑端:${target.failReason}`);
1685
+ emitter.emit("approval-failed", { requestId, taskId: target.taskId, decision, fromUser, chatid, reason: target.failReason, at: now() });
1686
+ }
1687
+
1688
+ // ---------- DSH 事件订阅(出站通知 + 入站转发共用) ----------
1689
+
1690
+ function ensureSubscriber() {
1691
+ if (subscriber) { wireSubscriber(); return; }
1692
+ if (options.subscriber) {
1693
+ subscriber = options.subscriber;
1694
+ ownsSubscriber = false;
1695
+ } else if (typeof options.createSubscriber === "function") {
1696
+ subscriber = options.createSubscriber();
1697
+ ownsSubscriber = true;
1698
+ } else if (options.upstream !== undefined) {
1699
+ try {
1700
+ subscriber = createEventSubscriber({
1701
+ upstream: typeof options.upstream === "function" ? options.upstream : String(options.upstream || ""),
1702
+ cookie: typeof options.cookie === "function" ? options.cookie : (options.cookie || ""),
1703
+ follow: true,
1704
+ log: (level, message) => {
1705
+ const line = `[events] ${message}`;
1706
+ if (level === "warn" || level === "error") log.warn(line); else log.debug(line);
1707
+ }
1708
+ });
1709
+ ownsSubscriber = true;
1710
+ } catch (e) {
1711
+ log.warn(`事件订阅器创建失败(出站通知/入站转发不可用):${truncateText(e && e.message ? e.message : e, 120)}`);
1712
+ subscriber = null;
1713
+ }
1714
+ }
1715
+ wireSubscriber();
1716
+ if (subscriber && typeof subscriber.start === "function") {
1717
+ try { subscriber.start(); } catch (e) {
1718
+ log.warn(`事件订阅器启动失败:${truncateText(e && e.message ? e.message : e, 120)}`);
1719
+ }
1720
+ }
1721
+ }
1722
+
1723
+ function wireSubscriber() {
1724
+ const sub = subscriber;
1725
+ if (!sub || wired) return;
1726
+ wired = true;
1727
+ try {
1728
+ sub.on("event", (node) => onEventNode(node));
1729
+ // 会话可选绑定(task-24):活跃会话 + 会话列表元数据随事件刷新(合并后写状态文件)
1730
+ sub.on("session-state", (info) => {
1731
+ if (!info || typeof info.sessionId !== "string" || !info.sessionId) return;
1732
+ if (info.state === "running") activeSession.id = info.sessionId;
1733
+ else if (info.state === "removed" && activeSession.id === info.sessionId) activeSession.id = "";
1734
+ scheduleSessionRefresh();
1735
+ });
1736
+ sub.on("control-activity", (info) => {
1737
+ if (info && typeof info.sessionId === "string" && info.sessionId) activeSession.id = info.sessionId;
1738
+ scheduleSessionRefresh();
1739
+ });
1740
+ sub.on("control-sessions", () => scheduleSessionRefresh());
1741
+ sub.on("sessions-discovered", () => scheduleSessionRefresh());
1742
+ sub.on("ready", () => scheduleSessionRefresh(true));
1743
+ sub.on("gap", (info) => {
1744
+ const from = info && info.from ? new Date(info.from).toISOString() : "?";
1745
+ const to = info && info.to ? new Date(info.to).toISOString() : "?";
1746
+ log.warn(`事件订阅断线窗口 ${from} → ${to},期间通知不可补发`);
1747
+ });
1748
+ sub.on("auth-error", (info) => {
1749
+ log.warn(`事件订阅鉴权失败(${(info && info.surface) || "?"}):需要刷新 dsh web 会话 cookie`);
1750
+ });
1751
+ sub.on("fault", (info) => {
1752
+ const expected = info && info.expected === true;
1753
+ const msg = `事件订阅异常 code=${(info && info.code) || "?"}${info && info.sessionId ? ` session=${shortSession(info.sessionId)}` : ""}`;
1754
+ if (expected) log.debug(msg); else log.warn(`${msg}:${truncateText(info && info.message, 80)}`);
1755
+ });
1756
+ } catch (e) {
1757
+ log.warn(`挂事件处理器失败:${truncateText(e && e.message ? e.message : e, 120)}`);
1758
+ }
1759
+ }
1760
+
1761
+ /**
1762
+ * 这个 turn/end 是不是「我们刚转发进去的那次对话」的收尾?
1763
+ *
1764
+ * 判据(宁可不回传,也不要瞎回传):
1765
+ * · 必须**实时** turn/end(`fromSnapshot` = 重连时补报的旧 turn,绝不能拿旧回答当新回答);
1766
+ * · 会话必须是最近一次入站转发成功的那个;
1767
+ * · 距那次入站 < TTL(默认 10 分钟)—— 超时就当另一次对话,走原来的标题通知;
1768
+ * · 事件时间不能早于入站时间(给 5s 时钟/批处理余量)。
1769
+ */
1770
+ function shouldRelayAnswer(node) {
1771
+ if (!answerRelayEnabled) return false;
1772
+ if (node.kind !== NODE_KINDS.TURN_END) return false;
1773
+ if (node.fromSnapshot === true) return false;
1774
+ if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
1775
+ const at = now();
1776
+ if (at - lastInbound.at >= answerRelayTtlMs) return false;
1777
+ if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
1778
+ return true;
1779
+ }
1780
+
1781
+ /**
1782
+ * 回答回传:把 agent 的最终回答正文发回企微(不再发「✅ 任务完成|标题」那条)。
1783
+ *
1784
+ * 取正文用事件订阅器的 `lastAssistantText({sessionId})`(dsh-events 已有,永不抛);
1785
+ * 取不到 → **降级退回原来的标题通知**(不丢消息,也不假装有正文);
1786
+ * 日志只打长度/会话短 id,**绝不打印正文**(回答里可能有代码/密钥/隐私)。
1787
+ */
1788
+ async function relayAnswer(node, sessionId) {
1789
+ const target = state.boundChatId || targetChatId;
1790
+ if (!target) {
1791
+ const at = now();
1792
+ if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
1793
+ lastNoTargetLogAt = at;
1794
+ log.info("有回答要回传,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
1795
+ }
1796
+ return;
1797
+ }
1798
+ let text = "";
1799
+ let why = "";
1800
+ const sub = subscriber;
1801
+ if (!sub || typeof sub.lastAssistantText !== "function") {
1802
+ why = "no_subscriber";
1803
+ } else {
1804
+ for (const delay of ANSWER_FETCH_RETRY_DELAYS_MS) {
1805
+ if (delay > 0) {
1806
+ await sleepTimer(delay);
1807
+ if (stopped) return; // 等待期间桥已退出:不再发
1808
+ }
1809
+ try {
1810
+ const r = await sub.lastAssistantText({ sessionId });
1811
+ if (r && r.ok && typeof r.text === "string" && r.text.trim()) {
1812
+ text = r.text.trim();
1813
+ break;
1814
+ }
1815
+ why = String((r && (r.code || r.message)) || "empty");
1816
+ // 硬失败(page-failed / session-not-found)重试也没意义;只有「ok 但正文还没落库」才补一次
1817
+ if (!(r && r.ok)) break;
1818
+ } catch (e) {
1819
+ why = truncateText(e && e.message ? e.message : e, 60);
1820
+ break;
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ // 降级:拿不到正文 → 退回标题通知(与改动前的行为一致)
1826
+ if (!text) {
1827
+ const fallback = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
1828
+ if (!fallback) return;
1829
+ if (!notifyRateOk()) {
1830
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
1831
+ return;
1832
+ }
1833
+ state.notified += 1;
1834
+ sendMarkdown(target, fallback);
1835
+ log.info(`取不到回答正文(${truncateText(why || "empty", 60)}),已降级为标题通知`);
1836
+ return;
1837
+ }
1838
+
1839
+ const truncated = text.length > answerMaxChars;
1840
+ let body = truncated ? `${text.slice(0, answerMaxChars)}${ANSWER_TRUNCATED_NOTE}` : text;
1841
+ // 非正常结束(出错/被拦/中止)时保留一句状态行,避免"看到回答却不知道它不完整"
1842
+ const reasonKind = String(node.reason || "");
1843
+ if (reasonKind && reasonKind !== "completed") {
1844
+ const status = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
1845
+ if (status) body = `${status}\n\n${body}`;
1846
+ }
1847
+ if (!notifyRateOk()) {
1848
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
1849
+ return;
1850
+ }
1851
+ state.notified += 1;
1852
+ state.answers += 1;
1853
+ const sent = sendMarkdown(target, body);
1854
+ // 只打长度与截断标记:正文绝不能进日志
1855
+ log.info(`回答回传 chat=${chatIdHash(target)} len=${body.length}${truncated ? "(已截断)" : ""}${sent && sent.ok ? "" : ` 发送失败=${(sent && sent.reason) || "?"}`}`);
1856
+ }
1857
+
1858
+ function onEventNode(node) {
1859
+ if (!node || typeof node !== "object") return;
1860
+ if (typeof node.sessionId === "string" && node.sessionId) {
1861
+ activeSession.id = node.sessionId;
1862
+ scheduleSessionRefresh();
1863
+ }
1864
+ if (stopped || !notifyAllowed || !state.authenticated) return;
1865
+ // ── 回答回传:入站后的同一会话 turn/end → 发回答正文(消费一次)──
1866
+ if (shouldRelayAnswer(node)) {
1867
+ const key = notifyKey(node);
1868
+ if (key && !rememberNotifyKey(key)) return;
1869
+ const sid = node.sessionId;
1870
+ lastInbound = { sessionId: "", at: 0 }; // 同一次入站只回传一次
1871
+ void relayAnswer(node, sid);
1872
+ return;
1873
+ }
1874
+ // ── 审批类事件:优先发**可点按钮的卡片**(允许一次/拒绝),发不出去再退回 markdown ──
1875
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
1876
+ const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
1877
+ const key = notifyKey(node);
1878
+ if (key && !rememberNotifyKey(key)) return;
1879
+ const requestId = String(node.eventId || "");
1880
+ const target = state.boundChatId || targetChatId;
1881
+ if (!target) {
1882
+ const at = now();
1883
+ if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
1884
+ lastNoTargetLogAt = at;
1885
+ log.info("有审批要推,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
1886
+ }
1887
+ return;
1888
+ }
1889
+ if (!notifyRateOk()) {
1890
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
1891
+ return;
1892
+ }
1893
+ state.notified += 1;
1894
+ if (approvalCardsEnabled && requestId) {
1895
+ const r = sendApprovalCard(target, {
1896
+ requestId,
1897
+ title: "需要你在电脑上拍板",
1898
+ summary: node.reason || node.toolName || "",
1899
+ toolName: node.toolName || ""
1900
+ });
1901
+ if (r.ok) return;
1902
+ log.warn(`审批卡片发送失败(${r.reason}),退回文本通知`);
1903
+ }
1904
+ if (markdown) sendMarkdown(target, markdown);
1905
+ return;
1906
+ }
1907
+ const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
1908
+ if (!markdown) return;
1909
+ const key = notifyKey(node);
1910
+ if (key && !rememberNotifyKey(key)) return;
1911
+ const target = state.boundChatId || targetChatId;
1912
+ if (!target) {
1913
+ // 首次使用:用户还没给机器人发过话,也没有配 targetChatId → 无处可推(如实记日志,不刷屏)
1914
+ const at = now();
1915
+ if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
1916
+ lastNoTargetLogAt = at;
1917
+ log.info("有通知要发,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
1918
+ }
1919
+ return;
1920
+ }
1921
+ if (!notifyRateOk()) {
1922
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
1923
+ return;
1924
+ }
1925
+ state.notified += 1;
1926
+ sendMarkdown(target, markdown);
1927
+ }
1928
+
1929
+ function notifyKey(node) {
1930
+ const sid = String(node.sessionId || "");
1931
+ if (node.kind === NODE_KINDS.TURN_END) return `turn:${sid}:${node.turn ?? "?"}:${node.seq ?? "?"}:${node.reason || "?"}`;
1932
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.USER_QUESTION || node.kind === NODE_KINDS.PLAN_REVIEW) {
1933
+ return `approval:${node.eventId || ""}:${sid}`;
1934
+ }
1935
+ if (node.kind === NODE_KINDS.SESSION_ERROR) return `error:${sid}:${node.code || node.message || ""}:${node.at || ""}`;
1936
+ return "";
1937
+ }
1938
+
1939
+ function rememberNotifyKey(key) {
1940
+ const at = now();
1941
+ // 有界 + TTL:Map 保插入序,超上限丢最旧
1942
+ for (const [k, t] of notifiedKeys) {
1943
+ if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k);
1944
+ else break;
1945
+ }
1946
+ if (notifiedKeys.has(key)) return false;
1947
+ notifiedKeys.set(key, at);
1948
+ if (notifiedKeys.size > 200) {
1949
+ const first = notifiedKeys.keys().next();
1950
+ if (!first.done) notifiedKeys.delete(first.value);
1951
+ }
1952
+ return true;
1953
+ }
1954
+
1955
+ function notifyRateOk() {
1956
+ const at = now();
1957
+ while (notifyTimes.length && at - notifyTimes[0] > 60_000) notifyTimes.shift();
1958
+ if (notifyTimes.length >= maxNotifyPerMinute) return false;
1959
+ notifyTimes.push(at);
1960
+ return true;
1961
+ }
1962
+
1963
+ function pruneSeen() {
1964
+ const at = now();
1965
+ for (const [k, t] of seenMsgs) {
1966
+ if (at - t > SEEN_MSG_TTL_MS) seenMsgs.delete(k);
1967
+ else break;
1968
+ }
1969
+ if (seenMsgs.size <= SEEN_MSG_MAX) return;
1970
+ const over = seenMsgs.size - SEEN_MSG_MAX;
1971
+ let i = 0;
1972
+ for (const k of seenMsgs.keys()) {
1973
+ if (i >= over) break;
1974
+ seenMsgs.delete(k);
1975
+ i += 1;
1976
+ }
1977
+ }
1978
+
1979
+ // ---------- 对外 API ----------
1980
+
1981
+ function emitStatus() {
1982
+ try { emitter.emit("status", status()); } catch { /* 忽略 */ }
1983
+ }
1984
+
1985
+ /** 只读状态快照(**不含 secret/完整帧**;面板/诊断/测试都读它)。 */
1986
+ function status() {
1987
+ return {
1988
+ enabled: Boolean(botId && secret) && !stopped,
1989
+ running: state.running === true,
1990
+ connected: state.connected === true,
1991
+ authenticated: state.authenticated === true,
1992
+ authFailed: state.authFailed === true,
1993
+ lastError: truncateText(maskSecrets(state.lastError, [secret]), 160),
1994
+ lastConnectedAt: Number(state.lastConnectedAt) || 0,
1995
+ lastMessageAt: Number(state.lastMessageAt) || 0,
1996
+ boundChatId: state.boundChatId || "",
1997
+ targetChatId: targetChatId || "",
1998
+ reconnectAttempts: state.reconnectAttempts,
1999
+ nextRetryInMs: state.nextRetryInMs,
2000
+ lastPingAt: state.lastPingAt,
2001
+ lastPongAt: state.lastPongAt,
2002
+ inbound: state.inbound,
2003
+ rejected: state.rejected,
2004
+ notified: state.notified,
2005
+ answers: state.answers,
2006
+ sent: state.sent,
2007
+ sendFailed: state.sendFailed,
2008
+ subscriber: subscriber && typeof subscriber.state === "string" ? subscriber.state : "none",
2009
+ // 会话可选绑定(task-24):只读元数据,不含消息正文
2010
+ sessions: normalizeSessionStatusList(sessionList),
2011
+ activeSessionId: activeSession.id || "",
2012
+ activeSessionTitle: titleOfSession(activeSession.id, activeSession.title),
2013
+ boundSessionId: boundSessionId || "",
2014
+ previews: state.previews,
2015
+ cards: state.cards,
2016
+ approvalDecisions: state.approvalDecisions,
2017
+ previews: state.previews,
2018
+ previewClicks: state.previewClicks
2019
+ };
2020
+ }
2021
+
2022
+ return {
2023
+ /** 启动:连上 → 认证 → 心跳 → 挂事件订阅(同步返回;连接异步推进)。 */
2024
+ start() {
2025
+ if (!stopped) return status();
2026
+ if (!botId || !secret) {
2027
+ log.warn("未配置 Bot ID / Secret,不启动长连接");
2028
+ state.lastError = "未配置 Bot ID / Secret";
2029
+ writeStatus();
2030
+ return status();
2031
+ }
2032
+ stopped = false;
2033
+ state.running = true;
2034
+ state.authFailed = false;
2035
+ emitStatus();
2036
+ writeStatus();
2037
+ ensureSubscriber();
2038
+ connect();
2039
+ return status();
2040
+ },
2041
+
2042
+ /** 停止:清定时器、关订阅(只关自己创建的)、关连接、写最后一次状态。 */
2043
+ async stop() {
2044
+ if (stopped) return status();
2045
+ stopped = true;
2046
+ state.running = false;
2047
+ state.connected = false;
2048
+ state.authenticated = false;
2049
+ state.nextRetryInMs = 0;
2050
+ try { if (reconnectTimer) clearTimeout(reconnectTimer); } catch { /* 忽略 */ }
2051
+ reconnectTimer = null;
2052
+ stopHeartbeat();
2053
+ clearAuthTimer();
2054
+ for (const [, p] of pending) { try { clearTimeout(p.timer); } catch { /* 忽略 */ } }
2055
+ pending.clear();
2056
+ if (ownsSubscriber && subscriber && typeof subscriber.close === "function") {
2057
+ try { subscriber.close(); } catch { /* 忽略 */ }
2058
+ }
2059
+ subscriber = null;
2060
+ wired = false;
2061
+ dropSocket();
2062
+ writeStatus();
2063
+ emitStatus();
2064
+ log.info("长连接已停止");
2065
+ return status();
2066
+ },
2067
+
2068
+ sendMarkdown,
2069
+ sendApprovalCard,
2070
+ sendPreviewCard,
2071
+ replyStream,
2072
+ status,
2073
+ /** 事件:message / bot-event / authenticated / auth-failed / status / inbound-*(EventEmitter 语义)。 */
2074
+ on(event, listener) { emitter.on(event, listener); return () => emitter.off(event, listener); },
2075
+ once(event, listener) { emitter.once(event, listener); return () => emitter.off(event, listener); },
2076
+ off(event, listener) { emitter.off(event, listener); },
2077
+ /** 仅测试/诊断:内部日志器(lines 里可断言 secret 从未出现)。 */
2078
+ _logger: log
2079
+ };
2080
+ }
2081
+
2082
+ /** 可等待的短延迟(unref:不阻止进程退出)。 */
2083
+ function sleepTimer(ms) {
2084
+ return new Promise((resolve) => {
2085
+ const t = setTimeout(resolve, Math.max(0, ms));
2086
+ if (t && typeof t.unref === "function") t.unref();
2087
+ });
2088
+ }
2089
+
2090
+ function positiveNumber(value, fallback) {
2091
+ const n = Number(value);
2092
+ return Number.isFinite(n) && n > 0 ? n : fallback;
2093
+ }
2094
+
2095
+ // ===========================================================================
2096
+ // 6. 控制器:配置读取 + 热更新(bridge 只需要这一个入口)
2097
+ // ===========================================================================
2098
+
2099
+ /**
2100
+ * 创建企业微信长连接控制器(bridge 侧入口)。
2101
+ *
2102
+ * 职责(与 task-21 §3 对应):
2103
+ * · 默认**关闭**:没有 `im-bots.json` / `wecom.enabled !== true` / 缺 botId+secret → 不起连接;
2104
+ * · 启动时读配置;`fs.watchFile(2s)` 监听变化 → 配置变了才重建连接(禁用/改凭据/改 targetChatId 都生效);
2105
+ * · 退出时 stop(等长连接真正关掉,最多等 close 事件兜底)。
2106
+ *
2107
+ * 环境开关(都可选,默认不拦):
2108
+ * · `DSH_IM_BOTS=0` —— 整个 IM 机器人功能强制关闭(连配置都不看)
2109
+ * · `DSH_IM_BOT_NOTIFY=0` —— 只关出站通知(入站转发照常)
2110
+ * · `DSH_PUSH_REPORT=0` —— 既有推送总闸,同样关掉本通知(保持一致)
2111
+ *
2112
+ * @param {{
2113
+ * relayDir: string,
2114
+ * log?: Function|object,
2115
+ * env?: Record<string,string|undefined>,
2116
+ * clock?: () => number,
2117
+ * configPollMs?: number,
2118
+ * notify?: boolean,
2119
+ * createBot?: (opts:object)=>any, // 仅测试:替换 bot 工厂
2120
+ * botOptions?: object, // 透传给 createWecomBot(upstream/cookie/sessionTitle…)
2121
+ * onApprovalDecision?: (requestId, decision, meta) => Promise<{ok, reason?, via?}|boolean>,
2122
+ * // 审批卡片点击回调(bridge 注入;与手机拍板同一条路)
2123
+ * }} options
2124
+ */
2125
+ export function createWecomBotController(options = {}) {
2126
+ const relayDir = String(options.relayDir || "");
2127
+ const env = options.env && typeof options.env === "object" ? options.env : process.env;
2128
+ const now = typeof options.clock === "function" ? options.clock : () => Date.now();
2129
+ const pollMs = positiveNumber(options.configPollMs, CONFIG_POLL_MS);
2130
+ const log = createScopedLogger({ sink: options.log, prefix: "[wecom]" });
2131
+ const configFile = path.join(relayDir, IM_BOTS_FILE);
2132
+ const statusFile = path.join(relayDir, IM_BOT_STATUS_FILE);
2133
+ const botFactory = typeof options.createBot === "function" ? options.createBot : createWecomBot;
2134
+
2135
+ const state = {
2136
+ started: false,
2137
+ enabled: false,
2138
+ configFound: false,
2139
+ reason: "not_started",
2140
+ configUpdatedAt: 0,
2141
+ lastError: ""
2142
+ };
2143
+
2144
+ let bot = null;
2145
+ let currentDigest = "";
2146
+ let listener = null;
2147
+ let reloading = false;
2148
+ /**
2149
+ * 「预览卡片」已处理到的 previewCardAt(跨重启从状态文件 `wecom.lastPreviewCardAt` 恢复,
2150
+ * 保证**相同值不重复发**;插件每点一次「发送预览」写一个新时间戳)。
2151
+ */
2152
+ let lastPreviewAt = 0;
2153
+ if (relayDir) {
2154
+ try {
2155
+ const st = readJsonFile(statusFile);
2156
+ lastPreviewAt = Number(st && st.wecom && st.wecom.lastPreviewCardAt) || 0;
2157
+ } catch { /* 忽略 */ }
2158
+ }
2159
+
2160
+ function notifyAllowed() {
2161
+ if (String(env.DSH_IM_BOT_NOTIFY || "") === "0") return false;
2162
+ if (String(env.DSH_PUSH_REPORT || "") === "0") return false;
2163
+ return options.notify !== false;
2164
+ }
2165
+
2166
+ function hardDisabled() {
2167
+ return String(env.DSH_IM_BOTS || "") === "0";
2168
+ }
2169
+
2170
+ /** 配置摘要(**含 secret 的 sha1**,只在内存里比较;绝不落文件/日志)。 */
2171
+ function digestOf(cfg) {
2172
+ if (!cfg) return "missing";
2173
+ const w = cfg.wecom || {};
2174
+ let secretHash = "";
2175
+ try { secretHash = w.secret ? crypto.createHash("sha1").update(String(w.secret)).digest("hex") : ""; } catch { secretHash = "?"; }
2176
+ return JSON.stringify([
2177
+ w.enabled === true, w.botId || "", secretHash, w.url || "",
2178
+ w.targetChatId || "", w.allowFrom || [], notifyAllowed(),
2179
+ // 会话可选绑定(task-24):改了固定会话就要重建 bot(转发目标立即生效,无需重启 bridge)
2180
+ w.sessionId || "", w.sessionTitle || ""
2181
+ ]);
2182
+ }
2183
+
2184
+ /** 写「未启用」状态(配置存在但关着/不全时也要让插件看到原因)。 */
2185
+ function writeDisabledStatus(reason) {
2186
+ if (!relayDir) return;
2187
+ try {
2188
+ const prev = readJsonFile(statusFile);
2189
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
2190
+ atomicWriteJson(statusFile, {
2191
+ ...base,
2192
+ version: 1,
2193
+ updatedAt: Number(now()) || Date.now(),
2194
+ wecom: {
2195
+ ...(base.wecom && typeof base.wecom === "object" ? base.wecom : {}),
2196
+ enabled: false,
2197
+ connected: false,
2198
+ authenticated: false,
2199
+ lastError: truncateText(reason, 160),
2200
+ boundChatId: "",
2201
+ targetChatId: "",
2202
+ sessions: [],
2203
+ activeSessionId: "",
2204
+ activeSessionTitle: "",
2205
+ boundSessionId: ""
2206
+ }
2207
+ }, { onWarn: (m) => log.warn(m) });
2208
+ } catch (e) {
2209
+ log.warn(`状态文件写入失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
2210
+ }
2211
+ }
2212
+
2213
+ async function stopBot() {
2214
+ const b = bot;
2215
+ bot = null;
2216
+ if (!b) return;
2217
+ try { await b.stop(); } catch (e) {
2218
+ log.warn(`停止长连接失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
2219
+ }
2220
+ }
2221
+
2222
+ /** 把「已处理的 previewCardAt」写进状态文件(跨重启防重复;面板只读,不依赖它)。 */
2223
+ function writePreviewMarker(at) {
2224
+ if (!relayDir) return;
2225
+ try {
2226
+ const prev = readJsonFile(statusFile);
2227
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
2228
+ atomicWriteJson(statusFile, {
2229
+ ...base,
2230
+ version: 1,
2231
+ updatedAt: Number(now()) || Date.now(),
2232
+ wecom: { ...(base.wecom && typeof base.wecom === "object" ? base.wecom : {}), lastPreviewCardAt: Number(at) || 0 }
2233
+ }, { onWarn: (m) => log.warn(m) });
2234
+ } catch (e) {
2235
+ log.warn(`预览标记写入失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
2236
+ }
2237
+ }
2238
+
2239
+ /** 认证后按当前绑定关系发预览卡(无接收方 → 只记日志,不报错)。 */
2240
+ function sendPreviewNow(targetBot, at) {
2241
+ if (targetBot !== bot) return; // 等待期间连接被重建/停用 → 不再发
2242
+ const st = typeof targetBot.status === "function" ? targetBot.status() : {};
2243
+ const to = String(st.boundChatId || st.targetChatId || "");
2244
+ if (!to) {
2245
+ log.info("预览卡片跳过:还没有绑定接收方(先在企业微信里给机器人发一句话,再点一次「发送预览」)");
2246
+ return;
2247
+ }
2248
+ const r = targetBot.sendPreviewCard(to, at);
2249
+ if (r && r.ok) log.info(`预览卡片已按配置发送 chat=${chatIdHash(to)}`);
2250
+ else log.warn(`预览卡片发送失败(${(r && r.reason) || "?"})`);
2251
+ }
2252
+
2253
+ /**
2254
+ * 处理「发送预览卡片」信号(im-bots.json 的 wecom.previewCardAt)。
2255
+ *
2256
+ * 规则:previewCardAt > 上次已处理值(且 >0)→ 向 boundChatId || targetChatId 发一张预览卡;
2257
+ * 相同值绝不重复发(先记再发,watchFile 抖动也只会处理一次)。连接配置没变时**不重建连接**。
2258
+ * bot 还没认证时最多等 20s(认证事件驱动),超时放弃(下次插件再点一次即可)。
2259
+ */
2260
+ async function handlePreview(cfg) {
2261
+ try {
2262
+ const w = cfg && cfg.wecom && typeof cfg.wecom === "object" ? cfg.wecom : null;
2263
+ if (!w || w.enabled !== true || !w.botId || !w.secret) return;
2264
+ const at = Number(w.previewCardAt) || 0;
2265
+ if (!(at > lastPreviewAt)) return;
2266
+ lastPreviewAt = at; // 先记账:同一值不重复发
2267
+ writePreviewMarker(at);
2268
+ const b = bot;
2269
+ if (!b || typeof b.sendPreviewCard !== "function") return;
2270
+ const st = typeof b.status === "function" ? b.status() : {};
2271
+ if (st.authenticated === true) { sendPreviewNow(b, at); return; }
2272
+ await new Promise((resolve) => {
2273
+ let done = false;
2274
+ let off = null;
2275
+ const timer = setTimeout(finish, 20_000);
2276
+ if (timer && typeof timer.unref === "function") timer.unref();
2277
+ function finish() {
2278
+ if (done) return;
2279
+ done = true;
2280
+ try { clearTimeout(timer); } catch { /* 忽略 */ }
2281
+ try { if (typeof off === "function") off(); } catch { /* 忽略 */ }
2282
+ sendPreviewNow(b, at);
2283
+ resolve();
2284
+ }
2285
+ try { off = typeof b.on === "function" ? b.on("authenticated", finish) : null; } catch { off = null; }
2286
+ });
2287
+ } catch (e) {
2288
+ log.warn(`预览卡片处理失败(忽略):${truncateText(e && e.message ? e.message : e, 120)}`);
2289
+ }
2290
+ }
2291
+
2292
+ /** 按当前配置应用(幂等:连接配置没变就只处理预览等"不重建连接"的信号)。 */
2293
+ async function applyConfig() {
2294
+ if (reloading) return; // 单飞:watch 回调可能连着触两次
2295
+ reloading = true;
2296
+ try {
2297
+ if (hardDisabled()) {
2298
+ state.enabled = false;
2299
+ state.reason = "disabled_by_env";
2300
+ await stopBot();
2301
+ return;
2302
+ }
2303
+ const cfg = readImBotsConfig(relayDir);
2304
+ state.configFound = cfg !== null;
2305
+ const digest = digestOf(cfg); // 只含"连接配置"(不含 previewCardAt:预览不需要重建连接)
2306
+ if (digest === currentDigest) {
2307
+ // 连接配置没变(watchFile 会因 mtime 反复触发)→ 仍要处理「发送预览」信号
2308
+ await handlePreview(cfg);
2309
+ return;
2310
+ }
2311
+ currentDigest = digest;
2312
+ await stopBot();
2313
+ if (!cfg) {
2314
+ state.enabled = false;
2315
+ state.reason = "no_config";
2316
+ log.info("未找到 im-bots.json,IM 机器人通道不启动");
2317
+ return;
2318
+ }
2319
+ state.configUpdatedAt = Number(now()) || Date.now();
2320
+ const w = cfg.wecom;
2321
+ if (!w.enabled) {
2322
+ state.enabled = false;
2323
+ state.reason = "disabled";
2324
+ log.info("im-bots.json 里 wecom.enabled=false,IM 机器人通道不启动");
2325
+ writeDisabledStatus("未启用(配置里 wecom.enabled=false)");
2326
+ return;
2327
+ }
2328
+ if (!w.botId || !w.secret) {
2329
+ state.enabled = false;
2330
+ state.reason = "missing_credentials";
2331
+ log.warn("im-bots.json 里缺少 botId/secret,IM 机器人通道不启动");
2332
+ writeDisabledStatus("缺少 Bot ID / Secret(请在插件「企业微信机器人」里填写)");
2333
+ return;
2334
+ }
2335
+ const botOptions = {
2336
+ botId: w.botId,
2337
+ secret: w.secret,
2338
+ url: w.url || undefined,
2339
+ relayDir,
2340
+ targetChatId: w.targetChatId,
2341
+ allowFrom: w.allowFrom,
2342
+ // 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话;状态文件里写 boundSessionId)
2343
+ configuredSessionId: w.sessionId,
2344
+ configuredSessionTitle: w.sessionTitle,
2345
+ // 审批卡片点击 → 决策回调(bridge 注入:approvalCoordinator.resolve → pushReporter.answerApproval)
2346
+ onApprovalDecision: typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : undefined,
2347
+ notify: notifyAllowed(),
2348
+ logger: options.log,
2349
+ clock: options.clock,
2350
+ ...(options.botOptions && typeof options.botOptions === "object" ? options.botOptions : {})
2351
+ };
2352
+ bot = botFactory(botOptions);
2353
+ if (!bot || typeof bot.start !== "function") {
2354
+ state.enabled = false;
2355
+ state.reason = "bot_factory_invalid";
2356
+ state.lastError = "bot 工厂返回了无效对象";
2357
+ log.warn(state.lastError);
2358
+ return;
2359
+ }
2360
+ state.enabled = true;
2361
+ state.reason = "started";
2362
+ state.lastError = "";
2363
+ bot.start();
2364
+ log.info(`IM 机器人通道已启动(chat 兜底=${w.targetChatId ? chatIdHash(w.targetChatId) : "未配置"},通知=${notifyAllowed() ? "开" : "关"})`);
2365
+ await handlePreview(cfg);
2366
+ } catch (e) {
2367
+ state.enabled = false;
2368
+ state.reason = "apply_failed";
2369
+ state.lastError = truncateText(e && e.message ? e.message : e, 160);
2370
+ log.warn(`应用 IM 机器人配置失败(忽略):${state.lastError}`);
2371
+ } finally {
2372
+ reloading = false;
2373
+ }
2374
+ }
2375
+
2376
+ function watchConfig() {
2377
+ if (listener) return;
2378
+ listener = () => { void applyConfig(); };
2379
+ try {
2380
+ fs.watchFile(configFile, { interval: pollMs }, listener);
2381
+ } catch (e) {
2382
+ log.warn(`监听 im-bots.json 失败(改配置后需重启 bridge):${truncateText(e && e.message ? e.message : e, 120)}`);
2383
+ listener = null;
2384
+ }
2385
+ }
2386
+
2387
+ function unwatchConfig() {
2388
+ if (!listener) return;
2389
+ try { fs.unwatchFile(configFile, listener); } catch { /* 忽略 */ }
2390
+ listener = null;
2391
+ }
2392
+
2393
+ /** 控制器只读快照(start/stop 内部与外部都读它)。 */
2394
+ function statusSnapshot() {
2395
+ const bs = bot && typeof bot.status === "function" ? bot.status() : null;
2396
+ return {
2397
+ started: state.started,
2398
+ enabled: state.enabled,
2399
+ configFound: state.configFound,
2400
+ reason: state.reason,
2401
+ configPath: configFile,
2402
+ statusPath: statusFile,
2403
+ configUpdatedAt: state.configUpdatedAt,
2404
+ lastError: state.lastError || (bs && bs.lastError) || "",
2405
+ bot: bs
2406
+ };
2407
+ }
2408
+
2409
+ return {
2410
+ /** 启动:读配置(可能不起连接)+ 开始监听配置变化。 */
2411
+ async start() {
2412
+ if (state.started) return statusSnapshot();
2413
+ state.started = true;
2414
+ if (hardDisabled()) {
2415
+ state.reason = "disabled_by_env";
2416
+ log.info("IM 机器人通道已被 DSH_IM_BOTS=0 关闭");
2417
+ return statusSnapshot();
2418
+ }
2419
+ if (!relayDir) {
2420
+ state.reason = "no_relay_dir";
2421
+ log.warn("缺少 relayDir,IM 机器人通道不启动");
2422
+ return statusSnapshot();
2423
+ }
2424
+ await applyConfig();
2425
+ watchConfig();
2426
+ return statusSnapshot();
2427
+ },
2428
+
2429
+ /** 停止:先停 watch,再停连接(顺序重要:避免停的过程中又起一个)。 */
2430
+ async stop() {
2431
+ unwatchConfig();
2432
+ state.started = false;
2433
+ state.enabled = false;
2434
+ state.reason = "stopped";
2435
+ await stopBot();
2436
+ return statusSnapshot();
2437
+ },
2438
+
2439
+ status: statusSnapshot,
2440
+
2441
+ /** 仅测试:当前 bot 实例(不参与生产逻辑)。 */
2442
+ _bot() { return bot; }
2443
+ };
2444
+ }