@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.
- package/CHANGELOG.md +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/feishu/node.js
DELETED
|
@@ -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 是占位标记(如 ``、`<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
|
-
}
|