@qzsy/dsh-bridge 2.18.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -1094
  2. package/LICENSE +21 -21
  3. package/README.md +73 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +414 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +303 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,1152 +0,0 @@
1
- // dsh-bridge Feishu / Lark conversation node
2
- // 把飞书 Channel SDK 归一化后的入站消息交给平台无关的 ConversationBridge 处理,
3
- // 出站经 FeishuGateway(官方 Channel SDK)发送文本 / 卡片 / 文件,轮次内使用流式卡片。
4
-
5
- import fs from 'node:fs'
6
- import path from 'node:path'
7
- import { ConversationBridge } from '../platform/conversation-bridge.js'
8
- import { escapeMarkdown } from '../platform/activity.js'
9
- import { buildCommentPeerId, isCommentPeerId } from './comment.js'
10
-
11
- /** 审批结果 → 卡片标题/配色(CardKit header template 取值:blue/green/red/orange/grey…) */
12
- const APPROVAL_RESOLUTIONS = {
13
- approved: { emoji: '✅', text: '**已批准执行**', template: 'green' },
14
- rejected: { emoji: '❌', text: '**已拒绝执行**', template: 'red' },
15
- timeout: { emoji: '⏰', text: '**已超时(自动拒绝)**', template: 'grey' },
16
- cancelled: { emoji: '⚪', text: '**已取消**(任务中止)', template: 'grey' },
17
- }
18
-
19
- /** 纯媒体消息类型:SDK 归一化后的 content 是占位标记(如 `![](image_key)`、`<file .../>`),
20
- * 不投喂给模型;附件一律以下载后的本地路径描述(与既有行为一致)。 */
21
- const MEDIA_ONLY_TYPES = new Set(['image', 'file', 'audio', 'video', 'media', 'sticker'])
22
-
23
- /**
24
- * 预热状态卡的建卡延迟(issue #77 ② 与历史"(no content)"空卡事故的折中)。
25
- *
26
- * 为什么需要延迟:`openStream()` 一旦调用,飞书的卡片就已经创建(SDK 在回调里给出
27
- * controller),此后若因轮次结束/正文已建卡而"收掉不用",用户会看到一张从未写入内容的
28
- * 空卡(真机截图 "(no content)")。因此先用一个短延迟观察:这段时间内正文到达或轮次结束,
29
- * 就直接取消、不建卡;只有状态真的持续存在(工具在跑)时才建卡承载。
30
- * 400ms 对"长任务里正文最后才出"的场景足够快,对短任务则完全无感。
31
- */
32
- const WARM_CARD_DELAY_MS = 400
33
-
34
- /**
35
- * 平台适配对象(桥通过 `this.platform` 调用各能力)。
36
- *
37
- * @param {object} gateway 飞书网关
38
- * @param {{ setActivityStatus?: (line: string) => Promise<void> }} [hooks]
39
- * 会话桥需要的能力钩子。**注意**:桥调用的是 `this.platform.xxx`,而非节点自身;
40
- * 把能力只定义在节点类上、忘了挂到这里,桥就会因 `typeof !== 'function'` 静默跳过
41
- * (2026-10-16 本轮真机前自查发现过这个错,务必保持两边一致)。
42
- */
43
- function makePlatform(gateway, hooks = {}) {
44
- return {
45
- id: 'feishu',
46
- name: 'Feishu / Lark',
47
- get status() { return gateway?.status ?? 'idle' },
48
- // 能力字段与 base.js 约定对齐
49
- capabilities: { supportsGroup: true, supportsMedia: true, supportsTyping: false, maxMessageChars: 2000 },
50
- async sendText(peerId, text, opts = {}) {
51
- return gateway?.sendText(peerId, text, opts)
52
- },
53
- async sendMediaFile(peerId, filePath, opts = {}) {
54
- return gateway?.sendMediaFile(peerId, filePath, opts)
55
- },
56
- async sendTyping() {},
57
- // 轮次中的"活动状态":仅在节点提供了钩子时才存在(桥按鸭子类型探测)
58
- ...(typeof hooks.setActivityStatus === 'function'
59
- // 第三参 parts(结构化状态片段)必须透传:detail 需要转义,丢掉它就退化成
60
- // 未转义的纯文本状态行(`*`/`_`/反引号会破坏引用块样式)。
61
- ? { setActivityStatus: (line, opts, parts) => hooks.setActivityStatus(line, opts, parts) }
62
- : {}),
63
- dispose() {},
64
- }
65
- }
66
-
67
-
68
- /**
69
- * 按文件**魔数**推断真实格式(只认几种常见签名,识别不出返回 null、绝不瞎猜)。
70
- *
71
- * 为什么需要:飞书图片不带文件名,旧实现一律按 `.png` 命名,而图片实际多为 JPEG
72
- * (2026-10-16 真机实测:截图是 JPEG 字节却存成 .png,按扩展名读取报"格式不符"、
73
- * 用户也打不开)。文件/音视频同样可能缺文件名,会退化成无意义的 `.bin`。
74
- *
75
- * @param {Buffer} head 文件开头若干字节
76
- * @returns {string|null} 形如 '.jpg';无法识别返回 null
77
- */
78
- function sniffExtension(head) {
79
- if (!head || head.length < 3) return null
80
- const ascii = (start, len) => head.subarray(start, start + len).toString('ascii')
81
- // 图片
82
- if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return '.jpg'
83
- if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
84
- if (ascii(0, 3) === 'GIF') return '.gif'
85
- if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return '.webp'
86
- if (head[0] === 0x42 && head[1] === 0x4d) return '.bmp'
87
- // 文档 / 归档(docx/xlsx/pptx 本质是 zip,无法从魔数细分,统一给 .zip 更诚实)
88
- if (ascii(0, 4) === '%PDF') return '.pdf'
89
- if (head[0] === 0x50 && head[1] === 0x4b && (head[2] === 0x03 || head[2] === 0x05 || head[2] === 0x07)) return '.zip'
90
- if (head[0] === 0x1f && head[1] === 0x8b) return '.gz'
91
- if (ascii(0, 4) === 'Rar!') return '.rar'
92
- if (head[0] === 0x37 && head[1] === 0x7a && head[2] === 0xbc && head[3] === 0xaf) return '.7z'
93
- // 音视频
94
- if (ascii(0, 4) === 'fLaC') return '.flac'
95
- if (ascii(0, 3) === 'ID3') return '.mp3'
96
- if (ascii(0, 4) === 'OggS') return '.ogg'
97
- if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WAVE') return '.wav'
98
- if (head.length >= 12 && ascii(4, 4) === 'ftyp') return '.mp4'
99
- return null
100
- }
101
-
102
- /** 读取文件头部若干字节(失败返回 null)。 */
103
- async function readHead(filePath, len = 16) {
104
- try {
105
- const fh = await fs.promises.open(filePath, 'r')
106
- try {
107
- const buf = Buffer.alloc(len)
108
- const { bytesRead } = await fh.read(buf, 0, len, 0)
109
- return buf.subarray(0, bytesRead)
110
- } finally {
111
- await fh.close()
112
- }
113
- } catch { return null }
114
- }
115
-
116
- /** 无意义/占位的扩展名:这些情况下按魔数校正不会覆盖用户的真实意图。 */
117
- const PLACEHOLDER_EXTS = new Set(['', '.bin', '.dat', '.tmp'])
118
-
119
- /**
120
- * 校正落盘文件的扩展名,使其与**实际字节**一致。
121
- *
122
- * 规则(刻意保守,避免"自作聪明"改掉用户明确给的名字):
123
- * - 图片:一律校正——飞书图片通常不带文件名,且实测存在扩展名与实际格式不符;
124
- * - 其它资源:仅在**没有扩展名**或扩展名是占位值(.bin/.dat/.tmp)时按魔数补全,
125
- * 用户明确给的名字(如 `报告.pdf`)原样保留。
126
- * 识别不出格式就保持原样(宁可留 `.bin`,也不猜错)。
127
- *
128
- * @param {string} filePath 落盘路径
129
- * @param {boolean} isImage 资源是否声明为图片
130
- * @returns {Promise<string>} 校正后的路径(必要时已重命名)
131
- */
132
- async function correctMediaExtension(filePath, isImage) {
133
- const current = path.extname(filePath).toLowerCase()
134
- const isPlaceholder = PLACEHOLDER_EXTS.has(current)
135
- // 图片之外、且已有明确扩展名 → 尊重用户给的名字,不动
136
- if (!isImage && !isPlaceholder) return filePath
137
-
138
- const head = await readHead(filePath)
139
- const wanted = sniffExtension(head)
140
- if (!wanted) return filePath
141
- // .jpeg 与 .jpg 等价,无需改名
142
- if (current === wanted || (wanted === '.jpg' && current === '.jpeg')) return filePath
143
-
144
- const renamed = path.join(path.dirname(filePath), `${path.basename(filePath, path.extname(filePath))}${wanted}`)
145
- try {
146
- await fs.promises.rename(filePath, renamed)
147
- return renamed
148
- } catch {
149
- // 重命名失败(跨设备/竞态)不致命:保留原路径
150
- return filePath
151
- }
152
- }
153
-
154
- export class FeishuConversationNode extends ConversationBridge {
155
- constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange, gateway: explicitGateway, instanceId } = {}) {
156
- // issue #78:显式接收所属网关,不再依赖 ctx.feishu 槽位(多实例时槽位会被覆盖)
157
- const gateway = explicitGateway ?? ctx.feishu
158
- super({
159
- ctx,
160
- logger,
161
- config,
162
- // 状态钩子用**惰性转发**:makePlatform 在 super() 参数里构造,此刻 this 尚不可用,
163
- // 故先挂一个转发函数,运行时再取 this.setActivityStatus(方法定义在类上)。
164
- platform: makePlatform(gateway, {
165
- setActivityStatus: (line, opts, parts) => this.setActivityStatus(line, opts, parts),
166
- }),
167
- onFirstSender,
168
- onActiveSessionChange,
169
- })
170
- this.gateway = gateway
171
- // 事件过滤标识:只处理本实例网关发出的事件,避免同类型多账号串消息
172
- this.instanceId = instanceId ?? gateway?.instanceId ?? 'feishu'
173
- this._lastPeer = null // { peerId(chat_id), senderId(open_id), isGroup }
174
- this._stream = null // 当前轮次的流式卡片控制器 { to, setContent, finish }
175
- this._activityLine = '' // 轮次中的活动状态行(如 `⌨️ 正在运行命令(npm test)`),收尾时清空
176
- // issue #77 ②:预热卡建卡是异步的(openStream 有网络往返)。并发 tool/call 可能在
177
- // 同一时刻多次调 setActivityStatus,若不合并会重复建卡(旧实现曾出现空卡 "(no content)")。
178
- this._warmOpening = false
179
- this._warmTimer = null // 预热建卡的延迟定时器(正文到达/轮次结束会取消它)
180
- this._warmCancel = null // 取消定时器时唤醒等待中的 setActivityStatus(否则 await 永挂)
181
- this._approvalOperatorName = '' // 最近一次按钮决议者昵称(飞书提供时用于卡片结果行)
182
- this._commentPeers = new Set() // 已处理过的评论会话(同线程后续回复不再要求 @)
183
- this._commentBuffers = new Map() // 评论轮次的出站缓冲:peerId → 文本(turn/end 统一发一条)
184
- this._inTurn = false
185
-
186
- // 事件仅属于本实例(旧事件无 instanceId 时按单实例兼容放行)
187
- const forThisInstance = (event) => !event?.instanceId || event.instanceId === this.instanceId
188
-
189
- // 订阅网关入站消息事件
190
- this.disposers.push(this.ctx.on('feishu/message', (event) => {
191
- if (!forThisInstance(event)) return
192
- return this._handleInbound(event)
193
- }))
194
-
195
- // 订阅云文档评论事件(@机器人 提问)
196
- this.disposers.push(this.ctx.on('feishu/comment', (event) => {
197
- if (!forThisInstance(event)) return
198
- return this._handleComment(event)
199
- }))
200
-
201
- // 订阅卡片交互事件(审批按钮点击)
202
- this.disposers.push(this.ctx.on('feishu/action', (event) => {
203
- if (!forThisInstance(event)) return
204
- return this._handleAction(event)
205
- }))
206
-
207
- // 连接断开/错误时收尾未完成的流式卡片,避免流式控制器悬挂
208
- this.disposers.push(this.ctx.on('feishu/status', (status) => {
209
- if (status?.instanceId && status.instanceId !== this.instanceId) return
210
- if (status?.status === 'offline' || status?.status === 'error') void this._finishStream()
211
- }))
212
-
213
- // 监听轮次事件:turn/start 开启流式会话,turn/end 收尾
214
- this.disposers.push(this.ctx.on('session/event', (session, event) => {
215
- if (session.id !== this.activeSessionId) return
216
- if (event.type === 'turn/start') {
217
- // 上一轮若因异常未收到 turn/end,先收尾旧卡片再开新一轮
218
- this._cancelWarmStream()
219
- void this._finishStream()
220
- this._inTurn = true
221
- this._activityLine = ''
222
- } else if (event.type === 'turn/end') {
223
- this._inTurn = false
224
- // 轮次结束:状态行随收尾一起消失,最终卡片只留正文
225
- this._activityLine = ''
226
- // 还没建出的预热卡直接取消:轮次都结束了,不该再凭空冒出一张状态卡
227
- this._cancelWarmStream()
228
- void this._finishStream()
229
- void this._flushCommentBuffers()
230
- }
231
- }))
232
- }
233
-
234
- async _handleInbound(event) {
235
- if (this.gateway?._closing || this.gateway?.status === 'offline') return
236
- const { chatId, senderId, isGroup, messageId, resources, rawContentType } = event
237
- const threadId = event.threadId ?? ''
238
- const senderName = event.senderName ?? ''
239
- this._lastPeer = { peerId: chatId, senderId, isGroup, messageId, threadId, senderName }
240
-
241
- // 纯媒体消息的 content 是 SDK 占位标记,不作为正文
242
- let text = MEDIA_ONLY_TYPES.has(rawContentType) ? '' : (event.text || '')
243
-
244
- let mediaFiles = []
245
- if (resources?.length) {
246
- mediaFiles = await this._processMedia({ messageId, resources }, this._activeSessionCwd() || this.config.cwd)
247
- }
248
-
249
- if (!text && mediaFiles.length === 0) {
250
- this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${chatId}`)
251
- return
252
- }
253
-
254
- let fullText = text
255
- // 群聊里让 agent 知道"谁在说话"(昵称由 SDK 按会话成员表填充,p2p 无需标注)
256
- if (isGroup && senderName) fullText = `[来自 ${senderName}] ${fullText}`
257
- if (mediaFiles.length > 0) {
258
- const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
259
- fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
260
- }
261
-
262
- // 引用回复:拉取被引用消息正文作为上下文(否则模型看不到"他引用的那句话")
263
- const quoted = await this._fetchQuotedContext(event)
264
- if (quoted) fullText = `[引用消息] ${quoted}\n\n${fullText}`
265
-
266
- this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${chatId} (group=${isGroup}): ${fullText}`)
267
-
268
- // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
269
- if (!this.activeSessionId) {
270
- await this._pickDefaultSession().catch(() => {})
271
- }
272
-
273
- // 转交通用 ConversationBridge 路由
274
- // 单聊/群聊的出站目标统一是 chat_id(oc_…)——飞书已限制按发送者 open_id 主动发消息(230101);
275
- // 群聊以"群"为授权主体,单聊以发送者 open_id 为身份(白名单 / 审批决议者校验)。
276
- return this.handleInbound({
277
- senderId: isGroup ? chatId : senderId,
278
- isGroup,
279
- text: fullText,
280
- senderLabel: senderName,
281
- outboundPeer: { peerId: chatId, isGroup, messageId, threadId },
282
- })
283
- }
284
-
285
- /**
286
- * 云文档评论入站:@机器人(或同一评论线程内的后续回复)→ 拉正文 → 交给会话桥。
287
- * 评论只能回纯文本、无卡片,因此合成 peer `comment:<fileToken>:<commentId>` 并在出站侧分流。
288
- */
289
- async _handleComment(event) {
290
- if (this.gateway?._closing || this.gateway?.status === 'offline') return
291
- const peerId = buildCommentPeerId(event)
292
- const known = this._commentPeers.has(peerId)
293
- if (!event.mentionedBot && !known) {
294
- this.logger.debug?.('[dsh-bridge feishu] 忽略未 @机器人 的评论:%s', peerId)
295
- return
296
- }
297
-
298
- let detail
299
- try {
300
- detail = await this.gateway.fetchComment({
301
- peerId,
302
- fileToken: event.fileToken,
303
- fileType: event.fileType,
304
- commentId: event.commentId,
305
- })
306
- } catch (err) {
307
- this.logger.warn?.('[dsh-bridge feishu] 拉取评论失败:%s', err?.message ?? err)
308
- return
309
- }
310
- if (!detail) return
311
-
312
- if (this._commentPeers.size >= 200) {
313
- const oldest = this._commentPeers.values().next().value
314
- this._commentPeers.delete(oldest)
315
- }
316
- this._commentPeers.add(peerId)
317
- this._lastPeer = { peerId, senderId: event.operatorId, isGroup: false, messageId: '', threadId: '', senderName: '' }
318
-
319
- let text = detail.text
320
- // 评论里的 @机器人 会被拍平成 `@<open_id>` 之类的占位,去掉机器人自身提及
321
- const botOpenId = this.gateway?.botInfo?.openId
322
- if (botOpenId) text = text.split(`@${botOpenId}`).join(' ')
323
- if (detail.quote) text = `[选中内容] ${detail.quote}\n\n${text}`
324
- text = text.trim()
325
- if (!text) {
326
- this.logger.debug?.('[dsh-bridge feishu] 评论正文为空,忽略:%s', peerId)
327
- return
328
- }
329
-
330
- if (!this.activeSessionId) await this._pickDefaultSession().catch(() => {})
331
- return this.handleInbound({
332
- senderId: event.operatorId,
333
- isGroup: false,
334
- text,
335
- outboundPeer: { peerId, isGroup: false, comment: true },
336
- })
337
- }
338
-
339
- /** 评论轮次的出站先缓冲,turn/end 时合并成一条评论回复(评论无流式/卡片) */
340
- async _flushCommentBuffers() {
341
- if (this._commentBuffers.size === 0) return
342
- const pending = [...this._commentBuffers.entries()]
343
- this._commentBuffers.clear()
344
- for (const [peerId, text] of pending) {
345
- try {
346
- await this.gateway.sendText(peerId, text)
347
- } catch (err) {
348
- this.logger.warn?.('[dsh-bridge feishu] 评论回复发送失败:%s', err?.message ?? err)
349
- }
350
- }
351
- }
352
-
353
- /**
354
- * 取"引用回复"的被引用正文(截断),失败静默降级为无上下文。
355
- * 只服务于普通会话的引用;话题群每条消息都带 parent_id/thread_id,若也去拉取会变成每条一次接口调用,
356
- * 且话题内的位置语义已由 replyInThread 承载,故跳过。
357
- */
358
- async _fetchQuotedContext(event) {
359
- if (!event?.replyToMessageId || event?.threadId) return ''
360
- try {
361
- const quoted = await this.gateway.fetchMessage(event.replyToMessageId)
362
- const content = String(quoted?.content ?? '').trim()
363
- if (!content) return ''
364
- return content.length > 600 ? `${content.slice(0, 600)}…` : content
365
- } catch (err) {
366
- this.logger.debug?.('[dsh-bridge feishu] 拉取引用消息失败:%s', err?.message ?? err)
367
- return ''
368
- }
369
- }
370
-
371
- /** 出站参数:话题群里的回复留在原话题内(普通会话保持不引用,观感与既往一致) */
372
- _replyOptions(source) {
373
- if (!source?.threadId || !source?.messageId) return {}
374
- return { replyTo: source.messageId, replyInThread: true }
375
- }
376
-
377
- /** 当前活动会话的工作区(DSH 把 cwd 放在 session.header.cwd;顶层无 cwd) */
378
- _activeSessionCwd() {
379
- const id = this.activeSessionId
380
- if (!id) return ''
381
- try {
382
- const sessions = this.ctx.sessions
383
- const sess = sessions?.get?.(id) ?? sessions?.list?.().find((s) => s?.id === id)
384
- return sess?.header?.cwd || sess?.cwd || ''
385
- } catch { return '' }
386
- }
387
-
388
- /**
389
- * 下载消息附件到会话工作区的 .feishu-media 目录。
390
- * SDK 的 downloadResourceToFile 直接流式落盘(大文件不进内存)。
391
- */
392
- async _processMedia({ messageId, resources }, sessionCwd) {
393
- if (!messageId || !resources?.length) return []
394
- const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
395
- try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
396
-
397
- const downloaded = []
398
- for (const resource of resources) {
399
- // SDK 约定:图片资源用 'image',其余(文件/音视频/表情)统一 'file'
400
- const type = resource.type === 'image' ? 'image' : 'file'
401
- const fileName = resource.fileName
402
- const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
403
- const safeName = fileName
404
- ? path.basename(fileName)
405
- : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
406
- const filePath = path.join(mediaDir, safeName)
407
- try {
408
- const result = await this.gateway.downloadResourceToFile(messageId, resource.fileKey, type, filePath)
409
- const size = result?.bytesWritten ?? 0
410
- if (size <= 0) {
411
- this.logger.warn?.(`[dsh-bridge feishu] download media empty: ${safeName}`)
412
- continue
413
- }
414
- // 校正扩展名使其与**实际字节**一致:图片一律校正(飞书图片通常不带文件名,
415
- // 实测存在扩展名与实际格式不符);文件/音视频在缺扩展名或为占位值(.bin 等)时
416
- // 按魔数补全,用户明确给的名字保持不动。
417
- const finalPath = await correctMediaExtension(filePath, resource.type === 'image')
418
- const finalName = path.basename(finalPath)
419
- downloaded.push({ filename: finalName, path: finalPath, size })
420
- this.logger.info?.(`[dsh-bridge feishu] downloaded media ${finalName} (${size} bytes)`)
421
- } catch (err) {
422
- this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err?.message ?? err}`)
423
- }
424
- }
425
- return downloaded
426
- }
427
-
428
- async _handleAction(event) {
429
- const { operatorId, operatorName, value } = event
430
- const approvalId = Number(value.approvalId)
431
- const action = value.action
432
-
433
- if (approvalId && (action === 'approve' || action === 'reject')) {
434
- const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
435
- const pending = this.pending.get(approvalId)
436
- if (pending) {
437
- // 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
438
- // 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
439
- if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
440
- this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
441
- return
442
- }
443
- this._approvalOperatorName = operatorName || ''
444
- this.clearApproval(approvalId)
445
- pending.resolve(outcome)
446
- }
447
- return
448
- }
449
-
450
- // ---- Agent 决策问答的按钮回调 ----
451
- // value: { action: 'ask', askId, askOption }(askOption 为选项下标,0-based)
452
- if (action === 'ask') {
453
- const askId = Number(value.askId)
454
- const entry = this.getAsk(askId)
455
- if (!entry) {
456
- this.logger?.info?.('[dsh-bridge feishu] ask #%s 按钮点击时问题已结束(或已超时并清理)', value.askId)
457
- return
458
- }
459
- // 作答者校验:单聊限定发起者本人;群聊允许群内成员(与审批同一模型)
460
- if (!entry.isGroup && entry.peerId && operatorId && entry.peerId !== operatorId) {
461
- this.logger?.warn?.('[dsh-bridge feishu] ask #%d blocked: operator %s is not the initiator %s', askId, operatorId, entry.peerId)
462
- return
463
- }
464
- const question = entry.questions?.[0]
465
- const options = Array.isArray(question?.options) ? question.options : []
466
- const idx = Number(value.askOption)
467
- if (!Number.isInteger(idx) || idx < 0 || idx >= options.length) return
468
- const label = String(options[idx]?.label ?? '')
469
- entry.resolve({ answers: [{ id: question.id, selected: [label] }] })
470
- return
471
- }
472
- }
473
-
474
- /**
475
- * 渲染"Agent 提问"卡片(单问单选)。
476
- *
477
- * 为什么用卡片而不是纯文字:手机端点选比手打 `/ask 1` 顺手得多;而选项文字的
478
- * 语义(哪个是推荐项、各自代价)需要排版才看得清。
479
- *
480
- * 复杂度受控的边界:只在"单问 + 单选 + 选项数适中"时走卡片(canRenderAsButtons),
481
- * 多选/多问/自由输入一律走文字(`/ask`),避免卡片不能表达时的误导。
482
- *
483
- * @returns {boolean} 是否成功送达卡片
484
- */
485
- async sendAskCard(peer, question, entry, promptText = '') {
486
- const options = Array.isArray(question?.options) ? question.options : []
487
- if (options.length === 0) return false
488
-
489
- const detail = String(question?.detail ?? '').replace(/\|/g, '|').trim()
490
- const rows = []
491
- rows.push(`**${String(question?.question ?? '').replace(/\|/g, '|')}**`)
492
- if (detail) rows.push('', detail)
493
- // 选项**全文写进正文**(markdown 不截断),按钮只放短标签。
494
- //
495
- // 为什么不能只靠按钮:飞书卡片的按钮文字有**平台级单行截断**(实测约 13 个汉字后
496
- // 变省略号),`width: 'fill'` 只能把按钮铺满、并不能避免截断——2026-10-16 两次真机
497
- // 截图确认(第一次以为是列容器压窄,改用 fill 后仍截断)。一旦选项全文只存在于按钮里,
498
- // 被截断即**信息丢失**;放进正文则永远完整可见。
499
- options.forEach((opt, i) => {
500
- const label = String(opt?.label ?? `选项 ${i + 1}`)
501
- const recommended = /\(recommended\)|(推荐)/i.test(label)
502
- const desc = String(opt?.description ?? '').trim()
503
- rows.push(`${i + 1}. ${recommended ? '⭐ ' : ''}${label}${desc ? `\n ${desc}` : ''}`)
504
- })
505
- if (entry.timeoutSec > 0) {
506
- const min = Math.max(1, Math.round(entry.timeoutSec / 60))
507
- rows.push('', `> 等待超时:${min} 分钟(超时后 Agent 可能先继续其它工作,你稍后仍可 \`/ask\` 补答)`)
508
- }
509
-
510
- // 按钮用**短标签**(序号 + 简称),保证不会被平台截断;
511
- // 完整语义在正文里,点击等价于 `/ask <序号>`。
512
- //
513
- // 简称取"第一个分隔符之前"的短语(选项文字常写成「标签:说明」或「标签 — 说明」),
514
- // 比硬截断更像人话;实在没有分隔符才按长度截。**不留省略号**:省略号会让用户以为
515
- // 内容被吞了(历史事故的观感问题),序号 + 正文已足够定位。
516
- const buttonElements = options.map((opt, i) => {
517
- const label = String(opt?.label ?? `选项 ${i + 1}`).trim()
518
- const recommended = /\(recommended\)|(推荐)/i.test(label)
519
- const bare = label
520
- .replace(/\s*[((]\s*recommended\s*[))]\s*/i, '')
521
- .replace(/\s*[((]推荐[))]\s*/, '')
522
- .trim()
523
- const head = bare.split(/[::—\-–((]/)[0].trim() || bare
524
- const short = (head.length > 10 ? head.slice(0, 10) : head) || `选项 ${i + 1}`
525
- return {
526
- tag: 'button',
527
- text: { tag: 'plain_text', content: `${recommended ? '⭐ ' : ''}${i + 1}. ${short}` },
528
- type: recommended ? 'primary' : 'default',
529
- width: 'fill',
530
- size: 'medium',
531
- value: { action: 'ask', askId: entry.number, askOption: i },
532
- }
533
- })
534
-
535
- const elements = [{ tag: 'markdown', content: rows.join('\n') }]
536
- for (const el of buttonElements) elements.push(el)
537
- elements.push({ tag: 'markdown', content: '> 也可以文字回复:`/ask 1`(第 1 项)或 `/ask =你的答案`' })
538
-
539
- const card = {
540
- schema: '2.0',
541
- header: { title: { tag: 'plain_text', content: `❓ 需要你的决定 (#${entry.number})` }, template: 'blue' },
542
- body: { elements },
543
- }
544
-
545
- try {
546
- await this._finishStream()
547
- await this.gateway.sendCard(peer?.peerId ?? this.resolveSendTarget(entry.sendOpts), card, this._replyOptions(peer))
548
- return true
549
- } catch (err) {
550
- this.logger?.warn?.('[dsh-bridge feishu] 提问卡片发送失败(回退文字):%s', err?.message ?? err)
551
- return false
552
- }
553
- }
554
-
555
- /**
556
- * 出站文本。
557
- * - 轮次内(turn 期间):整轮共用一张 SDK 流式卡片(首片按 sendChunkDelayMs 打字机推进,
558
- * 后续输出覆盖为最新内容),由 SDK 负责建卡、节流、超限换卡与收尾;
559
- * - 轮次外(指令响应 / 系统提示 / 心跳摘要等):单条 Markdown 消息,SDK 负责
560
- * 富文本渲染、超长分片、限流重试与格式错误降级。
561
- */
562
- /**
563
- * 出站目标解析(覆盖基类):飞书单聊必须发会话 chat_id(oc_…),
564
- * 发送者 open_id 只用于身份校验(否则会触发 230101)。
565
- */
566
- resolveSendTarget(opts = {}) {
567
- const bound = opts?.outboundPeer && typeof opts.outboundPeer.isGroup === 'boolean' ? opts.outboundPeer : null
568
- const candidate = bound?.peerId || this._lastPeer?.peerId || ''
569
- if (candidate) return candidate
570
- // 兜底(issue #77 ③):单聊里基类把 this.peerId 赋成**发送者 senderId**(open_id,
571
- // ou_…),它不是合法出站目标(飞书按 open_id 主动发消息会 230101 失败)。
572
- // 因此只在 it 明显不是 open_id 时才敢用;解析不到就返回空串由调用方跳过,
573
- // 绝不"发错人"或静默落到 open_id。
574
- const fallback = this.peerId ? String(this.peerId) : ''
575
- return fallback && !fallback.startsWith('ou_') ? fallback : ''
576
- }
577
-
578
- async _sendTextNow(text, opts = {}) {
579
- if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
580
- // T2.3:轮次绑定的 outboundPeer({ peerId, isGroup })优先生效
581
- const bound = opts?.outboundPeer && typeof opts.outboundPeer.isGroup === 'boolean' ? opts.outboundPeer : null
582
- const peerId = this.resolveSendTarget(opts)
583
- if (!peerId) {
584
- // 不静默丢出站消息:解析不到合法目标(例如只剩发送者 open_id)时记一条告警,
585
- // 便于真机排查"消息没发出去"(issue #77 ③ 的同类问题)。
586
- this.logger?.warn?.('[dsh-bridge feishu] 出站目标解析为空(无 outboundPeer 且无最近入站 chat_id),本条已跳过:%s', String(text || '').slice(0, 80))
587
- return
588
- }
589
- const content = String(text || '').trim()
590
- if (!content) return
591
-
592
- // 评论会话:无流式/无卡片,轮次内先缓冲、非轮次直接回一条纯文本
593
- if (isCommentPeerId(peerId)) {
594
- if (this._inTurn) {
595
- const buffered = this._commentBuffers.get(peerId)
596
- this._commentBuffers.set(peerId, buffered ? `${buffered}\n\n${content}` : content)
597
- return
598
- }
599
- try {
600
- await this.gateway.sendText(peerId, content)
601
- } catch (err) {
602
- this.logger.warn?.('[dsh-bridge feishu] 评论回复发送失败:%s', err?.message ?? err)
603
- }
604
- return
605
- }
606
-
607
- // 话题群:回复留在话题内(bound 优先,其次最近一次入站)
608
- const replyOpts = this._replyOptions(bound || this._lastPeer)
609
-
610
- try {
611
- // standalone:本次内容必须独立成条,不得并入轮次流式卡片。
612
- // 用于"本轮改动摘要"这类**轮次级通知**:宿主在 turn/end 之前发出 workspace/changes
613
- // (实测 seq 2945 < 2946),此时 _inTurn 仍为 true;若走 _sendTurnOutput 就会被并进
614
- // 正在输出的卡片、随即被 turn/end 的收尾覆盖 —— 用户侧表现为"摘要一条都收不到"。
615
- if (this._inTurn && !opts?.standalone) {
616
- await this._sendTurnOutput(peerId, content, replyOpts)
617
- return
618
- }
619
- // 独立成条前先收尾在途卡片,保证它排在正文之后、不会被后续流式输出吞掉
620
- if (opts?.standalone && this._stream) await this._finishStream()
621
- await this.gateway.sendText(peerId, content, replyOpts)
622
- } catch (err) {
623
- // 显式记录,绝不静默丢弃(历史问题:出站失败无日志,用户侧完全无感知)
624
- this.logger.warn?.('[dsh-bridge feishu] outbound send failed:%s', err?.message ?? err)
625
- }
626
- }
627
-
628
- async _sendTurnOutput(peerId, content, replyOpts = {}) {
629
- // 正文已经要建/复用卡片了,未建出的预热卡不再需要(避免晚到的定时器再冒出一张空卡)
630
- this._cancelWarmStream()
631
- // 同一轮内**累积**文本,而不是用最新片段覆盖整张卡片。
632
- //
633
- // 为什么必须累积:宿主对每个 step 发一条独立的 `assistant/message`(surfaceOp=append),
634
- // 每条只是**该步的文本片段**,不是整轮累积全文。旧实现把每个片段直接 setContent 覆盖
635
- // 卡片 → 用户看到"输出一半突然消失、又重新输出",最终只剩最后一个片段
636
- //(2026-10-16 真机反馈:"输出一半,然后又突然全部消失,又重新输出…只保留了最后一条")。
637
- // Web 端是把这些 append 事件累积渲染的,IM 侧必须做同样的事。
638
- if (this._stream && (this._stream.to === peerId || this._stream.warm)) {
639
- // issue #77 ③:预热卡"转正"。预热/正文的目标解析同源(resolveSendTarget),
640
- // 正常情况下 to === peerId;这里放宽到 warm 是防御性兼容(例如评论/话题场景
641
- // 的目标字段差异),避免把刚建好的状态卡收掉、正文退化成另一条普通消息。
642
- // 渲染/收尾都使用 _stream.to,不受入参 peerId 影响。
643
- this._stream.warm = false
644
- // 合并规则要同时兼容宿主的两种出站形态(都已实测/有用例覆盖):
645
- // 1) **片段**(实测主流):每个 step 一条 assistant/message,只含该步文本 → 需追加;
646
- // 2) **已累积全文**:content 以已渲染内容开头(调用方自己累积过)→ 直接替换,避免重复。
647
- // 若只做追加,形态 2 会渲染成"正在思考...\n\n正在思考...\n已完成…"这种重复。
648
- // 基于**已渲染**内容做合并(不是 text:打字机可能仍在推进,text 是完整目标)。
649
- const prev = this._stream.rendered ?? ''
650
- const merged = !prev ? content
651
- : content.startsWith(prev) ? content
652
- : `${prev}\n\n${content}`
653
- // text / rendered 同步推进:后续片段合并以 rendered 为准
654
- this._stream.text = merged
655
- this._stream.rendered = merged
656
- await this._renderStream(peerId, merged)
657
- return
658
- }
659
- // 目标会话变化(或上一轮残留):先收尾旧卡片
660
- await this._finishStream()
661
-
662
- const stream = await this.gateway.openStream(peerId, replyOpts)
663
- // `text` = 本轮的**完整目标内容**;`rendered` = **已真正写进卡片的部分**。
664
- // 两者必须分开:首片打字机是"逐片推进"的,若把 text(全文)当成已渲染内容,
665
- // 并发的状态更新会直接把卡片跳到全文,紧接着打字机又把内容**倒回**前面的片
666
- // → 用户看到"反复覆盖、中间内容消失"(2026-10-16 真机反馈)。
667
- this._stream = {
668
- to: peerId,
669
- streamRef: stream,
670
- setContent: stream.setContent,
671
- finish: stream.finish,
672
- text: content,
673
- rendered: '',
674
- }
675
-
676
- // **不再在应用层做打字机** —— 飞书流式卡片自带逐字打印动画。
677
- //
678
- // 真凶(2026-10-17 定位,之前多轮都改错了地方):SDK 建卡时固定开启飞书原生流式动画:
679
- // config: { streaming_mode: true,
680
- // streaming_config: { print_frequency_ms: 70, print_step: 1, print_strategy: 'fast' } }
681
- // 即**每 70ms 逐字打印**。而本插件此前在应用层又切一次片、每片 sleep 后再 setContent,
682
- // 每次 setContent 更新都会让飞书**对整段新内容从头重播逐字动画** →
683
- // 用户看到的就是"反复覆盖、重复打字",且越长越慢(旧实现在 1954 字上切 33 片 = 33 次重播)。
684
- // 结论:这里的应用层打字机是**纯多余**,删掉后由飞书原生动画负责观感,
685
- // 我们只需在拿到新内容时**一次性写入完整累积文本**。
686
- if (this._stream?.streamRef !== stream) return
687
- this._stream.rendered = content
688
- await stream.setContent(this._withStatus(content))
689
- }
690
-
691
- /**
692
- * 把"当前活动"状态行拼到正文前面(一并在流式卡片里更新,不额外发消息)。
693
- *
694
- * 为什么放卡片里而不是单发一条:工具调用很密集,单发会把会话刷爆;卡片原地更新
695
- * 既能实时反映"agent 在干什么",又不留垃圾消息(轮次结束收尾时状态行自动消失)。
696
- *
697
- * **样式上刻意区别于正文**(用户提问:"这种带状态的要不在字体上区分于正文?"):
698
- * 用飞书原生支持的**引用块 + 斜体**(官方文档 official-message-content.md:
699
- * 「引用 | `> quote` | 支持引用块语法」,斜体 `_text_` 亦支持),渲染成引用条 + 斜体,
700
- * 一眼可辨"这是过程状态、不是正文"。引用块后接正文**必须** `\n\n` 分隔(官方硬性要求)。
701
- *
702
- * **转义**:detail 来自工具参数(命令原文、文件名…)可能含 markdown 元字符
703
- * (`*` `_` `` ` `` `[]` …)。不转义时 `echo *bold*` 会让外层斜体提前闭合、
704
- * 甚至把后续正文一起吞进样式里;故只对**受控文案**加样式、detail 一律转义。
705
- */
706
- _withStatus(text) {
707
- const status = this._activityLine
708
- if (!status) return text
709
- const styled = `> _${status}_`
710
- return text ? `${styled}\n\n${text}` : styled
711
- }
712
-
713
- /** 写入流式卡片(自动带上当前状态行)。 */
714
- async _renderStream(peerId, text) {
715
- if (!this._stream) return
716
- await this._stream.setContent(this._withStatus(text))
717
- }
718
-
719
- /**
720
- * 用**指定**的状态行渲染文本(不读 this._activityLine)。
721
- * 供预热卡竞态收尾使用:此时状态可能已被清空,但仍要把快照写进卡片,避免空内容卡片。
722
- */
723
- _withStatusText(status, text) {
724
- if (!status) return text
725
- const styled = `> _${status}_`
726
- return text ? `${styled}\n\n${text}` : styled
727
- }
728
-
729
- /**
730
- * 更新"当前活动"状态(由会话桥在 tool/call 时调用)。
731
- *
732
- * @param {string} line 形如 `⌨️ 正在运行命令(npm test) · 第 3 轮`;空串表示清除
733
- * @param {object} [_opts]
734
- * @param {{icon:string,text:string,detail:string,suffix:string}} [parts]
735
- * 结构化形式。给了就按它**安全地**重新组装(detail 转义),
736
- * 避免用户可控的命令原文破坏状态行的斜体/引用样式。
737
- */
738
- async setActivityStatus(line, opts, parts) {
739
- this._activityLine = parts
740
- ? this._styleActivityParts(parts)
741
- : (line || '')
742
- // **必须渲染 `rendered`(已真正写进卡片的部分),不能用 `text`(本轮完整目标)**:
743
- // 首片打字机仍在逐片推进时,用 text 会把卡片直接跳到全文,紧接着打字机又把内容
744
- // 倒回更小的片 → 用户看到"反复覆盖、中间内容消失"(2026-10-16 真机反馈)。
745
- if (this._stream && typeof this._stream.rendered === 'string') {
746
- try {
747
- await this._renderStream(this._stream.to, this._stream.rendered)
748
- } catch (err) {
749
- this.logger?.debug?.('[dsh-bridge feishu] 状态行刷新失败:%s', err?.message ?? err)
750
- }
751
- return
752
- }
753
- // issue #77 ②:轮次内、还没有卡片时,先建一张只含状态行的「预热卡」。
754
- // 旧实现假设"tool/call 与正文总是同一时刻到达",但在 DSH 0.2.1-rc.2 上长任务
755
- // 正文往往最后才出:整段时间 _stream 为 null,状态行只存在变量里、无处渲染,
756
- // 用户全程看不到"正在运行命令"。这里先给状态行一个载体;正文到达时直接复用
757
- // 这张卡(见 _sendTurnOutput),整轮结束仍无正文则由 _finishStream 收掉空卡。
758
- if (!this._inTurn || !this._activityLine) {
759
- // 状态被清空 / 不在轮次内:撤掉尚未建出的预热卡
760
- this._cancelWarmStream()
761
- return
762
- }
763
- await this._scheduleWarmStream(opts)
764
- }
765
-
766
- /** 取消尚未执行的预热建卡(定时器),并唤醒等待中的调度者。 */
767
- _cancelWarmStream() {
768
- if (this._warmTimer) {
769
- clearTimeout(this._warmTimer)
770
- this._warmTimer = null
771
- }
772
- // 必须唤醒 await 中的 _scheduleWarmStream:clearTimeout 只是不执行回调,
773
- // 不会 resolve 那个 Promise(曾导致 setActivityStatus 永久挂起、测试超时)。
774
- if (this._warmCancel) {
775
- const cancel = this._warmCancel
776
- this._warmCancel = null
777
- cancel()
778
- }
779
- }
780
-
781
- /**
782
- * 调度预热建卡:先等一个短窗口,期间正文到达/轮次结束就取消(见 WARM_CARD_DELAY_MS)。
783
- * 窗口过后状态仍在,才真正建卡。
784
- */
785
- async _scheduleWarmStream(opts = {}) {
786
- const delay = Number.isFinite(this.config?.warmCardDelayMs)
787
- ? Math.max(0, Number(this.config.warmCardDelayMs))
788
- : WARM_CARD_DELAY_MS
789
- if (delay <= 0) return this._openWarmStream(opts)
790
- if (this._warmTimer || this._warmOpening) return
791
- const fired = await new Promise((resolve) => {
792
- let settled = false
793
- this._warmTimer = setTimeout(() => {
794
- this._warmTimer = null
795
- this._warmCancel = null
796
- settled = true
797
- resolve(true)
798
- }, delay)
799
- if (typeof this._warmTimer.unref === 'function') this._warmTimer.unref()
800
- // 供 _cancelWarmStream 唤醒:返回 false 表示被取消(不建卡)
801
- this._warmCancel = () => { if (!settled) { settled = true; resolve(false) } }
802
- })
803
- if (!fired) return
804
- // 定时器等待期间可能已经被正文/轮次结束取消或已无需建卡,交给 _openWarmStream 再判一次
805
- return this._openWarmStream(opts)
806
- }
807
-
808
- /**
809
- * 建一张只含状态行的预热卡(issue #77 ②)。
810
- *
811
- * 三个必须处理的竞态:
812
- * 1) 并发调用:`_warmOpening` 闸门合并,避免同一时刻建出两张卡;
813
- * 2) 等待建卡期间正文已建卡:这张预热卡多余,直接 finish 掉(不留空卡/双卡);
814
- * 3) 等待建卡期间轮次结束或状态被清空:同样收掉,不留下 "(no content)" 卡片。
815
- * 建卡目标统一走 resolveSendTarget(与正文/心跳同源,issue #77 ③)。
816
- */
817
- async _openWarmStream(opts = {}) {
818
- if (this._warmOpening || this._stream) return
819
- const peer = this.resolveSendTarget(opts)
820
- // 评论会话没有卡片/流式能力,跳过;解析不到合法目标(如只有 open_id)也跳过
821
- if (!peer || isCommentPeerId(peer)) return
822
- // 建卡前抓一份状态快照:万一 openStream 网络往返期间轮次结束、状态被清空,
823
- // 也要把这份快照写进卡片再收尾——**绝不留下从未写入内容的 "(no content)" 卡片**
824
- // (2026-10-17 真机事故,见 test/activity-status.test.mjs 的孤儿空卡用例)。
825
- const statusAtOpen = this._activityLine
826
- this._warmOpening = true
827
- try {
828
- const stream = await this.gateway.openStream(peer, this._replyOptions(opts?.outboundPeer || this._lastPeer))
829
- if (this._stream) {
830
- // 竞态 2:正文已建卡 → 预热卡多余。先写入快照(非空),再收掉
831
- try { await stream.setContent(this._withStatusText(statusAtOpen, '')) } catch { /* 写入失败也要收尾 */ }
832
- try { await stream.finish() } catch { /* 收尾失败不影响正文卡 */ }
833
- return
834
- }
835
- if (!this._inTurn || !this._activityLine) {
836
- // 竞态 3:轮次已结束/状态已清空 → 写入快照后收尾,不留空卡
837
- try { await stream.setContent(this._withStatusText(statusAtOpen, '')) } catch { /* 同上 */ }
838
- try { await stream.finish() } catch { /* 同上 */ }
839
- return
840
- }
841
- this._stream = {
842
- to: peer,
843
- streamRef: stream,
844
- setContent: stream.setContent,
845
- finish: stream.finish,
846
- text: '',
847
- rendered: '',
848
- warm: true,
849
- // 快照随控制器保存:首写失败或收尾时若仍无内容,用它补写,避免 "(no content)" 卡片
850
- warmStatus: statusAtOpen,
851
- }
852
- try {
853
- await this._renderStream(peer, '')
854
- } catch (err) {
855
- // 首写失败(网络抖动):卡片已经建出但零写入。立刻清掉 _stream(否则后续
856
- // _finishStream 的 warm 分支会直接 finish 出零内容卡片),并用快照补写后收尾。
857
- this._stream = null
858
- this.logger?.warn?.('[dsh-bridge feishu] 预热状态卡首写失败,改用快照补写后收尾:%s', err?.message ?? err)
859
- try { await stream.setContent(this._withStatusText(statusAtOpen, '')) } catch { /* 补写失败只能收尾 */ }
860
- try { await stream.finish() } catch { /* 收尾失败不影响正文 */ }
861
- }
862
- } catch (err) {
863
- // 建卡失败不致命:正文到达时会重新建卡;这里只记debug,不刷告警
864
- this.logger?.debug?.('[dsh-bridge feishu] 预热状态卡创建失败:%s', err?.message ?? err)
865
- } finally {
866
- this._warmOpening = false
867
- }
868
- }
869
-
870
- /**
871
- * 用结构化片段组装状态行:受控文案(图标/动作)保持原样,detail 转义。
872
- * @param {{icon:string,text:string,detail:string,suffix:string}} parts
873
- */
874
- _styleActivityParts(parts) {
875
- const icon = String(parts?.icon ?? '')
876
- const text = String(parts?.text ?? '')
877
- const suffix = String(parts?.suffix ?? '')
878
- const detail = parts?.detail ? escapeMarkdown(parts.detail) : ''
879
- const head = detail ? `${icon} ${text}(${detail})` : `${icon} ${text}`
880
- return `${head}${suffix}`
881
- }
882
-
883
- /** 收尾当前流式卡片(幂等):关闭 SDK 的 streaming_mode,落定最终内容 */
884
- async _finishStream() {
885
- this._cancelWarmStream()
886
- const current = this._stream
887
- this._stream = null
888
- if (!current) return
889
- // 收尾前把状态行**去掉**并重渲染一次:轮次结束还挂着「正在运行命令(…)」会让用户
890
- // 以为任务仍在进行(实测:只清空 _activityLine 而不重渲染,卡片会保留最后一次
891
- // 带状态的版本)。
892
- // 用 `text`(本轮**完整目标**)而非 `rendered`(打字机可能只推进到中途):
893
- // 收尾是终点,必须保证卡片是完整正文;用 rendered 会把内容截断在中间。
894
- this._activityLine = ''
895
- const finalText = current.text ?? current.rendered
896
- // 预热卡片但本轮**始终没有正文**(例如被中断、或只有工具调用没有回复):
897
- // 不留下"只有状态行"的空卡片,直接收尾掉,避免会话里残留无意义内容。
898
- if (current.warm && !finalText) {
899
- // 零写入兜底:预热卡若因任何原因从未写入内容,用建卡时保存的状态快照补一次,
900
- // 再收尾。否则飞书会显示 "(no content)" 空卡(2026-10-17 真机事故)。
901
- if (!current.rendered && current.warmStatus) {
902
- try { await current.setContent(this._withStatusText(current.warmStatus, '')) } catch { /* 补写失败仍收尾 */ }
903
- }
904
- try { await current.finish() } catch (err) {
905
- this.logger.debug?.('[dsh-bridge feishu] stream finish failed: %s', err?.message ?? err)
906
- }
907
- return
908
- }
909
- try {
910
- if (finalText !== undefined) await current.setContent(finalText)
911
- } catch { /* 最终渲染失败不影响收尾 */ }
912
- try {
913
- await current.finish()
914
- } catch (err) {
915
- this.logger.debug?.('[dsh-bridge feishu] stream finish failed: %s', err?.message ?? err)
916
- }
917
- }
918
-
919
- /**
920
- * 审批卡片(CardKit JSON 2.0)。
921
- * - 待决:详情表 + 「批准 / 拒绝」按钮;
922
- * - 已决议:附加"决议结果"行、去掉按钮、表头换成结果配色。
923
- */
924
- _buildApprovalCard({ number, toolName, reason, timeoutMin, initiator = '', resolution = null }) {
925
- const meta = resolution ? (APPROVAL_RESOLUTIONS[resolution.status] ?? APPROVAL_RESOLUTIONS.rejected) : null
926
- const rows = [
927
- '| 项目 | 详情 |',
928
- '| :--- | :--- |',
929
- `| **调用工具** | \`${toolName}\` |`,
930
- ...(initiator ? [`| **发起人** | ${String(initiator).replace(/\|/g, '|')} |`] : []),
931
- ...(reason ? [`| **申请原因** | ${String(reason).replace(/\|/g, '|')} |`] : []),
932
- `| **等待超时** | ${timeoutMin} 分钟${resolution ? '' : '(超时自动拒绝)'} |`,
933
- ]
934
- if (meta) {
935
- const operator = resolution.operatorName ? ` · ${resolution.operatorName}` : ''
936
- rows.push('', `| **决议结果** | ${meta.emoji} ${meta.text}${operator} |`, '', '本次审批已结束')
937
- }
938
-
939
- // 注意:卡片 JSON 2.0(CardKit)**不支持 note 元素**——曾用它写"已结束",
940
- // 服务端整卡更新返回 200861 unsupported tag note,导致结果态更新失败、卡片停在待办态。
941
- // 因此结果提示统一并入 markdown 元素。
942
- const elements = [{ tag: 'markdown', content: rows.join('\n') }]
943
- if (!meta) {
944
- elements.push({
945
- tag: 'column_set',
946
- flex_mode: 'flow',
947
- columns: [
948
- {
949
- tag: 'column',
950
- width: 'auto',
951
- elements: [{
952
- tag: 'button',
953
- text: { tag: 'plain_text', content: '✓ 批准执行' },
954
- type: 'primary',
955
- value: { action: 'approve', approvalId: number },
956
- }],
957
- },
958
- {
959
- tag: 'column',
960
- width: 'auto',
961
- elements: [{
962
- tag: 'button',
963
- text: { tag: 'plain_text', content: '✕ 拒绝执行' },
964
- type: 'danger',
965
- value: { action: 'reject', approvalId: number },
966
- }],
967
- },
968
- ],
969
- })
970
- }
971
-
972
- return {
973
- schema: '2.0',
974
- header: {
975
- title: {
976
- tag: 'plain_text',
977
- content: `${meta ? meta.emoji : '⚠️'} 操作权限确认 (#${number})`,
978
- },
979
- template: meta ? meta.template : 'orange',
980
- },
981
- body: { elements },
982
- }
983
- }
984
-
985
- // ---- 飞书专属卡片审批桥 ----
986
- //
987
- // 与基类(conversation-bridge)同一归属模型,仅卡片渲染不同:
988
- // - 必须 { prepend: true } 注册:否则宿主 apiproxy 的 GUI 认领监听器先行否决,
989
- // 飞书卡片永远发不出(用户实测:飞书发起审批收不到卡片、Web 反而弹窗);
990
- // - 只拦截"本轮由飞书发起"的轮次(_turnPeers 门槛),Web 轮次放行给宿主;
991
- // - 不调用 next():IM 发起的审批只在 IM 决议,避免 Web 弹窗在 IM 决议后残留。
992
-
993
- _attachApprovalBridge() {
994
- const listener = async (req, next) => {
995
- const sessionId = req.agent?.session?.id
996
- const turn = sessionId ? this._turnPeers.get(sessionId) : null
997
- if (!turn || !this.ownsAgent(req.agent)) {
998
- this.logger?.info?.('[dsh-bridge feishu] approval falls through to GUI: not a feishu-initiated turn (session=%s)', sessionId ?? '(none)')
999
- return next?.()
1000
- }
1001
- const peer = turn.outboundPeer
1002
- if (!peer?.peerId) return next?.()
1003
- if (peer.comment) {
1004
- // 评论会话没有可点的卡片:本轮审批交回宿主 GUI(用户可从远程面板处理)
1005
- this.logger?.info?.('[dsh-bridge feishu] 评论发起的轮次:审批交回宿主 GUI (session=%s)', sessionId ?? '(none)')
1006
- return next?.()
1007
- }
1008
- const initiator = turn.senderId
1009
- const sendOpts = { outboundPeer: peer }
1010
-
1011
- const number = this.nextApprovalNumber()
1012
- const timeoutSec = this.config.approvalTimeoutSec || 600
1013
- const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
1014
- const cardContext = {
1015
- number, toolName: req.toolName, reason: req.reason, timeoutMin,
1016
- initiator: turn.senderLabel || '',
1017
- }
1018
-
1019
- // 优先创建 CardKit 卡片实体:决议后可**原地整卡更新**(内联卡片消息做不到),
1020
- // 无需再补一条"已批准/已拒绝"的文字消息;创建失败则退回内联卡片 + 文字兜底。
1021
- let cardId = null
1022
- try {
1023
- cardId = await this.gateway.createCard(this._buildApprovalCard(cardContext))
1024
- } catch (err) {
1025
- this.logger?.debug?.('[dsh-bridge feishu] 卡片实体创建失败,退回内联卡片:%s', err?.message ?? err)
1026
- }
1027
-
1028
- // 先收尾本轮已有的流式卡片:否则用户在"审批卡之后"看到的续写会跑到**上方**那张旧卡片里
1029
- // (卡片顺序:旧流式卡 → 审批卡 → 新流式卡,符合阅读顺序)
1030
- await this._finishStream()
1031
-
1032
- let cardDelivered = false
1033
- try {
1034
- const cardOpts = this._replyOptions(peer)
1035
- if (cardId) await this.gateway.sendCardRef(peer.peerId, cardId, cardOpts)
1036
- else await this.gateway.sendCard(peer.peerId, this._buildApprovalCard(cardContext), cardOpts)
1037
- cardDelivered = true
1038
- } catch (err) {
1039
- this.logger?.warn?.('[dsh-bridge feishu] approval card failed (%s), fallback to markdown', err?.message ?? err)
1040
- }
1041
-
1042
- if (!cardDelivered) {
1043
- const textFallback = [
1044
- `## ⚠️ 操作权限确认 (#${number})`,
1045
- '',
1046
- `| 项目 | 详情 |`,
1047
- `| :--- | :--- |`,
1048
- `| **调用工具** | \`${req.toolName}\` |`,
1049
- ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
1050
- `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
1051
- '',
1052
- `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
1053
- `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
1054
- ].join('\n')
1055
- void this.sendText(textFallback, sendOpts)
1056
- }
1057
-
1058
- // sequence 必须严格递增(飞书拒绝乱序更新)
1059
- let cardSequence = 1
1060
- const updateApprovalCard = async (status, operatorName = '') => {
1061
- if (!cardId) return false
1062
- try {
1063
- await this.gateway.updateCardById(
1064
- cardId,
1065
- this._buildApprovalCard({ ...cardContext, resolution: { status, operatorName } }),
1066
- cardSequence++,
1067
- )
1068
- return true
1069
- } catch (err) {
1070
- this.logger?.warn?.('[dsh-bridge feishu] 审批卡片更新失败:%s', err?.message ?? err)
1071
- return false
1072
- }
1073
- }
1074
-
1075
- let settled = false
1076
- let settledBy = null // 'timeout' | 'abort' | null(用户决议)
1077
- let resolveIm
1078
- const imPromise = new Promise((resolve) => { resolveIm = resolve })
1079
- const settleIm = (outcome) => {
1080
- if (settled) return
1081
- settled = true
1082
- this.clearApproval(number)
1083
- resolveIm(outcome)
1084
- }
1085
-
1086
- const timer = setTimeout(() => {
1087
- settledBy = 'timeout'
1088
- settleIm('rejected')
1089
- }, timeoutSec * 1000)
1090
- if (typeof timer.unref === 'function') timer.unref()
1091
-
1092
- // turn 被停止时 DSH 会 abort req.signal:同步取消飞书侧待决审批
1093
- const onSignalAbort = () => {
1094
- settledBy = 'abort'
1095
- settleIm('cancelled')
1096
- }
1097
- req.signal?.addEventListener('abort', onSignalAbort, { once: true })
1098
-
1099
- // peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
1100
- // 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
1101
- this.registerApproval(number, {
1102
- number, request: req, resolve: resolveIm, timer,
1103
- peerId: initiator,
1104
- isGroup: Boolean(peer.isGroup),
1105
- })
1106
-
1107
- let outcome
1108
- try {
1109
- outcome = await imPromise
1110
- } finally {
1111
- req.signal?.removeEventListener('abort', onSignalAbort)
1112
- clearTimeout(timer)
1113
- settled = true
1114
- }
1115
-
1116
- this.logger?.info?.('[dsh-bridge feishu] approval #%d resolved: outcome=%s', number, outcome)
1117
-
1118
- if (settledBy === 'timeout') {
1119
- // 卡片直接变成"已超时",不再让按钮看起来还能点
1120
- await updateApprovalCard('timeout')
1121
- } else if (settledBy === 'abort') {
1122
- await updateApprovalCard('cancelled')
1123
- } else {
1124
- const status = outcome === 'allowed-once' ? 'approved' : 'rejected'
1125
- const updated = await updateApprovalCard(status, this._approvalOperatorName)
1126
- // 卡片不可更新(内联卡片 / 更新失败)时,保留原有的文字确认
1127
- if (!updated) {
1128
- const label = outcome === 'allowed-once' ? `✓ **已批准执行**` : outcome === 'rejected' ? `❌ **已拒绝执行**` : `**[${outcome}]**`
1129
- void this.sendText(`${label}(#${number})`, sendOpts)
1130
- }
1131
- }
1132
- return outcome
1133
- }
1134
-
1135
- const disposer = this.ctx.on('approval/request', listener, { prepend: true })
1136
- this.disposers.push(disposer)
1137
- }
1138
-
1139
- dispose() {
1140
- this._cancelWarmStream()
1141
- void this._flushCommentBuffers()
1142
- this._commentPeers.clear()
1143
- this._inTurn = false
1144
- void this._finishStream()
1145
- this._lastPeer = null
1146
- super.dispose()
1147
- }
1148
- }
1149
-
1150
- export const feishuNodeHelpers = {
1151
- makePlatform,
1152
- }