@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/gateway.js
DELETED
|
@@ -1,644 +0,0 @@
|
|
|
1
|
-
// dsh-bridge 飞书 / Lark 网关(官方 Channel SDK 适配层)
|
|
2
|
-
//
|
|
3
|
-
// 飞书侧的协议细节全部交给官方 @larksuite/channel(Channel SDK)实现:
|
|
4
|
-
// - 连接:WebSocket 长连接、自动重连、心跳、保活看门狗、连接状态快照
|
|
5
|
-
// - 入站:事件验签解密、事件归一化(NormalizedMessage)、去重、过期丢弃、
|
|
6
|
-
// 按会话串行、群聊 @ 提及策略、bot 回环防护
|
|
7
|
-
// - 出站:markdown → 飞书富文本渲染、超长内容按代码块安全分片、限流重试、
|
|
8
|
-
// 格式错误自动降级纯文本、流式打字机卡片(CardKit 原生 streaming_mode)
|
|
9
|
-
// - 媒体:图片/文件上传(含 SSRF 防护)与消息资源流式下载
|
|
10
|
-
//
|
|
11
|
-
// 本文件只做「会话桥 ↔ SDK」之间的映射,不自行实现任何协议逻辑。
|
|
12
|
-
// 参考文档(本地留档):docs/dev/lark-sdk/
|
|
13
|
-
|
|
14
|
-
import fs from 'node:fs/promises'
|
|
15
|
-
import path from 'node:path'
|
|
16
|
-
import { Service } from '@deepseek-ai/cordis'
|
|
17
|
-
import { createLarkChannel, registerApp, Domain, LoggerLevel } from './lark-bundled.mjs'
|
|
18
|
-
import { syncSlashCommands, isPermissionErrorCode } from './slash-commands.js'
|
|
19
|
-
import { normalizeFeishuMarkdown, hasMarkdownTable, splitMarkdownForCards, buildMarkdownCard } from './markdown.js'
|
|
20
|
-
import { isCommentPeerId, extractCommentInput } from './comment.js'
|
|
21
|
-
|
|
22
|
-
/** 图片扩展名(按扩展名选择发送为图片消息,其余走文件消息) */
|
|
23
|
-
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'])
|
|
24
|
-
|
|
25
|
-
/** 流式卡片首屏占位文案(SDK 在建卡时写入,随后被首片内容覆盖) */
|
|
26
|
-
const STREAM_INITIAL_TEXT = '正在思考…'
|
|
27
|
-
|
|
28
|
-
export class FeishuGateway extends Service {
|
|
29
|
-
static name = 'feishu'
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @param {object} ctx Cordis 上下文
|
|
33
|
-
* @param {object} [config] { appId, appSecret, domain, maxMessageChars, sendChunkDelayMs }
|
|
34
|
-
*/
|
|
35
|
-
constructor(ctx, config = {}) {
|
|
36
|
-
// issue #78:instanceId 用于事件隔离;serviceName 是唯一 cordis 服务名
|
|
37
|
-
// (D2:cordis 服务名不随 dispose 释放,重复创建必须换名)
|
|
38
|
-
const instanceId = config.instanceId || 'feishu'
|
|
39
|
-
super(ctx, config.serviceName || instanceId, true)
|
|
40
|
-
this.ctx = ctx
|
|
41
|
-
this.instanceId = instanceId
|
|
42
|
-
this.logger = ctx.logger?.('feishu') ?? console
|
|
43
|
-
this.config = {
|
|
44
|
-
appId: '',
|
|
45
|
-
appSecret: '',
|
|
46
|
-
domain: 'feishu',
|
|
47
|
-
// 会话工作目录:用于 outbound.allowedFileDirs(本地文件走 SDK 流式上传,不必读进内存)
|
|
48
|
-
cwd: '',
|
|
49
|
-
// 出站渲染参数:分片上限与流式节流(映射到 Channel SDK 的 outbound 配置)
|
|
50
|
-
maxMessageChars: 2000,
|
|
51
|
-
// 已创建的斜杠指令映射 { command: command_id },用于更新/清理(只动自己创建的)
|
|
52
|
-
slashCommandIds: {},
|
|
53
|
-
...config,
|
|
54
|
-
}
|
|
55
|
-
this.status = 'idle' // idle | starting | online | reconnecting | error | offline
|
|
56
|
-
this.error = null
|
|
57
|
-
this.botInfo = null
|
|
58
|
-
/** @type {import('./lark-bundled.mjs').LarkChannel|null} 当前 SDK 通道实例 */
|
|
59
|
-
this.channel = null
|
|
60
|
-
this._closing = false
|
|
61
|
-
this._startingPromise = null
|
|
62
|
-
this._unsubscribes = []
|
|
63
|
-
/** tenant_access_token 缓存(斜杠指令同步用;提前 3 分钟过期) */
|
|
64
|
-
this._tenantToken = null
|
|
65
|
-
/** 应用缺少斜杠指令权限时置位,本次连接内不再重试(避免刷日志) */
|
|
66
|
-
this._slashCommandsDisabled = false
|
|
67
|
-
/** 由 FeishuService 注入:把 { command: command_id } 映射持久化 */
|
|
68
|
-
this.onSlashCommandIds = null
|
|
69
|
-
/** 评论会话注册表:peerId → { target, commentId, isWhole }(评论回复出站用,带上限) */
|
|
70
|
-
this._commentPeers = new Map()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
get configured() {
|
|
74
|
-
return Boolean(this.config.appId && this.config.appSecret)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
setStatus(status, error = null) {
|
|
78
|
-
this.status = status
|
|
79
|
-
this.error = error ? String(error?.message || error) : null
|
|
80
|
-
this.ctx.emit('feishu/status', {
|
|
81
|
-
instanceId: this.instanceId,
|
|
82
|
-
status: this.status,
|
|
83
|
-
error: this.error,
|
|
84
|
-
botInfo: this.botInfo,
|
|
85
|
-
configured: this.configured,
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
updateConfig(cfg = {}) {
|
|
90
|
-
this.config = { ...this.config, ...cfg }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** 别名:外部(备份导入 / 恢复)以 setCredentials 调用 */
|
|
94
|
-
setCredentials(cfg = {}) {
|
|
95
|
-
this.updateConfig(cfg)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ---- SDK 通道工厂(测试注入点) ----
|
|
99
|
-
|
|
100
|
-
_createChannel(options) {
|
|
101
|
-
return createLarkChannel(options)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
_registerApp(options) {
|
|
105
|
-
return registerApp(options)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
_requireChannel() {
|
|
109
|
-
if (!this.channel) {
|
|
110
|
-
throw new Error('飞书通道未启动:请先配置应用凭证并连接')
|
|
111
|
-
}
|
|
112
|
-
return this.channel
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* SDK 日志器适配:SDK 的 LoggerProxy 会按级别调用 error/warn/info/debug/trace,
|
|
117
|
-
* 缺哪个方法就会抛错,因此这里补齐五个方法并统一转发到插件日志器。
|
|
118
|
-
*/
|
|
119
|
-
_sdkLogger() {
|
|
120
|
-
return {
|
|
121
|
-
error: (...args) => this.logger?.error?.(...args),
|
|
122
|
-
warn: (...args) => this.logger?.warn?.(...args),
|
|
123
|
-
info: (...args) => this.logger?.info?.(...args),
|
|
124
|
-
debug: (...args) => this.logger?.debug?.(...args),
|
|
125
|
-
trace: () => {},
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** 路径是否落在 allowedFileDirs 内(用于决定"交给 SDK 读盘"还是"自己读 Buffer") */
|
|
130
|
-
_isInsideAllowedDirs(filePath) {
|
|
131
|
-
const resolved = path.resolve(filePath)
|
|
132
|
-
return this._allowedFileDirs().some((dir) => {
|
|
133
|
-
const base = path.resolve(dir)
|
|
134
|
-
return resolved === base || resolved.startsWith(base.endsWith(path.sep) ? base : base + path.sep)
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** 允许 SDK 直接读盘的目录(本地文件源白名单;不在其中则回退 Buffer) */
|
|
139
|
-
_allowedFileDirs() {
|
|
140
|
-
const dirs = [this.config.cwd, process.cwd()].filter((dir) => typeof dir === 'string' && dir.trim())
|
|
141
|
-
return [...new Set(dirs)]
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** 出站渲染参数 → Channel SDK OutboundConfig */
|
|
145
|
-
_outboundOptions() {
|
|
146
|
-
const chunkLimit = Number(this.config.maxMessageChars)
|
|
147
|
-
return {
|
|
148
|
-
// 超长内容按此上限分片(SDK 负责在代码块边界安全切分)
|
|
149
|
-
textChunkLimit: Number.isFinite(chunkLimit) && chunkLimit >= 200 ? chunkLimit : 2000,
|
|
150
|
-
streamInitialText: STREAM_INITIAL_TEXT,
|
|
151
|
-
// 本地文件源白名单:命中则 SDK 流式读盘上传,避免大文件读进内存
|
|
152
|
-
allowedFileDirs: this._allowedFileDirs(),
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
_channelOptions() {
|
|
157
|
-
return {
|
|
158
|
-
appId: this.config.appId,
|
|
159
|
-
appSecret: this.config.appSecret,
|
|
160
|
-
domain: this.config.domain === 'lark' ? Domain.Lark : Domain.Feishu,
|
|
161
|
-
source: 'dsh-bridge',
|
|
162
|
-
logger: this._sdkLogger(),
|
|
163
|
-
// 只放行 SDK 的告警与错误,避免 info 级噪声(如每次连接握手明细)
|
|
164
|
-
loggerLevel: LoggerLevel.warn,
|
|
165
|
-
policy: {
|
|
166
|
-
// 群聊要求 @ 机器人才响应(SDK 默认值,此处显式声明以免默认值变化)
|
|
167
|
-
requireMention: true,
|
|
168
|
-
// 两个机器人互 @ 会无限往返:启用 SDK 内置滑动窗口守卫;
|
|
169
|
-
// onTrip 用 'reject' 而非默认的 'drop',这样被静默停回时我们能留一条日志
|
|
170
|
-
botLoopGuard: { enabled: true, windowMs: 60_000, maxBotMentions: 5, scope: 'chat', onTrip: 'reject' },
|
|
171
|
-
},
|
|
172
|
-
// 审批卡片与消息共用会话队列会在「消息处理等待卡片点击」时互锁,故卡片动作独立排队
|
|
173
|
-
safety: { chatQueue: { cardActions: 'separate' } },
|
|
174
|
-
outbound: this._outboundOptions(),
|
|
175
|
-
// 填充发送者昵称(每会话一次缓存查询):群聊里让 agent 知道"谁在说话"、审批卡显示发起人
|
|
176
|
-
resolveSenderNames: true,
|
|
177
|
-
// SDK 内置看门狗:连接假死时强制重连,仍失败则回调上报
|
|
178
|
-
keepalive: {
|
|
179
|
-
enabled: true,
|
|
180
|
-
onUnrecoverable: (err) => {
|
|
181
|
-
this.logger.error?.('[dsh-bridge feishu] 连接不可恢复:%s', err?.message ?? err)
|
|
182
|
-
this.setStatus('error', err instanceof Error ? err : new Error(String(err)))
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ---- 生命周期 ----
|
|
189
|
-
|
|
190
|
-
async start() {
|
|
191
|
-
if (!this.configured) {
|
|
192
|
-
this.setStatus('idle')
|
|
193
|
-
return false
|
|
194
|
-
}
|
|
195
|
-
if (this.status === 'online') return true
|
|
196
|
-
if (this._startingPromise) return this._startingPromise
|
|
197
|
-
|
|
198
|
-
this._closing = false
|
|
199
|
-
this.setStatus('starting')
|
|
200
|
-
|
|
201
|
-
this._startingPromise = (async () => {
|
|
202
|
-
// 旧实例(断线重连 / 凭证变更)先释放,避免双连接分流事件
|
|
203
|
-
await this._teardownChannel()
|
|
204
|
-
try {
|
|
205
|
-
const channel = this._createChannel(this._channelOptions())
|
|
206
|
-
this.channel = channel
|
|
207
|
-
this._bindChannelEvents(channel)
|
|
208
|
-
await channel.connect()
|
|
209
|
-
this.botInfo = this._toBotInfo(channel.getBotIdentity())
|
|
210
|
-
this.setStatus('online')
|
|
211
|
-
this.logger.info?.(
|
|
212
|
-
'[dsh-bridge feishu] 已连接飞书开放平台:%s (%s)',
|
|
213
|
-
this.botInfo.appName || '(unknown)',
|
|
214
|
-
this.botInfo.openId || this.config.appId,
|
|
215
|
-
)
|
|
216
|
-
// 斜杠指令面板同步(默认开启,异步执行,失败不影响消息收发)
|
|
217
|
-
void this.syncSlashCommands()
|
|
218
|
-
return true
|
|
219
|
-
} catch (err) {
|
|
220
|
-
this.logger.error?.('[dsh-bridge feishu] 连接失败:%s', err?.message ?? err)
|
|
221
|
-
await this._teardownChannel()
|
|
222
|
-
this.setStatus('error', err)
|
|
223
|
-
return false
|
|
224
|
-
} finally {
|
|
225
|
-
this._startingPromise = null
|
|
226
|
-
}
|
|
227
|
-
})()
|
|
228
|
-
|
|
229
|
-
return this._startingPromise
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
async stop() {
|
|
233
|
-
this._closing = true
|
|
234
|
-
await this._teardownChannel()
|
|
235
|
-
this.botInfo = null
|
|
236
|
-
this.setStatus('offline')
|
|
237
|
-
return true
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
dispose() {
|
|
241
|
-
void this.stop()
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async _teardownChannel() {
|
|
245
|
-
this._tenantToken = null
|
|
246
|
-
this._slashCommandsDisabled = false
|
|
247
|
-
for (const off of this._unsubscribes) {
|
|
248
|
-
try { off?.() } catch { /* 忽略退订异常 */ }
|
|
249
|
-
}
|
|
250
|
-
this._unsubscribes = []
|
|
251
|
-
const channel = this.channel
|
|
252
|
-
this.channel = null
|
|
253
|
-
if (!channel) return
|
|
254
|
-
try {
|
|
255
|
-
await channel.disconnect()
|
|
256
|
-
} catch (err) {
|
|
257
|
-
this.logger.warn?.('[dsh-bridge feishu] disconnect 异常:%s', err?.message ?? err)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** NormalizedMessage / cardAction → 会话桥事件;错误与连接状态变化 → 状态事件 */
|
|
262
|
-
_bindChannelEvents(channel) {
|
|
263
|
-
const offMessage = channel.on('message', (msg) => this._emitInbound(msg))
|
|
264
|
-
const offAction = channel.on('cardAction', (evt) => this._handleCardAction(evt))
|
|
265
|
-
const offError = channel.on('error', (err) => {
|
|
266
|
-
this.logger.error?.('[dsh-bridge feishu] 通道错误 [%s]:%s', err?.code ?? 'unknown', err?.message ?? err)
|
|
267
|
-
})
|
|
268
|
-
// 策略拒绝(未 @、白名单外、bot 回环等)留痕:默认仅在 debug 级,便于用户自查"为什么没响应"
|
|
269
|
-
const offReject = channel.on('reject', (evt) => {
|
|
270
|
-
this.logger.debug?.('[dsh-bridge feishu] 消息被策略拒绝 reason=%s chat=%s sender=%s', evt?.reason, evt?.chatId, evt?.senderId)
|
|
271
|
-
})
|
|
272
|
-
const offComment = channel.on('comment', (evt) => this._emitComment(evt))
|
|
273
|
-
const offReconnecting = channel.on('reconnecting', () => this.setStatus('reconnecting'))
|
|
274
|
-
const offReconnected = channel.on('reconnected', () => this.setStatus('online'))
|
|
275
|
-
this._unsubscribes = [offMessage, offAction, offComment, offError, offReject, offReconnecting, offReconnected]
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
_emitInbound(msg) {
|
|
279
|
-
this.ctx.emit('feishu/message', {
|
|
280
|
-
instanceId: this.instanceId,
|
|
281
|
-
// 出站目标一律使用会话 chat_id(oc_…):飞书已限制按用户 open_id 主动发消息(错误码 230101)
|
|
282
|
-
peerId: msg.chatId,
|
|
283
|
-
chatId: msg.chatId,
|
|
284
|
-
chatType: msg.chatType,
|
|
285
|
-
isGroup: msg.chatType === 'group',
|
|
286
|
-
// 发送者身份(open_id):白名单与审批决议者校验使用
|
|
287
|
-
senderId: msg.senderId,
|
|
288
|
-
senderName: msg.senderName,
|
|
289
|
-
messageId: msg.messageId,
|
|
290
|
-
// 话题群(threadId 非空)出站要留在话题内;replyToMessageId 用于补齐"引用消息"上下文
|
|
291
|
-
threadId: msg.threadId ?? '',
|
|
292
|
-
replyToMessageId: msg.replyToMessageId ?? '',
|
|
293
|
-
text: msg.content,
|
|
294
|
-
rawContentType: msg.rawContentType,
|
|
295
|
-
resources: msg.resources ?? [],
|
|
296
|
-
mentionedBot: msg.mentionedBot,
|
|
297
|
-
createTime: msg.createTime,
|
|
298
|
-
})
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** 云文档评论事件 → 会话桥(正文需异步拉取,见 fetchComment) */
|
|
302
|
-
_emitComment(evt) {
|
|
303
|
-
this.ctx.emit('feishu/comment', {
|
|
304
|
-
instanceId: this.instanceId,
|
|
305
|
-
fileToken: evt?.fileToken ?? '',
|
|
306
|
-
fileType: evt?.fileType ?? '',
|
|
307
|
-
commentId: evt?.commentId ?? '',
|
|
308
|
-
replyId: evt?.replyId ?? '',
|
|
309
|
-
operatorId: evt?.operator?.openId ?? '',
|
|
310
|
-
mentionedBot: Boolean(evt?.mentionedBot),
|
|
311
|
-
timestamp: Number(evt?.timestamp) || Date.now(),
|
|
312
|
-
})
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* 拉取评论正文并登记评论会话(供出站回复)。
|
|
317
|
-
* @returns {Promise<{text:string, quote:string, isWhole:boolean, replyId:string}|null>}
|
|
318
|
-
*/
|
|
319
|
-
async fetchComment({ peerId, fileToken, fileType, commentId }) {
|
|
320
|
-
const channel = this._requireChannel()
|
|
321
|
-
const target = await channel.comments.resolveTarget(fileToken, fileType)
|
|
322
|
-
if (!target) {
|
|
323
|
-
this.logger.debug?.('[dsh-bridge feishu] 评论目标不支持:fileType=%s', fileType)
|
|
324
|
-
return null
|
|
325
|
-
}
|
|
326
|
-
const fetched = await channel.comments.fetch(target, commentId)
|
|
327
|
-
if (!fetched) {
|
|
328
|
-
this.logger.debug?.('[dsh-bridge feishu] 评论不存在或不可见:%s', commentId)
|
|
329
|
-
return null
|
|
330
|
-
}
|
|
331
|
-
this._registerCommentPeer(peerId, { target, commentId, isWhole: Boolean(fetched.isWhole) })
|
|
332
|
-
return extractCommentInput(fetched)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
_registerCommentPeer(peerId, entry) {
|
|
336
|
-
if (!peerId) return
|
|
337
|
-
this._commentPeers.set(peerId, entry)
|
|
338
|
-
// 带上限:只保留最近 200 个评论会话(避免长跑内存增长)
|
|
339
|
-
while (this._commentPeers.size > 200) {
|
|
340
|
-
const oldest = this._commentPeers.keys().next().value
|
|
341
|
-
this._commentPeers.delete(oldest)
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** 评论回复(纯文本;整篇文档评论直接发顶层,省掉 SDK 的 in-thread 探测) */
|
|
346
|
-
async sendCommentReply(peerId, text) {
|
|
347
|
-
const entry = this._commentPeers.get(peerId)
|
|
348
|
-
if (!entry) throw new Error('评论会话未登记:请先由评论事件触发')
|
|
349
|
-
const channel = this._requireChannel()
|
|
350
|
-
await channel.comments.reply(entry.target, entry.commentId, text, entry.isWhole ? { topLevel: true } : undefined)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
_handleCardAction(evt) {
|
|
354
|
-
const value = evt?.action?.value ?? {}
|
|
355
|
-
this.ctx.emit('feishu/action', {
|
|
356
|
-
instanceId: this.instanceId,
|
|
357
|
-
operatorId: evt?.operator?.openId ?? '',
|
|
358
|
-
operatorName: evt?.operator?.name ?? '',
|
|
359
|
-
action: value.action ?? evt?.action?.tag,
|
|
360
|
-
value,
|
|
361
|
-
messageId: evt?.messageId,
|
|
362
|
-
chatId: evt?.chatId,
|
|
363
|
-
})
|
|
364
|
-
const actionName = value.action === 'approve' ? '已批准' : value.action === 'reject' ? '已拒绝' : '已操作'
|
|
365
|
-
return { toast: { type: 'success', content: `操作成功:${actionName}` } }
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
_toBotInfo(identity) {
|
|
369
|
-
if (!identity) return null
|
|
370
|
-
return { openId: identity.openId || '', appName: identity.name || '', userId: identity.userId }
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ---- 出站 ----
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* 发送 Markdown 文本(非流式)。
|
|
377
|
-
* @param {string} to 会话 chat_id(oc_…)
|
|
378
|
-
* @param {string} text Markdown 正文
|
|
379
|
-
* @param {object} [opts] SDK SendOptions:{ replyTo, replyInThread }(话题群留在话题内用)
|
|
380
|
-
*/
|
|
381
|
-
async sendText(to, text, opts = {}) {
|
|
382
|
-
const raw = String(text ?? '').trim()
|
|
383
|
-
if (!raw) return null
|
|
384
|
-
// 评论会话:只能发纯文本评论回复(无卡片/无流式),原样发送不做 Markdown 归一化
|
|
385
|
-
if (isCommentPeerId(to)) {
|
|
386
|
-
await this.sendCommentReply(to, raw)
|
|
387
|
-
return null
|
|
388
|
-
}
|
|
389
|
-
const content = normalizeFeishuMarkdown(raw)
|
|
390
|
-
// 含表格的内容改用交互卡片渲染:post(`md` 元素)在部分客户端(实测鸿蒙版飞书)不渲染表格,
|
|
391
|
-
// 卡片走另一套渲染器、且与重构前观感一致;无表格仍走 SDK 的 post 路径(保留其分片/重试/降级)。
|
|
392
|
-
if (!hasMarkdownTable(content)) return this._requireChannel().send(to, { markdown: content }, opts)
|
|
393
|
-
return this._sendMarkdownCards(to, content, opts)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* 以交互卡片发送(含表格的回复):按单条消息上限分片,跨片自动补表头,失败回退富文本。
|
|
398
|
-
*/
|
|
399
|
-
async _sendMarkdownCards(to, content, opts = {}) {
|
|
400
|
-
const channel = this._requireChannel()
|
|
401
|
-
const limit = this._outboundOptions().textChunkLimit
|
|
402
|
-
const chunks = splitMarkdownForCards(content, limit)
|
|
403
|
-
const messageIds = []
|
|
404
|
-
try {
|
|
405
|
-
for (const chunk of chunks) {
|
|
406
|
-
const res = await channel.send(to, { card: buildMarkdownCard(chunk) }, opts)
|
|
407
|
-
if (res?.messageId) messageIds.push(res.messageId)
|
|
408
|
-
}
|
|
409
|
-
return { messageId: messageIds[0], chunkIds: messageIds }
|
|
410
|
-
} catch (err) {
|
|
411
|
-
if (messageIds.length > 0) {
|
|
412
|
-
// 已发出部分分片:不重发整段(避免重复内容),只如实上报
|
|
413
|
-
this.logger.warn?.('[dsh-bridge feishu] 卡片分片发送中断(已发出 %d/%d):%s', messageIds.length, chunks.length, err?.message ?? err)
|
|
414
|
-
return { messageId: messageIds[0], chunkIds: messageIds }
|
|
415
|
-
}
|
|
416
|
-
this.logger.warn?.('[dsh-bridge feishu] 卡片发送失败,回退富文本:%s', err?.message ?? err)
|
|
417
|
-
return channel.send(to, { markdown: content }, opts)
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* 发送飞书原生交互卡片(审批按钮等)。
|
|
423
|
-
* @param {string} to 会话 chat_id(oc_…)
|
|
424
|
-
* @param {object} card CardKit JSON 2.0
|
|
425
|
-
*/
|
|
426
|
-
async sendCard(to, card, opts = {}) {
|
|
427
|
-
if (!card) return null
|
|
428
|
-
return this._requireChannel().send(to, { card }, opts)
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* 发送本地文件/图片。
|
|
433
|
-
*
|
|
434
|
-
* 以 Buffer 作为媒体源:SDK 对「本地路径」源要求构造期配置 outbound.allowedFileDirs,
|
|
435
|
-
* 而 dsh-bridge 的工作区目录在运行期可变(多工作区 / 会话级 cwd),无法事先枚举;
|
|
436
|
-
* Buffer 是 SDK 文档明确支持的等价源(docs/dev/lark-sdk/README.zh.md「媒体 source」)。
|
|
437
|
-
* 路径安全由上层会话桥的 SEND_FILE 白名单校验负责。
|
|
438
|
-
*/
|
|
439
|
-
async sendMediaFile(to, filePath, opts = {}) {
|
|
440
|
-
if (!filePath) return { success: false, error: '缺少文件路径' }
|
|
441
|
-
if (isCommentPeerId(to)) {
|
|
442
|
-
this.logger.warn?.('[dsh-bridge feishu] 评论会话不支持发送文件,已忽略:%s', filePath)
|
|
443
|
-
return { success: false, error: '评论会话不支持发送文件' }
|
|
444
|
-
}
|
|
445
|
-
const fileName = path.basename(filePath)
|
|
446
|
-
const isImage = IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase())
|
|
447
|
-
// 命中 allowedFileDirs 的本地文件直接交给 SDK 读盘(流式,不占内存);否则回退 Buffer
|
|
448
|
-
const inAllowedDir = this._isInsideAllowedDirs(filePath)
|
|
449
|
-
let source
|
|
450
|
-
if (!inAllowedDir) {
|
|
451
|
-
try {
|
|
452
|
-
source = await fs.readFile(filePath)
|
|
453
|
-
} catch (err) {
|
|
454
|
-
const message = err?.message ?? String(err)
|
|
455
|
-
this.logger.warn?.('[dsh-bridge feishu] 读取待发送文件失败 %s:%s', filePath, message)
|
|
456
|
-
return { success: false, error: message }
|
|
457
|
-
}
|
|
458
|
-
} else {
|
|
459
|
-
source = filePath
|
|
460
|
-
}
|
|
461
|
-
try {
|
|
462
|
-
const result = isImage
|
|
463
|
-
? await this._requireChannel().send(to, { image: { source } }, opts)
|
|
464
|
-
: await this._requireChannel().send(to, { file: { source, fileName } }, opts)
|
|
465
|
-
return { success: true, messageId: result?.messageId }
|
|
466
|
-
} catch (err) {
|
|
467
|
-
const message = err?.message ?? String(err)
|
|
468
|
-
this.logger.warn?.('[dsh-bridge feishu] 发送文件失败 %s:%s', fileName, message)
|
|
469
|
-
return { success: false, error: message }
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* 创建 CardKit 卡片实体(`cardkit.v1.card.create`)。
|
|
475
|
-
* 与"内联卡片消息"的区别:实体可被 {@link updateCardById} 原地整卡更新,
|
|
476
|
-
* 因此审批卡片能在决议后直接变成结果态,而不是再补一条文字消息。
|
|
477
|
-
*
|
|
478
|
-
* @param {object} cardJson CardKit JSON 2.0
|
|
479
|
-
* @returns {Promise<string|null>} card_id
|
|
480
|
-
*/
|
|
481
|
-
async createCard(cardJson) {
|
|
482
|
-
const res = await this._requireChannel().createCard(cardJson)
|
|
483
|
-
return res?.cardId ?? null
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** 发送一条"引用卡片实体"的交互消息(消息内容随实体更新而重渲染) */
|
|
487
|
-
async sendCardRef(to, cardId, opts = {}) {
|
|
488
|
-
return this._requireChannel().send(to, { cardId }, opts)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/** 按 card_id 整卡更新;sequence 必须严格递增(飞书会拒绝乱序更新) */
|
|
492
|
-
async updateCardById(cardId, cardJson, sequence) {
|
|
493
|
-
return this._requireChannel().updateCardById(cardId, cardJson, sequence)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* 开启一段流式卡片(一轮 agent 输出对应一张卡片)。
|
|
498
|
-
*
|
|
499
|
-
* SDK 负责建卡、节流、超限自动换卡与收尾(关闭 streaming_mode);
|
|
500
|
-
* 调用方拿到控制器后按需 setContent 覆盖内容,最后 finish() 收尾。
|
|
501
|
-
*
|
|
502
|
-
* @param {string} to 会话 chat_id(oc_…)
|
|
503
|
-
* @returns {Promise<{ setContent: (full: string) => Promise<void>, finish: () => Promise<void> }>}
|
|
504
|
-
*/
|
|
505
|
-
async openStream(to, opts = {}) {
|
|
506
|
-
const channel = this._requireChannel()
|
|
507
|
-
let releaseFinish
|
|
508
|
-
const finishGate = new Promise((resolve) => { releaseFinish = resolve })
|
|
509
|
-
const state = { controller: null }
|
|
510
|
-
let settleReady
|
|
511
|
-
const ready = new Promise((resolve, reject) => { settleReady = { resolve, reject } })
|
|
512
|
-
|
|
513
|
-
const run = channel
|
|
514
|
-
.stream(to, {
|
|
515
|
-
markdown: async (controller) => {
|
|
516
|
-
state.controller = controller
|
|
517
|
-
settleReady.resolve()
|
|
518
|
-
await finishGate
|
|
519
|
-
},
|
|
520
|
-
}, opts)
|
|
521
|
-
.catch((err) => {
|
|
522
|
-
this.logger.warn?.('[dsh-bridge feishu] 流式卡片失败 [%s]:%s', err?.code ?? 'unknown', err?.message ?? err)
|
|
523
|
-
settleReady.reject(err)
|
|
524
|
-
return null
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
// 建卡失败(权限 / 网络)在这里抛出,由调用方降级为普通消息
|
|
528
|
-
await ready
|
|
529
|
-
|
|
530
|
-
let finished = false
|
|
531
|
-
return {
|
|
532
|
-
setContent: async (full) => {
|
|
533
|
-
// 流式卡片同样按飞书 markdown 规则归一化(表格需空行分隔)
|
|
534
|
-
await state.controller.setContent(normalizeFeishuMarkdown(full))
|
|
535
|
-
},
|
|
536
|
-
finish: async () => {
|
|
537
|
-
if (finished) return
|
|
538
|
-
finished = true
|
|
539
|
-
releaseFinish()
|
|
540
|
-
await run
|
|
541
|
-
},
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* 取 tenant_access_token(经 SDK 生成的 auth.v3.tenantAccessToken.internal 获取,本地缓存)。
|
|
547
|
-
* 走 SDK 生成接口而非裸 HTTP,token 获取/刷新规则仍由官方接口定义。
|
|
548
|
-
*/
|
|
549
|
-
async _getTenantToken(client) {
|
|
550
|
-
const now = Date.now()
|
|
551
|
-
if (this._tenantToken && this._tenantToken.expiresAt > now) return this._tenantToken.token
|
|
552
|
-
|
|
553
|
-
const res = await client.auth?.v3?.tenantAccessToken?.internal({
|
|
554
|
-
data: { app_id: this.config.appId, app_secret: this.config.appSecret },
|
|
555
|
-
})
|
|
556
|
-
const body = res?.tenant_access_token ? res : (res?.data ?? {})
|
|
557
|
-
const token = body.tenant_access_token
|
|
558
|
-
if (!token) throw new Error(`获取 tenant_access_token 失败:${body.msg ?? res?.msg ?? 'unknown'}`)
|
|
559
|
-
const expireSec = Number(body.expire) || 7200
|
|
560
|
-
// 提前 3 分钟失效,避免边界过期(官方 token 有效期 2 小时)
|
|
561
|
-
this._tenantToken = { token, expiresAt: now + Math.max(60, expireSec - 180) * 1000 }
|
|
562
|
-
return token
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* 把插件支持的指令注册到飞书输入框的「/」指令面板(Slash Command)。
|
|
567
|
-
*
|
|
568
|
-
* 默认开启、幂等:连接成功后异步执行;缺少权限时只记一条 warn 并停止重试,
|
|
569
|
-
* 不影响消息收发(指令仍可手打,例如 `/help`)。
|
|
570
|
-
*/
|
|
571
|
-
async syncSlashCommands() {
|
|
572
|
-
if (this._slashCommandsDisabled) return { ok: false, error: { message: 'slash command sync disabled' } }
|
|
573
|
-
const channel = this.channel
|
|
574
|
-
// 防御式判断:正常连接后 rawClient 一定存在(Channel SDK 的逃生通道)
|
|
575
|
-
if (!channel?.rawClient) {
|
|
576
|
-
this.logger.debug?.('[dsh-bridge feishu] 跳过斜杠指令同步:通道未就绪')
|
|
577
|
-
return { ok: false, error: { message: '通道未连接' } }
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
let result
|
|
581
|
-
try {
|
|
582
|
-
result = await syncSlashCommands({
|
|
583
|
-
client: channel.rawClient,
|
|
584
|
-
getToken: () => this._getTenantToken(channel.rawClient),
|
|
585
|
-
logger: this.logger,
|
|
586
|
-
managedIds: this.config.slashCommandIds ?? {},
|
|
587
|
-
})
|
|
588
|
-
} catch (err) {
|
|
589
|
-
// 兜底:同步失败绝不能影响消息收发(调用方多为 void 调用)
|
|
590
|
-
this.logger.warn?.('[dsh-bridge feishu] 斜杠指令同步异常:%s', err?.message ?? err)
|
|
591
|
-
return { ok: false, error: { message: err?.message ?? String(err) } }
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// 已创建/更新的映射需要落盘,便于后续更新与清理(含部分成功的情况)
|
|
595
|
-
if (result.ids && JSON.stringify(result.ids) !== JSON.stringify(this.config.slashCommandIds ?? {})) {
|
|
596
|
-
this.config.slashCommandIds = result.ids
|
|
597
|
-
try {
|
|
598
|
-
this.onSlashCommandIds?.(result.ids)
|
|
599
|
-
} catch (err) {
|
|
600
|
-
this.logger.warn?.('[dsh-bridge feishu] 斜杠指令映射持久化失败:%s', err?.message ?? err)
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if (result.ok) {
|
|
605
|
-
this.logger.info?.(
|
|
606
|
-
'[dsh-bridge feishu] 斜杠指令同步完成:新增 %d / 更新 %d / 已存在 %d / 清理 %d(客户端面板最长约 8 分钟后可见)',
|
|
607
|
-
result.created.length, result.updated.length, result.adopted.length, result.deleted.length,
|
|
608
|
-
)
|
|
609
|
-
return result
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
if (isPermissionErrorCode(result.error?.code)) {
|
|
613
|
-
this._slashCommandsDisabled = true
|
|
614
|
-
this.logger.warn?.(
|
|
615
|
-
'[dsh-bridge feishu] 未注册斜杠指令:应用缺少 application:app_slash_command 权限(或未发布)——%s。'
|
|
616
|
-
+ '可在开放平台补权限后重新发布,或使用面板「扫码一键创建应用」(默认已含该权限)。',
|
|
617
|
-
result.error?.message,
|
|
618
|
-
)
|
|
619
|
-
} else {
|
|
620
|
-
this.logger.warn?.('[dsh-bridge feishu] 斜杠指令同步失败:%s', result.error?.message)
|
|
621
|
-
}
|
|
622
|
-
return result
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** 取某条消息(归一化后)——用于补齐用户"引用回复"时的被引用内容 */
|
|
626
|
-
fetchMessage(messageId) {
|
|
627
|
-
return this._requireChannel().fetchMessage(messageId)
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* 下载消息中的资源到本地文件(SDK 以流式落盘,避免大文件堆积在内存)。
|
|
632
|
-
*/
|
|
633
|
-
downloadResourceToFile(messageId, fileKey, type, destPath) {
|
|
634
|
-
return this._requireChannel().downloadResourceToFile(messageId, fileKey, type, destPath)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* 一键扫码创建应用(OAuth 2.0 设备码流程)。
|
|
639
|
-
* 返回 { client_id, client_secret, user_info },由上层持久化后连接。
|
|
640
|
-
*/
|
|
641
|
-
registerApp(options) {
|
|
642
|
-
return this._registerApp(options)
|
|
643
|
-
}
|
|
644
|
-
}
|