@sidleo3/dsh-chat-feishu 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1605 @@
1
+ /**
2
+ * 飞书消息桥:入站消息 → hub 会话桥 → 出站呈现。
3
+ *
4
+ * 只做飞书这一侧的事:解析事件、属主/群聊门禁、去重、按会话类型挑过程展示模式。
5
+ * 会话绑定、上下文增强引擎、审批回传都由 hub 提供,桥只负责调用与呈现。
6
+ *
7
+ * @module dsh-chat-feishu/bridge
8
+ */
9
+
10
+ import { stat } from 'node:fs/promises';
11
+
12
+ import { createTurnPresenter } from './turn-presenter.mjs';
13
+ import { panelAction, panelButton, panelCard, panelPick } from './panel-card.mjs';
14
+
15
+ /** 交付文件的单文件上限(与主动投递一致:飞书上传超过这个量既慢又容易失败)。 */
16
+ const MAX_DELIVERABLE_BYTES = 30 * 1024 * 1024;
17
+
18
+ /** 一条入站文本的来源字段工厂用的取值上限(与上下文增强引擎一致)。 */
19
+ function messageText(message) {
20
+ if (message?.message_type !== 'text') return null;
21
+ try {
22
+ const parsed = JSON.parse(message.content ?? '{}');
23
+ return typeof parsed?.text === 'string' ? parsed.text : '';
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
28
+
29
+ /** DSH 只认这四种图片类型;其余一律按"不支持"处理。 */
30
+ const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
31
+
32
+ /** 命令清单卡一行放几个按钮(超出的换到下一行,绝不截断命令)。 */
33
+ const MENU_ROW_SIZE = 4;
34
+
35
+ /**
36
+ * 交互回传的卡片重画要**等回调应答先发出去**,再动手(默认延迟)。
37
+ *
38
+ * 飞书客户端在回调应答落地时会把卡片还原成"用户点击前"的快照:先更新再应答 = 更新被还原,
39
+ * 真机表现就是"卡片闪一下又变回原样",而日志里那次 update 明明是成功的。
40
+ * 延迟更新接口本来就是为"应答之后再更新"设计的(token 30 分钟内有效)。
41
+ */
42
+ const RESPONSE_SETTLE_MS = 50;
43
+
44
+ /**
45
+ * 「会断长连接」的动作排在应答之后多久执行。
46
+ *
47
+ * 1 秒是给回执帧留的余量:SDK 在 handler 的 promise 落地后才把应答写回长连接,
48
+ * 而这类动作(重连)接下来就会把那条连接关掉——抢在它前面执行就等于没有回执。
49
+ */
50
+ const SLOW_ACTION_SETTLE_MS = 1_000;
51
+
52
+ /**
53
+ * 读"被引用的消息"的时限:它是为了让提示词更完整,**不能拖住用户的提问**。
54
+ * 超时就当"引用内容不可用",当前消息照常进模型。
55
+ */
56
+ const REPLY_REFERENCE_TIMEOUT_MS = 3000;
57
+
58
+ /** 卡片上的时间戳(本地 时:分:秒):让"停在哪一次更新"在卡上可核对。 */
59
+ function panelClock() {
60
+ const now = new Date();
61
+ const pad = (value) => String(value).padStart(2, '0');
62
+ return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
63
+ }
64
+
65
+ /**
66
+ * 一行按钮(Card 2.0):按钮必须放在 `column_set` 的列里,1.0 那套 `tag: 'action'` 不适用。
67
+ *
68
+ * @param items - `[{ label, value, type? }]`。
69
+ * @returns column_set 元素。
70
+ */
71
+ function buttonRow(items) {
72
+ return {
73
+ tag: 'column_set',
74
+ flex_mode: 'none',
75
+ columns: items.map((item) => ({
76
+ tag: 'column',
77
+ width: 'weighted',
78
+ weight: 1,
79
+ elements: [{
80
+ tag: 'button',
81
+ type: item.type ?? 'default',
82
+ width: 'fill',
83
+ text: { tag: 'plain_text', content: item.label },
84
+ behaviors: [{ type: 'callback', value: item.value }],
85
+ }],
86
+ })),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * 判定图片类型:优先看响应头,再用魔数兜底。
92
+ *
93
+ * 飞书对同一张图可能给 `application/octet-stream`,只看头部会把能识别的图当成不支持。
94
+ *
95
+ * @param bytes - 资源内容。
96
+ * @param contentType - 响应头里的 content-type。
97
+ * @returns 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' | null。
98
+ */
99
+ function sniffImageMediaType(bytes, contentType) {
100
+ const declared = String(contentType ?? '').split(';')[0].trim().toLowerCase();
101
+ if (SUPPORTED_IMAGE_TYPES.has(declared)) return declared;
102
+ const head = bytes.subarray(0, 12);
103
+ if (head.length >= 8 && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e) return 'image/png';
104
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
105
+ if (head.length >= 6 && head.subarray(0, 4).toString('latin1') === 'GIF8') return 'image/gif';
106
+ if (head.length >= 12 && head.subarray(0, 4).toString('latin1') === 'RIFF'
107
+ && head.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * 解析入站消息的内容部分。
113
+ *
114
+ * @param message - 飞书消息体。
115
+ * @returns `{ kind:'text', text }` | `{ kind:'image', fileKey }` | `{ kind:'unsupported', label }`。
116
+ */
117
+ function parseInbound(message) {
118
+ const text = messageText(message);
119
+ if (text !== null) return { kind: 'text', text };
120
+ const type = String(message?.message_type ?? 'unknown');
121
+ if (type === 'image' || type === 'file') {
122
+ try {
123
+ const parsed = JSON.parse(message.content ?? '{}');
124
+ const fileKey = type === 'image' ? parsed?.image_key : parsed?.file_key;
125
+ if (typeof fileKey === 'string' && fileKey) {
126
+ const label = type === 'image' ? 'feishu-image' : 'feishu-file';
127
+ const fileName = type === 'file' && typeof parsed?.file_name === 'string' && parsed.file_name
128
+ ? parsed.file_name
129
+ : label;
130
+ return { kind: type, fileKey, fileName };
131
+ }
132
+ } catch {
133
+ // 落到"内容无法解析"。
134
+ }
135
+ return { kind: 'unsupported', label: `${type === 'image' ? '图片' : '文件'}(内容无法解析)` };
136
+ }
137
+ return { kind: 'unsupported', label: type };
138
+ }
139
+
140
+ /** 群聊里"是否 @ 了本机器人"。 */
141
+ function mentionsBot(message, botOpenId) {
142
+ if (!botOpenId || !Array.isArray(message?.mentions)) return false;
143
+ return message.mentions.some((mention) => mention?.id?.open_id === botOpenId);
144
+ }
145
+
146
+ /** 去掉 @ 占位符(飞书把 @ 渲染成 `@_user_1` 这样的 key)。 */
147
+ function stripMentions(text, mentions) {
148
+ if (!Array.isArray(mentions) || mentions.length === 0) return text;
149
+ let result = text;
150
+ for (const mention of mentions) {
151
+ const key = mention?.key;
152
+ if (typeof key === 'string' && key) result = result.split(key).join('');
153
+ }
154
+ return result.trim();
155
+ }
156
+
157
+ /**
158
+ * 是否本机器人的属主(属主绕过访问策略)。
159
+ *
160
+ * 规则在 `shared/access-policy.mjs`(所有渠道一致):`ownerOpenIds` 里的 `*` 表示
161
+ * **没有记录属主**(公开机器人),**不授权任何人绕过策略**。上游 dsh-im 把它当成
162
+ * "人人都是属主",结果这台机器人的访问策略完全失效——名单外的人 @ 一下就能用。
163
+ *
164
+ * @param bot - 机器人配置。
165
+ * @param senderId - 发送者 open_id。
166
+ * @returns true 表示属主。
167
+ */
168
+ function isOwner(policyService, bot, senderId) {
169
+ // 规则实现在 hub 的 access-policy 里,渠道经运行期服务取用(不 import hub 包)。
170
+ // 服务缺席时返回 false:那样"没人绕过策略",是保守方向。
171
+ return policyService?.isOwnerId?.(bot.ownerOpenIds, senderId) === true;
172
+ }
173
+
174
+ /**
175
+ * 创建飞书消息桥。
176
+ *
177
+ * @param options - { bot, deps, gateway, state, logger }。
178
+ * `deps` 是 hub 交给渠道的依赖包(storage / sessions / contextEnhancement / ready)。
179
+ * @returns { accept, status }。
180
+ */
181
+ export function createFeishuBridge({
182
+ bot, deps, gateway, state, logger = console,
183
+ /**
184
+ * 取"这个聊天叫什么"(群名/人名):控制器提供(它有名字缓存与缺权限退避)。
185
+ * 缺席或查不到就退回掩码 id——**标题是锦上添花,绝不因此挡住消息**。
186
+ */
187
+ resolveChatLabel = null,
188
+ }) {
189
+ if (!bot?.id) throw new TypeError('飞书桥需要机器人配置。');
190
+ if (!deps?.sessions || !deps?.contextEnhancement) {
191
+ throw new TypeError('飞书桥需要 hub 的会话桥与上下文增强引擎。');
192
+ }
193
+ let handled = 0;
194
+ let lastError = null;
195
+ let lastHandledAt = null;
196
+
197
+ /**
198
+ * 会话标题里的"聊天身份"(`群 张三` / `私聊 ou_2b7e4d1a9c…`)。
199
+ *
200
+ * 为什么要有:DSH 侧边栏里同一台机器人的会话标题长得一样,用户分不清哪个是哪个群/哪个人。
201
+ * 名字问控制器(缓存 + 缺权限退避),拿不到就退回掩码 id——这条**只用于标题**,
202
+ * 任何失败都不影响消息本身,所以整体被 try/catch 包住。
203
+ */
204
+ async function chatLabelFor({ conversationType, senderId, chatId }) {
205
+ const kind = conversationType === 'group' ? '群' : '私聊';
206
+ const id = conversationType === 'group' ? chatId : senderId;
207
+ try {
208
+ const name = await resolveChatLabel?.({ conversationType, senderId, chatId });
209
+ if (typeof name === 'string' && name.trim()) return `${kind} ${name.trim()}`;
210
+ } catch (error) {
211
+ logger.warn?.(`[dsh-chat-feishu] 取聊天名失败(标题里先用掩码 id):${error?.message ?? error}`);
212
+ }
213
+ const raw = typeof id === 'string' ? id : '';
214
+ return `${kind} ${raw.length > 12 ? `${raw.slice(0, 12)}…` : raw}`.trim();
215
+ }
216
+
217
+ /**
218
+ * 把一段文本发到某个会话(交互回传用):会话键就是 `p2p:<openId>` / `group:<chatId>`,
219
+ * 因此这里不需要额外状态。
220
+ */
221
+ async function sendToConversation({ key, text }) {
222
+ const separator = key.indexOf(':');
223
+ const kind = separator > 0 ? key.slice(0, separator) : '';
224
+ const id = separator > 0 ? key.slice(separator + 1) : key;
225
+ if (kind === 'group') return gateway.sendText({ chatId: id, text });
226
+ return gateway.sendText({ openId: id, text });
227
+ }
228
+
229
+ /**
230
+ * 延迟交付:`ask()` 超时之后那一轮要是自己跑完了,hub 会把结果交回这里补发。
231
+ *
232
+ * 补发**只发文字**(不重画面板卡):它可能是几十分钟后的一轮,卡片早换了上下文。
233
+ * 前面加一句说明,免得用户以为机器人精神了。
234
+ */
235
+ deps.deferred?.register?.({
236
+ channelId: deps.channelId,
237
+ botId: bot.id,
238
+ deliver: async ({ key, text }) => {
239
+ await sendToConversation({
240
+ key,
241
+ text: `(上一轮超时之后跑完了,补发结果)\n\n${text}`,
242
+ });
243
+ logger.info?.(`[dsh-chat-feishu] 延迟交付已补发:${bot.id} ${key} ${text.length} 字`);
244
+ },
245
+ });
246
+
247
+ /** 会话键 → 已经发出去的那张提问卡片(回答后就地更新,不再新发消息)。 */
248
+ const questionCards = new Map();
249
+ /** 会话键 → 最近一次渲染用的批次(勾选器要把序号反查成选项原文,得知道原样数据)。 */
250
+ const questionBatches = new Map();
251
+ /** 会话键 → 本轮"正在处理"那张卡(提问优先内嵌进它,答完收起)。 */
252
+ const activePresenters = new Map();
253
+
254
+
255
+ /**
256
+ * 卡片路径的失败也要落 `lastError`。
257
+ *
258
+ * `connection.status` 是排查"点了卡片没反应"的第一站(排查顺序见 AGENTS.md),
259
+ * 只写日志等于现场只留在一个地方——"发了没反应"这类故障已经栽过两次。
260
+ */
261
+ function noteCardError(what, reason) {
262
+ lastError = `${what}:${reason}`;
263
+ logger.error?.(`[dsh-chat-feishu] ${lastError}`);
264
+ }
265
+
266
+ /** 会话键 → 收发所需的 route(卡片交互要用同一个会话键把答案认领回来)。 */
267
+ function routeOf(key) {
268
+ const separator = key.indexOf(':');
269
+ const kind = separator > 0 ? key.slice(0, separator) : '';
270
+ const id = separator > 0 ? key.slice(separator + 1) : key;
271
+ return kind === 'group' ? { chatId: id } : { openId: id };
272
+ }
273
+
274
+ // 接入 IM 回传:agent 的提问/审批会发到会话里问,用户回复即答案。
275
+ // 能发卡片就发卡片(点按钮即可回答),发不出去再退回纯文本。
276
+ const detachInteractions = deps.interactions?.attach?.({
277
+ channelId: deps.channelId,
278
+ botId: bot.id,
279
+ send: sendToConversation,
280
+ // 一批问题一张卡:首次新建,之后按会话键找到那张卡就地更新(答完变绿)。
281
+ sendQuestions: async ({ key, questions, answered, final }) => {
282
+ questionBatches.set(key, { questions, answered });
283
+ // 优先内嵌进本轮"正在处理"那张进度卡:提问与过程共处一卡,答完收起。
284
+ const presenter = activePresenters.get(key);
285
+ if (presenter) {
286
+ try {
287
+ const embedded = await presenter.setQuestion({ questions, answered, final });
288
+ if (embedded) {
289
+ if (final) questionBatches.delete(key);
290
+ return;
291
+ }
292
+ } catch (error) {
293
+ logger.warn?.(`[dsh-chat-feishu] 提问内嵌进度卡失败,改用独立卡片:${error?.message ?? error}`);
294
+ }
295
+ }
296
+ // 兜底:没有进度卡(如过程展示为 off/post)时用独立卡片。
297
+ const existing = questionCards.get(key) ?? null;
298
+ const sent = await gateway.sendQuestionsCard({
299
+ ...routeOf(key),
300
+ questions,
301
+ answered,
302
+ final,
303
+ messageId: existing,
304
+ });
305
+ if (sent?.messageId) {
306
+ questionCards.set(key, sent.messageId);
307
+ // 记下"这张卡是发给哪个会话的":回调里只有 chatId,而群和私聊的 chat_id 长得一样,
308
+ // 缺了这条映射就只能在"群已解绑 + 点击者有私聊绑定"时判错方向,把群卡按私聊放行。
309
+ rememberCardConversation(sent.messageId, key);
310
+ }
311
+ if (final) {
312
+ questionCards.delete(key);
313
+ questionBatches.delete(key);
314
+ }
315
+ },
316
+ sendApproval: async ({ key, request }) => {
317
+ try {
318
+ const sent = await gateway.sendApprovalCard({ ...routeOf(key), request });
319
+ // 同提问卡:审批卡也要留下会话映射,否则身份门禁可能按错的会话类型判。
320
+ if (sent?.messageId) rememberCardConversation(sent.messageId, key);
321
+ } catch (error) {
322
+ noteCardError('审批卡片发送失败,已回退为文本', error?.message ?? error);
323
+ await sendToConversation({ key, text: '⚠️ 需要授权:回复「允许」执行一次,或「拒绝」取消。' });
324
+ }
325
+ },
326
+ });
327
+
328
+ /**
329
+ * 用户**引用/回复**了一条消息:把被引用的内容取回来交给 hub 拼提示词。
330
+ *
331
+ * 飞书的事件里只有 `parent_id`(正文要再查一次),所以这一步是"一次有界的延迟查询":
332
+ * - 超时(3 秒)、读不到(删除/无权限)、不是引用 → 都只影响引用块,**当前的提问照常进模型**;
333
+ * - 读不到时给 hub 一个 `reason`,由它放一句"引用内容不可用"的结构化标记(不丢当前问题,也不假装没引用)。
334
+ */
335
+ async function resolveReplyReference(message) {
336
+ const parentId = message?.parent_id ?? message?.parentId ?? null;
337
+ if (typeof parentId !== 'string' || !parentId) return null;
338
+ const timeout = new Promise((resolve) => {
339
+ const timer = setTimeout(() => resolve({ timedOut: true }), REPLY_REFERENCE_TIMEOUT_MS);
340
+ timer.unref?.();
341
+ });
342
+ try {
343
+ const fetched = await Promise.race([
344
+ gateway.getMessageText({ messageId: parentId }),
345
+ timeout,
346
+ ]);
347
+ if (fetched?.timedOut) {
348
+ logger.warn?.(`[dsh-chat-feishu] 读被引用的消息超时:${parentId}`);
349
+ return { messageId: parentId, reason: '读取超时' };
350
+ }
351
+ return {
352
+ messageId: fetched.messageId ?? parentId,
353
+ senderId: fetched.senderId ?? null,
354
+ kind: fetched.kind ?? 'text',
355
+ text: fetched.text ?? '',
356
+ fileName: fetched.fileName ?? null,
357
+ };
358
+ } catch (error) {
359
+ // 不静默:引用读不到要留痕,同时让模型知道"引用内容不可用"。
360
+ logger.warn?.(`[dsh-chat-feishu] 读被引用的消息失败(${parentId}):${error?.message ?? error}`);
361
+ return { messageId: parentId, reason: error?.message ?? String(error) };
362
+ }
363
+ }
364
+
365
+ /**
366
+ * 处理一条入站事件。
367
+ *
368
+ * @param event - `im.message.receive_v1` 的事件体。
369
+ */
370
+ async function accept(event) {
371
+ const message = event?.message;
372
+ if (!message?.message_id) return;
373
+ // 同一条消息可能因重连被重复投递。
374
+ if (!state.markSeen(message.message_id)) return;
375
+
376
+ const conversationType = message.chat_type === 'p2p' ? 'direct' : 'group';
377
+ const senderId = event?.sender?.sender_id?.open_id;
378
+ if (!senderId) return;
379
+ // 门禁要用到 hub 持有的访问策略;这一步只读内存快照,很便宜。
380
+ await deps.ready?.();
381
+ const accessPolicy = deps.storage.read(bot.id).accessPolicy;
382
+
383
+ // 门禁:属主绕过,其余按访问策略(open / allowlist)判定。
384
+ const messageAccess = deps.accessPolicy.evaluateAccess({
385
+ policy: accessPolicy,
386
+ conversationType,
387
+ senderIds: [senderId],
388
+ isOwner: isOwner(deps.accessPolicy, bot, senderId),
389
+ });
390
+ if (!messageAccess.allowed) {
391
+ logger.info?.(
392
+ `[dsh-chat-feishu] 忽略未放行的消息:${bot.id} ${conversationType} sender=${senderId}(${messageAccess.reason})`,
393
+ );
394
+ return;
395
+ }
396
+ const inbound = parseInbound(message);
397
+ if (inbound.kind === 'unsupported') {
398
+ await gateway.replyText({
399
+ messageId: message.message_id,
400
+ text: `暂时还不能处理「${inbound.label}」类型的消息(目前支持文本、图片与文件)。`,
401
+ });
402
+ return;
403
+ }
404
+
405
+ const conversationKey = conversationType === 'direct'
406
+ ? `p2p:${senderId}`
407
+ : `group:${message.chat_id}`;
408
+
409
+ // 正在等这个会话回答 agent 的提问/审批:这条消息就是答案,不再进模型。
410
+ // 位置很关键——放在门禁**之后**(陌生人不能替人回答)、@ 检查**之前**
411
+ // (回答问题时不需要再 @ 机器人)。
412
+ if (inbound.kind === 'text') {
413
+ const candidate = stripMentions(inbound.text, message.mentions);
414
+ if (candidate && deps.interactions?.offer?.({
415
+ channelId: deps.channelId,
416
+ botId: bot.id,
417
+ key: conversationKey,
418
+ text: candidate,
419
+ })) {
420
+ logger.info?.(`[dsh-chat-feishu] 认领为交互回答(${bot.id} ${conversationKey})`);
421
+ lastHandledAt = new Date().toISOString();
422
+ return;
423
+ }
424
+ }
425
+
426
+ if (conversationType === 'group' && bot.groupResponseMode !== 'all'
427
+ && !mentionsBot(message, bot.botOpenId)) {
428
+ logger.info?.(`[dsh-chat-feishu] 群消息未 @ 本机器人,忽略(${bot.id} group=${message.chat_id})`);
429
+ return;
430
+ }
431
+
432
+ // 收到即反馈:打一个「在做了」表情,处理完再撤掉(比等卡片刷新更即时,也不刷屏)。
433
+ const workingReaction = await markWorking(message);
434
+
435
+ // 图片/文件:先下载,再变成 PromptContentPart,和文本走同一条会话链路。
436
+ let attachmentParts = null;
437
+ let text = '';
438
+ if (inbound.kind === 'image' || inbound.kind === 'file') {
439
+ const isImage = inbound.kind === 'image';
440
+ let downloaded;
441
+ try {
442
+ downloaded = await gateway.downloadResource({
443
+ messageId: message.message_id,
444
+ fileKey: inbound.fileKey,
445
+ type: isImage ? 'image' : 'file',
446
+ });
447
+ } catch (error) {
448
+ const reason = error?.message ?? String(error);
449
+ lastError = reason;
450
+ logger.error?.(`[dsh-chat-feishu] 下载${isImage ? '图片' : '文件'}失败:${reason}`);
451
+ await gateway.replyText({
452
+ messageId: message.message_id,
453
+ text: `${isImage ? '图片' : '文件'}下载失败:${reason}`,
454
+ }).catch(() => {});
455
+ await clearWorking(message, workingReaction);
456
+ return;
457
+ }
458
+ if (isImage) {
459
+ const mediaType = sniffImageMediaType(downloaded.bytes, downloaded.contentType);
460
+ if (!mediaType) {
461
+ logger.info?.(`[dsh-chat-feishu] 忽略不支持的图片类型:${downloaded.contentType ?? '未知'}`);
462
+ await gateway.replyText({
463
+ messageId: message.message_id,
464
+ text: `这张图片的格式暂不支持(${downloaded.contentType ?? '未知类型'}),请发 PNG/JPEG/WebP/GIF。`,
465
+ });
466
+ await clearWorking(message, workingReaction);
467
+ return;
468
+ }
469
+ attachmentParts = [{
470
+ type: 'image',
471
+ mediaType,
472
+ data: downloaded.bytes.toString('base64'),
473
+ name: 'feishu-image',
474
+ }];
475
+ } else {
476
+ // 文件内容块只能引用"本会话上传"得到的 receipt,因此先上传再交给模型。
477
+ try {
478
+ const { sessionId } = await deps.sessions.ensure({
479
+ channelId: deps.channelId,
480
+ botId: bot.id,
481
+ key: conversationKey,
482
+ workspacePath: deps.storage.read(bot.id).workspace,
483
+ });
484
+ const uploaded = await deps.sessions.uploadFile({
485
+ sessionId,
486
+ name: inbound.fileName,
487
+ bytes: new Uint8Array(downloaded.bytes),
488
+ });
489
+ if (!uploaded?.receiptId) {
490
+ throw new Error('上传后没有拿到 receiptId');
491
+ }
492
+ attachmentParts = [{ type: 'file', receiptId: uploaded.receiptId }];
493
+ logger.info?.(`[dsh-chat-feishu] 已接收文件并入库:${inbound.fileName}`
494
+ + `(${downloaded.bytes.length} 字节,${bot.id})`);
495
+ } catch (error) {
496
+ const reason = error?.message ?? String(error);
497
+ lastError = reason;
498
+ logger.error?.(`[dsh-chat-feishu] 接收文件失败:${reason}`);
499
+ await gateway.replyText({
500
+ messageId: message.message_id,
501
+ text: `这个文件暂时没能收下:${reason}`,
502
+ }).catch(() => {});
503
+ await clearWorking(message, workingReaction);
504
+ return;
505
+ }
506
+ }
507
+ } else {
508
+ text = stripMentions(inbound.text, message.mentions);
509
+ if (!text) return;
510
+ }
511
+
512
+ // 命令优先:命令不进入模型、也不做上下文增强(图片消息没有文本,直接跳过)。
513
+ if (text) {
514
+ const commandAccess = commandAccessFor({
515
+ senderId,
516
+ conversationType,
517
+ accessPolicy,
518
+ });
519
+ if (!commandAccess.allowed && text.startsWith('/')) {
520
+ logger.info?.(`[dsh-chat-feishu] 命令被拒绝:${bot.id} sender=${senderId}(${commandAccess.reason})`);
521
+ await gateway.replyText({
522
+ messageId: message.message_id,
523
+ text: '你没有执行机器人命令的权限。',
524
+ });
525
+ return;
526
+ }
527
+ const command = await deps.commands?.handle?.({
528
+ text,
529
+ channelId: deps.channelId,
530
+ botId: bot.id,
531
+ key: conversationKey,
532
+ conversationType,
533
+ senderId,
534
+ // 属主判定只有渠道知道(属主名单在渠道配置里),带上给命令内核用。
535
+ isOwner: isOwner(deps.accessPolicy, bot, senderId),
536
+ botLabel: bot.botName ?? bot.id,
537
+ channelLabel: '飞书',
538
+ }).catch((error) => {
539
+ logger.warn?.(`[dsh-chat-feishu] 命令处理失败:${error?.message ?? error}`);
540
+ return null;
541
+ });
542
+ if (command?.handled) {
543
+ /**
544
+ * 控制面板优先:`/menu` 在飞书发的是**可交互卡**(下拉直接选模型/推理/预设/工作区),
545
+ * 发不出去再退回命令清单卡,最后退回文本——一层层退,绝不静默。
546
+ */
547
+ if (command.panel && message.chat_id) {
548
+ const sent = await renderPanel({
549
+ chatId: message.chat_id, key: conversationKey, panel: command.panel, source: 'menu',
550
+ // 手打 /menu:新发一张,别把老卡(可能已经滚到看不到的地方)当成回应。
551
+ fresh: true,
552
+ });
553
+ if (sent) {
554
+ lastHandledAt = new Date().toISOString();
555
+ await clearWorking(message, workingReaction);
556
+ return;
557
+ }
558
+ }
559
+ if (command.menu?.length && message.chat_id) {
560
+ try {
561
+ const sent = await gateway.sendCard({ chatId: message.chat_id, card: menuCard(command.menu) });
562
+ // 这张卡也要记住会话:否则它的按钮回调只能靠启发式,群里可能被判成私聊。
563
+ rememberCardConversation(sent?.messageId, conversationKey);
564
+ } catch (error) {
565
+ // 卡片发不出去不能把菜单吞掉:退回文本列表。
566
+ logger.warn?.(`[dsh-chat-feishu] 菜单卡片发送失败,退回文本:${error?.message ?? error}`);
567
+ if (command.reply) {
568
+ await gateway.replyText({ messageId: message.message_id, text: command.reply });
569
+ }
570
+ }
571
+ } else if (command.reply) {
572
+ await gateway.replyText({ messageId: message.message_id, text: command.reply });
573
+ }
574
+ lastHandledAt = new Date().toISOString();
575
+ await clearWorking(message, workingReaction);
576
+ return;
577
+ }
578
+ }
579
+
580
+ try {
581
+ await deps.ready?.();
582
+ const record = deps.storage.read(bot.id);
583
+ // 引用回复:用户引用了某条消息(飞书只给 parent_id,正文要再查一次)。
584
+ const replyTo = await resolveReplyReference(message);
585
+ const identity = {
586
+ senderId,
587
+ chatId: message.chat_id,
588
+ threadId: message.thread_id,
589
+ };
590
+ // 捕获而不是事后读取:排队中的消息保持收到它时的设置。
591
+ const captured = deps.contextEnhancement.captureContextEnhancementSource(
592
+ { botId: bot.id, channel: 'feishu', readConfig: () => record.contextEnhancement },
593
+ conversationType,
594
+ identity,
595
+ () => ({ channel: 'feishu', ...identity }),
596
+ );
597
+ // 文本保持"前缀拼进同一个文本块"的老形态;图片走内容数组,enhanceContent 会在
598
+ // 前面插一个上下文文本块,于是图片也带上来源信息。
599
+ /**
600
+ * 引用块在**来源块之后、正文之前**:先"这条消息从哪来",再"用户在回复哪条",最后才是问题本身。
601
+ * 拼装本身在 hub(`enhanceReplyReference`),渠道只负责把平台字段映射成 `reply`。
602
+ */
603
+ const withReply = (content) => (typeof deps.replyReference?.enhanceReplyReference === 'function'
604
+ ? deps.replyReference.enhanceReplyReference(content, replyTo)
605
+ : content);
606
+ let finalParts;
607
+ if (attachmentParts) {
608
+ const enhancedContent = deps.contextEnhancement.enhanceContent(
609
+ attachmentParts,
610
+ captured?.snapshot ?? null,
611
+ captured?.source,
612
+ );
613
+ finalParts = Array.isArray(enhancedContent) ? enhancedContent : attachmentParts;
614
+ } else {
615
+ const enhancedText = deps.contextEnhancement.enhanceContent(
616
+ text,
617
+ captured?.snapshot ?? null,
618
+ captured?.source,
619
+ );
620
+ finalParts = [{ type: 'text', text: enhancedText }];
621
+ }
622
+ if (replyTo) finalParts = withReply(finalParts);
623
+
624
+ const mode = conversationType === 'direct' ? bot.stepPushDirect : bot.stepPushGroup;
625
+ const presenter = createTurnPresenter({
626
+ mode,
627
+ gateway,
628
+ message,
629
+ chatType: conversationType,
630
+ bot,
631
+ logger,
632
+ });
633
+
634
+ activePresenters.set(conversationKey, presenter);
635
+ const result = await deps.sessions.ask({
636
+ channelId: deps.channelId,
637
+ botId: bot.id,
638
+ key: conversationKey,
639
+ workspacePath: record.workspace,
640
+ content: finalParts,
641
+ sourceGuidance: captured?.snapshot?.scope?.guidance,
642
+ // 会话标题里带上"哪个群/哪个人",否则侧边栏里一堆会话分不清。
643
+ chatLabel: await chatLabelFor({ conversationType, senderId, chatId: message.chat_id }),
644
+ // 同一会话已有回合在跑:先回一句"排队中",别让用户对着已读不回猜。
645
+ onQueued: (ahead) => {
646
+ void gateway.replyText({
647
+ messageId: message.message_id,
648
+ text: `已排队(前面还有 ${ahead} 条),处理完会依次回复。`,
649
+ }).catch(() => {});
650
+ },
651
+ // 会话列表里一眼看出渠道:工作区叫「飞书 · 张三-DSH」,会话标题加「飞书 · 」前缀。
652
+ channelLabel: '飞书',
653
+ botLabel: bot.botName ?? bot.id,
654
+ handlers: {
655
+ onToolCall: (toolEvent) => {
656
+ const name = toolEvent?.data?.name ?? '工具';
657
+ // 提问由交互服务渲染成"提问"行,这里不再重复占一行(与 Web 一致)。
658
+ if (name === 'ask_user_question') return;
659
+ presenter.tool({ name, arguments: toolEvent?.data?.arguments });
660
+ },
661
+ // 思考(推理摘要)进同一个折叠面板:一行一条,够看轮廓即可。
662
+ onAssistantMessage: (messageEvent) => {
663
+ const blocks = messageEvent?.data?.message?.content;
664
+ if (!Array.isArray(blocks)) return;
665
+ for (const block of blocks) {
666
+ if (block?.type !== 'reasoning' || typeof block.text !== 'string') continue;
667
+ presenter.think(block.text);
668
+ }
669
+ },
670
+ onTurnEnd: (turnEvent) => {
671
+ const reason = turnEvent?.data?.reason;
672
+ if (reason?.kind && reason.kind !== 'completed') {
673
+ void presenter.think(`⚠️ 回合未正常结束:${reason.kind}`);
674
+ }
675
+ },
676
+ },
677
+ });
678
+
679
+ activePresenters.delete(conversationKey);
680
+ logger.info?.(`[dsh-chat-feishu] 回合结束,准备回复:${bot.id} ${conversationKey}`
681
+ + ` reason=${result?.reason?.kind ?? 'unknown'} 文本=${(result?.text ?? '').length}字`);
682
+ await presenter.finish(result?.text, result?.reason);
683
+ logger.info?.(`[dsh-chat-feishu] 最终答案投递方式:${presenter.delivery?.() ?? 'unknown'}`
684
+ + `(${bot.id} ${conversationKey})`);
685
+ // agent 声明交付的文件要当附件真发出去(只写在回复文字里,用户拿不到文件)。
686
+ // 图片能内嵌进卡片(不再单发消息),普通文件只能走一条 post 消息的附件区。
687
+ await sendDeliverables(result?.files, message, {
688
+ replyInThread: conversationType === 'group' && bot.groupTopicReply === true,
689
+ });
690
+ handled += 1;
691
+ lastHandledAt = new Date().toISOString();
692
+ // 回合本身成功,但呈现层可能失败过(卡片建不出来等)。那也必须让设置页看得到,
693
+ // 否则用户"没收到回复"时只能靠终端日志。
694
+ lastError = presenter.lastError?.() ?? null;
695
+ } catch (error) {
696
+ activePresenters.delete(conversationKey);
697
+ lastError = error?.message ?? String(error);
698
+ logger.error?.(`[dsh-chat-feishu] 处理消息失败:${lastError}`);
699
+ try {
700
+ await gateway.replyText({
701
+ messageId: message.message_id,
702
+ text: `处理失败:${lastError}`,
703
+ });
704
+ } catch {
705
+ // 连失败回复都发不出去时,只留日志。
706
+ }
707
+ } finally {
708
+ // 无论走哪条路径(命令/下载失败/模型失败/正常结束),表情都要撤掉。
709
+ await clearWorking(message, workingReaction);
710
+ }
711
+ }
712
+
713
+ /**
714
+ * 打「在做了」表情。失败不致命(可能缺 im:message.reaction:write 权限),
715
+ * 但一定要留日志,别让人以为是没反应。
716
+ */
717
+ /**
718
+ * 把本轮 agent 交付的文件(`present` 声明的)当附件发出去。
719
+ *
720
+ * 失败必须可见:发不出去要回一句可读原因并写 `lastError`——"文件没收到"同样是最难
721
+ * 排查的故障形态,不能只留一行日志。
722
+ *
723
+ * 全部合成**一条**消息放进 post 的附件区——真机要求:不要刷屏、不要任何文字描述,
724
+ * 图片也算附件(所以图片不再单独内嵌进卡片)。
725
+ *
726
+ * @param files - `[{ path, description? }]`(来自会话桥的 `deliverables/presented`)。
727
+ * @param message - 入站消息(用 chat_id 作为收件人)。
728
+ * @param options - { replyInThread }。
729
+ */
730
+ async function sendDeliverables(files, message, { replyInThread = false } = {}) {
731
+ if (!Array.isArray(files) || files.length === 0) return;
732
+ // 先本地校验(存在、非空、不超限),再交给渠道一次发一条消息(飞书的 post 附件区能装多个)。
733
+ const items = [];
734
+ const failed = [];
735
+ for (const file of files) {
736
+ const path = typeof file?.path === 'string' ? file.path : '';
737
+ if (!path) continue;
738
+ const name = path.split('/').pop() || '交付文件';
739
+ try {
740
+ const info = await stat(path);
741
+ if (!info.isFile() || info.size === 0) throw new Error('不是普通文件或内容为空');
742
+ if (info.size > MAX_DELIVERABLE_BYTES) {
743
+ throw new Error(`超过 ${Math.round(MAX_DELIVERABLE_BYTES / 1024 / 1024)}MB 上限`);
744
+ }
745
+ items.push({ path, name, size: info.size, description: file.description });
746
+ } catch (error) {
747
+ failed.push({ name, reason: error?.message ?? String(error) });
748
+ }
749
+ }
750
+ if (items.length > 0) {
751
+ try {
752
+ const sent = await gateway.sendDeliverables({ chatId: message.chat_id, items });
753
+ failed.push(...(sent?.failed ?? []));
754
+ logger.info?.(`[dsh-chat-feishu] 交付物已发出(${bot.id}):`
755
+ + `${(sent?.files ?? []).join('、')}`);
756
+ } catch (error) {
757
+ const reason = error?.message ?? String(error);
758
+ for (const item of items) failed.push({ name: item.name, reason });
759
+ }
760
+ }
761
+ if (failed.length === 0) return;
762
+ lastError = `交付文件发送失败:${failed.map((entry) => `${entry.name}(${entry.reason})`).join(';')}`;
763
+ logger.error?.(`[dsh-chat-feishu] ${lastError}`);
764
+ try {
765
+ await gateway.replyText({
766
+ messageId: message.message_id,
767
+ text: failed.map((entry) => `交付文件「${entry.name}」没能发出去:${entry.reason}`).join('\n'),
768
+ replyInThread,
769
+ });
770
+ } catch {
771
+ // 连失败说明都发不出去时,至少日志与 lastError 有记录。
772
+ }
773
+ }
774
+
775
+ async function markWorking(message) {
776
+ try {
777
+ return await gateway.addReaction({ messageId: message.message_id, emojiType: 'OnIt' });
778
+ } catch (error) {
779
+ logger.info?.(`[dsh-chat-feishu] 添加表情回复失败(不影响处理):${error?.message ?? error}`);
780
+ return null;
781
+ }
782
+ }
783
+
784
+ /** 处理完撤掉表情。 */
785
+ async function clearWorking(message, reaction) {
786
+ if (!reaction?.reactionId) return;
787
+ try {
788
+ await gateway.removeReaction({
789
+ messageId: message.message_id,
790
+ reactionId: reaction.reactionId,
791
+ });
792
+ } catch (error) {
793
+ logger.info?.(`[dsh-chat-feishu] 撤销表情回复失败:${error?.message ?? error}`);
794
+ }
795
+ }
796
+
797
+ /**
798
+ * 命令清单卡(Card 2.0):把命令渲染成一组按钮。
799
+ *
800
+ * 按钮里带的是**命令行**,点击后走与"用户手打"完全同一条命令路径,
801
+ * 因此按钮与文本不会出现两套行为。
802
+ *
803
+ * 必须是 2.0:控制面板也是 2.0,飞书**不允许 patch 时换 schema**
804
+ * (真机报 `230099 schemaV2 card can not change schemaV1`),
805
+ * 所以两张卡用同一套 schema 才能互相切换。
806
+ *
807
+ * `last`(`{ command, reply }`)是"上一次点了什么、结果是什么":点完就地更新时把它
808
+ * 渲染进卡片正文——否则点一下只多了条新消息,用户看不出自己点到了没有(真机反馈过)。
809
+ */
810
+ function menuCard(items, last = null) {
811
+ const elements = [
812
+ { tag: 'markdown', content: '点按钮执行,也可以直接发文字命令。' },
813
+ ];
814
+ if (last?.command) {
815
+ // 输出可能很长(/status 之类):截断,免得一张卡片刷满整屏。
816
+ const reply = String(last.reply ?? '').trim();
817
+ const shown = reply.length > 800 ? `${reply.slice(0, 800)}…` : reply;
818
+ elements.push({ tag: 'hr' });
819
+ elements.push({ tag: 'markdown', content: `**${last.command}**\n${shown || '(没有输出)'}` });
820
+ }
821
+ /**
822
+ * 一行放几个按钮。以前是 `items.slice(0, 12)`——恰好把字母序后半截命令**静默丢掉**:
823
+ * 真机上 17 个命令只列出 12 个,`/session` `/status` `/stop` `/version` `/whoami`
824
+ * 在卡片上根本找不到(只能手打)。宁可多开几行,也不能少命令。
825
+ */
826
+ for (let index = 0; index < items.length; index += MENU_ROW_SIZE) {
827
+ elements.push(buttonRow(items.slice(index, index + MENU_ROW_SIZE).map((item) => ({
828
+ label: item.label,
829
+ value: { dsh_menu: item.command },
830
+ }))));
831
+ }
832
+ // 从控制面板点「命令清单」进来时,卡上要有一条回去的路(否则用户只能重发 /menu)。
833
+ elements.push(buttonRow([{ label: '⬅ 返回控制面板', value: { dsh_panel: 'panel' }, type: 'primary' }]));
834
+ return {
835
+ schema: '2.0',
836
+ config: { update_multi: true, width_mode: 'default' },
837
+ header: { template: 'blue', title: { tag: 'plain_text', content: '机器人菜单' } },
838
+ body: { direction: 'vertical', elements },
839
+ };
840
+ }
841
+
842
+ /**
843
+ * 取一份当前的菜单项(点完按钮后要就地重画按钮,得知道按钮原来有哪些)。
844
+ *
845
+ * 重新问一次命令内核,而不是把菜单塞进按钮的 value 里:按钮值只带命令行,
846
+ * 菜单本身就是 `/menu` 的输出,重问一次永远是最新的(且没有副作用)。
847
+ */
848
+ async function menuItemsFor(context) {
849
+ const result = await deps.commands?.handle?.({ ...context, text: '/menu' }).catch((error) => {
850
+ logger.warn?.(`[dsh-chat-feishu] 重取菜单失败:${error?.message ?? error}`);
851
+ return null;
852
+ });
853
+ return result?.menu?.length ? result.menu : [];
854
+ }
855
+
856
+ /** 读一次控制面板状态;拿不到就返回 null(调用方退回命令清单/文本)。 */
857
+ async function readPanel(context) {
858
+ if (typeof deps.panel?.read !== 'function') return null;
859
+ return deps.panel.read({
860
+ channelId: deps.channelId, botId: bot.id, key: context.key,
861
+ // 工作区候选只给属主:群里的卡片所有人都能展开。
862
+ isOwner: context.isOwner === true,
863
+ // 渠道自带字段(任务过程展示)要按私聊/群聊分别取值。
864
+ conversationType: context.conversationType ?? null,
865
+ }).catch((error) => {
866
+ logger.warn?.(`[dsh-chat-feishu] 读取控制面板失败:${error?.message ?? error}`);
867
+ return null;
868
+ });
869
+ }
870
+
871
+ /**
872
+ * 画一次控制面板:优先就地更新(`messageId`),否则新发一张。
873
+ *
874
+ * 失败一定留痕:patch 失败先 warn,再尝试新发;新发也失败就返回 false,
875
+ * 由调用方退回文本("点了没反应"是本项目最怕的故障形态)。
876
+ */
877
+ /**
878
+ * 每个会话"当前那张控制面板卡"的消息 id。
879
+ *
880
+ * 为什么要记:不记的话每次 `/m` 都新发一张,聊天里就堆着好几张几乎一样的卡
881
+ * (真机上就是这么把用户绕晕的:他点的是一张,看到的却是另一张,看起来像"变回去了")。
882
+ * 记的是进程内存,重启后失效——那时 patch 会失败,我们新发一张并重新记住。
883
+ */
884
+ const panelCards = new Map(); // 会话键 → messageId
885
+
886
+ /**
887
+ * 待确认的改动(卡片消息 id → `{ field, value, prompt }`)。
888
+ *
889
+ * 只有"放宽访问策略"这类动作需要它:下拉选完先不落盘,把确认做在**同一张卡**上。
890
+ * 存内存:确认是几秒钟内的交互,重启/换卡后过期——那时如实说"这次确认已失效",不静默改设置。
891
+ */
892
+ const pendingConfirms = new Map();
893
+
894
+ /**
895
+ * 画一次控制面板:优先更新"本会话已有的那张",其次 patch 调用方给的消息 id,最后新发。
896
+ *
897
+ * @param options - { chatId, key, messageId?, panel, last?, source }。
898
+ * `key` 是会话键(p2p:… / group:…),用于复用同一张卡;`messageId` 是用户刚点的那张卡。
899
+ */
900
+ async function renderPanel({
901
+ chatId, key = null, messageId = null, panel, last = null, source = 'unknown', token = null,
902
+ fresh = false, pending = null,
903
+ }) {
904
+ // 标题带上本次渲染时间:聊天里可能有多张面板卡(旧卡、重启前的卡),
905
+ // "哪张是刚更新的"必须一眼可辨,否则用户会以为卡片"变回去了"。
906
+ const card = panelCard(panel, { last, at: last?.at ?? panelClock(), pending });
907
+ /** 三条路都失败才算渲染失败:中间失败有兜底,不该把状态页写成"出错了"。 */
908
+ const renderErrors = [];
909
+ /**
910
+ * 目标消息的挑选,按"谁发起"分两种:
911
+ *
912
+ * - **手打 `/menu`(`fresh`)→ 新发一张**。用户刚发了一条消息,就期待下面出现回应;
913
+ * 复用并 patch 上面那张老卡会让聊天里**一条新消息都没有**,真机上就是"发 /menu 没反应"
914
+ * (卡片其实在历史里被就地更新了)。
915
+ * - **卡片交互 → 更新被点的那张**(有 `messageId`);回调没带 messageId 时退回复用本会话记住的那张。
916
+ */
917
+ const known = key ? panelCards.get(key) : null;
918
+ const targets = fresh ? [] : [messageId, messageId ? null : known].filter(Boolean);
919
+ // 每次渲染都留痕:卡上"停在哪一次更新"与日志能对上(排查"卡片被回滚"这类问题时唯一现场)。
920
+ logger.info?.(`[dsh-chat-feishu] 渲染控制面板 source=${source}`
921
+ + ` key=${key ?? '无'} 目标=${targets[0] ?? '新发'} token=${token ? '有' : '无'}`
922
+ + ` last=${last?.label ?? '无'}${last?.at ? `@${last.at}` : ''}`
923
+ + ` 字节=${JSON.stringify(card).length}`);
924
+ /**
925
+ * 交互驱动(有点击回调带来的 token)→ 必须走延迟更新接口。
926
+ *
927
+ * 这不是风格问题:飞书要求一次卡片交互里的更新用回调的 token 调
928
+ * `/interactive/v1/card/update`,用 `message.patch` 会被客户端还原
929
+ * ——真机上就是"卡片变了一下又变回去"。token 只有 2 次机会、30 分钟有效,
930
+ * 失败(用完了)就退回 patchCard,再不行新发一张。
931
+ */
932
+ if (token && messageId) {
933
+ const updated = await gateway.updateCard({ token, card }).then(() => true).catch((error) => {
934
+ renderErrors.push(`token 路径 ${error?.message ?? error}`);
935
+ logger.warn?.(`[dsh-chat-feishu] 控制面板延迟更新失败(token 路径):${error?.message ?? error}`);
936
+ return false;
937
+ });
938
+ if (updated) {
939
+ if (key) panelCards.set(key, messageId);
940
+ if (key) rememberCardConversation(messageId, key);
941
+ logger.info?.(`[dsh-chat-feishu] 控制面板已就地更新(token 路径 ${messageId})`);
942
+ return true;
943
+ }
944
+ }
945
+ for (const target of targets) {
946
+ const patched = await gateway.patchCard({ messageId: target, card }).then(() => true).catch((error) => {
947
+ renderErrors.push(`patch ${target} ${error?.message ?? error}`);
948
+ logger.warn?.(`[dsh-chat-feishu] 控制面板就地更新失败(${target}):${error?.message ?? error}`);
949
+ return false;
950
+ });
951
+ if (patched) {
952
+ if (key) panelCards.set(key, target);
953
+ if (key) rememberCardConversation(target, key);
954
+ logger.info?.(`[dsh-chat-feishu] 控制面板已就地更新(patch 路径 ${target})`);
955
+ return true;
956
+ }
957
+ }
958
+ const sent = await gateway.sendCard({ chatId, card }).then((result) => result ?? {}).catch((error) => {
959
+ renderErrors.push(`新发 ${error?.message ?? error}`);
960
+ logger.warn?.(`[dsh-chat-feishu] 控制面板发送失败:${error?.message ?? error}`);
961
+ return null;
962
+ });
963
+ if (sent) {
964
+ if (key && typeof sent.messageId === 'string' && sent.messageId) panelCards.set(key, sent.messageId);
965
+ if (key && typeof sent.messageId === 'string' && sent.messageId) {
966
+ rememberCardConversation(sent.messageId, key);
967
+ }
968
+ logger.info?.(`[dsh-chat-feishu] 控制面板已新发一张(${bot.id} ${sent.messageId ?? '未知id'})`);
969
+ }
970
+ if (!sent) noteCardError('控制面板渲染失败', renderErrors.join(';') || '未知原因');
971
+ return Boolean(sent);
972
+ }
973
+
974
+ /**
975
+ * 交互回传的身份门禁:**只免命令权限**,其余照判(谁能替属主批准/回答)。
976
+ * 与手打同样内容走的是同一条放行规则,避免"文字被挡、点按钮却能过"。
977
+ */
978
+ function evaluateInteractionAccess({ senderId, conversationType }) {
979
+ const policy = deps.storage?.read?.(bot.id)?.accessPolicy;
980
+ return deps.accessPolicy.evaluateAccess({
981
+ policy,
982
+ conversationType,
983
+ senderIds: [senderId],
984
+ isCommand: false,
985
+ isOwner: isOwner(deps.accessPolicy, bot, senderId),
986
+ });
987
+ }
988
+
989
+ /**
990
+ * 命令权限判定:**手打文字与卡片动作共用同一条**。
991
+ *
992
+ * 为什么要共用(真机上的洞):一开始只有"手打文字"这条路过了门禁,卡片按钮直接执行命令
993
+ * ——群聊里任何能看到卡片的人点一下按钮就能跑命令,绕过了命令权限。卡片上的每个动作
994
+ * 与手打同权,这是 dsh-im 的做法(`evaluateInboundAccess(..., isCommand: true)`)。
995
+ *
996
+ * @param options - { senderId, conversationType, accessPolicy }。
997
+ * `accessPolicy` 由调用方先读过时可直接传入,省一次读盘。
998
+ */
999
+ function commandAccessFor({ senderId, conversationType, accessPolicy: knownPolicy }) {
1000
+ const policy = knownPolicy ?? deps.storage?.read?.(bot.id)?.accessPolicy;
1001
+ return deps.accessPolicy.evaluateAccess({
1002
+ policy,
1003
+ conversationType,
1004
+ senderIds: [senderId],
1005
+ isCommand: true,
1006
+ isOwner: isOwner(deps.accessPolicy, bot, senderId),
1007
+ });
1008
+ }
1009
+
1010
+ /**
1011
+ * 记住"这张卡片是我们发给哪个会话的"。
1012
+ *
1013
+ * 卡片回调里只有 chatId,而**群和私聊的 chat_id 长得一样**(都是 `oc_…`),
1014
+ * 光看绑定推断会判错:群里第一条交互(比如刚发的 /menu)还没有群绑定时,
1015
+ * 就会被当成私聊,于是"新会话"解掉的是操作者私聊的绑定、模型也改到私聊会话上。
1016
+ * 所以"发卡时记住它是哪个会话的"是唯一可靠的判据——而且**必须落盘**
1017
+ * (`state.json`),否则重启后又只能靠猜。
1018
+ */
1019
+ function rememberCardConversation(messageId, key) {
1020
+ state?.rememberCard?.(messageId, key);
1021
+ }
1022
+
1023
+ /**
1024
+ * 卡片动作属于哪个会话(群还是私聊)以及会话键。
1025
+ *
1026
+ * 判据从可靠到保守:
1027
+ * ① 这张卡是我们发的 → 用发卡时记下的会话键(落盘,重启后仍在);
1028
+ * ② 该会话已有绑定 → 用绑定的那一侧;
1029
+ * ③ 都没有 → **按群处理**。判错方向的代价不对称:判成私聊会解错绑定、还会放宽命令门禁。
1030
+ */
1031
+ function conversationForCard(chatId, operatorId, messageId = null) {
1032
+ const groupKey = `group:${chatId}`;
1033
+ const p2pKey = `p2p:${operatorId}`;
1034
+ const known = messageId ? state?.cardConversation?.(messageId) : null;
1035
+ if (known) {
1036
+ return { conversationType: known.startsWith('group:') ? 'group' : 'direct', key: known };
1037
+ }
1038
+ const groupBound = deps.sessions?.bindings?.get?.(deps.channelId, bot.id, groupKey);
1039
+ const p2pBound = deps.sessions?.bindings?.get?.(deps.channelId, bot.id, p2pKey);
1040
+ // 保守方向:拿不准就当群。判成私聊会解错绑定、并让 direct 作用域的策略生效。
1041
+ const isGroup = groupBound ? true : !p2pBound;
1042
+ return { conversationType: isGroup ? 'group' : 'direct', key: isGroup ? groupKey : p2pKey };
1043
+ }
1044
+
1045
+ /**
1046
+ * 处理一次卡片点击:把按钮里的答案交给 hub 的交互服务认领。
1047
+ *
1048
+ * 与"用户手打文字"共用同一条认领路径(`offer`),所以按钮与文本不会有两套行为。
1049
+ * 返回值直接作为飞书客户端的应答(toast),用户点完立刻有反馈。
1050
+ *
1051
+ * @param event - SDK 归一化后的 `card.action.trigger` 事件。
1052
+ * @returns 飞书卡片回调应答。
1053
+ */
1054
+ async function handleCardAction(event) {
1055
+ const value = event?.action?.value ?? {};
1056
+ const operatorId = event?.operator?.openId;
1057
+ const chatId = event?.chatId;
1058
+ if (!operatorId || !chatId) {
1059
+ // 绝不静默:到了这里却认不出会话/操作者,一定留痕(字段名对不上就是在这里暴露的)。
1060
+ logger.warn?.('[dsh-chat-feishu] 卡片回调缺少会话或操作者,无法认领'
1061
+ + `(chatId=${chatId ?? '无'} operator=${operatorId ?? '无'})`);
1062
+ return undefined;
1063
+ }
1064
+
1065
+ // 与 accept() 对齐:设置与绑定都要等 hub 读完盘,否则启动窗口内会读到空文档,
1066
+ // 门禁退化成"无策略"把**非属主**误拒(属主因 isOwner 绕过,现象只出在别人身上)。
1067
+ await deps.ready?.();
1068
+
1069
+ const { conversationType, key } = conversationForCard(chatId, operatorId, event.messageId ?? null);
1070
+
1071
+ /**
1072
+ * 门禁:非交互的卡片动作等同于命令,先判权限再动手。
1073
+ *
1074
+ * 提问/审批按钮是"人在环回传",不走命令门禁(dsh-im 同样把它们排除在外)——
1075
+ * 它们本来只对已经进得来的消息负责,加命令门禁反而会让提问卡点不动。
1076
+ * 但**审批不只免命令权限**:谁能替属主批准是另一回事,见下面 `value.dsh === 'approval'` 处的身份门禁。
1077
+ */
1078
+ /**
1079
+ * 交互回传的三种形态:
1080
+ * - 单选按钮 / 审批按钮:`value.dsh = 'answer' | 'approval'`;
1081
+ * - **表单提交(多选勾选器、自由文本框):飞书没有 form_submit 事件**,它是
1082
+ * `action.tag='button'` + `action.form_value` 有值、`action.value` 为空。
1083
+ * 漏掉这一种,默认策略下"能对话、不能执行命令"的人就永远提交不了回答(功能性回归)。
1084
+ */
1085
+ const formFields = Object.keys(event?.action?.formValue ?? {});
1086
+ const isFormSubmit = formFields.some((field) => /^(chk_|multi_|text_)/u.test(field));
1087
+ const isInteractionResponse = value.dsh === 'answer' || value.dsh === 'approval' || isFormSubmit;
1088
+ if (!isInteractionResponse) {
1089
+ const commandAccess = commandAccessFor({ senderId: operatorId, conversationType });
1090
+ if (!commandAccess.allowed) {
1091
+ logger.warn?.(`[dsh-chat-feishu] 卡片动作被命令门禁拒绝:${bot.id}`
1092
+ + ` sender=${operatorId}(${commandAccess.reason})`);
1093
+ if (event.messageId) {
1094
+ await gateway.replyText({
1095
+ messageId: event.messageId,
1096
+ text: '你没有执行机器人命令的权限。',
1097
+ }).catch(() => {});
1098
+ }
1099
+ return { toast: { type: 'error', content: '你没有执行机器人命令的权限。' } };
1100
+ }
1101
+ }
1102
+
1103
+ // 控制面板:一个共用上下文(读状态、应用选择、重画卡片、执行命令都用它)。
1104
+ const viewerIsOwner = isOwner(deps.accessPolicy, bot, operatorId);
1105
+ const panelContext = {
1106
+ channelId: deps.channelId, botId: bot.id, key, conversationType,
1107
+ // 面板要按属主判机器人级字段(preset / workspace)。
1108
+ isOwner: viewerIsOwner,
1109
+ };
1110
+ const commandContext = {
1111
+ ...panelContext,
1112
+ senderId: operatorId,
1113
+ isOwner: viewerIsOwner,
1114
+ botLabel: bot.botName ?? bot.id,
1115
+ channelLabel: '飞书',
1116
+ };
1117
+ /**
1118
+ * 这张卡当前有没有待确认的改动(重启后内存里没有 → 显示"已失效")。
1119
+ *
1120
+ * 只有"点的是我们自己发的卡"时才认:messageId 缺失(例如回调没带)时不敢乱认。
1121
+ */
1122
+ function pendingForCard(messageId, { expired = false } = {}) {
1123
+ if (!messageId) return expired ? { prompt: '', expired: true } : null;
1124
+ const found = pendingConfirms.get(messageId);
1125
+ if (found) return found;
1126
+ return expired ? { prompt: '', expired: true } : null;
1127
+ }
1128
+
1129
+ /** 重画控制面板:读最新状态,并把"上一次做了什么、结果如何"画上去。 */
1130
+ async function repaintPanel(last = null, source = 'unknown', pending = null) {
1131
+ const state = await readPanel(commandContext);
1132
+ if (!state) {
1133
+ logger.warn?.(`[dsh-chat-feishu] 控制面板状态读取失败,无法重画(source=${source})`);
1134
+ return false;
1135
+ }
1136
+ return renderPanel({
1137
+ chatId, key, messageId: event.messageId ?? null, panel: state,
1138
+ last: last ? { at: panelClock(), ...last } : null,
1139
+ source,
1140
+ // 用回调带来的延迟更新 token —— 交互后的卡片更新只能走这条路。
1141
+ token: event.token ?? null,
1142
+ pending,
1143
+ });
1144
+ }
1145
+
1146
+ /**
1147
+ * 把一次卡片更新排到**回调应答之后**执行。
1148
+ *
1149
+ * 默认实现是"下一个宏任务 + 一点缓冲":SDK 在 handler 的 promise 落地后才把应答发回飞书,
1150
+ * 所以宏任务一定晚于应答的发送。测试可注入 `deps.scheduleAfterResponse` 收集这些任务,
1151
+ * 从而显式断言"重画发生在应答之后"。
1152
+ */
1153
+ function afterResponse(task, delayMs = RESPONSE_SETTLE_MS) {
1154
+ if (typeof deps.scheduleAfterResponse === 'function') {
1155
+ deps.scheduleAfterResponse(task);
1156
+ return;
1157
+ }
1158
+ const timer = setTimeout(() => { void task(); }, delayMs);
1159
+ timer.unref?.();
1160
+ }
1161
+
1162
+ /** 应答之后重画控制面板。失败照旧可见(日志 + `lastError`),只是不能进 toast 了。 */
1163
+ function repaintAfterResponse(last, source, pending = null) {
1164
+ afterResponse(() => repaintPanel(last, source, pending).then((ok) => {
1165
+ if (!ok) noteCardError(`控制面板应答后重画失败(${source})`, '未画出,见上面的 warn');
1166
+ }).catch((error) => {
1167
+ noteCardError(`控制面板应答后重画失败(${source})`, error?.message ?? error);
1168
+ }));
1169
+ }
1170
+
1171
+ /**
1172
+ * 应答之后就地更新当前卡片(按钮/命令清单这类"把这张卡换成另一张卡"的场景)。
1173
+ *
1174
+ * 优先用交互带来的延迟更新 token,没有或失败再退回 `message.patch`——patch 只在
1175
+ * "应答已经落地"之后才有用,顺序反了会被客户端还原。
1176
+ */
1177
+ function refreshCardAfterResponse({ card, label, fallbackText = '' }) {
1178
+ afterResponse(async () => {
1179
+ /** 三条路都没成时退回"回一句话",用户不会什么都收不到。 */
1180
+ const fallback = async () => {
1181
+ if (!fallbackText) return;
1182
+ await gateway.replyText({ messageId: event.messageId, text: fallbackText }).catch(() => {});
1183
+ };
1184
+ if (typeof event.token === 'string' && event.token) {
1185
+ try {
1186
+ await gateway.updateCard({ token: event.token, card, openIds: [operatorId] });
1187
+ logger.info?.(`[dsh-chat-feishu] ${label}已就地更新(token 路径)`);
1188
+ return;
1189
+ } catch (error) {
1190
+ logger.warn?.(`[dsh-chat-feishu] ${label}延迟更新失败,改用 patch:${error?.message ?? error}`);
1191
+ }
1192
+ }
1193
+ if (!event.messageId) {
1194
+ await fallback();
1195
+ return;
1196
+ }
1197
+ try {
1198
+ await gateway.patchCard({ messageId: event.messageId, card });
1199
+ logger.info?.(`[dsh-chat-feishu] ${label}已就地更新(patch 路径)`);
1200
+ } catch (error) {
1201
+ noteCardError(`${label}就地更新失败`, error?.message ?? error);
1202
+ await fallback();
1203
+ }
1204
+ });
1205
+ }
1206
+
1207
+ /**
1208
+ * 下拉(select_static):`behaviors.callback` 直接回调,选中值在 `event.action.options`。
1209
+ *
1210
+ * 这是这次改造的核心:选完立即生效并把同一张卡片重画(成功 ✅、失败 ❌ 带原因),
1211
+ * 不需要再点提交、也不需要用户记命令。
1212
+ */
1213
+ const pick = panelPick(value.action, event?.action?.options);
1214
+ if (pick) {
1215
+ logger.info?.(`[dsh-chat-feishu] 控制面板下拉:${value.action}=${pick.invalid ? '<没认出取值>' : pick.value}(${bot.id})`);
1216
+ if (pick.invalid) {
1217
+ // 认不出取值时必须报错:当成"恢复默认"会静默清掉推理等级/预设,还画一个 ✅。
1218
+ logger.warn?.('[dsh-chat-feishu] 控制面板下拉取值没认出来,已拒绝这次修改'
1219
+ + `(action=${value.action} options=${JSON.stringify(event?.action?.options ?? null)}`
1220
+ + ` 原始=${JSON.stringify(event?.action?.value ?? null)})`);
1221
+ return {
1222
+ toast: { type: 'error', content: '没认出这次选择,请重试(也可以手打 /model 等命令)。' },
1223
+ };
1224
+ }
1225
+ try {
1226
+ const applied = await deps.panel.apply({ ...panelContext, field: pick.field, value: pick.value });
1227
+ const message = applied?.message ?? '已生效。';
1228
+ /**
1229
+ * 需要二次确认(放宽访问策略):**先不落盘**,把确认画在同一张卡上。
1230
+ * 用 toast 说"需要确认"是不够的——toast 会消失,用户过两秒就不知道在确认什么。
1231
+ */
1232
+ if (applied?.requiresConfirm === true) {
1233
+ const pending = { field: pick.field, value: pick.value, prompt: applied.confirmPrompt ?? message };
1234
+ if (event.messageId) pendingConfirms.set(event.messageId, pending);
1235
+ else logger.warn?.('[dsh-chat-feishu] 这次回调没带 messageId,二次确认只能靠卡片上的提示');
1236
+ repaintAfterResponse({ label: pick.label, message, ok: true }, `pick:${value.action}(待确认)`, pending);
1237
+ return { toast: { type: 'info', content: '这次改动需要确认,请在卡片上点「✅ 确认」。' } };
1238
+ }
1239
+ if (event.messageId) pendingConfirms.delete(event.messageId);
1240
+ // 重画排在应答之后:先更新再应答会被客户端还原("闪一下又变回去")。
1241
+ repaintAfterResponse({ label: pick.label, message, ok: true }, `pick:${value.action}`);
1242
+ return { toast: { type: 'success', content: message.slice(0, 80) } };
1243
+ } catch (error) {
1244
+ // 失败必须可见:日志 + 卡片上的 ❌ 一行 + 错误 toast。
1245
+ noteCardError(`控制面板应用失败(${pick.field}=${pick.value})`, error?.message ?? error);
1246
+ const message = error?.message ?? String(error);
1247
+ // 这一步同样要排在应答之后,否则 ❌ 也会被还原。
1248
+ repaintAfterResponse({ label: pick.label, message, ok: false }, `pick:${value.action}(失败)`);
1249
+ return { toast: { type: 'error', content: message.slice(0, 80) } };
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * 卡片上的「✅ 确认 / ↩️ 取消」(面板下拉触发的二次确认)。
1255
+ *
1256
+ * 确认时才带着 `confirm: true` 再调一次 `panel.apply`——hub 侧只有在 `confirm: true` 时才落盘,
1257
+ * 所以"误点一下"永远改不了设置。找不到待确认项(重启/换卡后)就如实说失效,绝不猜值。
1258
+ */
1259
+ if (value.dsh_cancel === true) {
1260
+ if (event.messageId) pendingConfirms.delete(event.messageId);
1261
+ repaintAfterResponse({ label: '取消改动', message: '已取消,什么都没改。', ok: true }, 'cancel');
1262
+ return { toast: { type: 'info', content: '已取消。' } };
1263
+ }
1264
+ if (value.dsh_confirm === true) {
1265
+ const pending = event.messageId ? pendingConfirms.get(event.messageId) : null;
1266
+ if (!pending) {
1267
+ logger.warn?.(`[dsh-chat-feishu] 收到确认但找不到待确认项(${event.messageId ?? '无 messageId'})`);
1268
+ repaintAfterResponse(
1269
+ { label: '确认已失效', message: '这次确认已失效,请重新选一次。', ok: false },
1270
+ 'confirm(失效)',
1271
+ { prompt: '', expired: true },
1272
+ );
1273
+ return { toast: { type: 'error', content: '这次确认已失效,请重新选一次。' } };
1274
+ }
1275
+ try {
1276
+ const applied = await deps.panel.apply({
1277
+ ...panelContext, field: pending.field, value: pending.value, confirm: true,
1278
+ });
1279
+ const message = applied?.message ?? '已生效。';
1280
+ pendingConfirms.delete(event.messageId);
1281
+ repaintAfterResponse({ label: '已确认', message, ok: true }, 'confirm');
1282
+ return { toast: { type: 'success', content: message.slice(0, 80) } };
1283
+ } catch (error) {
1284
+ noteCardError(`控制面板确认失败(${pending.field}=${pending.value})`, error?.message ?? error);
1285
+ const message = error?.message ?? String(error);
1286
+ pendingConfirms.delete(event.messageId);
1287
+ repaintAfterResponse({ label: '确认失败', message, ok: false }, 'confirm(失败)');
1288
+ return { toast: { type: 'error', content: message.slice(0, 80) } };
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * 渠道自带的动作按钮(飞书:重连):交给渠道自己的 `panel.act` 执行。
1294
+ *
1295
+ * 它是机器人级操作(重连会断开长连接),能不能点由**渠道**按 `isOwner` 判——
1296
+ * hub 只透传,失败照旧把 code/message 原样交给用户看。
1297
+ */
1298
+ const channelAction = panelAction(value);
1299
+ if (channelAction) {
1300
+ logger.info?.(`[dsh-chat-feishu] 控制面板动作:${channelAction.action}(${bot.id})`);
1301
+ /**
1302
+ * 会断开长连接(或本来就很慢)的动作:**先把应答发出去,再执行**。
1303
+ *
1304
+ * 真机教训:点「🔌 重连」→ 卡片显示了"已重连",飞书却弹「目标回调服务器超时未响应」
1305
+ * ——因为重连恰好在回调链路上:它关掉的就是正在送回执的那条长连接。
1306
+ * 与 /history、/compact 同一条规矩:排到应答之后,靠延迟更新接口(HTTP)画结果。
1307
+ */
1308
+ if (value.dsh_action_deferred === true) {
1309
+ const label = channelAction.label;
1310
+ afterResponse(async () => {
1311
+ try {
1312
+ const done = await deps.panel.act({ ...panelContext, action: channelAction.action });
1313
+ const message = done?.message ?? '已执行。';
1314
+ await repaintPanel({ at: panelClock(), label, message, ok: true }, `act:${channelAction.action}`);
1315
+ } catch (error) {
1316
+ noteCardError(`控制面板动作失败(${channelAction.action})`, error?.message ?? error);
1317
+ await repaintPanel({
1318
+ at: panelClock(), label, message: error?.message ?? String(error), ok: false,
1319
+ }, `act:${channelAction.action}(失败)`);
1320
+ }
1321
+ }, SLOW_ACTION_SETTLE_MS);
1322
+ // 不写"已重连":真正做完了才画到卡上(失败会落 lastError)。
1323
+ return { toast: { type: 'info', content: `正在执行「${label}」…` } };
1324
+ }
1325
+ try {
1326
+ const done = await deps.panel.act({ ...panelContext, action: channelAction.action });
1327
+ const message = done?.message ?? '已执行。';
1328
+ repaintAfterResponse({ label: channelAction.label, message, ok: true }, `act:${channelAction.action}`);
1329
+ return { toast: { type: 'success', content: message.slice(0, 80) } };
1330
+ } catch (error) {
1331
+ noteCardError(`控制面板动作失败(${channelAction.action})`, error?.message ?? error);
1332
+ const message = error?.message ?? String(error);
1333
+ repaintAfterResponse({ label: channelAction.label, message, ok: false }, `act:${channelAction.action}(失败)`);
1334
+ return { toast: { type: 'error', content: message.slice(0, 80) } };
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * 面板按钮:新会话就地生效并重画;命令清单切到命令卡(卡上有「返回控制面板」);
1340
+ * 状态/停止复用命令行,输出画回面板。
1341
+ */
1342
+ let fromPanel = false;
1343
+ if (typeof value.dsh_panel === 'string') {
1344
+ const action = panelButton(value.dsh_panel);
1345
+ logger.info?.(`[dsh-chat-feishu] 控制面板按钮:${value.dsh_panel} → ${JSON.stringify(action ?? null)}(${bot.id})`);
1346
+ if (!action) return { toast: { type: 'error', content: '这个按钮已经失效了,请重发 /menu。' } };
1347
+ /**
1348
+ * 输出是**文本**、执行还可能很慢的命令(历史/压缩):应答之后再跑,结果用一条文字消息回。
1349
+ *
1350
+ * 两个原因:① 飞书要求回调在 3 秒内应答,压缩动辄更久;② 历史可能几十行,
1351
+ * 写进面板卡会把设置区整个埋掉(卡片也有大小上限)。
1352
+ */
1353
+ if (action.asText) {
1354
+ const text = action.command;
1355
+ afterResponse(async () => {
1356
+ const result = await deps.commands?.handle?.({ ...commandContext, text })
1357
+ .catch((error) => {
1358
+ noteCardError(`面板命令失败(${text})`, error?.message ?? error);
1359
+ return null;
1360
+ });
1361
+ const reply = result?.reply ?? '(没有输出)';
1362
+ await gateway.replyText({ messageId: event.messageId, text: reply }).catch((error) => {
1363
+ noteCardError(`面板命令回文字失败(${text})`, error?.message ?? error);
1364
+ });
1365
+ });
1366
+ return { toast: { type: 'info', content: `正在执行 ${text}…` } };
1367
+ }
1368
+ if (action.panel) {
1369
+ repaintAfterResponse(null, 'button:panel');
1370
+ // 同上:不宣称"已回到",等应答之后的这次重画落地(失败落 lastError)。
1371
+ return { toast: { type: 'info', content: '正在返回控制面板…' } };
1372
+ }
1373
+ if (action.menu) {
1374
+ const items = await menuItemsFor(commandContext);
1375
+ if (items.length > 0 && event.messageId) {
1376
+ // 同样是交互驱动的更新:**排在应答之后**,否则一切换就被还原。
1377
+ refreshCardAfterResponse({ card: menuCard(items), label: '命令清单' });
1378
+ // 不写"已切到":更新排在应答之后,此刻还没画上去(画失败会落 lastError)。
1379
+ return { toast: { type: 'info', content: '正在切到命令清单…' } };
1380
+ }
1381
+ value.dsh_menu = '/help';
1382
+ fromPanel = true;
1383
+ } else {
1384
+ // 新会话:直接调面板(面板里它就是 field=session),不必绕命令行。
1385
+ if (action.field === 'session') {
1386
+ try {
1387
+ const applied = await deps.panel.apply({ ...panelContext, field: 'session', value: action.value });
1388
+ const message = applied?.message ?? '已生效。';
1389
+ repaintAfterResponse({ label: action.label, message, ok: true }, 'button:new');
1390
+ return { toast: { type: 'success', content: message.slice(0, 80) } };
1391
+ } catch (error) {
1392
+ noteCardError('控制面板应用失败(session=new)', error?.message ?? error);
1393
+ const message = error?.message ?? String(error);
1394
+ repaintAfterResponse({ label: action.label, message, ok: false }, 'button:new(失败)');
1395
+ return { toast: { type: 'error', content: message.slice(0, 80) } };
1396
+ }
1397
+ }
1398
+ value.dsh_menu = action.command;
1399
+ fromPanel = true;
1400
+ }
1401
+ }
1402
+
1403
+ // 菜单卡片:按钮里带的是命令行,走与"用户手打"同一条路径。
1404
+ if (typeof value.dsh_menu === 'string' && value.dsh_menu.startsWith('/')) {
1405
+ const command = await deps.commands?.handle?.({ ...commandContext, text: value.dsh_menu })
1406
+ .catch((error) => {
1407
+ logger.warn?.(`[dsh-chat-feishu] 菜单命令失败:${error?.message ?? error}`);
1408
+ return null;
1409
+ });
1410
+ if (!command?.handled) return { toast: { type: 'error', content: '命令没有执行。' } };
1411
+ /**
1412
+ * 从控制面板点进来的命令(状态/停止):输出画回**面板**,不把面板换成命令卡——
1413
+ * 用户的上下文是"我在面板上调设置",不该被一次查询打断。
1414
+ */
1415
+ if (fromPanel) {
1416
+ const reply = String(command.reply ?? '');
1417
+ repaintAfterResponse({
1418
+ label: value.dsh_menu,
1419
+ message: reply || '(没有输出)',
1420
+ ok: !reply.startsWith('命令执行失败'),
1421
+ }, `command-from-panel:${value.dsh_menu}`);
1422
+ return { toast: { type: 'success', content: `已执行 ${value.dsh_menu}` } };
1423
+ }
1424
+ // 就地更新:把"点了哪个命令 + 输出"画回同一张卡片,按钮保持可用。
1425
+ // 取不到卡片 messageId 时退回原路(回文字),行为不变。
1426
+ const items = command.menu?.length ? command.menu : await menuItemsFor(commandContext);
1427
+ if (items.length > 0 && event.messageId) {
1428
+ // 点按钮是交互驱动:更新必须排在应答之后(否则"点了又变回去")。
1429
+ refreshCardAfterResponse({
1430
+ card: menuCard(items, { command: value.dsh_menu, reply: command.reply ?? '' }),
1431
+ label: `菜单卡片 ${value.dsh_menu}`,
1432
+ fallbackText: command.reply ?? '',
1433
+ });
1434
+ return { toast: { type: 'success', content: `已执行 ${value.dsh_menu}` } };
1435
+ }
1436
+ if (command.reply) {
1437
+ if (event.messageId) {
1438
+ await gateway.replyText({ messageId: event.messageId, text: command.reply });
1439
+ } else {
1440
+ await gateway.sendText({ chatId, text: command.reply });
1441
+ }
1442
+ }
1443
+ return { toast: { type: 'success', content: '已执行' } };
1444
+ }
1445
+
1446
+ // 审批卡片:直接按按钮里的结论回答
1447
+ if (value.dsh === 'approval') {
1448
+ const decision = value.decision === 'allowed-once' ? 'allowed-once' : 'rejected';
1449
+ const answer = decision === 'allowed-once' ? '允许' : '拒绝';
1450
+ /**
1451
+ * 身份门禁:审批按钮只免**命令权限**,不免"谁能替属主批准"。
1452
+ *
1453
+ * 提问那条路在认领前逐个候选键判 `evaluateAccess(isCommand: false)`;审批以前直接
1454
+ * `offer`,于是群聊 allowlist 策略下任何能看到审批卡的人都能点「允许一次」替属主
1455
+ * 批准工具执行(而同一句话当文字发出来会被 accept() 的门禁丢掉)。
1456
+ */
1457
+ // 按**这张卡实际所在的会话类型**判(`conversationType` 已由卡片映射算出),
1458
+ // 不能"两个作用域任一放行就算过"——群卡片用私聊作用域的宽松策略放行正是这个洞。
1459
+ const allowed = evaluateInteractionAccess({
1460
+ senderId: operatorId, conversationType,
1461
+ }).allowed;
1462
+ if (!allowed) {
1463
+ logger.warn?.(`[dsh-chat-feishu] 审批按钮被身份门禁拒绝:${bot.id} sender=${operatorId}`);
1464
+ if (event.messageId) {
1465
+ await gateway.replyText({
1466
+ messageId: event.messageId,
1467
+ text: '你没有处理这次授权的权限。',
1468
+ }).catch(() => {});
1469
+ }
1470
+ return { toast: { type: 'error', content: '你没有处理这次授权的权限。' } };
1471
+ }
1472
+ /**
1473
+ * 先认领**这张卡真实所在的那个会话**,认不到再退到另一个候选。
1474
+ *
1475
+ * 以前是固定"先私聊再群":同一机器人在群和私聊里同时各有一轮在等审批时,在群卡片上
1476
+ * 点「允许一次」会先把私聊那轮决定掉,而群里的审批仍挂着(卡片却已被刷成"已允许"),
1477
+ * 用户看到的是"点了允许,任务还在等"。`key` 是发卡时登记、落盘的权威判据。
1478
+ */
1479
+ const candidates = [key, conversationType === 'group' ? `p2p:${operatorId}` : `group:${chatId}`];
1480
+ let claimed = false;
1481
+ for (const candidate of candidates) {
1482
+ if (deps.interactions?.offer?.({
1483
+ channelId: deps.channelId,
1484
+ botId: bot.id,
1485
+ key: candidate,
1486
+ text: answer,
1487
+ })) {
1488
+ claimed = true;
1489
+ break;
1490
+ }
1491
+ }
1492
+ if (!claimed) {
1493
+ logger.info?.(`[dsh-chat-feishu] 卡片回调没有匹配的待审批(${bot.id} ${operatorId})`);
1494
+ return { toast: { type: 'info', content: '这次授权已经处理过了。' } };
1495
+ }
1496
+ // 同样排在应答之后:否则卡片上的"已允许"也会被还原成两个按钮。
1497
+ const markTitle = decision === 'allowed-once' ? '已允许' : '已拒绝';
1498
+ afterResponse(() => markAnswered(event, value.dsh, markTitle)
1499
+ .catch((error) => noteCardError('审批卡片标记失败', error?.message ?? error)));
1500
+ return { toast: { type: 'success', content: decision === 'allowed-once' ? '已允许执行' : '已拒绝' } };
1501
+ }
1502
+
1503
+ // 表单提交(勾选器 / 文本输入框):值在 action.form_value[组件name]。
1504
+ // 组件名约定:`chk_<序号>_<问题id>`(勾选器,值为布尔)、`text_<问题id>`(输入框)。
1505
+ const formValue = event?.action?.formValue ?? {};
1506
+ const formEntries = Object.entries(formValue)
1507
+ .filter(([field]) => field.startsWith('chk_') || field.startsWith('multi_') || field.startsWith('text_'));
1508
+ const truthy = (raw) => raw === true || raw === 'true' || raw === 1 || raw === '1';
1509
+ let label = '';
1510
+ let questionId;
1511
+ if (formEntries.length > 0) {
1512
+ const picked = [];
1513
+ for (const [field, raw] of formEntries) {
1514
+ if (field.startsWith('chk_')) {
1515
+ // 勾选器:只认"被勾上"的;标签从桥记住的批次里按 序号 + 问题id 反查
1516
+ const matched = /^chk_(\d+)_(.+)$/.exec(field);
1517
+ if (!matched || !truthy(raw)) continue;
1518
+ const [, indexText, id] = matched;
1519
+ questionId = id;
1520
+ const question = questionBatches.get(`p2p:${operatorId}`)?.questions?.find((item) => item?.id === id)
1521
+ ?? questionBatches.get(`group:${chatId}`)?.questions?.find((item) => item?.id === id);
1522
+ const optionLabel = question?.options?.[Number(indexText)]?.label;
1523
+ if (typeof optionLabel === 'string' && optionLabel) picked.push(optionLabel);
1524
+ continue;
1525
+ }
1526
+ questionId = field.slice(field.indexOf('_') + 1);
1527
+ if (field.startsWith('multi_')) {
1528
+ for (const item of Array.isArray(raw) ? raw : [raw]) {
1529
+ if (typeof item === 'string' && item.trim()) picked.push(item.trim());
1530
+ }
1531
+ } else if (typeof raw === 'string' && raw.trim()) {
1532
+ picked.push(raw.trim());
1533
+ }
1534
+ }
1535
+ // 多选拼接用「、」:hub 的 parseAnswer 对多选正是按 、/, 拆开,解析路径与按钮一致。
1536
+ label = picked.join('、');
1537
+ if (!label) {
1538
+ logger.info?.(`[dsh-chat-feishu] 卡片表单提交没有内容(${bot.id} ${operatorId})`);
1539
+ return { toast: { type: 'info', content: '还没有勾选或填写内容。' } };
1540
+ }
1541
+ } else if (value.dsh === 'answer') {
1542
+ label = typeof value.label === 'string' ? value.label : '';
1543
+ questionId = typeof value.questionId === 'string' ? value.questionId : undefined;
1544
+ } else {
1545
+ return undefined;
1546
+ }
1547
+ if (!label) return undefined;
1548
+
1549
+ // 身份门禁与文本回答一致:不该由谁回答,就不认领。
1550
+ await deps.ready?.();
1551
+ const accessPolicy = deps.storage.read(bot.id).accessPolicy;
1552
+ const keys = [
1553
+ { key: `group:${chatId}`, conversationType: 'group' },
1554
+ { key: `p2p:${operatorId}`, conversationType: 'direct' },
1555
+ ];
1556
+ for (const candidate of keys) {
1557
+ const access = deps.accessPolicy.evaluateAccess({
1558
+ policy: accessPolicy,
1559
+ conversationType: candidate.conversationType,
1560
+ senderIds: [operatorId],
1561
+ isOwner: isOwner(deps.accessPolicy, bot, operatorId),
1562
+ });
1563
+ if (!access.allowed) continue;
1564
+ if (deps.interactions?.offer?.({
1565
+ channelId: deps.channelId,
1566
+ botId: bot.id,
1567
+ key: candidate.key,
1568
+ text: label,
1569
+ questionId: questionId || (typeof value.questionId === 'string' ? value.questionId : undefined),
1570
+ })) {
1571
+ logger.info?.(`[dsh-chat-feishu] 卡片回答已认领:${bot.id} ${candidate.key} → ${label}`);
1572
+ lastHandledAt = new Date().toISOString();
1573
+ // 这里**不**把卡片替换成静态卡:一批问题共用一张卡,hub 会带着"已回答"状态
1574
+ // 重新渲染(把剩下没答的继续留在卡上)。替换掉会把其余问题一起抹掉。
1575
+ return { toast: { type: 'success', content: `已选择:${label}` } };
1576
+ }
1577
+ }
1578
+ logger.info?.(`[dsh-chat-feishu] 卡片回调没有匹配的待回答问题(${bot.id} ${operatorId})`);
1579
+ return { toast: { type: 'info', content: '这个问题已经处理过了。' } };
1580
+ }
1581
+
1582
+ /** 把已答的卡片替换成静态卡片:视觉上明确"已处理",也避免重复点。 */
1583
+ async function markAnswered(event, title, content) {
1584
+ if (!event?.messageId || typeof gateway.markCardAnswered !== 'function') return;
1585
+ try {
1586
+ // 带 token:这是"用户刚点了这张卡",更新要走延迟更新接口,否则会被客户端还原。
1587
+ // 审批卡是 Card 1.0,而 1.0 的延迟更新**必须在 card 里带 open_ids**(否则飞书报 300090),
1588
+ // 所以要把它操作者的 open_id 一起给网关。
1589
+ const openIds = event.operator?.openId ? [event.operator.openId] : null;
1590
+ await gateway.markCardAnswered({
1591
+ messageId: event.messageId, token: event.token ?? null, openIds, title, content,
1592
+ });
1593
+ } catch (error) {
1594
+ logger.warn?.(`[dsh-chat-feishu] 更新提问卡片失败:${error?.message ?? error}`);
1595
+ }
1596
+ }
1597
+
1598
+ return {
1599
+ accept,
1600
+ handleCardAction,
1601
+ status: () => Object.freeze({ handled, lastError, lastHandledAt }),
1602
+ /** 停止时把 IM 回传的发送器摘掉:不能让停掉的机器人继续"接单"。 */
1603
+ dispose: () => detachInteractions?.(),
1604
+ };
1605
+ }