dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1668 @@
1
+ /**
2
+ * feishu-bot — 飞书「企业自建应用」长连接机器人通道(bridge 侧,2026-11-08 / task-27)。
3
+ *
4
+ * ## 为什么走长连接 + 为什么必须懒加载官方 SDK
5
+ *
6
+ * 飞书长连接是 **protobuf 帧(pbbp2.Frame)+ SDK 内部 HTTP bootstrap 握手**,官方只开放 SDK、
7
+ * 没有公开原始协议(调研 `research/2026-11-strategy/06-multiagent/feishu-long-connection.md` §2.2/§2.10)。
8
+ * 自写 = 逆向非公开协议 = 重演 ilink 老坑,所以**只用官方 SDK**。
9
+ *
10
+ * 但官方 `@larksuiteoapi/node-sdk` 体积大(1.6MB tgz / 41MB 解压 / 7 个传递依赖),业主明确要求
11
+ * **不随包分发、配置前才按需下载**。于是本模块:
12
+ * · `package.json` **不声明**这个依赖;
13
+ * · 运行时从 `<relayDir>/im-deps/node_modules/@larksuiteoapi/node-sdk` **动态 import**(懒加载 + 缓存);
14
+ * · 目录不存在 → 状态 `sdk_missing`,**不崩、不阻塞企微/微信/隧道**;面板据此显示「下载依赖」入口。
15
+ *
16
+ * ## 与企微契约对齐(复用优先)
17
+ *
18
+ * | 复用 | 来源 |
19
+ * |---|---|
20
+ * | `maskSecrets` / `chatIdHash` / `truncateText` / `createScopedLogger` / `hardenFile` / `atomicWriteJson` / `readJsonFile` / `normalizeAllowList` / `toMarkdownNotification` | `wecom-bot.mjs`(同一套脱敏、原子写、通知文案) |
21
+ * | 通道格式化 | `im-format.mjs` 的 `formatForChannel("feishu", …)`(飞书无 markdown 文本 → 纯文本/卡片字段) |
22
+ * | 会员判定 | `wechat-runtime.mjs` 的 `WECHAT_CAPABILITY_TABLE` / `limitsFor` / `capabilitiesFor` / `grantsCap`(**单一真相源**:免费 20 条/月、通知+拍板免费、派活受闸) |
23
+ * | 事件源 | `dsh-events.mjs` 的 `createEventSubscriber`(与企微同一路:`turn/end`、`approval/request`…) |
24
+ * | 审批决策 | bridge 注入的 `onApprovalDecision(requestId, decision)`(**与企微卡片同一路**) |
25
+ *
26
+ * ## 设计边界(每一条都重要)
27
+ *
28
+ * 1. **默认关闭**:`im-bots.json` 没有 `feishu.enabled && appId && appSecret` → 零连接、零文件、零日志噪音。
29
+ * 2. **懒加载 & 可注入**:`sdkFactory` / `loadSdk` / `fetchImpl` / `timers` / `clock` 都可注入 → 单测不装真包、不联网。
30
+ * 3. **3 秒 ACK 铁律**:卡片按钮回调**先返回 toast/更新卡片,再异步交决策**;绝不在 handler 里 await 网络/写库
31
+ * (超时会重推 → 重复回执)。幂等键 = `requestId + action`。
32
+ * 4. **凭据错 ≠ 网络断**:appId/secret 错、应用未发版、权限不足 → 明确人话错误并**停止重连**;
33
+ * 网络类错误 → 交给 SDK 的 autoReconnect(我们只如实报状态)。
34
+ * 5. **secret 绝不进日志**:模块只打 appId 掩码/chat 哈希;logger 再兜一层字面量脱敏。
35
+ * 6. **文件按契约**:`im-bots.json`(插件写、桥只读)/ `im-bot-status.json`(桥写、原子替换 + 0600)。
36
+ * status 的 `feishu` 段与企微字段一一对应,另加 `state` 与 `deps.feishu`。
37
+ *
38
+ * ## 对外 API
39
+ *
40
+ * ```js
41
+ * // 依赖探测(面板「下载依赖」前后都能调,不发网络)
42
+ * feishuSdkStatus({ relayDir, env }) // {installed, version, path, depsDir, lastError}
43
+ * await loadFeishuSdk({ relayDir }) // {ok, sdk?, status, error?}(懒加载 + 缓存)
44
+ *
45
+ * // 纯函数(单测友好)
46
+ * validateAppId / normalizeFeishuConfig / readFeishuConfig
47
+ * buildApprovalCard / buildDecidedCard / parseCardAction / formatFeishuText / feishuErrorHint
48
+ *
49
+ * // 传输层
50
+ * createFeishuSender({ appId, appSecret, domain, fetchImpl, clock }) // token + 发消息
51
+ * createFeishuBot({ appId, appSecret, …, sdkFactory, onApprovalDecision }) // WSClient + 事件
52
+ *
53
+ * // bridge 只需要这个(与 createWecomBotController 同形)
54
+ * createFeishuBotController({ relayDir, onApprovalDecision, capsProvider, log, … })
55
+ * → {start, stop, status}
56
+ * ```
57
+ *
58
+ * ## 配置页 8 步向导(契约常量,供插件半直接渲染;不在此模块画 UI)
59
+ *
60
+ * `FEISHU_SETUP_GUIDE` 里有官方文档链接、注册地址与 8 步文案(含「保存事件配置时长连接必须已在线」的顺序坑)。
61
+ */
62
+
63
+ import fs from "node:fs";
64
+ import path from "node:path";
65
+ import crypto from "node:crypto";
66
+ import { EventEmitter } from "node:events";
67
+ import { pathToFileURL } from "node:url";
68
+
69
+ import {
70
+ maskSecrets,
71
+ chatIdHash,
72
+ truncateText,
73
+ createScopedLogger,
74
+ hardenFile,
75
+ atomicWriteJson,
76
+ readJsonFile,
77
+ normalizeAllowList,
78
+ toMarkdownNotification,
79
+ IM_BOTS_FILE,
80
+ IM_BOT_STATUS_FILE,
81
+ APPROVAL_CARD_TTL_MS
82
+ } from "./wecom-bot.mjs";
83
+ import { formatForChannel } from "./im-format.mjs";
84
+ import {
85
+ WECHAT_CAPABILITY_TABLE,
86
+ WECHAT_TIER_LIMITS,
87
+ capabilitiesFor,
88
+ grantsCap,
89
+ limitsFor
90
+ } from "./wechat-runtime.mjs";
91
+ import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
92
+
93
+ // ===========================================================================
94
+ // 1. 常量 / 契约
95
+ // ===========================================================================
96
+
97
+ /** 官方 Node SDK 包名(**不写进 package.json**,运行时懒加载)。 */
98
+ export const FEISHU_SDK_PACKAGE = "@larksuiteoapi/node-sdk";
99
+ /** 按需依赖目录名(在 relayDir 下)。 */
100
+ export const IM_DEPS_DIR_NAME = "im-deps";
101
+ /** 官方开放平台域名(国际版 Lark 用 larksuite)。 */
102
+ export const FEISHU_DOMAINS = Object.freeze({
103
+ feishu: "https://open.feishu.cn",
104
+ lark: "https://open.larksuite.com"
105
+ });
106
+ /** SDK 内部对 appId 的**严格**正则(源码里的校验;不匹配时 start() 只打日志不抛错 → "保存成功但连不上")。 */
107
+ export const APP_ID_STRICT_RE = /^cli_[0-9a-fA-F]{16}$/;
108
+ /** 我们前置校验的**宽松**正则:只拦明显错的(新格式 ID 可能含非 hex 字符,见调研 R3)。 */
109
+ export const APP_ID_LOOSE_RE = /^cli_[A-Za-z0-9]{8,40}$/;
110
+ /** tenant_access_token 获取 / 消息发送路径。 */
111
+ export const FEISHU_TOKEN_PATH = "/open-apis/auth/v3/tenant_access_token/internal";
112
+ export const FEISHU_SEND_PATH = "/open-apis/im/v1/messages";
113
+ /** 默认接收方类型(open_id;群/会话场景配置里改 chat_id)。 */
114
+ export const DEFAULT_RECEIVE_ID_TYPE = "open_id";
115
+ /** 卡片按钮回调 ACK 的硬期限(飞书要求 3 秒内响应,否则重推)。 */
116
+ export const ACK_DEADLINE_MS = 3000;
117
+ /** 已处理回执的幂等 TTL / 上限。 */
118
+ export const SEEN_CARD_TTL_MS = 10 * 60 * 1000;
119
+ export const SEEN_CARD_MAX = 200;
120
+ /** 通知去重 TTL / 上限(对齐企微)。 */
121
+ export const NOTIFY_KEY_TTL_MS = 10 * 60 * 1000;
122
+ export const NOTIFY_KEY_MAX = 200;
123
+ /** 通知限速:每分钟最多 20 条(对齐官方单应用/单群频控,也防事件风暴刷屏)。 */
124
+ export const NOTIFY_PER_MINUTE_MAX = 20;
125
+ /** 连接状态轮询间隔。 */
126
+ export const CONNECTION_POLL_MS = 2000;
127
+ /** 配置热更新轮询间隔(与企微一致)。 */
128
+ export const CONFIG_POLL_MS = 2000;
129
+ /** SDK 缺失时给人话(面板据此提示「下载依赖(约 41MB)」)。 */
130
+ export const SDK_MISSING_HINT =
131
+ "飞书官方 SDK 未下载(约 41MB,按需安装、不进安装包)。请在插件「通知与消息通道 → 飞书」点「下载依赖」后重试。";
132
+ /** 长连接不支持代理(官方文档)→ 连接失败时给这条。 */
133
+ export const PROXY_HINT = "飞书长连接不支持代理:如果公司网络必须走代理,这条通道在当前网络下连不上(可改用手机远程)。";
134
+
135
+ /** 飞书官方文档 / 注册入口(配置页 8 步向导直接引用;不在此模块画 UI)。 */
136
+ export const FEISHU_SETUP_GUIDE = Object.freeze({
137
+ docs: Object.freeze({
138
+ longConnectionEvent: "https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case",
139
+ longConnectionCallback: "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address",
140
+ sendMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/create",
141
+ receiveMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive",
142
+ registerApp: "https://open.feishu.cn/app"
143
+ }),
144
+ steps: Object.freeze([
145
+ "打开飞书开放平台(open.feishu.cn/app)→ 创建**企业自建应用**(商店应用不支持长连接)",
146
+ "「凭证与基础信息」→ 复制 **App ID / App Secret**(App ID 形如 cli_ 开头的一串字符)",
147
+ "「添加应用能力」→ 开启**机器人**",
148
+ "「权限管理」→ 申请收消息(im:message.p2p_msg:readonly、im:message.group_at_msg:readonly)与发消息(im:message:send_as_bot)",
149
+ "「事件与回调 → 事件配置」→ 订阅方式选**使用长连接接收事件** → 添加「接收消息 v2.0」",
150
+ "「事件与回调 → 回调配置」→ 订阅方式选**使用长连接接收回调** → 添加 card.action.trigger(拍板卡片用)",
151
+ "「版本管理与发布」→ 创建版本并**发布**(不发布不生效);「可用范围」包含你自己/目标成员",
152
+ "回到本面板:填 App ID / Secret → 「下载依赖」→「测试连接」→ 在飞书里给机器人**发一句话**完成绑定"
153
+ ]),
154
+ // 最容易被忽略的顺序坑:保存事件配置时,必须已经有一个长连接在线。
155
+ orderWarning: "顺序很重要:先在本机填好凭据让长连接在线,再去开发者后台保存「事件/回调」配置(否则保存会提示「应用未建立长连接」)。",
156
+ oneAppOnePc: "同一个飞书应用只挂一台电脑:多台设备会互抢事件(官方集群随机分发,上限 50 连接)。"
157
+ });
158
+
159
+ /** 飞书常见错误码 → 人话(面板展示;不含任何凭据)。 */
160
+ export const FEISHU_ERROR_HINTS = Object.freeze({
161
+ 99991663: "应用凭证失效:App ID / App Secret 不对,或应用已被停用",
162
+ 99991661: "应用缺少机器人能力,或修改后没有发布版本",
163
+ 99991668: "应用未开通所需权限(收消息 / 发消息)",
164
+ 230002: "机器人不在这个会话里:去飞书群里把机器人添加进来,或先私聊它一句",
165
+ 230006: "发送太频繁,稍后再试",
166
+ 230013: "你不在应用可用范围内:开发者后台把可用范围改成包含你,然后发版",
167
+ 1000040350: "同一应用的连接数超限(上限 50):检查是否有别的电脑挂着同一个应用"
168
+ });
169
+
170
+ /** 把飞书错误码/消息翻成人话(未知码原样带出,便于搜官方文档)。 */
171
+ export function feishuErrorHint(code, message = "") {
172
+ const n = Number(code);
173
+ const hint = FEISHU_ERROR_HINTS[n];
174
+ const detail = truncateText(String(message || ""), 160);
175
+ if (hint) return detail ? `${hint}(${detail})` : hint;
176
+ if (!Number.isFinite(n) && !detail) return "未知错误";
177
+ return detail ? `飞书返回错误 ${Number.isFinite(n) ? n : ""}:${detail}`.trim() : `飞书返回错误 ${n}`;
178
+ }
179
+
180
+ // ===========================================================================
181
+ // 2. 纯函数:App ID 校验 / 配置 / 卡片 / 入站解析
182
+ // ===========================================================================
183
+
184
+ /**
185
+ * appId 前置校验。**宽松**只拦明显错的;同时告诉调用方 SDK 的严格正则是否通过 ——
186
+ * SDK 不匹配严格正则时只打一行 error 就 return(见调研 R3),所以必须提前暴露这个信号。
187
+ * @param {string} appId
188
+ * @returns {{ok:boolean, strict:boolean, warning?:string, reason?:string}}
189
+ */
190
+ export function validateAppId(appId) {
191
+ const v = String(appId || "").trim();
192
+ if (!v) return { ok: false, strict: false, reason: "App ID 为空" };
193
+ if (!APP_ID_LOOSE_RE.test(v)) {
194
+ return {
195
+ ok: false,
196
+ strict: false,
197
+ reason: "App ID 形如 cli_ 开头 + 一串字符,在开发者后台「凭证与基础信息」里复制"
198
+ };
199
+ }
200
+ const strict = APP_ID_STRICT_RE.test(v);
201
+ return {
202
+ ok: true,
203
+ strict,
204
+ ...(strict ? {} : {
205
+ warning: "这个 App ID 没有通过官方 SDK 的严格校验(cli_ + 16 位十六进制):旧版 SDK 可能「保存成功但一直连不上」,请务必用「测试连接」确认,或升级 SDK 版本"
206
+ })
207
+ };
208
+ }
209
+
210
+ /** 域名归一:feishu(默认)/ lark。 */
211
+ export function normalizeDomain(raw) {
212
+ const v = String(raw || "").trim().toLowerCase();
213
+ return v === "lark" || v === "larksuite" ? "lark" : "feishu";
214
+ }
215
+
216
+ /** 接收方类型归一:open_id / chat_id(空 = 默认 open_id)。 */
217
+ export function normalizeReceiveIdType(raw, fallback = DEFAULT_RECEIVE_ID_TYPE) {
218
+ const v = String(raw || "").trim().toLowerCase();
219
+ if (v === "open_id" || v === "chat_id") return v;
220
+ return fallback;
221
+ }
222
+
223
+ /**
224
+ * `im-bots.json` 里 `feishu` 段的规范形状(缺字段补默认;非法值当未配置,**绝不抛**)。
225
+ *
226
+ * 注意:**不加** `verifyToken` / `encryptKey` —— 长连接模式下事件/回调由 SDK 内部解密分发,
227
+ * 这两个字段只属于「事件发送至开发者服务器」的 HTTP 模式(调研 §4.1)。
228
+ *
229
+ * @param {any} raw 完整 im-bots 对象或直接是 feishu 段
230
+ * @returns {{enabled:boolean,appId:string,appSecret:string,domain:"feishu"|"lark",receiveId:string,receiveIdType:"open_id"|"chat_id",boundChatId:string,allowFrom:string[],cardApproval:boolean,sdkVersion:string,sessionId:string,sessionTitle:string}}
231
+ */
232
+ export function normalizeFeishuConfig(raw) {
233
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
234
+ const f = src.feishu && typeof src.feishu === "object" && !Array.isArray(src.feishu) ? src.feishu : src;
235
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
236
+ return {
237
+ enabled: f.enabled === true,
238
+ appId: str(f.appId),
239
+ appSecret: typeof f.appSecret === "string" ? f.appSecret : "",
240
+ domain: normalizeDomain(f.domain),
241
+ receiveId: str(f.receiveId),
242
+ receiveIdType: normalizeReceiveIdType(f.receiveIdType, ""),
243
+ boundChatId: str(f.boundChatId),
244
+ allowFrom: normalizeAllowList(f.allowFrom),
245
+ cardApproval: f.cardApproval !== false, // 默认开
246
+ sdkVersion: str(f.sdkVersion),
247
+ // 复用企微的「固定转发目标会话」(空 = 跟随当前活跃会话)
248
+ sessionId: str(f.sessionId),
249
+ sessionTitle: str(f.sessionTitle)
250
+ };
251
+ }
252
+
253
+ /**
254
+ * 从 `relayDir/im-bots.json` 读飞书配置(**桥只读**;插件写)。
255
+ * 注意:不走 `wecom-bot.readImBotsConfig`,因为它的 normalize 只保留 feishu 的 3 个字段。
256
+ * @returns {{config:ReturnType<typeof normalizeFeishuConfig>, raw:any}|null}
257
+ */
258
+ export function readFeishuConfig(relayDir) {
259
+ if (!relayDir) return null;
260
+ const raw = readJsonFile(path.join(String(relayDir), IM_BOTS_FILE));
261
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
262
+ return { config: normalizeFeishuConfig(raw), raw };
263
+ }
264
+
265
+ /** 飞书文本格式化:无 markdown → 纯文本(表格/加粗降级由 im-format 统一处理)。 */
266
+ export function formatFeishuText(text, opts = {}) {
267
+ return formatForChannel("feishu", { ...opts, text });
268
+ }
269
+
270
+ /** appId 掩码(日志/状态里只给前后各 4 位)。 */
271
+ export function maskAppId(appId) {
272
+ const v = String(appId || "");
273
+ if (v.length <= 10) return v ? "cli_***" : "";
274
+ return `${v.slice(0, 8)}***${v.slice(-2)}`;
275
+ }
276
+
277
+ function oneLine(value, max = 200) {
278
+ return truncateText(String(value == null ? "" : value).replace(/\s+/g, " ").trim(), max);
279
+ }
280
+
281
+ /**
282
+ * 审批卡片(卡片 JSON **2.0**;调研 R11:1.0 与 2.0 不兼容,统一 2.0)。
283
+ * 按钮用 `behaviors:[{type:"callback", value:{action, requestId, nonce, expiresAt}}]`。
284
+ *
285
+ * @param {{requestId:string, toolName?:string, summary?:string, sessionTitle?:string, expiresAt?:number, nonce?:string}} info
286
+ */
287
+ export function buildApprovalCard(info = {}) {
288
+ const requestId = String(info.requestId || "");
289
+ const toolName = oneLine(info.toolName || "一步操作", 60);
290
+ const summary = oneLine(info.summary || "", 200);
291
+ const expiresAt = Number(info.expiresAt) || 0;
292
+ const nonce = String(info.nonce || "");
293
+ const value = (action) => ({ action, requestId, nonce, expiresAt });
294
+ const bodyLines = [`工具:${toolName}`];
295
+ if (summary) bodyLines.push(`内容:${summary}`);
296
+ bodyLines.push(`有效期 ${Math.max(1, Math.round(APPROVAL_CARD_TTL_MS / 1000))} 秒;到期未回执按「拒绝」处理(不会默认放行)。`);
297
+ return {
298
+ schema: "2.0",
299
+ config: { update_multi: true },
300
+ header: {
301
+ title: { tag: "plain_text", content: "🛂 需要你拍板" },
302
+ template: "orange"
303
+ },
304
+ body: {
305
+ elements: [
306
+ { tag: "div", text: { tag: "plain_text", content: bodyLines.join("\n") } },
307
+ {
308
+ tag: "action",
309
+ actions: [
310
+ {
311
+ tag: "button",
312
+ text: { tag: "plain_text", content: "允许一次" },
313
+ type: "primary",
314
+ behaviors: [{ type: "callback", value: value("allow") }]
315
+ },
316
+ {
317
+ tag: "button",
318
+ text: { tag: "plain_text", content: "拒绝" },
319
+ type: "danger",
320
+ behaviors: [{ type: "callback", value: value("deny") }]
321
+ }
322
+ ]
323
+ }
324
+ ]
325
+ }
326
+ };
327
+ }
328
+
329
+ /** 回执后的卡片(原地更新,把按钮换成状态;3 秒 ACK 时直接返回它)。 */
330
+ export function buildDecidedCard(info = {}) {
331
+ const requestId = String(info.requestId || "");
332
+ const decision = info.decision === "allow" ? "allow" : "deny";
333
+ const operator = oneLine(info.operator || "", 40);
334
+ const allowed = decision === "allow";
335
+ return {
336
+ schema: "2.0",
337
+ config: { update_multi: true },
338
+ header: {
339
+ title: { tag: "plain_text", content: allowed ? "✅ 已允许(一次)" : "🛑 已拒绝" },
340
+ template: allowed ? "green" : "red"
341
+ },
342
+ body: {
343
+ elements: [
344
+ {
345
+ tag: "div",
346
+ text: {
347
+ tag: "plain_text",
348
+ content: [
349
+ `请求:${oneLine(requestId, 64)}`,
350
+ operator ? `操作人:${operator}` : "",
351
+ allowed ? "本次只放行这一次,不会变成永久允许。" : "这一步不会执行(拒绝)。"
352
+ ].filter(Boolean).join("\n")
353
+ }
354
+ }
355
+ ]
356
+ }
357
+ };
358
+ }
359
+
360
+ /**
361
+ * 解析卡片按钮回调(`card.action.trigger`)。
362
+ *
363
+ * 官方事件形状(长连接回调):
364
+ * ```json
365
+ * { "event_id":"…", "event": { "operator":{"operator_id":{"open_id":"ou_…"}},
366
+ * "action":{"value":{"action":"allow","requestId":"…"}},
367
+ * "context":{"open_chat_id":"oc_…","open_message_id":"om_…"} } }
368
+ * ```
369
+ * 兼容 `data.event` / `data` 两种外层;**不认的形状返回 ok:false**(上层回 toast 错误,绝不乱放行)。
370
+ *
371
+ * @param {any} payload
372
+ * @returns {{ok:boolean, requestId?:string, decision?:"allow"|"deny", action?:string, operatorOpenId?:string, chatId?:string, messageId?:string, eventKey?:string, expiresAt?:number, nonce?:string, reason?:string}}
373
+ */
374
+ export function parseCardAction(payload) {
375
+ if (!payload || typeof payload !== "object") return { ok: false, reason: "回调为空" };
376
+ const event = payload.event && typeof payload.event === "object" ? payload.event : payload;
377
+ const action = event.action && typeof event.action === "object" ? event.action : {};
378
+ const value = action.value && typeof action.value === "object" ? action.value : {};
379
+ const requestId = String(value.requestId || value.request_id || "").trim();
380
+ const rawAction = String(value.action || "").toLowerCase();
381
+ const decision = rawAction === "allow" || rawAction === "approve" || rawAction === "allowed"
382
+ ? "allow"
383
+ : rawAction === "deny" || rawAction === "reject" || rawAction === "denied"
384
+ ? "deny"
385
+ : "";
386
+ if (!requestId) return { ok: false, reason: "回调里没有 requestId(卡片 version 可能不是本模块发的)" };
387
+ if (!decision) return { ok: false, reason: `不认识的按钮动作:${truncateText(rawAction || "空", 20)}` };
388
+ const operator = event.operator && typeof event.operator === "object" ? event.operator : {};
389
+ const operatorOpenId = String(
390
+ (operator.operator_id && operator.operator_id.open_id) || operator.open_id || ""
391
+ );
392
+ const context = event.context && typeof event.context === "object" ? event.context : {};
393
+ const eventId = String(payload.event_id || payload.event?.event_id || "");
394
+ return {
395
+ ok: true,
396
+ requestId,
397
+ decision,
398
+ action: rawAction,
399
+ operatorOpenId,
400
+ chatId: String(context.open_chat_id || ""),
401
+ messageId: String(context.open_message_id || ""),
402
+ eventKey: eventId || `${requestId}:${decision}`,
403
+ expiresAt: Number(value.expiresAt) || 0,
404
+ nonce: String(value.nonce || "")
405
+ };
406
+ }
407
+
408
+ /** 从飞书消息 content(JSON 字符串)里抽文本;不认识的类型返回 ""(**绝不猜**)。 */
409
+ export function extractFeishuMessageText(msgType, contentRaw) {
410
+ const type = String(msgType || "");
411
+ let parsed = contentRaw;
412
+ if (typeof contentRaw === "string") {
413
+ try { parsed = JSON.parse(contentRaw); } catch { return ""; }
414
+ }
415
+ if (!parsed || typeof parsed !== "object") return "";
416
+ if (type === "text") {
417
+ const t = parsed.text;
418
+ return typeof t === "string" ? t.trim() : "";
419
+ }
420
+ // 富文本 post:{title, content:[[{tag:"text",text}]]}(结构有多个历史版本,尽力而为)
421
+ if (type === "post") {
422
+ const parts = [];
423
+ const walk = (node) => {
424
+ if (!node) return;
425
+ if (typeof node === "string") { parts.push(node); return; }
426
+ if (Array.isArray(node)) { node.forEach(walk); return; }
427
+ if (typeof node === "object") {
428
+ if (typeof node.text === "string") parts.push(node.text);
429
+ if (Array.isArray(node.content)) node.content.forEach(walk);
430
+ }
431
+ };
432
+ walk(parsed.title || "");
433
+ walk(parsed.content || []);
434
+ return parts.join(" ").replace(/\s+/g, " ").trim();
435
+ }
436
+ return "";
437
+ }
438
+
439
+ /**
440
+ * 把 `im.v1.message.receive_v1` 的 data 归一化。
441
+ * @param {any} data SDK 回调入参(`{event:{message,sender,...}}` 或直接 event)
442
+ * @returns {{ok:boolean, chatId?:string, chatType?:string, openId?:string, senderType?:string, messageId?:string, msgType?:string, text?:string, reason?:string}}
443
+ */
444
+ export function normalizeInboundEvent(data) {
445
+ const event = data && typeof data === "object" && data.event && typeof data.event === "object" ? data.event : data;
446
+ if (!event || typeof event !== "object") return { ok: false, reason: "入站事件为空" };
447
+ const message = event.message && typeof event.message === "object" ? event.message : null;
448
+ const sender = event.sender && typeof event.sender === "object" ? event.sender : {};
449
+ if (!message) return { ok: false, reason: "入站事件没有 message" };
450
+ const msgType = String(message.message_type || message.msg_type || "");
451
+ const text = extractFeishuMessageText(msgType, message.content);
452
+ return {
453
+ ok: true,
454
+ chatId: String(message.chat_id || ""),
455
+ chatType: String(message.chat_type || ""),
456
+ openId: String((sender.sender_id && sender.sender_id.open_id) || sender.open_id || ""),
457
+ senderType: String(sender.sender_type || ""),
458
+ messageId: String(message.message_id || ""),
459
+ msgType,
460
+ text
461
+ };
462
+ }
463
+
464
+ // ===========================================================================
465
+ // 3. 懒加载 SDK(按需下载、不随包分发)
466
+ // ===========================================================================
467
+
468
+ /** 模块级缓存:`dir -> {sdk, status}`;注入工厂用 WeakMap 按函数身份缓存(不同工厂不能互相污染)。 */
469
+ const sdkCache = new Map();
470
+ let factoryCache = new WeakMap();
471
+ let lastSdkError = "";
472
+
473
+ /** SDK 目录:`<relayDir>/im-deps/node_modules/@larksuiteoapi/node-sdk`。 */
474
+ export function resolveFeishuSdkDir({ relayDir = "", env = process.env } = {}) {
475
+ const root = String(env?.DSH_IM_DEPS_DIR || "").trim() || (relayDir ? path.join(String(relayDir), IM_DEPS_DIR_NAME) : "");
476
+ if (!root) return "";
477
+ return path.join(root, "node_modules", "@larksuiteoapi", "node-sdk");
478
+ }
479
+
480
+ /**
481
+ * 依赖状态(**只读文件,不发网络**):面板据此显示「未下载 / 已安装 vX」。
482
+ * @returns {{installed:boolean, version:string, path:string, depsDir:string, lastError:string, injected?:boolean}}
483
+ */
484
+ export function feishuSdkStatus({ relayDir = "", env = process.env, fsImpl = fs, sdkFactory = null } = {}) {
485
+ const depsRoot = String(env?.DSH_IM_DEPS_DIR || "").trim() || (relayDir ? path.join(String(relayDir), IM_DEPS_DIR_NAME) : "");
486
+ if (typeof sdkFactory === "function") {
487
+ return { installed: true, injected: true, version: "injected", path: "injected", depsDir: depsRoot, lastError: "" };
488
+ }
489
+ const dir = resolveFeishuSdkDir({ relayDir, env });
490
+ if (!dir) return { installed: false, version: "", path: "", depsDir: "", lastError: lastSdkError };
491
+ let version = "";
492
+ try {
493
+ const pkg = JSON.parse(fsImpl.readFileSync(path.join(dir, "package.json"), "utf8"));
494
+ version = String(pkg?.version || "");
495
+ } catch (e) {
496
+ return { installed: false, version: "", path: dir, depsDir: depsRoot, lastError: lastSdkError || truncateText(e?.message || e, 120) };
497
+ }
498
+ const entry = resolveSdkEntry(dir, { fsImpl });
499
+ return { installed: Boolean(entry), version, path: entry || dir, depsDir: depsRoot, lastError: lastSdkError };
500
+ }
501
+
502
+ /** 从 package.json 解析入口(main/exports 都可能用;找不到回退 index.js)。 */
503
+ function resolveSdkEntry(dir, { fsImpl = fs } = {}) {
504
+ try {
505
+ const pkg = JSON.parse(fsImpl.readFileSync(path.join(dir, "package.json"), "utf8"));
506
+ const main = typeof pkg.main === "string" && pkg.main.trim() ? pkg.main.trim() : "index.js";
507
+ const candidates = [main, "index.js", "lib/index.js"];
508
+ for (const rel of candidates) {
509
+ const p = path.join(dir, rel);
510
+ try { if (fsImpl.statSync(p).isFile()) return p; } catch { /* 下一个 */ }
511
+ }
512
+ } catch { /* 交给回退 */ }
513
+ return "";
514
+ }
515
+
516
+ /**
517
+ * 懒加载官方 SDK(**只有飞书启用且真正要建连时才调**;成功后模块级缓存)。
518
+ *
519
+ * @param {{relayDir?:string, env?:object, sdkFactory?:Function, importImpl?:Function, fsImpl?:object}} opts
520
+ * @returns {Promise<{ok:boolean, sdk?:any, status:object, error?:string}>}
521
+ */
522
+ export async function loadFeishuSdk({
523
+ relayDir = "",
524
+ env = process.env,
525
+ sdkFactory = null,
526
+ importImpl = null,
527
+ fsImpl = fs
528
+ } = {}) {
529
+ if (typeof sdkFactory === "function") {
530
+ if (factoryCache.has(sdkFactory)) return factoryCache.get(sdkFactory);
531
+ try {
532
+ const sdk = sdkFactory();
533
+ const value = { ok: true, sdk, status: feishuSdkStatus({ relayDir, env, sdkFactory }) };
534
+ factoryCache.set(sdkFactory, value);
535
+ return value;
536
+ } catch (e) {
537
+ lastSdkError = truncateText(e?.message || e, 160);
538
+ return { ok: false, status: feishuSdkStatus({ relayDir, env, sdkFactory }), error: lastSdkError };
539
+ }
540
+ }
541
+
542
+ const status = feishuSdkStatus({ relayDir, env, fsImpl });
543
+ if (!status.installed) {
544
+ lastSdkError = status.lastError || "";
545
+ return { ok: false, status, error: SDK_MISSING_HINT };
546
+ }
547
+ const key = status.path;
548
+ if (sdkCache.has(key)) return sdkCache.get(key);
549
+ try {
550
+ const importer = typeof importImpl === "function" ? importImpl : (u) => import(u);
551
+ const mod = await importer(pathToFileURL(status.path).href);
552
+ const sdk = mod && (mod.default || mod);
553
+ const value = { ok: true, sdk, status };
554
+ sdkCache.set(key, value);
555
+ lastSdkError = "";
556
+ return value;
557
+ } catch (e) {
558
+ lastSdkError = truncateText(e?.message || e, 160);
559
+ return { ok: false, status: { ...status, lastError: lastSdkError }, error: `飞书 SDK 加载失败:${lastSdkError}` };
560
+ }
561
+ }
562
+
563
+ /** 仅测试:清空 SDK 缓存(生产不需要)。 */
564
+ export function _resetFeishuSdkCache() {
565
+ sdkCache.clear();
566
+ factoryCache = new WeakMap();
567
+ lastSdkError = "";
568
+ }
569
+
570
+ // ===========================================================================
571
+ // 4. 发送器(tenant_access_token + im/v1/messages)
572
+ // ===========================================================================
573
+
574
+ /**
575
+ * 创建发送器:拿 tenant_access_token(缓存)+ 发消息 + 可选更新卡片。
576
+ *
577
+ * 为什么自写 HTTP 而不是用 SDK 的 Client:发送是**简单 REST**,自写可注入 fetch、可单测、
578
+ * 也能在 SDK 版本升级时少受一层影响;长连接那部分才必须用 SDK。
579
+ *
580
+ * @param {{appId:string, appSecret:string, domain?:"feishu"|"lark", fetchImpl?:Function, clock?:Function, logger?:object, tokenSkewMs?:number}} opts
581
+ */
582
+ export function createFeishuSender({
583
+ appId,
584
+ appSecret,
585
+ domain = "feishu",
586
+ fetchImpl = (...args) => globalThis.fetch(...args),
587
+ clock = () => Date.now(),
588
+ logger,
589
+ tokenSkewMs = 60_000
590
+ } = {}) {
591
+ const base = FEISHU_DOMAINS[normalizeDomain(domain)] || FEISHU_DOMAINS.feishu;
592
+ const log = logger || createScopedLogger({ prefix: "[feishu]", secrets: [String(appSecret || "")] });
593
+ const state = { token: "", expiresAt: 0, lastError: "", sends: 0, tokenFetches: 0 };
594
+
595
+ async function readJson(res) {
596
+ if (!res || typeof res.json !== "function") return {};
597
+ try { return await res.json(); } catch { return {}; }
598
+ }
599
+
600
+ async function ensureToken({ force = false } = {}) {
601
+ const at = Number(clock()) || Date.now();
602
+ if (!force && state.token && at < state.expiresAt) return state.token;
603
+ state.tokenFetches += 1;
604
+ const res = await fetchImpl(`${base}${FEISHU_TOKEN_PATH}`, {
605
+ method: "POST",
606
+ headers: { "content-type": "application/json; charset=utf-8" },
607
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret })
608
+ });
609
+ const json = await readJson(res);
610
+ const code = Number(json.code);
611
+ if (!json || code !== 0 || !json.tenant_access_token) {
612
+ const hint = feishuErrorHint(code || res?.status, json?.msg || json?.message || "");
613
+ state.lastError = hint;
614
+ log.warn(`获取 token 失败:${hint}`);
615
+ throw Object.assign(new Error(hint), { code: code || -1 });
616
+ }
617
+ state.token = String(json.tenant_access_token);
618
+ const expireSec = Number(json.expire) || 7200;
619
+ state.expiresAt = at + Math.max(60, expireSec) * 1000 - Math.max(0, tokenSkewMs);
620
+ state.lastError = "";
621
+ return state.token;
622
+ }
623
+
624
+ async function sendMessage({ receiveId, receiveIdType = DEFAULT_RECEIVE_ID_TYPE, msgType = "text", content } = {}) {
625
+ const rid = String(receiveId || "");
626
+ if (!rid) return { ok: false, reason: "缺少接收方(先让用户在飞书里给机器人发一句话以完成绑定)" };
627
+ const body = {
628
+ receive_id: rid,
629
+ msg_type: String(msgType || "text"),
630
+ content: typeof content === "string" ? content : JSON.stringify(content ?? {})
631
+ };
632
+ const doSend = async () => {
633
+ const token = await ensureToken();
634
+ const url = `${base}${FEISHU_SEND_PATH}?receive_id_type=${encodeURIComponent(normalizeReceiveIdType(receiveIdType))}`;
635
+ const res = await fetchImpl(url, {
636
+ method: "POST",
637
+ headers: { "content-type": "application/json; charset=utf-8", authorization: `Bearer ${token}` },
638
+ body: JSON.stringify(body)
639
+ });
640
+ const json = await readJson(res);
641
+ return { status: res?.status, json, code: Number(json?.code) };
642
+ };
643
+ try {
644
+ let r = await doSend();
645
+ // token 失效:刷新一次再重试(只重试一次,绝不无限打)
646
+ if (r.code === 99991663) {
647
+ state.token = "";
648
+ r = await doSend();
649
+ }
650
+ state.sends += 1;
651
+ if (r.code === 0) {
652
+ return { ok: true, code: 0, messageId: String(r.json?.data?.message_id || ""), data: r.json?.data };
653
+ }
654
+ const hint = feishuErrorHint(r.code || r.status, r.json?.msg || r.json?.message || "");
655
+ state.lastError = hint;
656
+ log.warn(`发送失败 chat=${chatIdHash(rid)} msg_type=${body.msg_type}:${hint}`);
657
+ return { ok: false, code: r.code, reason: hint };
658
+ } catch (e) {
659
+ const hint = feishuErrorHint(-1, e?.message || e);
660
+ state.lastError = hint;
661
+ log.warn(`发送异常 chat=${chatIdHash(rid)}:${hint}`);
662
+ return { ok: false, reason: hint };
663
+ }
664
+ }
665
+
666
+ return {
667
+ /** 发纯文本(内容已按飞书无 markdown 的规则格式化由调用方决定)。 */
668
+ async sendText(receiveId, text, { receiveIdType = DEFAULT_RECEIVE_ID_TYPE } = {}) {
669
+ return sendMessage({
670
+ receiveId,
671
+ receiveIdType,
672
+ msgType: "text",
673
+ content: { text: formatFeishuText(text) }
674
+ });
675
+ },
676
+ /** 发卡片(审批拍板用)。 */
677
+ async sendCard(receiveId, card, { receiveIdType = DEFAULT_RECEIVE_ID_TYPE } = {}) {
678
+ return sendMessage({ receiveId, receiveIdType, msgType: "interactive", content: card });
679
+ },
680
+ /** 原地更新卡片(best-effort;失败不算致命,调用方只记日志)。 */
681
+ async patchCard(messageId, card) {
682
+ const mid = String(messageId || "");
683
+ if (!mid) return { ok: false, reason: "缺少 message_id" };
684
+ try {
685
+ const token = await ensureToken();
686
+ const res = await fetchImpl(`${base}${FEISHU_SEND_PATH}/${encodeURIComponent(mid)}`, {
687
+ method: "PATCH",
688
+ headers: { "content-type": "application/json; charset=utf-8", authorization: `Bearer ${token}` },
689
+ body: JSON.stringify({ content: JSON.stringify(card) })
690
+ });
691
+ const json = await readJson(res);
692
+ const code = Number(json?.code);
693
+ if (code === 0) return { ok: true };
694
+ return { ok: false, code, reason: feishuErrorHint(code || res?.status, json?.msg || "") };
695
+ } catch (e) {
696
+ return { ok: false, reason: truncateText(e?.message || e, 120) };
697
+ }
698
+ },
699
+ /** 发送器只读状态(token 不回显)。 */
700
+ state() {
701
+ return {
702
+ tokenReady: Boolean(state.token),
703
+ expiresAt: state.expiresAt,
704
+ sends: state.sends,
705
+ tokenFetches: state.tokenFetches,
706
+ lastError: state.lastError
707
+ };
708
+ }
709
+ };
710
+ }
711
+
712
+ // ===========================================================================
713
+ // 5. 长连接 bot(WSClient + 事件/回调分发)
714
+ // ===========================================================================
715
+
716
+ /**
717
+ * 创建飞书长连接 bot(**未 start 前不加载 SDK、不建连**)。
718
+ *
719
+ * 事件:
720
+ * · `'message'`:归一化入站消息(已过滤 bot 自己、已过 allowFrom)
721
+ * · `'cardAction'`:卡片按钮(已 ACK,决策已异步交给 onApprovalDecision)
722
+ * · `'ready' | 'error' | 'status'`:状态变化
723
+ *
724
+ * @param {object} options
725
+ */
726
+ export function createFeishuBot({
727
+ appId,
728
+ appSecret,
729
+ domain = "feishu",
730
+ receiveId = "",
731
+ receiveIdType = "",
732
+ allowFrom = [],
733
+ cardApproval = true,
734
+ relayDir = "",
735
+ env = process.env,
736
+ logger,
737
+ clock = () => Date.now(),
738
+ sdkFactory = null,
739
+ loadSdk = loadFeishuSdk,
740
+ importImpl = null,
741
+ fetchImpl = (...args) => globalThis.fetch(...args),
742
+ onApprovalDecision,
743
+ ackDeadlineMs = ACK_DEADLINE_MS,
744
+ connectionPollMs = CONNECTION_POLL_MS,
745
+ timers = { setTimeout, clearTimeout, setInterval, clearInterval }
746
+ } = {}) {
747
+ const emitter = new EventEmitter();
748
+ const log = logger && typeof logger.info === "function" && typeof logger.warn === "function"
749
+ ? logger
750
+ : createScopedLogger({
751
+ prefix: "[feishu]",
752
+ secrets: [String(appSecret || "")],
753
+ ...(typeof logger === "function" ? { sink: logger } : {})
754
+ });
755
+ const seenCards = new Map();
756
+ let sender = null;
757
+ let wsClient = null;
758
+ let dispatcherHandlers = null;
759
+ let pollTimer = null;
760
+ let stopped = false;
761
+ let started = false;
762
+
763
+ const state = {
764
+ connected: false,
765
+ authenticated: false,
766
+ state: "idle",
767
+ lastError: "",
768
+ lastConnectedAt: 0,
769
+ lastMessageAt: 0,
770
+ boundChatId: "",
771
+ sdkVersion: "",
772
+ ackCount: 0,
773
+ decisionCount: 0
774
+ };
775
+
776
+ function emitStatus() {
777
+ try { emitter.emit("status", status()); } catch { /* 忽略 */ }
778
+ }
779
+
780
+ function readConnectionState() {
781
+ try {
782
+ if (wsClient && typeof wsClient.getConnectionStatus === "function") {
783
+ const st = wsClient.getConnectionStatus();
784
+ const s = String(st?.state || st?.status || "");
785
+ if (s) return s;
786
+ }
787
+ } catch { /* 忽略 */ }
788
+ return "";
789
+ }
790
+
791
+ function refreshConnection() {
792
+ if (stopped) return;
793
+ const s = readConnectionState();
794
+ if (!s) return;
795
+ const connected = s === "connected";
796
+ if (connected && !state.connected) {
797
+ state.connected = true;
798
+ state.authenticated = true; // 飞书语义:bootstrap 成功即已鉴权
799
+ state.state = "connected";
800
+ state.lastConnectedAt = Number(clock()) || Date.now();
801
+ log.info("长连接已就绪");
802
+ try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
803
+ } else if (!connected) {
804
+ state.connected = false;
805
+ state.authenticated = false;
806
+ state.state = /reconnect/i.test(s) ? "reconnecting" : s;
807
+ }
808
+ emitStatus();
809
+ }
810
+
811
+ function stopPolling() {
812
+ if (!pollTimer) return;
813
+ try { timers.clearInterval(pollTimer); } catch { /* 忽略 */ }
814
+ pollTimer = null;
815
+ }
816
+
817
+ function startPolling() {
818
+ stopPolling();
819
+ // 有 getConnectionStatus 才轮询;否则视为"start 成功即已连上"(乐观,失败会走 'error')
820
+ if (wsClient && typeof wsClient.getConnectionStatus === "function") {
821
+ pollTimer = timers.setInterval(refreshConnection, connectionPollMs);
822
+ if (pollTimer && typeof pollTimer.unref === "function") pollTimer.unref();
823
+ refreshConnection();
824
+ // 首拍可能仍是 connecting:给一拍宽限
825
+ timers.setTimeout(refreshConnection, 200);
826
+ } else {
827
+ state.connected = true;
828
+ state.authenticated = true;
829
+ state.state = "connected";
830
+ state.lastConnectedAt = Number(clock()) || Date.now();
831
+ try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
832
+ emitStatus();
833
+ }
834
+ }
835
+
836
+ function rememberCardKey(key) {
837
+ const at = Number(clock()) || Date.now();
838
+ for (const [k, t] of seenCards) {
839
+ if (at - t > SEEN_CARD_TTL_MS) seenCards.delete(k);
840
+ else break;
841
+ }
842
+ if (seenCards.has(key)) return false;
843
+ seenCards.set(key, at);
844
+ if (seenCards.size > SEEN_CARD_MAX) {
845
+ const oldest = seenCards.keys().next().value;
846
+ if (oldest !== undefined) seenCards.delete(oldest);
847
+ }
848
+ return true;
849
+ }
850
+
851
+ /**
852
+ * 卡片按钮回调 —— **同步返回 ACK**,决策异步执行(3 秒铁律)。
853
+ * 返回的 `{toast, card:{type:"raw",data}}` 就是飞书要求的回调响应。
854
+ */
855
+ function handleCardAction(payload) {
856
+ const parsed = parseCardAction(payload);
857
+ if (!parsed.ok) {
858
+ log.warn(`卡片回执无法解析:${truncateText(parsed.reason, 80)}`);
859
+ return { toast: { type: "warning", content: `回执无效:${truncateText(parsed.reason, 40)}` } };
860
+ }
861
+ const at = Number(clock()) || Date.now();
862
+ if (parsed.expiresAt && at > parsed.expiresAt) {
863
+ log.warn(`卡片回执已过期 requestId=${truncateText(parsed.requestId, 24)}`);
864
+ return { toast: { type: "info", content: "这条审批已过期(未回复按拒绝处理)" } };
865
+ }
866
+ if (!rememberCardKey(parsed.eventKey)) {
867
+ return { toast: { type: "info", content: "这条回执已处理过了" } };
868
+ }
869
+ const operator = parsed.operatorOpenId ? maskOperator(parsed.operatorOpenId) : "";
870
+ const decidedCard = buildDecidedCard({ requestId: parsed.requestId, decision: parsed.decision, operator });
871
+ state.ackCount += 1;
872
+ // 先 ACK(同步返回),再异步交决策 —— 绝不能 await 网络/写库
873
+ if (typeof onApprovalDecision === "function") {
874
+ Promise.resolve()
875
+ .then(() => onApprovalDecision(parsed.requestId, parsed.decision))
876
+ .then((r) => {
877
+ state.decisionCount += 1;
878
+ if (r && r.ok === false) {
879
+ log.warn(`审批回执提交失败 requestId=${truncateText(parsed.requestId, 24)}:${truncateText(r.reason || r.error || "?", 80)}`);
880
+ }
881
+ })
882
+ .catch((e) => log.warn(`审批回执异常:${truncateText(e?.message || e, 100)}`));
883
+ } else {
884
+ log.warn("没有注入 onApprovalDecision:卡片回执只 ACK、不生效(编辑/接线问题)");
885
+ }
886
+ try { emitter.emit("cardAction", { ...parsed, ackAt: at }); } catch { /* 忽略 */ }
887
+ return {
888
+ toast: { type: "success", content: parsed.decision === "allow" ? "已回传:允许一次" : "已回传:拒绝" },
889
+ card: { type: "raw", data: decidedCard }
890
+ };
891
+ }
892
+
893
+ function handleMessageEvent(payload) {
894
+ const inbound = normalizeInboundEvent(payload);
895
+ if (!inbound.ok) {
896
+ log.warn(`入站消息无法解析:${truncateText(inbound.reason, 80)}`);
897
+ return;
898
+ }
899
+ // 过滤机器人自己(飞书会推 sender_type==='bot'),避免自回环
900
+ if (inbound.senderType === "bot") return;
901
+ if (!inbound.chatId) return;
902
+ if (!inbound.text) {
903
+ // 非文本:不猜内容,回一句可预期的话(不消耗额度、不算派活)
904
+ log.info(`收到暂不支持的消息类型 type=${truncateText(inbound.msgType, 20)}`);
905
+ void sender?.sendText?.(inbound.chatId, "暂不支持该消息类型,请发文字(或在电脑上选择会话)。", { receiveIdType: "chat_id" });
906
+ return;
907
+ }
908
+ if (Array.isArray(allowFrom) && allowFrom.length > 0 && inbound.openId && !allowFrom.includes(inbound.openId)) {
909
+ log.warn(`入站被白名单拦下 open_id=${maskOperator(inbound.openId)}`);
910
+ void sender?.sendText?.(inbound.chatId, "你不在这个通道的白名单里,无法指挥这台电脑。", { receiveIdType: "chat_id" });
911
+ return;
912
+ }
913
+ state.boundChatId = inbound.chatId;
914
+ state.lastMessageAt = Number(clock()) || Date.now();
915
+ emitStatus();
916
+ try { emitter.emit("message", inbound); } catch (e) { log.warn(`message 处理异常:${truncateText(e?.message || e, 100)}`); }
917
+ }
918
+
919
+ function buildDispatcher(sdk) {
920
+ const dispatcher = new sdk.EventDispatcher({});
921
+ const handlers = {
922
+ "im.message.receive_v1": (data) => {
923
+ try { handleMessageEvent(data); } catch (e) { log.warn(`im.message 处理异常:${truncateText(e?.message || e, 100)}`); }
924
+ // 事件回调要求 3 秒内返回:业务已经 fire-and-forget,这里立刻返回
925
+ return { ok: true };
926
+ },
927
+ "card.action.trigger": (data) => handleCardAction(data)
928
+ };
929
+ dispatcherHandlers = handlers;
930
+ if (typeof dispatcher.register === "function") dispatcher.register(handlers);
931
+ else if (typeof dispatcher.on === "function") {
932
+ for (const [k, fn] of Object.entries(handlers)) dispatcher.on(k, fn);
933
+ }
934
+ return { dispatcher, handlers };
935
+ }
936
+
937
+ function status() {
938
+ return {
939
+ enabled: started && !stopped,
940
+ connected: state.connected,
941
+ authenticated: state.authenticated,
942
+ state: state.state,
943
+ lastError: state.lastError,
944
+ lastConnectedAt: state.lastConnectedAt,
945
+ lastMessageAt: state.lastMessageAt,
946
+ boundChatId: state.boundChatId,
947
+ appId: maskAppId(appId),
948
+ domain: normalizeDomain(domain),
949
+ receiveIdType: normalizeReceiveIdType(receiveIdType, ""),
950
+ cardApproval: cardApproval !== false,
951
+ sdkVersion: state.sdkVersion,
952
+ ackCount: state.ackCount,
953
+ decisionCount: state.decisionCount
954
+ };
955
+ }
956
+
957
+ return {
958
+ /** 建连(懒加载 SDK)。**永不抛错**:失败以 `{ok:false, reason}` + 状态返回。 */
959
+ async start() {
960
+ if (started && !stopped) return { ok: state.connected, status: status() };
961
+ started = true;
962
+ stopped = false;
963
+ state.state = "connecting";
964
+ emitStatus();
965
+
966
+ // ① appId 前置校验(SDK 静默拒绝的坑:见调研 R3)
967
+ const check = validateAppId(appId);
968
+ if (!check.ok) {
969
+ state.state = "failed";
970
+ state.lastError = check.reason || "App ID 不合法";
971
+ log.warn(`拒绝连接:${state.lastError}`);
972
+ emitStatus();
973
+ return { ok: false, reason: "invalid_app_id", status: status() };
974
+ }
975
+ if (check.warning) log.warn(check.warning);
976
+
977
+ // ② 懒加载 SDK
978
+ const loaded = await loadSdk({ relayDir, env, sdkFactory, importImpl });
979
+ if (!loaded.ok) {
980
+ state.state = "sdk_missing";
981
+ state.lastError = loaded.error || SDK_MISSING_HINT;
982
+ log.warn(`飞书 SDK 不可用:${state.lastError}`);
983
+ emitStatus();
984
+ return { ok: false, reason: "sdk_missing", status: status() };
985
+ }
986
+ const sdk = loaded.sdk || {};
987
+ state.sdkVersion = String(loaded.status?.version || "");
988
+
989
+ // ③ 发送器(REST)
990
+ sender = createFeishuSender({
991
+ appId,
992
+ appSecret,
993
+ domain,
994
+ fetchImpl,
995
+ clock,
996
+ logger: log
997
+ });
998
+
999
+ // ④ WSClient(长连接)。domain 仅国际版需要显式传(SDK 用 Domain.Lark 枚举)
1000
+ try {
1001
+ const wsOpts = { appId, appSecret };
1002
+ if (normalizeDomain(domain) === "lark") {
1003
+ const larkDomain = sdk?.Domain?.Lark;
1004
+ if (larkDomain) wsOpts.domain = larkDomain;
1005
+ }
1006
+ const { dispatcher } = buildDispatcher(sdk);
1007
+ wsClient = new sdk.WSClient(wsOpts);
1008
+ if (!wsClient || typeof wsClient.start !== "function") throw new Error("SDK 的 WSClient 形状不认识");
1009
+ await Promise.resolve(wsClient.start({ eventDispatcher: dispatcher }));
1010
+ state.state = "connecting";
1011
+ startPolling();
1012
+ log.info(`长连接已发起 appId=${maskAppId(appId)} domain=${normalizeDomain(domain)} cardApproval=${cardApproval !== false}`);
1013
+ emitStatus();
1014
+ return { ok: true, status: status() };
1015
+ } catch (e) {
1016
+ state.state = "failed";
1017
+ state.lastError = mapConnectError(e);
1018
+ log.warn(`建连失败:${state.lastError}`);
1019
+ emitStatus();
1020
+ // 建连失败不抛:上面已给明确状态;调用方(controller)只写状态
1021
+ return { ok: false, reason: "connect_failed", error: state.lastError, status: status() };
1022
+ }
1023
+ },
1024
+
1025
+ /** 关闭连接(幂等)。 */
1026
+ async stop() {
1027
+ stopped = true;
1028
+ started = false;
1029
+ stopPolling();
1030
+ const c = wsClient;
1031
+ wsClient = null;
1032
+ if (c) {
1033
+ try { if (typeof c.close === "function") await c.close(); else if (typeof c.stop === "function") await c.stop(); } catch (e) {
1034
+ log.warn(`关闭长连接失败(忽略):${truncateText(e?.message || e, 100)}`);
1035
+ }
1036
+ }
1037
+ state.connected = false;
1038
+ state.authenticated = false;
1039
+ state.state = "idle";
1040
+ emitStatus();
1041
+ return { ok: true };
1042
+ },
1043
+
1044
+ /** 发文本(自动按飞书通道格式化)。 */
1045
+ async sendText(to, text, opts = {}) {
1046
+ const target = String(to || receiveId || state.boundChatId || "");
1047
+ if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
1048
+ const r = await sender.sendText(target, text, {
1049
+ receiveIdType: opts.receiveIdType || normalizeReceiveIdType(receiveIdType, state.boundChatId ? "chat_id" : DEFAULT_RECEIVE_ID_TYPE)
1050
+ });
1051
+ if (r.ok) {
1052
+ state.lastMessageAt = Number(clock()) || Date.now();
1053
+ emitStatus();
1054
+ }
1055
+ return r;
1056
+ },
1057
+
1058
+ /** 发审批卡片(返回值带 messageId,供回执后原地更新)。 */
1059
+ async sendCard(to, card, opts = {}) {
1060
+ const target = String(to || receiveId || state.boundChatId || "");
1061
+ if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
1062
+ return sender.sendCard(target, card, {
1063
+ receiveIdType: opts.receiveIdType || normalizeReceiveIdType(receiveIdType, state.boundChatId ? "chat_id" : DEFAULT_RECEIVE_ID_TYPE)
1064
+ });
1065
+ },
1066
+
1067
+ /** 更新卡片(best-effort)。 */
1068
+ async patchCard(messageId, card) {
1069
+ if (!sender) return { ok: false, reason: "发送器未初始化" };
1070
+ return sender.patchCard(messageId, card);
1071
+ },
1072
+
1073
+ /** 事件订阅:'message' | 'cardAction' | 'ready' | 'error' | 'status'。 */
1074
+ on(evt, cb) {
1075
+ emitter.on(evt, cb);
1076
+ return () => emitter.off(evt, cb);
1077
+ },
1078
+
1079
+ status,
1080
+ senderState: () => (sender ? sender.state() : null),
1081
+ /** 仅测试:等价 SDK 分发一次事件/回调(不经过网络)。 */
1082
+ _dispatch(kind, payload) {
1083
+ const fn = dispatcherHandlers && dispatcherHandlers[kind];
1084
+ if (!fn) return { ok: false, reason: `no-handler:${kind}` };
1085
+ return fn(payload);
1086
+ }
1087
+ };
1088
+ }
1089
+
1090
+ function maskOperator(openId) {
1091
+ const v = String(openId || "");
1092
+ if (!v) return "";
1093
+ return `${v.slice(0, 4)}***${v.slice(-2)}`;
1094
+ }
1095
+
1096
+ /** 建连错误 → 人话(凭据/权限/网络分档;网络类给代理提示)。 */
1097
+ export function mapConnectError(error) {
1098
+ const raw = String(error?.message || error || "");
1099
+ const code = error?.code || error?.response?.code || "";
1100
+ const joined = `${code} ${raw}`;
1101
+ if (/auth_failed|invalid app|app_id|app_secret|401|514/i.test(joined)) {
1102
+ return `应用凭据校验失败:检查 App ID / App Secret(${truncateText(raw, 120)})`;
1103
+ }
1104
+ if (/forbidden|403|scope|permission/i.test(joined)) {
1105
+ return `应用没有权限或类型不对(长连接仅支持企业自建应用;${truncateText(raw, 120)})`;
1106
+ }
1107
+ if (/exceed_conn_limit|1000040350/i.test(joined)) {
1108
+ return feishuErrorHint(1000040350, raw);
1109
+ }
1110
+ if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|proxy/i.test(joined)) {
1111
+ return `连不上飞书长连接:检查企业网络/代理。${PROXY_HINT}(${truncateText(raw, 100)})`;
1112
+ }
1113
+ return `建连失败:${truncateText(raw || "未知错误", 160)}`;
1114
+ }
1115
+
1116
+ // ===========================================================================
1117
+ // 6. 控制器(读配置 / 热更新 / 状态文件 / 会员闸 / 事件通知)
1118
+ // ===========================================================================
1119
+
1120
+ function monthKey(at) {
1121
+ try { return new Date(Number(at) || Date.now()).toISOString().slice(0, 7); } catch { return ""; }
1122
+ }
1123
+
1124
+ /**
1125
+ * bridge 只需要这个(与 `createWecomBotController` 同形)。
1126
+ *
1127
+ * @param {object} options
1128
+ * @param {string} options.relayDir
1129
+ * @param {(requestId:string, decision:"allow"|"deny")=>Promise<{ok?:boolean}>} [options.onApprovalDecision]
1130
+ * @param {()=>Promise<string|{plan?:string}>} [options.capsProvider] 会员档位(复用 resolveWechatTier)
1131
+ * @param {Function} [options.createBot] 测试注入
1132
+ * @param {Function} [options.sdkFactory] 测试注入(返回假 SDK)
1133
+ * @param {Function} [options.subscriberFactory] 测试注入(返回假事件订阅器)
1134
+ * @param {object} [options.botOptions] 传给 bot 的额外参数(upstream/cookie 等给订阅器用)
1135
+ */
1136
+ export function createFeishuBotController(options = {}) {
1137
+ const relayDir = String(options.relayDir || "");
1138
+ const env = options.env && typeof options.env === "object" ? options.env : process.env;
1139
+ const clock = typeof options.clock === "function" ? options.clock : () => Date.now();
1140
+ const pollMs = Number.isFinite(Number(options.configPollMs)) && Number(options.configPollMs) > 0
1141
+ ? Number(options.configPollMs) : CONFIG_POLL_MS;
1142
+ const log = createScopedLogger({ sink: options.log, prefix: "[feishu]" });
1143
+ const configFile = relayDir ? path.join(relayDir, IM_BOTS_FILE) : "";
1144
+ const statusFile = relayDir ? path.join(relayDir, IM_BOT_STATUS_FILE) : "";
1145
+ const createBot = typeof options.createBot === "function" ? options.createBot : createFeishuBot;
1146
+ const sdkFactory = typeof options.sdkFactory === "function" ? options.sdkFactory : null;
1147
+ const loadSdk = typeof options.loadSdk === "function" ? options.loadSdk : loadFeishuSdk;
1148
+ const onApprovalDecision = typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : null;
1149
+ const capsProvider = typeof options.capsProvider === "function" ? options.capsProvider : null;
1150
+ const notifyEnabled = options.notify !== false && String(env.DSH_IM_BOT_NOTIFY || "") !== "0" && String(env.DSH_PUSH_REPORT || "") !== "0";
1151
+ const cardApprovalAllowed = options.cardApproval !== false && String(env.DSH_FEISHU_CARD_APPROVAL || "") !== "0";
1152
+ const timers = options.timers && typeof options.timers === "object" ? options.timers : { setTimeout, clearTimeout, setInterval, clearInterval };
1153
+ const botOptions = options.botOptions && typeof options.botOptions === "object" ? options.botOptions : {};
1154
+ const appUrl = String(options.appUrl || "");
1155
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : undefined;
1156
+
1157
+ const state = {
1158
+ started: false,
1159
+ enabled: false,
1160
+ configFound: false,
1161
+ reason: "not_started",
1162
+ configUpdatedAt: 0,
1163
+ lastError: ""
1164
+ };
1165
+ /** 会员快照(与企微同一套表;拿不到 → free fail-closed)。 */
1166
+ const membership = { tier: "", caps: [], limits: {}, usage: { month: "", count: 0 }, checkedAt: 0 };
1167
+
1168
+ let bot = null;
1169
+ let currentDigest = "";
1170
+ let listener = null;
1171
+ let reloading = false;
1172
+ let subscriber = null;
1173
+ let notifyMinute = { at: 0, count: 0 };
1174
+ const notifiedKeys = new Map();
1175
+
1176
+ // ── 会员 ────────────────────────────────────────────────────────────────
1177
+ function tierNow() {
1178
+ return membership.tier || "free";
1179
+ }
1180
+ function capsNow() {
1181
+ return Array.isArray(membership.caps) && membership.caps.length
1182
+ ? membership.caps
1183
+ : capabilitiesFor(tierNow(), WECHAT_CAPABILITY_TABLE);
1184
+ }
1185
+ function canCap(need) {
1186
+ return grantsCap(capsNow(), need);
1187
+ }
1188
+ function limitsNow() {
1189
+ return limitsFor(tierNow(), WECHAT_TIER_LIMITS);
1190
+ }
1191
+ function usageThisMonth() {
1192
+ const m = monthKey(clock());
1193
+ return membership.usage.month === m ? Number(membership.usage.count || 0) : 0;
1194
+ }
1195
+ async function refreshTier({ force = false, minIntervalMs = 30_000 } = {}) {
1196
+ if (!capsProvider) return tierNow();
1197
+ const at = Number(clock()) || Date.now();
1198
+ if (!force && at - membership.checkedAt < minIntervalMs) return tierNow();
1199
+ let got = null;
1200
+ try { got = await capsProvider(); } catch { got = null; }
1201
+ membership.checkedAt = at;
1202
+ if (!got) return tierNow();
1203
+ const plan = typeof got === "string" ? got : String(got.plan || "");
1204
+ if (!plan) return tierNow();
1205
+ membership.tier = plan;
1206
+ const caps = typeof got === "object" && Array.isArray(got.caps) ? got.caps : capabilitiesFor(plan, WECHAT_CAPABILITY_TABLE);
1207
+ membership.caps = caps;
1208
+ membership.limits = typeof got === "object" && got.limits && typeof got.limits === "object" ? got.limits : limitsFor(plan, WECHAT_TIER_LIMITS);
1209
+ writeStatus();
1210
+ return tierNow();
1211
+ }
1212
+ function bumpUsage() {
1213
+ const m = monthKey(clock());
1214
+ const used = membership.usage.month === m ? Number(membership.usage.count || 0) : 0;
1215
+ membership.usage = { month: m, count: used + 1 };
1216
+ writeStatus();
1217
+ }
1218
+
1219
+ /** 重启恢复:usage/tier 从状态文件回读(免费额度不能因重启清零)。 */
1220
+ function hydrateFromStatus() {
1221
+ if (!statusFile) return;
1222
+ try {
1223
+ const prev = readJsonFile(statusFile);
1224
+ const f = prev && prev.feishu && typeof prev.feishu === "object" ? prev.feishu : null;
1225
+ if (!f) return;
1226
+ if (f.usage && typeof f.usage === "object" && typeof f.usage.month === "string") {
1227
+ membership.usage = { month: f.usage.month, count: Math.max(0, Number(f.usage.messages ?? f.usage.count) || 0) };
1228
+ }
1229
+ if (capsProvider && typeof f.tier === "string" && f.tier) {
1230
+ membership.tier = f.tier;
1231
+ membership.caps = Array.isArray(f.caps) ? f.caps : capabilitiesFor(f.tier, WECHAT_CAPABILITY_TABLE);
1232
+ membership.limits = f.limits && typeof f.limits === "object" ? f.limits : limitsFor(f.tier, WECHAT_TIER_LIMITS);
1233
+ }
1234
+ } catch { /* 忽略 */ }
1235
+ }
1236
+
1237
+ // ── 状态文件 ────────────────────────────────────────────────────────────
1238
+ function sdkStatusNow() {
1239
+ try {
1240
+ return feishuSdkStatus({ relayDir, env, sdkFactory });
1241
+ } catch (e) {
1242
+ return { installed: false, version: "", path: "", depsDir: "", lastError: truncateText(e?.message || e, 120) };
1243
+ }
1244
+ }
1245
+ function feishuSection() {
1246
+ const bs = bot && typeof bot.status === "function" ? bot.status() : null;
1247
+ const dep = sdkStatusNow();
1248
+ return {
1249
+ enabled: state.enabled === true,
1250
+ state: (bs && bs.state) || state.reason || "idle",
1251
+ connected: bs ? bs.connected === true : false,
1252
+ authenticated: bs ? bs.authenticated === true : false,
1253
+ lastError: truncateText(state.lastError || (bs && bs.lastError) || "", 160),
1254
+ lastConnectedAt: bs?.lastConnectedAt || 0,
1255
+ lastMessageAt: bs?.lastMessageAt || 0,
1256
+ boundChatId: (bs && bs.boundChatId) || "",
1257
+ appId: bs?.appId || maskAppId(""),
1258
+ domain: bs?.domain || "feishu",
1259
+ receiveIdType: bs?.receiveIdType || "",
1260
+ cardApproval: bs ? bs.cardApproval === true : cardApprovalAllowed,
1261
+ sdkVersion: bs?.sdkVersion || dep.version || "",
1262
+ tier: tierNow(),
1263
+ caps: capsNow(),
1264
+ limits: limitsNow(),
1265
+ usage: { month: membership.usage.month || monthKey(clock()), messages: usageThisMonth() },
1266
+ ackCount: bs?.ackCount || 0,
1267
+ decisionCount: bs?.decisionCount || 0
1268
+ };
1269
+ }
1270
+ function writeStatus() {
1271
+ if (!statusFile) return;
1272
+ // 没有配置、也没有历史状态时不制造空文件:bridge 在仓库目录被调试启动(relayDir=repo)时
1273
+ // 不该凭空往工作区写 im-bot-status.json;真实用户有配置时才会写。
1274
+ try {
1275
+ if (!fs.existsSync(configFile) && !fs.existsSync(statusFile)) return;
1276
+ } catch { /* 检查失败就照常写 */ }
1277
+ try {
1278
+ const prev = readJsonFile(statusFile);
1279
+ const base = prev && typeof prev === "object" && !Array.isArray(prev) ? prev : {};
1280
+ const dep = sdkStatusNow();
1281
+ atomicWriteJson(statusFile, {
1282
+ ...base,
1283
+ version: 1,
1284
+ updatedAt: Number(clock()) || Date.now(),
1285
+ feishu: feishuSection(),
1286
+ deps: {
1287
+ ...(base.deps && typeof base.deps === "object" ? base.deps : {}),
1288
+ feishu: {
1289
+ installed: dep.installed === true,
1290
+ version: dep.version || "",
1291
+ path: dep.path || "",
1292
+ lastError: dep.lastError || ""
1293
+ }
1294
+ }
1295
+ }, { onWarn: (m) => log.warn(m) });
1296
+ } catch (e) {
1297
+ log.warn(`状态文件写入失败(忽略):${truncateText(e?.message || e, 120)}`);
1298
+ }
1299
+ }
1300
+
1301
+ // ── 通知 ────────────────────────────────────────────────────────────────
1302
+ function notifyRateOk() {
1303
+ const at = Number(clock()) || Date.now();
1304
+ if (at - notifyMinute.at > 60_000) notifyMinute = { at, count: 0 };
1305
+ if (notifyMinute.count >= NOTIFY_PER_MINUTE_MAX) return false;
1306
+ notifyMinute.count += 1;
1307
+ return true;
1308
+ }
1309
+ function rememberNotifyKey(key) {
1310
+ const at = Number(clock()) || Date.now();
1311
+ for (const [k, t] of notifiedKeys) {
1312
+ if (at - t > NOTIFY_KEY_TTL_MS) notifiedKeys.delete(k);
1313
+ else break;
1314
+ }
1315
+ if (notifiedKeys.has(key)) return false;
1316
+ notifiedKeys.set(key, at);
1317
+ if (notifiedKeys.size > NOTIFY_KEY_MAX) {
1318
+ const oldest = notifiedKeys.keys().next().value;
1319
+ if (oldest !== undefined) notifiedKeys.delete(oldest);
1320
+ }
1321
+ return true;
1322
+ }
1323
+ function notifyKey(node) {
1324
+ const sid = String(node?.sessionId || "");
1325
+ if (!node) return "";
1326
+ if (node.kind === NODE_KINDS.TURN_END) return `turn:${sid}:${node.turn ?? "?"}:${node.seq ?? "?"}:${node.reason || "?"}`;
1327
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.USER_QUESTION || node.kind === NODE_KINDS.PLAN_REVIEW) {
1328
+ return `approval:${node.eventId || ""}:${sid}`;
1329
+ }
1330
+ if (node.kind === NODE_KINDS.SESSION_ERROR) return `error:${sid}:${node.code || node.message || ""}:${node.at || ""}`;
1331
+ return "";
1332
+ }
1333
+ function targetOf() {
1334
+ if (!bot) return "";
1335
+ const bs = bot.status();
1336
+ return configNow?.receiveId || bs.boundChatId || "";
1337
+ }
1338
+ let configNow = null;
1339
+
1340
+ async function onEventNode(node) {
1341
+ if (!node || typeof node !== "object") return;
1342
+ if (!notifyEnabled || !bot) return;
1343
+ const target = targetOf();
1344
+ if (!target) {
1345
+ log.info("有事件要推,但还没绑定接收方:先在飞书里给机器人发一句话,或在配置里填 receiveId");
1346
+ return;
1347
+ }
1348
+ if (!notifyRateOk()) {
1349
+ log.warn("通知超过每分钟上限,已丢弃(防事件风暴)");
1350
+ return;
1351
+ }
1352
+ // 审批:优先卡片(可点按钮)
1353
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
1354
+ const key = notifyKey(node);
1355
+ if (key && !rememberNotifyKey(key)) return;
1356
+ const requestId = String(node.eventId || "");
1357
+ if (cardApprovalAllowed && requestId) {
1358
+ const card = buildApprovalCard({
1359
+ requestId,
1360
+ toolName: node.toolName || node.reason || "",
1361
+ summary: node.detail || node.summary || "",
1362
+ sessionTitle: node.sessionTitle || "",
1363
+ expiresAt: (Number(clock()) || Date.now()) + APPROVAL_CARD_TTL_MS,
1364
+ nonce: crypto.randomBytes(6).toString("hex")
1365
+ });
1366
+ const r = await bot.sendCard(target, card);
1367
+ if (r.ok) {
1368
+ log.info(`审批卡片已发送 requestId=${truncateText(requestId, 24)} chat=${chatIdHash(target)}`);
1369
+ return;
1370
+ }
1371
+ log.warn(`审批卡片发送失败(${r.reason || "?"}),退回文本通知`);
1372
+ }
1373
+ const text = toMarkdownNotification(node, {});
1374
+ if (text) await bot.sendText(target, text);
1375
+ return;
1376
+ }
1377
+ const text = toMarkdownNotification(node, {});
1378
+ if (!text) return;
1379
+ const key = notifyKey(node);
1380
+ if (key && !rememberNotifyKey(key)) return;
1381
+ await bot.sendText(target, text);
1382
+ }
1383
+
1384
+ // ── 入站 ────────────────────────────────────────────────────────────────
1385
+ async function onInboundMessage(msg) {
1386
+ if (!msg || !msg.text) return;
1387
+ const at = Number(clock()) || Date.now();
1388
+ // 白名单在 bot 层已过滤;这里做会员闸:派活要 assign.continue + 月度额度
1389
+ await refreshTier({ minIntervalMs: 30_000 });
1390
+ if (!canCap("assign.continue")) {
1391
+ await refreshTier({ force: true }); // 可能是刚升级:再确认一次
1392
+ if (!canCap("assign.continue")) {
1393
+ const promo = appUrl ? `${appUrl.replace(/\/+$/, "")}/app/promo` : "";
1394
+ await bot.sendText(msg.chatId, `当前档位不支持从飞书派活${promo ? `(升级:${promo})` : ""}。审批与通知不受影响。`, { receiveIdType: "chat_id" });
1395
+ log.info(`入站被会员闸拦下(无 assign.continue,tier=${tierNow()})`);
1396
+ return;
1397
+ }
1398
+ }
1399
+ const limit = Number(limitsNow().messages_per_month || 0);
1400
+ if (limit > 0 && usageThisMonth() >= limit) {
1401
+ await refreshTier({ force: true }); // 可能是刚升级:再确认一次
1402
+ const limit2 = Number(limitsNow().messages_per_month || 0);
1403
+ if (limit2 > 0 && usageThisMonth() >= limit2) {
1404
+ const promo = appUrl ? `${appUrl.replace(/\/+$/, "")}/app/promo` : "";
1405
+ await bot.sendText(msg.chatId, `免费额度已用完(每月 ${limit2} 条)${promo ? `,升级后不限:${promo}` : ""}。审批与通知仍免费。`, { receiveIdType: "chat_id" });
1406
+ log.info(`入站被额度闸拦下(${usageThisMonth()}/${limit2},tier=${tierNow()})`);
1407
+ return;
1408
+ }
1409
+ }
1410
+ // 选会话:配置固定 → 订阅器已 follow 的第一个 → listSessions 第一个
1411
+ const sub = subscriber;
1412
+ let sessionId = configNow?.sessionId || "";
1413
+ if (!sessionId && sub && typeof sub.sessions === "function") {
1414
+ const list = sub.sessions();
1415
+ sessionId = Array.isArray(list) && list.length ? String(list[0]) : "";
1416
+ }
1417
+ if (!sessionId && sub && typeof sub.listSessions === "function") {
1418
+ try {
1419
+ const r = await sub.listSessions();
1420
+ const rows = Array.isArray(r) ? r : Array.isArray(r?.sessions) ? r.sessions : [];
1421
+ sessionId = rows.length ? String(rows[0].sessionId || rows[0].id || "") : "";
1422
+ } catch { /* 忽略 */ }
1423
+ }
1424
+ if (!sessionId || !sub || typeof sub.promptSession !== "function") {
1425
+ await bot.sendText(msg.chatId, "还没在电脑端选择要接管的会话:先在电脑上打开一个会话,再从这里发消息。", { receiveIdType: "chat_id" });
1426
+ return;
1427
+ }
1428
+ bumpUsage();
1429
+ try {
1430
+ await sub.promptSession({ sessionId, text: msg.text });
1431
+ await bot.sendText(msg.chatId, "已收到,转给电脑上的会话了。", { receiveIdType: "chat_id" });
1432
+ } catch (e) {
1433
+ log.warn(`转发入站消息失败:${truncateText(e?.message || e, 120)}`);
1434
+ await bot.sendText(msg.chatId, "没转出去:电脑端可能忙或会话已结束,到电脑上看一眼。", { receiveIdType: "chat_id" });
1435
+ }
1436
+ }
1437
+
1438
+ // ── 生命周期 ────────────────────────────────────────────────────────────
1439
+ function digestOf(cfg) {
1440
+ if (!cfg) return "missing";
1441
+ const f = cfg;
1442
+ let secretHash = "";
1443
+ try { secretHash = f.appSecret ? crypto.createHash("sha1").update(String(f.appSecret)).digest("hex") : ""; } catch { secretHash = "?"; }
1444
+ return JSON.stringify([
1445
+ f.enabled === true, f.appId || "", secretHash, f.domain,
1446
+ f.receiveId || "", f.receiveIdType || "", Array.isArray(f.allowFrom) ? f.allowFrom : [],
1447
+ f.cardApproval !== false, notifyEnabled, cardApprovalAllowed, f.sessionId || ""
1448
+ ]);
1449
+ }
1450
+ async function stopBot() {
1451
+ const b = bot;
1452
+ bot = null;
1453
+ if (!b) return;
1454
+ try { await b.stop(); } catch (e) { log.warn(`停止飞书长连接失败(忽略):${truncateText(e?.message || e, 100)}`); }
1455
+ }
1456
+ function unwatchConfig() {
1457
+ if (!listener) return;
1458
+ try { fs.unwatchFile(configFile, listener); } catch { /* 忽略 */ }
1459
+ listener = null;
1460
+ }
1461
+ function watchConfig() {
1462
+ if (listener || !configFile) return;
1463
+ listener = () => { void applyConfig(); };
1464
+ // persistent:false —— watcher 本身不持有事件循环(bridge 有隧道保活;测试/停止后不会挂住进程)
1465
+ try { fs.watchFile(configFile, { interval: pollMs, persistent: false }, listener); } catch (e) {
1466
+ log.warn(`监听 im-bots.json 失败(改配置后需重启 bridge):${truncateText(e?.message || e, 120)}`);
1467
+ listener = null;
1468
+ }
1469
+ }
1470
+ async function stopSubscriber() {
1471
+ const s = subscriber;
1472
+ subscriber = null;
1473
+ if (!s) return;
1474
+ try { if (typeof s.stop === "function") await s.stop(); } catch { /* 忽略 */ }
1475
+ }
1476
+ function ensureSubscriber() {
1477
+ if (subscriber || !notifyEnabled) return subscriber;
1478
+ try {
1479
+ if (typeof options.subscriberFactory === "function") subscriber = options.subscriberFactory();
1480
+ else {
1481
+ const upstream = typeof botOptions.upstream === "function" ? botOptions.upstream : botOptions.upstream;
1482
+ if (!upstream) return null;
1483
+ subscriber = createEventSubscriber({
1484
+ upstream,
1485
+ cookie: botOptions.cookie || "",
1486
+ log: (levelOrLine, maybeLine) => {
1487
+ const line = typeof maybeLine === "string" ? maybeLine : String(levelOrLine || "");
1488
+ log.info(line);
1489
+ }
1490
+ });
1491
+ }
1492
+ if (subscriber && typeof subscriber.on === "function") {
1493
+ subscriber.on("event", (node) => { void onEventNode(node).catch(() => {}); });
1494
+ subscriber.on("ready", () => log.info("事件订阅器已就绪(通知可用)"));
1495
+ subscriber.on("auth-error", (info) => log.warn(`事件订阅鉴权失败:${truncateText(info?.message || "", 100)}`));
1496
+ }
1497
+ if (subscriber && typeof subscriber.start === "function") subscriber.start();
1498
+ return subscriber;
1499
+ } catch (e) {
1500
+ log.warn(`事件订阅器启动失败(不影响长连接):${truncateText(e?.message || e, 120)}`);
1501
+ subscriber = null;
1502
+ return null;
1503
+ }
1504
+ }
1505
+
1506
+ async function applyConfig() {
1507
+ if (reloading) return;
1508
+ reloading = true;
1509
+ try {
1510
+ if (String(env.DSH_IM_BOTS || "") === "0") {
1511
+ state.enabled = false;
1512
+ state.reason = "disabled_by_env";
1513
+ await stopBot();
1514
+ await stopSubscriber();
1515
+ writeStatus();
1516
+ return;
1517
+ }
1518
+ const read = relayDir ? readFeishuConfig(relayDir) : null;
1519
+ state.configFound = Boolean(read);
1520
+ const cfg = read ? read.config : normalizeFeishuConfig({});
1521
+ configNow = cfg;
1522
+ const digest = digestOf(cfg);
1523
+ if (digest === currentDigest) { writeStatus(); return; }
1524
+ currentDigest = digest;
1525
+ await stopBot();
1526
+ await stopSubscriber();
1527
+ state.configUpdatedAt = Number(clock()) || Date.now();
1528
+ state.lastError = "";
1529
+
1530
+ if (!cfg.enabled) {
1531
+ state.enabled = false;
1532
+ state.reason = "disabled";
1533
+ log.info("im-bots.json 里 feishu.enabled=false,飞书通道不启动");
1534
+ writeStatus();
1535
+ return;
1536
+ }
1537
+ if (!cfg.appId || !cfg.appSecret) {
1538
+ state.enabled = false;
1539
+ state.reason = "missing_credentials";
1540
+ state.lastError = "缺少 App ID / App Secret(开发者后台「凭证与基础信息」里复制)";
1541
+ log.warn(state.lastError);
1542
+ writeStatus();
1543
+ return;
1544
+ }
1545
+ const appCheck = validateAppId(cfg.appId);
1546
+ if (!appCheck.ok) {
1547
+ state.enabled = false;
1548
+ state.reason = "invalid_app_id";
1549
+ state.lastError = appCheck.reason || "App ID 不合法";
1550
+ log.warn(`飞书 App ID 校验失败:${state.lastError}`);
1551
+ writeStatus();
1552
+ return;
1553
+ }
1554
+ const dep = sdkStatusNow();
1555
+ if (!dep.installed) {
1556
+ state.enabled = false;
1557
+ state.reason = "sdk_missing";
1558
+ state.lastError = SDK_MISSING_HINT;
1559
+ log.info("飞书 SDK 未下载(按需依赖):面板点「下载依赖」后会自动重试");
1560
+ writeStatus();
1561
+ return;
1562
+ }
1563
+ await refreshTier({ force: true });
1564
+
1565
+ try {
1566
+ bot = createBot({
1567
+ appId: cfg.appId,
1568
+ appSecret: cfg.appSecret,
1569
+ domain: cfg.domain,
1570
+ receiveId: cfg.receiveId,
1571
+ receiveIdType: cfg.receiveIdType,
1572
+ allowFrom: cfg.allowFrom,
1573
+ cardApproval: cardApprovalAllowed && cfg.cardApproval,
1574
+ relayDir,
1575
+ env,
1576
+ clock,
1577
+ timers,
1578
+ sdkFactory,
1579
+ loadSdk,
1580
+ ...(fetchImpl ? { fetchImpl } : {}),
1581
+ ...(botOptions || {}),
1582
+ onApprovalDecision,
1583
+ logger: options.log
1584
+ });
1585
+ } catch (e) {
1586
+ bot = null;
1587
+ state.enabled = false;
1588
+ state.reason = "bot_factory_invalid";
1589
+ state.lastError = truncateText(e?.message || e, 160);
1590
+ log.warn(`创建飞书 bot 失败:${state.lastError}`);
1591
+ writeStatus();
1592
+ return;
1593
+ }
1594
+ state.enabled = true;
1595
+ state.reason = "starting";
1596
+ if (bot && typeof bot.on === "function") {
1597
+ bot.on("ready", () => { state.reason = "connected"; writeStatus(); ensureSubscriber(); });
1598
+ bot.on("status", () => { writeStatus(); });
1599
+ bot.on("message", (msg) => { void onInboundMessage(msg).catch(() => {}); });
1600
+ }
1601
+ const r = bot && typeof bot.start === "function" ? await bot.start() : { ok: false, reason: "bot_without_start" };
1602
+ if (r && r.ok) {
1603
+ state.reason = "connected";
1604
+ ensureSubscriber();
1605
+ } else {
1606
+ const bs = bot && typeof bot.status === "function" ? bot.status() : {};
1607
+ state.reason = bs.state || r?.reason || "failed";
1608
+ state.lastError = (r && (r.error || r.reason)) || bs.lastError || "";
1609
+ if (state.reason === "sdk_missing") state.enabled = false;
1610
+ log.warn(`飞书通道未就绪(${state.reason}):${state.lastError}`);
1611
+ }
1612
+ writeStatus();
1613
+ } catch (e) {
1614
+ state.enabled = false;
1615
+ state.reason = "apply_failed";
1616
+ state.lastError = truncateText(e?.message || e, 160);
1617
+ log.warn(`应用飞书配置失败(忽略):${state.lastError}`);
1618
+ writeStatus();
1619
+ } finally {
1620
+ reloading = false;
1621
+ }
1622
+ }
1623
+
1624
+ function statusSnapshot() {
1625
+ return {
1626
+ started: state.started,
1627
+ enabled: state.enabled,
1628
+ configFound: state.configFound,
1629
+ reason: state.reason,
1630
+ configPath: configFile,
1631
+ statusPath: statusFile,
1632
+ configUpdatedAt: state.configUpdatedAt,
1633
+ lastError: state.lastError,
1634
+ deps: sdkStatusNow(),
1635
+ bot: bot && typeof bot.status === "function" ? bot.status() : null
1636
+ };
1637
+ }
1638
+
1639
+ return {
1640
+ async start() {
1641
+ if (state.started) return statusSnapshot();
1642
+ state.started = true;
1643
+ hydrateFromStatus();
1644
+ if (!relayDir) {
1645
+ state.reason = "no_relay_dir";
1646
+ log.warn("缺少 relayDir,飞书通道不启动");
1647
+ return statusSnapshot();
1648
+ }
1649
+ await applyConfig();
1650
+ watchConfig();
1651
+ return statusSnapshot();
1652
+ },
1653
+ async stop() {
1654
+ unwatchConfig();
1655
+ state.started = false;
1656
+ state.enabled = false;
1657
+ state.reason = "stopped";
1658
+ await stopBot();
1659
+ await stopSubscriber();
1660
+ writeStatus();
1661
+ return statusSnapshot();
1662
+ },
1663
+ status: statusSnapshot,
1664
+ /** 仅测试。 */
1665
+ _bot: () => bot,
1666
+ _membership: () => ({ ...membership, usage: { ...membership.usage } })
1667
+ };
1668
+ }