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,550 @@
|
|
|
1
|
+
// Feishu / Lark Bot OpenAPI & WebSocket Gateway
|
|
2
|
+
// Official SDK: @larksuiteoapi/node-sdk
|
|
3
|
+
// Reference: https://open.feishu.cn/document/home/event-subscription-via-websocket/overview
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
8
|
+
import LarkSdk from './lark-bundled.mjs'
|
|
9
|
+
const Lark = LarkSdk.default || LarkSdk
|
|
10
|
+
|
|
11
|
+
export class FeishuGateway extends Service {
|
|
12
|
+
static name = 'feishu'
|
|
13
|
+
|
|
14
|
+
constructor(ctx, config = {}) {
|
|
15
|
+
super(ctx, 'feishu', true)
|
|
16
|
+
this.ctx = ctx
|
|
17
|
+
this.logger = ctx.logger?.('feishu') ?? console
|
|
18
|
+
this.config = {
|
|
19
|
+
appId: '',
|
|
20
|
+
appSecret: '',
|
|
21
|
+
domain: 'feishu', // 'feishu' | 'lark'
|
|
22
|
+
...config,
|
|
23
|
+
}
|
|
24
|
+
this.status = 'idle'
|
|
25
|
+
this.error = null
|
|
26
|
+
this.botInfo = null
|
|
27
|
+
this.client = null
|
|
28
|
+
this.wsClient = null
|
|
29
|
+
this.eventDispatcher = null
|
|
30
|
+
this._closing = false
|
|
31
|
+
this.seenMessageIds = new Set()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get configured() {
|
|
35
|
+
return Boolean(this.config.appId && this.config.appSecret)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setStatus(status, error = null) {
|
|
39
|
+
this.status = status
|
|
40
|
+
this.error = error ? String(error?.message || error) : null
|
|
41
|
+
this.ctx.emit('feishu/status', {
|
|
42
|
+
status: this.status,
|
|
43
|
+
error: this.error,
|
|
44
|
+
botInfo: this.botInfo,
|
|
45
|
+
configured: this.configured,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
updateConfig(cfg = {}) {
|
|
50
|
+
this.config = { ...this.config, ...cfg }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async start() {
|
|
54
|
+
if (!this.configured) {
|
|
55
|
+
this.setStatus('idle')
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.status === 'online') {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this._startingPromise) {
|
|
64
|
+
return this._startingPromise
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this._closing = false
|
|
68
|
+
this.setStatus('starting')
|
|
69
|
+
|
|
70
|
+
this._startingPromise = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const isLark = this.config.domain === 'lark'
|
|
73
|
+
const domain = isLark ? Lark.Domain.Lark : Lark.Domain.Feishu
|
|
74
|
+
|
|
75
|
+
const sdkLogger = {
|
|
76
|
+
debug: (...args) => this.logger?.debug?.('[lark-sdk debug]', ...args),
|
|
77
|
+
info: (...args) => this.logger?.debug?.('[lark-sdk info]', ...args),
|
|
78
|
+
warn: (...args) => this.logger?.debug?.('[lark-sdk warn]', ...args),
|
|
79
|
+
error: (...args) => this.logger?.error?.('[lark-sdk error]', ...args),
|
|
80
|
+
trace: () => {},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 1. 初始化 REST API Client
|
|
84
|
+
this.client = new Lark.Client({
|
|
85
|
+
appId: this.config.appId,
|
|
86
|
+
appSecret: this.config.appSecret,
|
|
87
|
+
domain,
|
|
88
|
+
appType: Lark.AppType.SelfBuild,
|
|
89
|
+
logger: sdkLogger,
|
|
90
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// 2. 初始化长连接 WSClient
|
|
94
|
+
this.wsClient = new Lark.WSClient({
|
|
95
|
+
appId: this.config.appId,
|
|
96
|
+
appSecret: this.config.appSecret,
|
|
97
|
+
domain,
|
|
98
|
+
logger: sdkLogger,
|
|
99
|
+
loggerLevel: Lark.LoggerLevel.error,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// 3. 构建事件分发器
|
|
103
|
+
this.eventDispatcher = new Lark.EventDispatcher({
|
|
104
|
+
logger: sdkLogger,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// 注册接收消息事件
|
|
108
|
+
this.eventDispatcher.register({
|
|
109
|
+
'im.message.receive_v1': async (data) => {
|
|
110
|
+
try {
|
|
111
|
+
await this._handleMessageReceive(data)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
this.logger.error?.('[dsh-bridge feishu] error in im.message.receive_v1:', err)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
'card.action.trigger': async (data) => {
|
|
117
|
+
try {
|
|
118
|
+
return await this._handleCardActionTrigger(data)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this.logger.error?.('[dsh-bridge feishu] error in card.action.trigger:', err)
|
|
121
|
+
return { toast: { type: 'error', content: '处理失败' } }
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
'im.message.message_read_v1': async () => {
|
|
125
|
+
// 消息已读回执事件,忽略
|
|
126
|
+
},
|
|
127
|
+
'im.chat.access_event.bot_p2p_chat_entered_v1': async () => {
|
|
128
|
+
// 用户进入与机器人的单聊窗口事件,忽略
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// 4. 尝试获取机器人信息(验证凭证,非阻断)
|
|
133
|
+
try {
|
|
134
|
+
const botRes = await this.client.bot.v3.bot.get({}).catch(() => null)
|
|
135
|
+
if (botRes?.bot) {
|
|
136
|
+
this.botInfo = {
|
|
137
|
+
openId: botRes.bot.open_id,
|
|
138
|
+
appName: botRes.bot.app_name,
|
|
139
|
+
avatarUrl: botRes.bot.avatar_url,
|
|
140
|
+
}
|
|
141
|
+
this.logger.info?.(`[dsh-bridge feishu] bot authenticated: ${this.botInfo.appName} (${this.botInfo.openId})`)
|
|
142
|
+
}
|
|
143
|
+
} catch (authErr) {
|
|
144
|
+
this.logger.warn?.(`[dsh-bridge feishu] fetch bot info error: ${authErr.message}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 5. 启动 WebSocket 长连接
|
|
148
|
+
await this.wsClient.start({ eventDispatcher: this.eventDispatcher })
|
|
149
|
+
this.setStatus('online')
|
|
150
|
+
this._startWatchdog()
|
|
151
|
+
this.logger.info?.('[dsh-bridge feishu] WebSocket connected to Feishu Open Platform')
|
|
152
|
+
return true
|
|
153
|
+
} catch (err) {
|
|
154
|
+
this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
|
|
155
|
+
this.setStatus('error', err)
|
|
156
|
+
return false
|
|
157
|
+
} finally {
|
|
158
|
+
this._startingPromise = null
|
|
159
|
+
}
|
|
160
|
+
})()
|
|
161
|
+
|
|
162
|
+
return this._startingPromise
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_startWatchdog() {
|
|
166
|
+
this._stopWatchdog()
|
|
167
|
+
this._watchdogTimer = setInterval(async () => {
|
|
168
|
+
if (this.status === 'online' && !this._closing && this.configured && this.client) {
|
|
169
|
+
try {
|
|
170
|
+
await this.client.bot.v3.bot.get({}).catch(() => null)
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.logger?.warn?.('[dsh-bridge feishu] liveness probe warning: %s', err?.message ?? err)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}, 60_000)
|
|
176
|
+
if (typeof this._watchdogTimer?.unref === 'function') this._watchdogTimer.unref()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_stopWatchdog() {
|
|
180
|
+
if (this._watchdogTimer) clearInterval(this._watchdogTimer)
|
|
181
|
+
this._watchdogTimer = null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async stop() {
|
|
185
|
+
this._closing = true
|
|
186
|
+
this._stopWatchdog()
|
|
187
|
+
try {
|
|
188
|
+
if (this.wsClient) {
|
|
189
|
+
const wsInstance = this.wsClient.wsConfig?.getWSInstance?.()
|
|
190
|
+
if (wsInstance) {
|
|
191
|
+
try { wsInstance.close?.() } catch {}
|
|
192
|
+
try { wsInstance.terminate?.() } catch {}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
this.logger.warn?.('[dsh-bridge feishu] stop error:', err)
|
|
197
|
+
} finally {
|
|
198
|
+
this.client = null
|
|
199
|
+
this.wsClient = null
|
|
200
|
+
this.eventDispatcher = null
|
|
201
|
+
this.setStatus('offline')
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- 消息收发 ----
|
|
206
|
+
|
|
207
|
+
async _handleMessageReceive(data) {
|
|
208
|
+
if (this._closing || this.status === 'offline') return
|
|
209
|
+
if (!data?.message) return
|
|
210
|
+
const { message, sender } = data
|
|
211
|
+
const messageId = message.message_id
|
|
212
|
+
if (!messageId) return
|
|
213
|
+
|
|
214
|
+
// 消息去重
|
|
215
|
+
if (this.seenMessageIds.has(messageId)) return
|
|
216
|
+
this.seenMessageIds.add(messageId)
|
|
217
|
+
if (this.seenMessageIds.size > 500) {
|
|
218
|
+
const first = this.seenMessageIds.values().next().value
|
|
219
|
+
this.seenMessageIds.delete(first)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const senderOpenId = sender?.sender_id?.open_id || ''
|
|
223
|
+
const chatId = message.chat_id || ''
|
|
224
|
+
const chatType = message.chat_type || 'p2p' // 'p2p' or 'group'
|
|
225
|
+
const isGroup = chatType === 'group'
|
|
226
|
+
|
|
227
|
+
// 解析消息体(文本 / 图片 / 文件 / 音频 / 视频)
|
|
228
|
+
let text = ''
|
|
229
|
+
let contentObj = {}
|
|
230
|
+
try {
|
|
231
|
+
contentObj = JSON.parse(message.content || '{}')
|
|
232
|
+
if (message.message_type === 'text') {
|
|
233
|
+
text = contentObj.text || ''
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
text = message.content || ''
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 群聊中剥离机器人 @ 占位符(例如 @_user_1 等)
|
|
240
|
+
if (isGroup && text) {
|
|
241
|
+
text = text.replace(/@_user_\d+/g, '').trim()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const peerId = isGroup ? chatId : senderOpenId
|
|
245
|
+
if (!peerId) return
|
|
246
|
+
|
|
247
|
+
this.ctx.emit('feishu/message', {
|
|
248
|
+
peerId,
|
|
249
|
+
senderId: senderOpenId,
|
|
250
|
+
chatId,
|
|
251
|
+
chatType,
|
|
252
|
+
isGroup,
|
|
253
|
+
messageId,
|
|
254
|
+
text,
|
|
255
|
+
contentObj,
|
|
256
|
+
messageType: message.message_type,
|
|
257
|
+
raw: data,
|
|
258
|
+
createTime: Number(message.create_time || Date.now()),
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 下载消息中的资源文件(图片/文件/音频/媒体)
|
|
264
|
+
* @param {object} opts
|
|
265
|
+
* @param {string} opts.messageId 消息 ID
|
|
266
|
+
* @param {string} opts.fileKey 资源 Key
|
|
267
|
+
* @param {string} [opts.type] 'file' | 'image'
|
|
268
|
+
* @returns {Promise<Buffer|null>}
|
|
269
|
+
*/
|
|
270
|
+
async downloadMessageResource({ messageId, fileKey, type = 'file' }) {
|
|
271
|
+
if (!this.client) throw new Error('Feishu client not initialized')
|
|
272
|
+
try {
|
|
273
|
+
const resp = await this.client.im.messageResource.get({
|
|
274
|
+
path: {
|
|
275
|
+
message_id: messageId,
|
|
276
|
+
file_key: fileKey,
|
|
277
|
+
},
|
|
278
|
+
params: {
|
|
279
|
+
type: type === 'image' ? 'image' : 'file',
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
if (Buffer.isBuffer(resp)) return resp
|
|
283
|
+
if (resp && typeof resp.pipe === 'function') {
|
|
284
|
+
const chunks = []
|
|
285
|
+
for await (const chunk of resp) chunks.push(Buffer.from(chunk))
|
|
286
|
+
return Buffer.concat(chunks)
|
|
287
|
+
}
|
|
288
|
+
if (resp?.data) {
|
|
289
|
+
if (Buffer.isBuffer(resp.data)) return resp.data
|
|
290
|
+
if (resp.data.pipe) {
|
|
291
|
+
const chunks = []
|
|
292
|
+
for await (const chunk of resp.data) chunks.push(Buffer.from(chunk))
|
|
293
|
+
return Buffer.concat(chunks)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return null
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.logger?.warn?.('[dsh-bridge feishu] downloadMessageResource error: %s', err?.message ?? err)
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async _handleCardActionTrigger(data) {
|
|
304
|
+
const action = data?.action || {}
|
|
305
|
+
const operator = data?.operator || {}
|
|
306
|
+
const operatorOpenId = operator.open_id || ''
|
|
307
|
+
const value = action.value || {}
|
|
308
|
+
|
|
309
|
+
this.ctx.emit('feishu/action', {
|
|
310
|
+
operatorId: operatorOpenId,
|
|
311
|
+
action: value.action || action.tag,
|
|
312
|
+
value,
|
|
313
|
+
raw: data,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const actionName = value.action === 'approve' ? '已批准' : value.action === 'reject' ? '已拒绝' : '已操作'
|
|
317
|
+
return {
|
|
318
|
+
toast: {
|
|
319
|
+
type: 'success',
|
|
320
|
+
content: `操作成功:${actionName}`,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 发送文本消息
|
|
327
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
328
|
+
* @param {string} text - 发送文本
|
|
329
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
330
|
+
*/
|
|
331
|
+
async sendText(receiveId, text, opts = {}) {
|
|
332
|
+
if (!this.client || !receiveId || !text) return null
|
|
333
|
+
let receiveIdType = opts.receiveIdType
|
|
334
|
+
if (!receiveIdType) {
|
|
335
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
336
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
337
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
338
|
+
else receiveIdType = 'open_id'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
this.logger.debug?.(`[dsh-bridge feishu] sending text to ${receiveId} (${receiveIdType})`)
|
|
343
|
+
const res = await this.client.im.v1.message.create({
|
|
344
|
+
params: { receive_id_type: receiveIdType },
|
|
345
|
+
data: {
|
|
346
|
+
receive_id: receiveId,
|
|
347
|
+
msg_type: 'text',
|
|
348
|
+
content: JSON.stringify({ text }),
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
return res?.data
|
|
352
|
+
} catch (err) {
|
|
353
|
+
this.logger.error?.(`[dsh-bridge feishu] sendText error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
|
|
354
|
+
throw err
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 发送飞书原生交互卡片
|
|
360
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
361
|
+
* @param {object} cardData - 卡片结构对象 (JSON 2.0)
|
|
362
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
363
|
+
*/
|
|
364
|
+
async sendCard(receiveId, cardData, opts = {}) {
|
|
365
|
+
if (!this.client || !receiveId || !cardData) return null
|
|
366
|
+
let receiveIdType = opts.receiveIdType
|
|
367
|
+
if (!receiveIdType) {
|
|
368
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
369
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
370
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
371
|
+
else receiveIdType = 'open_id'
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
this.logger.debug?.(`[dsh-bridge feishu] sending card to ${receiveId} (${receiveIdType})`)
|
|
376
|
+
const res = await this.client.im.v1.message.create({
|
|
377
|
+
params: { receive_id_type: receiveIdType },
|
|
378
|
+
data: {
|
|
379
|
+
receive_id: receiveId,
|
|
380
|
+
msg_type: 'interactive',
|
|
381
|
+
content: JSON.stringify(cardData),
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
return res?.data
|
|
385
|
+
} catch (err) {
|
|
386
|
+
this.logger.error?.(`[dsh-bridge feishu] sendCard error (${receiveId}, ${receiveIdType}):`, err?.message ?? err)
|
|
387
|
+
throw err
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 发送 Markdown 交互卡片 (JSON 2.0)
|
|
393
|
+
* @param {string} receiveId - 接收人 open_id 或群 chat_id
|
|
394
|
+
* @param {string} markdownText - Markdown 文本
|
|
395
|
+
* @param {object} opts - { isGroup, receiveIdType }
|
|
396
|
+
*/
|
|
397
|
+
async sendMarkdownCard(receiveId, markdownText, opts = {}) {
|
|
398
|
+
const card = {
|
|
399
|
+
schema: '2.0',
|
|
400
|
+
config: {
|
|
401
|
+
wide_screen_mode: true,
|
|
402
|
+
update_multi: true,
|
|
403
|
+
},
|
|
404
|
+
body: {
|
|
405
|
+
elements: [
|
|
406
|
+
{
|
|
407
|
+
tag: 'markdown',
|
|
408
|
+
content: String(markdownText || '').trim(),
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
return this.sendCard(receiveId, card, opts)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 更新已发送的交互卡片(流式更新 / 原地替换,JSON 2.0)
|
|
418
|
+
* @param {string} messageId - 已发送消息的 message_id
|
|
419
|
+
* @param {string} markdownText - 最新完整 Markdown 文本
|
|
420
|
+
*/
|
|
421
|
+
async patchCard(messageId, markdownText) {
|
|
422
|
+
if (!this.client || !messageId) return null
|
|
423
|
+
const card = {
|
|
424
|
+
schema: '2.0',
|
|
425
|
+
config: {
|
|
426
|
+
wide_screen_mode: true,
|
|
427
|
+
update_multi: true,
|
|
428
|
+
},
|
|
429
|
+
body: {
|
|
430
|
+
elements: [
|
|
431
|
+
{
|
|
432
|
+
tag: 'markdown',
|
|
433
|
+
content: String(markdownText || '').trim(),
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
this.logger.debug?.(`[dsh-bridge feishu] patching card message ${messageId}`)
|
|
440
|
+
const res = await this.client.im.v1.message.patch({
|
|
441
|
+
path: { message_id: messageId },
|
|
442
|
+
data: {
|
|
443
|
+
content: JSON.stringify(card),
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
return res?.data
|
|
447
|
+
} catch (err) {
|
|
448
|
+
this.logger.error?.(`[dsh-bridge feishu] patchCard error (${messageId}):`, err?.message ?? err)
|
|
449
|
+
throw err
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 上传并发送本地图片
|
|
455
|
+
*/
|
|
456
|
+
async sendLocalImage(receiveId, filePath, opts = {}) {
|
|
457
|
+
if (!this.client || !receiveId || !filePath) return null
|
|
458
|
+
let receiveIdType = opts.receiveIdType
|
|
459
|
+
if (!receiveIdType) {
|
|
460
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
461
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
462
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
463
|
+
else receiveIdType = 'open_id'
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const imageStream = fs.createReadStream(filePath)
|
|
468
|
+
const uploadRes = await this.client.im.v1.image.create({
|
|
469
|
+
data: {
|
|
470
|
+
image_type: 'message',
|
|
471
|
+
image: imageStream,
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
const imageKey = uploadRes?.image_key
|
|
475
|
+
if (!imageKey) throw new Error('Feishu image upload returned no image_key')
|
|
476
|
+
|
|
477
|
+
const res = await this.client.im.v1.message.create({
|
|
478
|
+
params: { receive_id_type: receiveIdType },
|
|
479
|
+
data: {
|
|
480
|
+
receive_id: receiveId,
|
|
481
|
+
msg_type: 'image',
|
|
482
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
return res?.data
|
|
486
|
+
} catch (err) {
|
|
487
|
+
this.logger.warn?.(`[dsh-bridge feishu] sendLocalImage error (${receiveId}, ${filePath}):`, err?.message ?? err)
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 上传并发送本地文件
|
|
494
|
+
*/
|
|
495
|
+
async sendLocalFile(receiveId, filePath, opts = {}) {
|
|
496
|
+
if (!this.client || !receiveId || !filePath) return null
|
|
497
|
+
let receiveIdType = opts.receiveIdType
|
|
498
|
+
if (!receiveIdType) {
|
|
499
|
+
if (receiveId.startsWith('ou_')) receiveIdType = 'open_id'
|
|
500
|
+
else if (receiveId.startsWith('oc_')) receiveIdType = 'chat_id'
|
|
501
|
+
else if (opts.isGroup) receiveIdType = 'chat_id'
|
|
502
|
+
else receiveIdType = 'open_id'
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const fileName = path.basename(filePath)
|
|
507
|
+
const fileStream = fs.createReadStream(filePath)
|
|
508
|
+
const uploadRes = await this.client.im.v1.file.create({
|
|
509
|
+
data: {
|
|
510
|
+
file_type: 'stream',
|
|
511
|
+
file_name: fileName,
|
|
512
|
+
file: fileStream,
|
|
513
|
+
},
|
|
514
|
+
})
|
|
515
|
+
const fileKey = uploadRes?.file_key
|
|
516
|
+
if (!fileKey) throw new Error('Feishu file upload returned no file_key')
|
|
517
|
+
|
|
518
|
+
const res = await this.client.im.v1.message.create({
|
|
519
|
+
params: { receive_id_type: receiveIdType },
|
|
520
|
+
data: {
|
|
521
|
+
receive_id: receiveId,
|
|
522
|
+
msg_type: 'file',
|
|
523
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
524
|
+
},
|
|
525
|
+
})
|
|
526
|
+
return res?.data
|
|
527
|
+
} catch (err) {
|
|
528
|
+
this.logger.warn?.(`[dsh-bridge feishu] sendLocalFile error (${receiveId}, ${filePath}):`, err?.message ?? err)
|
|
529
|
+
return null
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 发送本地媒体文件(自动识别图片/文档)
|
|
535
|
+
*/
|
|
536
|
+
async sendMediaFile(receiveId, filePath, opts = {}) {
|
|
537
|
+
if (!fs.existsSync(filePath)) return null
|
|
538
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
539
|
+
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
|
|
540
|
+
if (isImage) {
|
|
541
|
+
return this.sendLocalImage(receiveId, filePath, opts)
|
|
542
|
+
}
|
|
543
|
+
return this.sendLocalFile(receiveId, filePath, opts)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 释放资源
|
|
547
|
+
dispose() {
|
|
548
|
+
void this.stop()
|
|
549
|
+
}
|
|
550
|
+
}
|