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.
Files changed (68) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +408 -0
  4. package/README.md +430 -0
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +5209 -0
  7. package/client/index.js +4967 -0
  8. package/cordis.patch.yml +4 -0
  9. package/docs/banner.jpg +0 -0
  10. package/docs/custom-tunnel.md +220 -0
  11. package/docs/feishu-usage.md +116 -0
  12. package/docs/fix-tunnel-sse-and-session-list.md +134 -0
  13. package/docs/platform-abstraction-design.md +473 -0
  14. package/docs/qq-usage.md +387 -0
  15. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  16. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  17. package/docs/screenshots/feishu-chat.jpg +0 -0
  18. package/docs/screenshots/lan-access.jpg +0 -0
  19. package/docs/screenshots/mobile-chat.jpg +0 -0
  20. package/docs/screenshots/mobile-drawer.jpg +0 -0
  21. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  22. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  23. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  24. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  25. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  26. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  27. package/docs/screenshots/qq-bot-config.jpg +0 -0
  28. package/docs/screenshots/qq-chat.jpg +0 -0
  29. package/docs/screenshots/qq-group.jpg +0 -0
  30. package/docs/screenshots/qr-scan.jpg +0 -0
  31. package/docs/screenshots/remote-auth-login.jpg +0 -0
  32. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  33. package/docs/screenshots/security-auth-config.jpg +0 -0
  34. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  35. package/docs/screenshots/tunnel-access.jpg +0 -0
  36. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  37. package/docs/screenshots/wechat-chat.jpg +0 -0
  38. package/docs/telegram-usage.md +93 -0
  39. package/docs/wechat-usage.md +104 -0
  40. package/lib/auth/login-template.js +382 -0
  41. package/lib/auth/manager.js +470 -0
  42. package/lib/bridge-rpc-constants.js +55 -0
  43. package/lib/bridge-rpc.js +553 -0
  44. package/lib/cloudflared-manager.mjs +345 -0
  45. package/lib/feishu/gateway.js +550 -0
  46. package/lib/feishu/index.js +222 -0
  47. package/lib/feishu/lark-bundled.mjs +125547 -0
  48. package/lib/feishu/node.js +409 -0
  49. package/lib/gateway/cert.js +214 -0
  50. package/lib/index.js +1963 -0
  51. package/lib/platform/base.js +156 -0
  52. package/lib/platform/conversation-bridge.js +1570 -0
  53. package/lib/platform/index.js +10 -0
  54. package/lib/platform/manager.js +74 -0
  55. package/lib/qq/gateway.js +616 -0
  56. package/lib/qq/index.js +309 -0
  57. package/lib/qq/node.js +533 -0
  58. package/lib/security/path-validator.js +208 -0
  59. package/lib/security/rate-limiter.js +93 -0
  60. package/lib/telegram/gateway.js +630 -0
  61. package/lib/telegram/index.js +213 -0
  62. package/lib/telegram/node.js +351 -0
  63. package/lib/tunnel-client.mjs +402 -0
  64. package/lib/wechat/gateway.js +960 -0
  65. package/lib/wechat/index.js +241 -0
  66. package/lib/wechat/media.js +281 -0
  67. package/lib/wechat/node.js +350 -0
  68. 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
+ }