dsh-bridge-gateway 0.1.0
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 +250 -0
- package/LICENSE +21 -0
- package/README.en.md +408 -0
- package/README.md +430 -0
- package/client/build.mjs +43 -0
- package/client/client.js +5209 -0
- package/client/index.js +4967 -0
- package/cordis.patch.yml +4 -0
- package/docs/banner.jpg +0 -0
- package/docs/custom-tunnel.md +220 -0
- package/docs/feishu-usage.md +116 -0
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/qq-usage.md +387 -0
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -0
- package/docs/screenshots/mobile-chat.jpg +0 -0
- package/docs/screenshots/mobile-drawer.jpg +0 -0
- package/docs/screenshots/mobile-remote-settings.jpg +0 -0
- package/docs/screenshots/mobile-settings-im.jpg +0 -0
- package/docs/screenshots/mobile-settings-lan.jpg +0 -0
- package/docs/screenshots/mobile-settings-security.jpg +0 -0
- package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
- package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
- package/docs/screenshots/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +93 -0
- package/docs/wechat-usage.md +104 -0
- package/lib/auth/login-template.js +382 -0
- package/lib/auth/manager.js +470 -0
- package/lib/bridge-rpc-constants.js +55 -0
- package/lib/bridge-rpc.js +553 -0
- package/lib/cloudflared-manager.mjs +345 -0
- package/lib/feishu/gateway.js +550 -0
- package/lib/feishu/index.js +222 -0
- package/lib/feishu/lark-bundled.mjs +125547 -0
- package/lib/feishu/node.js +409 -0
- package/lib/gateway/cert.js +214 -0
- package/lib/index.js +1963 -0
- package/lib/platform/base.js +156 -0
- package/lib/platform/conversation-bridge.js +1570 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/manager.js +74 -0
- package/lib/qq/gateway.js +616 -0
- package/lib/qq/index.js +309 -0
- package/lib/qq/node.js +533 -0
- package/lib/security/path-validator.js +208 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/telegram/gateway.js +630 -0
- package/lib/telegram/index.js +213 -0
- package/lib/telegram/node.js +351 -0
- package/lib/tunnel-client.mjs +402 -0
- package/lib/wechat/gateway.js +960 -0
- package/lib/wechat/index.js +241 -0
- package/lib/wechat/media.js +281 -0
- package/lib/wechat/node.js +350 -0
- package/package.json +102 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// dsh-bridge Feishu / Lark conversation node
|
|
2
|
+
// 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
|
|
3
|
+
// ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
8
|
+
|
|
9
|
+
function makePlatform(gateway) {
|
|
10
|
+
return {
|
|
11
|
+
id: 'feishu',
|
|
12
|
+
name: 'Feishu / Lark',
|
|
13
|
+
capabilities: { supportsGroup: true, group: true, media: true, approvals: true },
|
|
14
|
+
async sendText(peerId, text, opts = {}) {
|
|
15
|
+
return gateway?.sendMarkdownCard(peerId, text, opts)
|
|
16
|
+
},
|
|
17
|
+
async sendMediaFile(peerId, filePath, opts = {}) {
|
|
18
|
+
return gateway?.sendMediaFile(peerId, filePath, opts)
|
|
19
|
+
},
|
|
20
|
+
async sendTyping() {},
|
|
21
|
+
dispose() {},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitIntoIncremental(content, maxChunk = 150) {
|
|
30
|
+
const chunks = []
|
|
31
|
+
let start = 0
|
|
32
|
+
while (start < content.length) {
|
|
33
|
+
if (content.length - start <= maxChunk) {
|
|
34
|
+
chunks.push(content.slice(start))
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
const windowStart = start + Math.floor(maxChunk * 0.6)
|
|
38
|
+
const windowEnd = start + maxChunk
|
|
39
|
+
let cut = content.lastIndexOf('\n', windowEnd)
|
|
40
|
+
if (cut <= windowStart || cut === -1) cut = windowEnd
|
|
41
|
+
chunks.push(content.slice(start, cut))
|
|
42
|
+
start = cut
|
|
43
|
+
}
|
|
44
|
+
if (chunks.length <= 1 && content.length > 30) {
|
|
45
|
+
const step = Math.max(20, Math.floor(content.length / 4))
|
|
46
|
+
chunks.length = 0
|
|
47
|
+
for (let i = 0; i < content.length; i += step) {
|
|
48
|
+
chunks.push(content.slice(i, Math.min(i + step, content.length)))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const slices = []
|
|
52
|
+
let acc = ''
|
|
53
|
+
for (const s of chunks) {
|
|
54
|
+
acc += s
|
|
55
|
+
slices.push(acc)
|
|
56
|
+
}
|
|
57
|
+
return slices
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class FeishuConversationNode extends ConversationBridge {
|
|
61
|
+
constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
|
|
62
|
+
super({
|
|
63
|
+
ctx,
|
|
64
|
+
logger,
|
|
65
|
+
config,
|
|
66
|
+
platform: makePlatform(ctx.feishu),
|
|
67
|
+
onFirstSender,
|
|
68
|
+
onActiveSessionChange,
|
|
69
|
+
})
|
|
70
|
+
this.gateway = ctx.feishu
|
|
71
|
+
this._lastPeer = null // { peerId, senderId, isGroup }
|
|
72
|
+
this._streamCardId = null // 当前轮次的流式卡片 message_id
|
|
73
|
+
this._streamContent = '' // 当前轮次流式卡片的内容
|
|
74
|
+
this._patchTimer = null // 节流定时器
|
|
75
|
+
this._inTurn = false
|
|
76
|
+
|
|
77
|
+
// 订阅网关入站消息事件
|
|
78
|
+
this.disposers.push(this.ctx.on('feishu/message', (event) => this._handleInbound(event)))
|
|
79
|
+
|
|
80
|
+
// 订阅卡片交互事件(审批按钮点击)
|
|
81
|
+
this.disposers.push(this.ctx.on('feishu/action', (event) => this._handleAction(event)))
|
|
82
|
+
|
|
83
|
+
// 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
|
|
84
|
+
this.disposers.push(this.ctx.on('session/event', (session, event) => {
|
|
85
|
+
if (session.id !== this.activeSessionId) return
|
|
86
|
+
if (event.type === 'turn/start') {
|
|
87
|
+
this._inTurn = true
|
|
88
|
+
this._streamCardId = null
|
|
89
|
+
this._streamContent = ''
|
|
90
|
+
if (this._patchTimer) {
|
|
91
|
+
clearTimeout(this._patchTimer)
|
|
92
|
+
this._patchTimer = null
|
|
93
|
+
}
|
|
94
|
+
} else if (event.type === 'turn/end') {
|
|
95
|
+
this._inTurn = false
|
|
96
|
+
if (this._patchTimer) {
|
|
97
|
+
clearTimeout(this._patchTimer)
|
|
98
|
+
this._patchTimer = null
|
|
99
|
+
}
|
|
100
|
+
if (this._streamCardId && this._streamContent) {
|
|
101
|
+
void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
102
|
+
}
|
|
103
|
+
this._streamCardId = null
|
|
104
|
+
this._streamContent = ''
|
|
105
|
+
}
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async _handleInbound(event) {
|
|
110
|
+
if (this.gateway?._closing || this.gateway?.status === 'offline') return
|
|
111
|
+
const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
|
|
112
|
+
this._lastPeer = { peerId, senderId, isGroup }
|
|
113
|
+
|
|
114
|
+
let mediaFiles = []
|
|
115
|
+
if (messageType && messageType !== 'text') {
|
|
116
|
+
mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!text && mediaFiles.length === 0) {
|
|
120
|
+
this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let fullText = text || ''
|
|
125
|
+
if (mediaFiles.length > 0) {
|
|
126
|
+
const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
|
|
127
|
+
fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
|
|
131
|
+
|
|
132
|
+
// 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
|
|
133
|
+
if (!this.activeSessionId) {
|
|
134
|
+
await this._pickDefaultSession().catch(() => {})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 转交通用 ConversationBridge 路由
|
|
138
|
+
return this.handleInbound({
|
|
139
|
+
senderId: isGroup ? peerId : senderId,
|
|
140
|
+
peerId,
|
|
141
|
+
isGroup,
|
|
142
|
+
text: fullText,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
|
|
147
|
+
if (!messageId || !messageType) return []
|
|
148
|
+
const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
|
|
149
|
+
try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
|
|
150
|
+
|
|
151
|
+
const downloaded = []
|
|
152
|
+
const fileKey = contentObj.image_key || contentObj.file_key
|
|
153
|
+
const fileName = contentObj.file_name
|
|
154
|
+
|
|
155
|
+
if (fileKey) {
|
|
156
|
+
try {
|
|
157
|
+
const type = messageType === 'image' ? 'image' : 'file'
|
|
158
|
+
const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
|
|
159
|
+
if (buf && buf.length > 0) {
|
|
160
|
+
const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
|
|
161
|
+
const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
|
|
162
|
+
const filePath = path.join(mediaDir, safeName)
|
|
163
|
+
await fs.promises.writeFile(filePath, buf)
|
|
164
|
+
downloaded.push({ filename: safeName, path: filePath, size: buf.length })
|
|
165
|
+
this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return downloaded
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _handleAction(event) {
|
|
175
|
+
const { operatorId, value } = event
|
|
176
|
+
const approvalId = Number(value.approvalId)
|
|
177
|
+
const action = value.action
|
|
178
|
+
|
|
179
|
+
if (approvalId && (action === 'approve' || action === 'reject')) {
|
|
180
|
+
const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
|
|
181
|
+
const pending = this.pending.get(approvalId)
|
|
182
|
+
if (pending) {
|
|
183
|
+
this.clearApproval(approvalId)
|
|
184
|
+
pending.resolve(outcome)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async sendText(text) {
|
|
190
|
+
const peerId = this._lastPeer?.peerId || this.peerId
|
|
191
|
+
if (!peerId) return
|
|
192
|
+
const isGroup = this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_'))
|
|
193
|
+
const content = String(text || '').trim()
|
|
194
|
+
if (!content) return
|
|
195
|
+
|
|
196
|
+
// 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
|
|
197
|
+
if (this._inTurn) {
|
|
198
|
+
const maxChunk = 150
|
|
199
|
+
const slices = splitIntoIncremental(content, maxChunk)
|
|
200
|
+
const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
|
|
201
|
+
|
|
202
|
+
if (!this._streamCardId) {
|
|
203
|
+
// 首片:创建卡片并记录 message_id
|
|
204
|
+
const firstSlice = slices[0] || content
|
|
205
|
+
try {
|
|
206
|
+
const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
|
|
207
|
+
this._streamCardId = res?.message_id || null
|
|
208
|
+
this._streamContent = firstSlice
|
|
209
|
+
} catch (err) {
|
|
210
|
+
this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
|
|
211
|
+
await this.gateway.sendText(peerId, content, { isGroup })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
|
|
216
|
+
for (let i = 1; i < slices.length; i++) {
|
|
217
|
+
await sleep(delayMs)
|
|
218
|
+
this._streamContent = slices[i]
|
|
219
|
+
if (this._streamCardId) {
|
|
220
|
+
await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
// 如果卡片已存在(同轮次后续输出):更新到最终内容
|
|
225
|
+
this._streamContent = content
|
|
226
|
+
await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
|
|
232
|
+
try {
|
|
233
|
+
if (content.length <= 25000) {
|
|
234
|
+
await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
|
|
235
|
+
} else {
|
|
236
|
+
const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
|
|
237
|
+
for (const chunk of chunks) {
|
|
238
|
+
await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
|
|
243
|
+
await this.gateway.sendText(peerId, content, { isGroup })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- 飞书专属卡片审批桥 ----
|
|
248
|
+
|
|
249
|
+
_attachApprovalBridge() {
|
|
250
|
+
const listener = async (req, next) => {
|
|
251
|
+
if (!this.ownsAgent(req.agent)) return next?.()
|
|
252
|
+
const peer = this._lastPeer
|
|
253
|
+
if (!peer?.peerId) return next?.()
|
|
254
|
+
|
|
255
|
+
const number = this.nextApprovalNumber()
|
|
256
|
+
const timeoutSec = this.config.approvalTimeoutSec || 600
|
|
257
|
+
const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
|
|
258
|
+
|
|
259
|
+
// 构建飞书原生交互卡片 (JSON 2.0)
|
|
260
|
+
const card = {
|
|
261
|
+
schema: '2.0',
|
|
262
|
+
header: {
|
|
263
|
+
title: {
|
|
264
|
+
tag: 'plain_text',
|
|
265
|
+
content: `⚠️ 操作权限确认 (#${number})`,
|
|
266
|
+
},
|
|
267
|
+
template: 'orange',
|
|
268
|
+
},
|
|
269
|
+
body: {
|
|
270
|
+
elements: [
|
|
271
|
+
{
|
|
272
|
+
tag: 'markdown',
|
|
273
|
+
content: [
|
|
274
|
+
`| 项目 | 详情 |`,
|
|
275
|
+
`| :--- | :--- |`,
|
|
276
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
277
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
278
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
279
|
+
].join('\n'),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
tag: 'column_set',
|
|
283
|
+
flex_mode: 'flow',
|
|
284
|
+
columns: [
|
|
285
|
+
{
|
|
286
|
+
tag: 'column',
|
|
287
|
+
width: 'auto',
|
|
288
|
+
elements: [
|
|
289
|
+
{
|
|
290
|
+
tag: 'button',
|
|
291
|
+
text: {
|
|
292
|
+
tag: 'plain_text',
|
|
293
|
+
content: '✓ 批准执行',
|
|
294
|
+
},
|
|
295
|
+
type: 'primary',
|
|
296
|
+
value: {
|
|
297
|
+
action: 'approve',
|
|
298
|
+
approvalId: number,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
tag: 'column',
|
|
305
|
+
width: 'auto',
|
|
306
|
+
elements: [
|
|
307
|
+
{
|
|
308
|
+
tag: 'button',
|
|
309
|
+
text: {
|
|
310
|
+
tag: 'plain_text',
|
|
311
|
+
content: '✕ 拒绝执行',
|
|
312
|
+
},
|
|
313
|
+
type: 'danger',
|
|
314
|
+
value: {
|
|
315
|
+
action: 'reject',
|
|
316
|
+
approvalId: number,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 发送飞书卡片
|
|
328
|
+
try {
|
|
329
|
+
await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// 卡片发送失败降级发送 Markdown
|
|
332
|
+
const textFallback = [
|
|
333
|
+
`## ⚠️ 操作权限确认 (#${number})`,
|
|
334
|
+
'',
|
|
335
|
+
`| 项目 | 详情 |`,
|
|
336
|
+
`| :--- | :--- |`,
|
|
337
|
+
`| **调用工具** | \`${req.toolName}\` |`,
|
|
338
|
+
...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
|
|
339
|
+
`| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
|
|
340
|
+
'',
|
|
341
|
+
`> 回复 \`/yes\` (或 \`1\`) 批准执行`,
|
|
342
|
+
`> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
|
|
343
|
+
].join('\n')
|
|
344
|
+
void this.gateway.sendMarkdownCard(peer.peerId, textFallback, { isGroup: peer.isGroup })
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
|
|
348
|
+
let nextPromise = null
|
|
349
|
+
if (typeof next === 'function') {
|
|
350
|
+
try {
|
|
351
|
+
const res = next()
|
|
352
|
+
if (res && typeof res.then === 'function') {
|
|
353
|
+
nextPromise = res
|
|
354
|
+
}
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let timeoutFired = false
|
|
359
|
+
let winner = 'feishu'
|
|
360
|
+
const feishuPromise = new Promise((resolve) => {
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
timeoutFired = true
|
|
363
|
+
this.clearApproval(number)
|
|
364
|
+
resolve('rejected')
|
|
365
|
+
}, timeoutSec * 1000)
|
|
366
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
367
|
+
this.registerApproval(number, { number, request: req, resolve, timer })
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const outcome = await (nextPromise
|
|
371
|
+
? Promise.race([
|
|
372
|
+
feishuPromise.then(res => { winner = 'feishu'; return res }),
|
|
373
|
+
nextPromise.then(res => { winner = 'web'; return res }),
|
|
374
|
+
])
|
|
375
|
+
: feishuPromise)
|
|
376
|
+
|
|
377
|
+
// 如果 Web 端先决议,清除飞书端 pending 记录
|
|
378
|
+
if (winner === 'web') {
|
|
379
|
+
this.clearApproval(number)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!timeoutFired) {
|
|
383
|
+
const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
|
|
384
|
+
const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
|
|
385
|
+
void this.gateway.sendMarkdownCard(peer.peerId, `${label}(#${number})`, { isGroup: peer.isGroup })
|
|
386
|
+
}
|
|
387
|
+
return outcome
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const disposer = this.ctx.on('approval/request', listener)
|
|
391
|
+
this.disposers.push(disposer)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
dispose() {
|
|
395
|
+
if (this._patchTimer) {
|
|
396
|
+
clearTimeout(this._patchTimer)
|
|
397
|
+
this._patchTimer = null
|
|
398
|
+
}
|
|
399
|
+
this._inTurn = false
|
|
400
|
+
this._streamCardId = null
|
|
401
|
+
this._streamContent = ''
|
|
402
|
+
this._lastPeer = null
|
|
403
|
+
super.dispose()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const feishuNodeHelpers = {
|
|
408
|
+
makePlatform,
|
|
409
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// 零依赖自签证书生成器(IP 直连场景适用)
|
|
2
|
+
// 仅用 Node 内置 crypto:RSA 密钥对 + DER 编码一张最基本的自签 X.509 v3 证书。
|
|
3
|
+
// 无需 openssl 命令行、无需额外 npm 依赖,可在 macOS / Linux / Windows 开箱生成 TLS 证书。
|
|
4
|
+
//
|
|
5
|
+
// 注意:个人远程访问(IP 直连)使用本证书时,浏览器首次会提示"证书无效/不安全",
|
|
6
|
+
// 用户需选择"继续前往"。若要彻底消除告警,请在网关配置区上传域名/权威证书。
|
|
7
|
+
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
generateKeyPairSync,
|
|
11
|
+
sign,
|
|
12
|
+
randomBytes,
|
|
13
|
+
} from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
// --- 最小 ASN.1 DER 编码助手 ---
|
|
16
|
+
|
|
17
|
+
function len(n) {
|
|
18
|
+
if (n < 0x80) return [n];
|
|
19
|
+
const bytes = [];
|
|
20
|
+
while (n > 0) {
|
|
21
|
+
bytes.unshift(n & 0xff);
|
|
22
|
+
n >>>= 8;
|
|
23
|
+
}
|
|
24
|
+
return [0x80 | bytes.length, ...bytes];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function tlv(tag, content) {
|
|
28
|
+
return [tag, ...len(content.length), ...content];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function integerBytes(bytes) {
|
|
32
|
+
if (bytes.length && (bytes[0] & 0x80)) bytes = [0, ...bytes];
|
|
33
|
+
return tlv(0x02, bytes);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function oid(oidArr) {
|
|
37
|
+
let body = [40 * oidArr[0] + oidArr[1]];
|
|
38
|
+
for (let i = 2; i < oidArr.length; i++) {
|
|
39
|
+
let v = oidArr[i];
|
|
40
|
+
let sub = [];
|
|
41
|
+
if (v === 0) {
|
|
42
|
+
sub = [0];
|
|
43
|
+
} else {
|
|
44
|
+
while (v > 0) {
|
|
45
|
+
sub.unshift(v & 0x7f);
|
|
46
|
+
v >>>= 7;
|
|
47
|
+
}
|
|
48
|
+
for (let j = 0; j < sub.length - 1; j++) sub[j] |= 0x80;
|
|
49
|
+
}
|
|
50
|
+
body.push(...sub);
|
|
51
|
+
}
|
|
52
|
+
return tlv(0x06, body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nullValue() {
|
|
56
|
+
return tlv(0x05, []);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function printableString(str) {
|
|
60
|
+
return tlv(0x13, [...Buffer.from(str, 'utf8')]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bitString(bytes) {
|
|
64
|
+
return tlv(0x03, [0, ...bytes]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function octetString(...bytes) {
|
|
68
|
+
return tlv(0x04, bytes.flat());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sequence(...inner) {
|
|
72
|
+
return tlv(0x30, inner.flat());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setOf(...inner) {
|
|
76
|
+
return tlv(0x31, inner.flat());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function utcTimeStr(d) {
|
|
80
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
81
|
+
return (d.getUTCFullYear() % 100).toString().padStart(2, '0') +
|
|
82
|
+
p(d.getUTCMonth() + 1) + p(d.getUTCDate()) +
|
|
83
|
+
p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds()) + 'Z';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toPem(der, type) {
|
|
87
|
+
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n');
|
|
88
|
+
return `-----BEGIN ${type}-----\n${b64}\n-----END ${type}-----\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 生成单张可被浏览器接受的 IP / 域名自签证书。
|
|
93
|
+
* 关键点:subjectAltName 必须包含浏览器访问所用的宿主(IP 或域名)。
|
|
94
|
+
*/
|
|
95
|
+
export function generateSelfSignedCert({
|
|
96
|
+
commonName = 'dsh-bridge-gateway',
|
|
97
|
+
subjectAltNames = ['localhost', '127.0.0.1', '::1'],
|
|
98
|
+
days = 825,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
101
|
+
const keyObj = privateKey; // generateKeyPairSync 返回的已是 PrivateKeyObject,可直接用于签名
|
|
102
|
+
const pubObject = publicKey; // 同理:已是公钥 KeyObject
|
|
103
|
+
const derSpki = pubObject.export({ type: 'spki', format: 'der' });
|
|
104
|
+
|
|
105
|
+
// 从 SPKI DER 提取公钥 BIT STRING 内容
|
|
106
|
+
const spki = parseTlvs(derSpki);
|
|
107
|
+
// spki: [0]=SEQUENCE(AlgorithmIdentifier, BIT STRING)
|
|
108
|
+
const spkiSeq = parseTlvs(spki[0].content);
|
|
109
|
+
const pubBitString = spkiSeq[1].content.subarray(1); // 跳过第一个 0x00
|
|
110
|
+
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const notBefore = new Date(now - 60 * 60 * 1000);
|
|
113
|
+
const notAfter = new Date(now + days * 24 * 3600 * 1000);
|
|
114
|
+
|
|
115
|
+
// subjectAltName (GeneralNames):dNSName [2] IMPLICIT IA5String / iPAddress [7] IMPLICIT OCTET STRING
|
|
116
|
+
// 每个 GeneralName 都是 context-specific 标签,必须带长度前缀(首次手写漏了长度导致 X509 "wrong tag")。
|
|
117
|
+
const sanValues = [];
|
|
118
|
+
for (const name of subjectAltNames) {
|
|
119
|
+
if (isIpv4(name)) {
|
|
120
|
+
sanValues.push(tlv(0x87, name.split('.').map((n) => parseInt(n, 10))));
|
|
121
|
+
} else if (name === '::1') {
|
|
122
|
+
const raw = Buffer.alloc(16);
|
|
123
|
+
raw[15] = 1;
|
|
124
|
+
sanValues.push(tlv(0x87, [...raw]));
|
|
125
|
+
} else {
|
|
126
|
+
sanValues.push(tlv(0x82, [...Buffer.from(name, 'utf8')]));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const extensions = sequence(
|
|
131
|
+
// basicConstraints: critical CA:FALSE —— 值为空 SEQUENCE `30 00`
|
|
132
|
+
sequence(oid([2, 5, 29, 19]), octetString(...sequence(...[]))),
|
|
133
|
+
// subjectAltName
|
|
134
|
+
sequence(oid([2, 5, 29, 17]), octetString(...sequence(...sanValues))),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const tbs = sequence(
|
|
138
|
+
tlv(0xa0, integerTlv(2)), // version [0] EXPLICIT Version = v3
|
|
139
|
+
integerBytes([...randomBytes(16)]), // serial
|
|
140
|
+
sequence(oid([1, 2, 840, 113549, 1, 1, 11]), nullValue()), // sha256WithRSA 算法
|
|
141
|
+
sequence(setOf(sequence(oid([2, 5, 4, 3]), printableString(commonName)))), // issuer
|
|
142
|
+
sequence(
|
|
143
|
+
tlv(0x17, [...Buffer.from(utcTimeStr(notBefore), 'ascii')]),
|
|
144
|
+
tlv(0x17, [...Buffer.from(utcTimeStr(notAfter), 'ascii')]),
|
|
145
|
+
),
|
|
146
|
+
sequence(setOf(sequence(oid([2, 5, 4, 3]), printableString(commonName)))), // subject
|
|
147
|
+
sequence(
|
|
148
|
+
// SubjectPublicKeyInfo
|
|
149
|
+
sequence(oid([1, 2, 840, 113549, 1, 1, 1]), nullValue()),
|
|
150
|
+
bitString([...pubBitString]),
|
|
151
|
+
),
|
|
152
|
+
tlv(0xa3, extensions), // [3] extensions
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const algorithm = sequence(oid([1, 2, 840, 113549, 1, 1, 11]), nullValue());
|
|
156
|
+
const sig = sign('sha256', Buffer.from(tbs), keyObj);
|
|
157
|
+
const certDer = sequence(tbs, algorithm, bitString([...sig]));
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
161
|
+
cert: toPem(certDer, 'CERTIFICATE'),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- DER 解析(用于从 SPKI 提取公钥;返回元素数组,元素 {content: Buffer},忽略不用的 tag) ---
|
|
166
|
+
function parseTlvs(buf) {
|
|
167
|
+
const out = [];
|
|
168
|
+
let i = 0;
|
|
169
|
+
while (i < buf.length) {
|
|
170
|
+
const tag = buf[i++];
|
|
171
|
+
let n = buf[i++];
|
|
172
|
+
if (n & 0x80) {
|
|
173
|
+
const c = n & 0x7f;
|
|
174
|
+
n = 0;
|
|
175
|
+
for (let k = 0; k < c; k++) n = (n << 8) | buf[i++];
|
|
176
|
+
}
|
|
177
|
+
out.push({ tag, content: buf.subarray(i, i + n) });
|
|
178
|
+
i += n;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function integerTlv(n) {
|
|
184
|
+
return tlv(0x02, [n]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isIpv4(str) {
|
|
188
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(str);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 读取已缓存自签证书,否则生成并持久化到 DSH_HOME/dsh-bridge/certs/。
|
|
193
|
+
*/
|
|
194
|
+
export async function getOrCreateSelfSignedCert({ certFile, keyFile, logger }) {
|
|
195
|
+
const { readFile, writeFile, mkdir } = await import('node:fs/promises');
|
|
196
|
+
try {
|
|
197
|
+
const [cert, key] = await Promise.all([
|
|
198
|
+
readFile(certFile, 'utf8'),
|
|
199
|
+
readFile(keyFile, 'utf8'),
|
|
200
|
+
]);
|
|
201
|
+
if (cert && key) return { cert, key };
|
|
202
|
+
} catch {}
|
|
203
|
+
const { cert, key } = generateSelfSignedCert();
|
|
204
|
+
try {
|
|
205
|
+
await mkdir(dirname(certFile), { recursive: true });
|
|
206
|
+
await Promise.all([
|
|
207
|
+
writeFile(certFile, cert, 'utf8'),
|
|
208
|
+
writeFile(keyFile, key, 'utf8'),
|
|
209
|
+
]);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logger?.warn?.('dsh-bridge: 无法持久化自签证书: %s', err?.message ?? err);
|
|
212
|
+
}
|
|
213
|
+
return { cert, key };
|
|
214
|
+
}
|