@wenbin_wb/dsh-bridge 2.3.2 → 2.4.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.
@@ -2,6 +2,8 @@
2
2
  // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
3
  // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
4
 
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
5
7
  import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
6
8
 
7
9
  function makePlatform(gateway) {
@@ -12,6 +14,9 @@ function makePlatform(gateway) {
12
14
  async sendText(peerId, text, opts = {}) {
13
15
  return gateway?.sendMarkdownCard(peerId, text, opts)
14
16
  },
17
+ async sendMediaFile(peerId, filePath, opts = {}) {
18
+ return gateway?.sendMediaFile(peerId, filePath, opts)
19
+ },
15
20
  async sendTyping() {},
16
21
  dispose() {},
17
22
  }
@@ -70,14 +75,10 @@ export class FeishuConversationNode extends ConversationBridge {
70
75
  this._inTurn = false
71
76
 
72
77
  // 订阅网关入站消息事件
73
- this.ctx.on('feishu/message', (event) => {
74
- void this._handleInbound(event)
75
- })
78
+ this.ctx.on('feishu/message', (event) => this._handleInbound(event))
76
79
 
77
80
  // 订阅卡片交互事件(审批按钮点击)
78
- this.ctx.on('feishu/action', (event) => {
79
- void this._handleAction(event)
80
- })
81
+ this.ctx.on('feishu/action', (event) => this._handleAction(event))
81
82
 
82
83
  // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
83
84
  this.ctx.on('session/event', (session, event) => {
@@ -106,10 +107,26 @@ export class FeishuConversationNode extends ConversationBridge {
106
107
  }
107
108
 
108
109
  async _handleInbound(event) {
109
- const { peerId, senderId, isGroup, text } = event
110
+ const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
110
111
  this._lastPeer = { peerId, senderId, isGroup }
111
112
 
112
- this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${text}`)
113
+ let mediaFiles = []
114
+ if (messageType && messageType !== 'text') {
115
+ mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
116
+ }
117
+
118
+ if (!text && mediaFiles.length === 0) {
119
+ this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
120
+ return
121
+ }
122
+
123
+ let fullText = text || ''
124
+ if (mediaFiles.length > 0) {
125
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
126
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
127
+ }
128
+
129
+ this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
113
130
 
114
131
  // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
115
132
  if (!this.activeSessionId) {
@@ -117,14 +134,42 @@ export class FeishuConversationNode extends ConversationBridge {
117
134
  }
118
135
 
119
136
  // 转交通用 ConversationBridge 路由
120
- return super.handleInbound({
137
+ return this.handleInbound({
121
138
  senderId: isGroup ? peerId : senderId,
122
139
  peerId,
123
140
  isGroup,
124
- text,
141
+ text: fullText,
125
142
  })
126
143
  }
127
144
 
145
+ async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
146
+ if (!messageId || !messageType) return []
147
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
148
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
149
+
150
+ const downloaded = []
151
+ const fileKey = contentObj.image_key || contentObj.file_key
152
+ const fileName = contentObj.file_name
153
+
154
+ if (fileKey) {
155
+ try {
156
+ const type = messageType === 'image' ? 'image' : 'file'
157
+ const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
158
+ if (buf && buf.length > 0) {
159
+ const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
160
+ const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
161
+ const filePath = path.join(mediaDir, safeName)
162
+ await fs.promises.writeFile(filePath, buf)
163
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
164
+ this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
165
+ }
166
+ } catch (err) {
167
+ this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
168
+ }
169
+ }
170
+ return downloaded
171
+ }
172
+
128
173
  async _handleAction(event) {
129
174
  const { operatorId, value } = event
130
175
  const approvalId = Number(value.approvalId)
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ import { PlatformManager } from './platform/manager.js';
21
21
  import { WechatService } from './wechat/index.js';
22
22
  import { QqService } from './qq/index.js';
23
23
  import { FeishuService } from './feishu/index.js';
24
+ import { TelegramService } from './telegram/index.js';
24
25
 
25
26
  const name = 'dsh-bridge';
26
27
  // 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
@@ -569,6 +570,19 @@ function apply(ctx, config = {}) {
569
570
  });
570
571
  platformManager.register(feishu);
571
572
 
573
+ // Telegram Bot(官方 Long Polling + 代理支持)—— 作为 Platform 子类注册进平台管理器
574
+ const telegram = new TelegramService({
575
+ ctx,
576
+ logger,
577
+ config: config.telegram ?? {},
578
+ onPersist: async (patch) => {
579
+ const stored = await loadConfig();
580
+ stored.telegram = { ...(stored.telegram ?? {}), ...patch };
581
+ await saveConfig(stored);
582
+ },
583
+ });
584
+ platformManager.register(telegram);
585
+
572
586
  // 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
573
587
  loadConfig().then(async (stored) => {
574
588
  if (stored?.wechat) {
@@ -681,11 +695,50 @@ function apply(ctx, config = {}) {
681
695
  }
682
696
  }).catch(() => {});
683
697
 
698
+ // 启动时读取已保存的 Telegram Bot 配置(凭证 + 代理 + 白名单 + 活动会话)
699
+ loadConfig().then(async (stored) => {
700
+ if (stored?.telegram) {
701
+ const cfg = stored.telegram;
702
+ telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
703
+ if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
704
+ if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
705
+ if (cfg.maxMessageChars != null) telegram.node.config.maxMessageChars = Number(cfg.maxMessageChars);
706
+ if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
707
+
708
+ telegram.node._restoringConfig = (async () => {
709
+ try {
710
+ if (cfg.activeSessionId) {
711
+ telegram.node.activeSessionId = cfg.activeSessionId;
712
+ logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
713
+ } else {
714
+ await telegram.node._pickDefaultSession().catch(() => {});
715
+ }
716
+ } finally {
717
+ telegram.node._configRestored = true;
718
+ }
719
+ })();
720
+
721
+ await telegram.node._restoringConfig;
722
+
723
+ if (cfg.botToken) {
724
+ telegram.gateway.setCredentials({
725
+ botToken: cfg.botToken,
726
+ proxy: cfg.proxy || '',
727
+ });
728
+ logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
729
+ await telegram.start().catch((err) => {
730
+ logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
731
+ });
732
+ }
733
+ }
734
+ }).catch(() => {});
735
+
684
736
  const disposeRpc = installBridgeRpc(ctx, {
685
737
  service,
686
738
  wechat,
687
739
  qq,
688
740
  feishu,
741
+ telegram,
689
742
  platformManager,
690
743
  logger,
691
744
  saveCustomTunnelConfig: async (serverUrl, accessToken) => {
@@ -705,9 +758,11 @@ function apply(ctx, config = {}) {
705
758
  try { disposeRpc(); } catch {}
706
759
  await wechat.destroy();
707
760
  await qq.destroy();
761
+ await feishu.destroy();
762
+ await telegram.destroy();
708
763
  platformManager.dispose();
709
764
  await service.dispose();
710
- }, 'dsh-bridge: stop wechat, qq, proxy and tunnels');
765
+ }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy and tunnels');
711
766
  }
712
767
 
713
768
  export { name, inject, apply };
@@ -140,4 +140,17 @@ export class Platform {
140
140
  this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
141
141
  }
142
142
  }
143
+
144
+ dispose() {
145
+ for (const d of this.disposers) {
146
+ try { d() } catch {}
147
+ }
148
+ this.disposers = []
149
+ this.bridge?.dispose?.()
150
+ this.bridge = null
151
+ }
152
+
153
+ async destroy() {
154
+ this.dispose()
155
+ }
143
156
  }
@@ -582,12 +582,13 @@ export class ConversationBridge {
582
582
  }
583
583
  const onEvent = (session, event) => {
584
584
  if (session.id !== this.activeSessionId) return
585
- const state = digestState.get(session.id) ?? { startedTurns: new Set() }
585
+ const state = digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
586
586
  digestState.set(session.id, state)
587
587
 
588
588
  if (event.type === 'turn/start') {
589
- const turn = event.data.turn
590
- if (!state.startedTurns.has(turn)) {
589
+ const turn = event.data?.turn
590
+ state.createdFiles = new Set()
591
+ if (turn != null && !state.startedTurns.has(turn)) {
591
592
  state.startedTurns.add(turn)
592
593
  // 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
593
594
  if (this.peerId) this.sendTyping(1).catch(() => {})
@@ -595,6 +596,14 @@ export class ConversationBridge {
595
596
  startHeartbeat(session, state)
596
597
  return
597
598
  }
599
+ if (event.type === 'tool/call') {
600
+ const args = event.data?.parameters || event.data?.args || {}
601
+ const target = args.TargetFile || args.targetFile || args.target_file || args.path || args.filePath
602
+ if (target && typeof target === 'string') {
603
+ state.createdFiles.add(target)
604
+ }
605
+ return
606
+ }
598
607
  if (event.type === 'assistant/message') {
599
608
  const text = textOfAssistantMessage(event.data.message)
600
609
  if (text.trim()) void this.sendText(text)
@@ -603,7 +612,7 @@ export class ConversationBridge {
603
612
  if (event.type === 'turn/end') {
604
613
  stopHeartbeat(state)
605
614
  if (this.peerId) this.sendTyping(2).catch(() => {})
606
- const reason = event.data.reason
615
+ const reason = event.data?.reason || {}
607
616
  if (reason.kind === 'error') {
608
617
  void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`)
609
618
  } else if (reason.kind === 'aborted') {
@@ -611,6 +620,25 @@ export class ConversationBridge {
611
620
  } else if (reason.kind === 'max-tokens') {
612
621
  void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
613
622
  }
623
+ // 如果本轮生成/修改了产物文件,下发产物清单通知并尝试直接上传文件至聊天窗口
624
+ if (state.createdFiles && state.createdFiles.size > 0) {
625
+ const files = Array.from(state.createdFiles)
626
+ const fileLines = files.map((f) => `- \`${f}\``).join('\n')
627
+ void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
628
+
629
+ // 如果平台支持 sendMediaFile,自动尝试直接发送文件/图片到聊天窗口
630
+ if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
631
+ for (const f of files) {
632
+ try {
633
+ if (statSync(f).isFile()) {
634
+ void this.platform.sendMediaFile(this.peerId, f)
635
+ }
636
+ } catch {}
637
+ }
638
+ }
639
+
640
+ state.createdFiles.clear()
641
+ }
614
642
  return
615
643
  }
616
644
  }
package/lib/qq/gateway.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // QQ Bot OpenAPI v2 gateway
2
2
  // Official API: https://bot.q.qq.com/wiki/develop/api-v2/
3
3
 
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
4
6
  import { Service } from '@deepseek-ai/cordis'
5
7
  import WebSocket from 'ws'
6
8
 
@@ -66,6 +68,7 @@ function normalizeEvent(payload) {
66
68
  senderId: data.author?.user_openid || data.user_openid,
67
69
  peerId: data.author?.user_openid || data.user_openid,
68
70
  text: stringValue(data.content).trim(),
71
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
69
72
  message: data,
70
73
  messageReference: data.message_reference,
71
74
  msgSeq: data.msg_seq, // 用于避免去重
@@ -81,6 +84,7 @@ function normalizeEvent(payload) {
81
84
  peerId: data.group_openid || data.group_id,
82
85
  groupId: data.group_openid || data.group_id,
83
86
  text: stringValue(data.content).trim(),
87
+ attachments: Array.isArray(data.attachments) ? data.attachments : [],
84
88
  message: data,
85
89
  messageReference: data.message_reference,
86
90
  msgSeq: data.msg_seq, // 用于避免去重
@@ -137,6 +141,8 @@ export class QqGateway extends Service {
137
141
  this.heartbeatTimer = null
138
142
  this.heartbeatInterval = 30_000
139
143
  this.sequence = null
144
+ this.sessionId = null
145
+ this.unackedHeartbeats = 0
140
146
  this.tokenPromise = null
141
147
  this.dedup = new Map()
142
148
  }
@@ -213,6 +219,7 @@ export class QqGateway extends Service {
213
219
  }
214
220
 
215
221
  async runLoop() {
222
+ let backoffMs = this.config.reconnectDelayMs
216
223
  while (!this.stopRequested) {
217
224
  try {
218
225
  this.setStatus('starting')
@@ -223,11 +230,13 @@ export class QqGateway extends Service {
223
230
  || ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
224
231
  || DEFAULT_GATEWAY
225
232
  await this.connect(gateway, token)
233
+ backoffMs = this.config.reconnectDelayMs
226
234
  } catch (error) {
227
235
  if (this.stopRequested) break
228
236
  this.setStatus('reconnecting')
229
- this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
230
- await sleep(this.config.reconnectDelayMs)
237
+ this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s, retrying in %dms...', error?.message ?? error, backoffMs)
238
+ await sleep(backoffMs)
239
+ backoffMs = Math.min(Math.round(backoffMs * 1.5), 30_000)
231
240
  }
232
241
  }
233
242
  this.setStatus('idle')
@@ -262,22 +271,48 @@ export class QqGateway extends Service {
262
271
  if (op === 10) {
263
272
  this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
264
273
  this.startHeartbeat(ws)
265
- ws.send(JSON.stringify({
266
- op: 2,
267
- d: {
268
- token: `QQBot ${token}`,
269
- intents: this.config.intents,
270
- shard: [0, 1],
271
- properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
272
- },
273
- }))
274
+ if (this.sessionId && this.sequence != null) {
275
+ // 快速恢复模式(Resume):携带 sessionId 与 sequence 避免重新全量鉴权与丢失消息
276
+ this.logger?.info?.('[dsh-bridge qq] attempting session resume (sessionId=%s, seq=%s)', this.sessionId, this.sequence)
277
+ ws.send(JSON.stringify({
278
+ op: 6,
279
+ d: {
280
+ token: `QQBot ${token}`,
281
+ session_id: this.sessionId,
282
+ seq: this.sequence,
283
+ },
284
+ }))
285
+ } else {
286
+ // 全量鉴权模式(Identify)
287
+ ws.send(JSON.stringify({
288
+ op: 2,
289
+ d: {
290
+ token: `QQBot ${token}`,
291
+ intents: this.config.intents,
292
+ shard: [0, 1],
293
+ properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
294
+ },
295
+ }))
296
+ }
297
+ return
298
+ }
299
+ if (op === 11) {
300
+ // 心跳确认(Heartbeat ACK):重置未确认计数器
301
+ this.unackedHeartbeats = 0
274
302
  return
275
303
  }
276
304
  if (op === 0) {
277
305
  if (payload.t === 'READY') {
306
+ this.sessionId = payload.d?.session_id || this.sessionId
278
307
  this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
308
+ this.unackedHeartbeats = 0
279
309
  await this.persist({ accountId: this.accountId })
280
310
  this.setStatus('connected')
311
+ this.logger?.info?.('[dsh-bridge qq] gateway READY (session_id=%s)', this.sessionId)
312
+ } else if (payload.t === 'RESUMED') {
313
+ this.unackedHeartbeats = 0
314
+ this.setStatus('connected')
315
+ this.logger?.info?.('[dsh-bridge qq] gateway RESUMED successfully')
281
316
  }
282
317
  const event = normalizeEvent(payload)
283
318
  if (event.type === 'message' && event.id && !this.seen(event.id)) {
@@ -288,14 +323,31 @@ export class QqGateway extends Service {
288
323
  }
289
324
  return
290
325
  }
291
- if (op === 7) { try { ws.close() } catch {}; return }
292
- if (op === 9) throw new Error('QQ gateway invalid session')
326
+ if (op === 7) {
327
+ this.logger?.info?.('[dsh-bridge qq] gateway requested reconnect (OpCode 7)')
328
+ try { ws.close() } catch {}
329
+ return
330
+ }
331
+ if (op === 9) {
332
+ this.sessionId = null
333
+ this.sequence = null
334
+ throw new Error('QQ gateway invalid session (OpCode 9)')
335
+ }
293
336
  }
294
337
 
295
338
  startHeartbeat(ws) {
296
339
  this.clearHeartbeat()
340
+ this.unackedHeartbeats = 0
297
341
  const beat = () => {
298
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: 1, d: this.sequence }))
342
+ if (ws.readyState === WebSocket.OPEN) {
343
+ if (this.unackedHeartbeats >= 2) {
344
+ this.logger?.warn?.('[dsh-bridge qq] heartbeat ACK missed (count=%d), dead link detected, reconnecting...', this.unackedHeartbeats)
345
+ try { ws.terminate() } catch {}
346
+ return
347
+ }
348
+ this.unackedHeartbeats += 1
349
+ ws.send(JSON.stringify({ op: 1, d: this.sequence }))
350
+ }
299
351
  }
300
352
  beat()
301
353
  this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
@@ -304,6 +356,7 @@ export class QqGateway extends Service {
304
356
  clearHeartbeat() {
305
357
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
306
358
  this.heartbeatTimer = null
359
+ this.unackedHeartbeats = 0
307
360
  }
308
361
 
309
362
  seen(id) {
@@ -485,6 +538,33 @@ export class QqGateway extends Service {
485
538
  return this.api(`/v2/panels/${encodeURIComponent(panelId)}/target`, { method: 'PUT', body })
486
539
  }
487
540
 
541
+ /**
542
+ * 上传并发送本地媒体文件(图片等)
543
+ */
544
+ async sendMediaFile(peerId, filePath, opts = {}) {
545
+ if (!fs.existsSync(filePath)) return null
546
+ const ext = path.extname(filePath).toLowerCase()
547
+ const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
548
+ const fileType = isImage ? 1 : 1
549
+ try {
550
+ const buf = await fs.promises.readFile(filePath)
551
+ const base64Data = buf.toString('base64')
552
+ const ep = this.endpoint(peerId, opts.scope, 'files')
553
+ const res = await this.api(ep, {
554
+ method: 'POST',
555
+ body: {
556
+ file_type: fileType,
557
+ file_data: base64Data,
558
+ srv_send_msg: true,
559
+ },
560
+ })
561
+ return res
562
+ } catch (err) {
563
+ this.logger?.warn?.('[dsh-bridge qq] sendMediaFile error: %s', err?.message ?? err)
564
+ return null
565
+ }
566
+ }
567
+
488
568
  setCredentials(values = {}) {
489
569
  for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
490
570
  if (values[key] !== undefined) this.config[key] = values[key]
package/lib/qq/node.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
7
7
  // - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
8
8
 
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
9
11
  import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
10
12
  import { gatewayConstants } from './gateway.js'
11
13
 
@@ -114,6 +116,7 @@ function makePlatform(gateway) {
114
116
  get capabilities() { return gateway.capabilities },
115
117
  // sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
116
118
  sendText: (peer, text) => gateway.sendText(peer, text, {}),
119
+ sendMediaFile: (peer, filePath, opts = {}) => gateway.sendMediaFile(peer, filePath, opts),
117
120
  sendTyping: () => Promise.resolve({ ok: true }),
118
121
  sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
119
122
  }
@@ -144,12 +147,8 @@ export class QqConversationNode extends ConversationBridge {
144
147
  this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
145
148
 
146
149
  // 订阅网关入站事件
147
- this.ctx.on('qq/message', (event) => {
148
- void this._handleInbound(event)
149
- })
150
- this.ctx.on('qq/interaction', (event) => {
151
- void this._handleInteraction(event)
152
- })
150
+ this.ctx.on('qq/message', (event) => this._handleInbound(event))
151
+ this.ctx.on('qq/interaction', (event) => this._handleInteraction(event))
153
152
  }
154
153
 
155
154
  // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
@@ -373,7 +372,16 @@ export class QqConversationNode extends ConversationBridge {
373
372
  }
374
373
 
375
374
  const text = String(event.text ?? '').trim()
376
- if (!text) {
375
+ const attachments = event.attachments || []
376
+
377
+ let mediaFiles = []
378
+ if (attachments.length > 0) {
379
+ this.logger?.info?.(`[dsh-bridge qq] processing ${attachments.length} attachment(s) from ${sender}...`)
380
+ mediaFiles = await this._processAttachments(attachments, this.config.cwd)
381
+ this.logger?.info?.(`[dsh-bridge qq] downloaded ${mediaFiles.length} file(s)`)
382
+ }
383
+
384
+ if (!text && mediaFiles.length === 0) {
377
385
  this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
378
386
  return
379
387
  }
@@ -384,10 +392,16 @@ export class QqConversationNode extends ConversationBridge {
384
392
  if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
385
393
  }
386
394
 
395
+ let fullText = text
396
+ if (mediaFiles.length > 0) {
397
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
398
+ fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
399
+ }
400
+
387
401
  // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
388
402
  // 则自动创建新会话并发送消息
389
403
  const messageReference = event.messageReference
390
- if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
404
+ if (messageReference && !this.activeSessionId && !fullText.startsWith('/')) {
391
405
  this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
392
406
  // 先创建新会话,再处理消息
393
407
  await this.handleInbound({ senderId: authId, text: '/new', isGroup })
@@ -396,7 +410,33 @@ export class QqConversationNode extends ConversationBridge {
396
410
  }
397
411
 
398
412
  // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
399
- await this.handleInbound({ senderId: authId, text, isGroup })
413
+ await this.handleInbound({ senderId: authId, text: fullText, isGroup })
414
+ }
415
+
416
+ /** 下载附件(图片/文件)并保存到本地工作目录 */
417
+ async _processAttachments(attachments, sessionCwd) {
418
+ if (!Array.isArray(attachments) || attachments.length === 0) return []
419
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.qq-media')
420
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
421
+
422
+ const downloaded = []
423
+ for (const att of attachments) {
424
+ if (!att?.url) continue
425
+ try {
426
+ const rawUrl = att.url.startsWith('http') ? att.url : `https://${att.url}`
427
+ const res = await fetch(rawUrl, { signal: AbortSignal.timeout(15000) })
428
+ if (!res.ok) continue
429
+ const buf = Buffer.from(await res.arrayBuffer())
430
+ const ext = att.filename ? path.extname(att.filename) : (att.content_type?.includes('image') ? '.png' : '.bin')
431
+ const safeName = att.filename ? path.basename(att.filename) : `qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
432
+ const filePath = path.join(mediaDir, safeName)
433
+ await fs.promises.writeFile(filePath, buf)
434
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
435
+ } catch (err) {
436
+ this.logger?.warn?.('[dsh-bridge qq] download attachment error: %s', err?.message ?? err)
437
+ }
438
+ }
439
+ return downloaded
400
440
  }
401
441
 
402
442
  // ---- 互动事件 ----