@wenbin_wb/dsh-bridge 2.10.2 → 2.10.3

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/lib/qq/node.js CHANGED
@@ -1,532 +1,539 @@
1
- // dsh-bridge QQ conversation node
2
- // 把 QQ OpenAPI v2 的入站事件(C2C 私聊 / 群聊 @提及)解析后交给平台无关的
3
- // ConversationBridge 处理,出站通过 QqGateway 发送文本 / Markdown / 按钮。
4
- // 平台特定部分:
5
- // - 入站解析:event.scope 决定 c2c / group / guild,text 直接来自 content
6
- // - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
7
- // - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
8
-
9
- import fs from 'node:fs'
10
- import path from 'node:path'
11
- import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
12
- import { cumulativeSlices } from '../platform/stream-slices.js'
13
- import { gatewayConstants } from './gateway.js'
14
-
15
- const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
16
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
17
-
18
- // QQ 流式消息:markdown 内容每片最大长度(append 模式下每片为追加片段)
19
- const STREAM_CHUNK_SIZE = 400
20
-
21
- /**
22
- * 将文本转成 QQ 安全 Markdown:
23
- * - 标题降级:QQ 仅标准支持 # 与 ##,超出(###、####)降级为 ## 或加粗
24
- * - 列表/表格前空行:QQ 规定列表前若为普通文本必须有空行隔开,自动补齐空行
25
- * - markdown 图片语法 ![alt](url) 转为链接 [alt](url)(避免图片转存失败导致整条失败)
26
- * - 代码块内容原样保留
27
- */
28
- function sanitizeQQMarkdown(text) {
29
- if (!text) return ''
30
- const lines = String(text).split('\n')
31
- const out = []
32
- let inFence = false
33
- const isListOrTable = (l) => /^\s*(-|\*|\d+\.|\/|\|)\s+/.test(l) || /^\s*\|.*\|\s*$/.test(l)
34
- const isHeading = (l) => /^\s*#{1,6}\s+/.test(l)
35
-
36
- for (let i = 0; i < lines.length; i++) {
37
- let line = lines[i]
38
- if (/^\s*```/.test(line)) {
39
- inFence = !inFence
40
- out.push(line)
41
- continue
42
- }
43
- if (inFence) { out.push(line); continue }
44
-
45
- // 标题级别规范化:QQ 标准支持 # 和 ##,超出(###+)统一转为 ##
46
- if (isHeading(line)) {
47
- line = line.replace(/^\s*#{3,}\s+/, '## ')
48
- }
49
-
50
- // 图片语法转为链接
51
- line = line.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)')
52
-
53
- // 列表/表格前空行保障:若前一行是非空普通文本且不是标题/列表/引用/表格,则插入空行保证 QQ 正确解析
54
- if (isListOrTable(line) && out.length > 0) {
55
- const prev = out[out.length - 1]
56
- if (prev && prev.trim() && !isListOrTable(prev) && !isHeading(prev) && !prev.startsWith('>')) {
57
- out.push('')
58
- }
59
- }
60
-
61
- out.push(line)
62
- }
63
- return out.join('\n')
64
- }
65
-
66
- /**
67
- * replace 模式流式分片:把完整内容切成「递增前缀」序列。
68
- * 官方要求 replace 模式下每片 content_raw 为当前全量正文,
69
- * 且须以上游已下发内容开头;服务端逐片覆盖显示 → 手机端看到一条消息逐渐变长。
70
- * @param {string} content
71
- * @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
72
- * @returns {string[]} 递增前缀数组,最后一片为完整内容
73
- */
74
- function splitIntoIncremental(content, maxChunk) {
75
- return cumulativeSlices(splitIntoChunks(content, maxChunk))
76
- }
77
-
78
- /**
79
- * 按段落边界切分内容(避免切断行内内容),单块时拆两片保证流式过渡
80
- * @param {string} content - 要分片的内容
81
- * @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
82
- */
83
- function splitIntoChunks(content, maxChunk) {
84
- const chunks = []
85
- let start = 0
86
- while (start < content.length) {
87
- if (content.length - start <= maxChunk) { chunks.push(content.slice(start)); break }
88
- const windowStart = start + Math.floor(maxChunk * 0.6)
89
- const windowEnd = start + maxChunk
90
- let cut = content.lastIndexOf('\n', windowEnd)
91
- if (cut <= windowStart || cut === -1) cut = windowEnd
92
- chunks.push(content.slice(start, cut))
93
- start = cut
94
- }
95
- // 单块时拆两片,保证「生成中 → 生成结束」的流式过渡
96
- if (chunks.length === 1 && content.length > 0) {
97
- const half = Math.ceil(content.length / 2)
98
- chunks.length = 0
99
- chunks.push(content.slice(0, half), content.slice(half))
100
- }
101
- return chunks
102
- }
103
-
104
- // 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
105
- function makePlatform(gateway) {
106
- return {
107
- id: 'qq',
108
- name: 'QQ',
109
- get accountId() { return gateway.accountId ?? '' },
110
- get capabilities() { return gateway.capabilities },
111
- // 真实 status:bridge 的离线守卫依赖它,鸭子对象不再恒为 undefined(T3.3)
112
- get status() { return gateway.status ?? 'idle' },
113
- // sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
114
- sendText: (peer, text) => gateway.sendText(peer, text, {}),
115
- sendMediaFile: (peer, filePath, opts = {}) => gateway.sendMediaFile(peer, filePath, opts),
116
- sendTyping: () => Promise.resolve({ ok: true }),
117
- sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
118
- }
119
- }
120
-
121
- export class QqConversationNode extends ConversationBridge {
122
- /**
123
- * @param {object} ctx Cordis 上下文(含 ctx.qq 网关服务)
124
- * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
125
- * @param {object} logger 日志器
126
- * @param {object} [opts]
127
- * @param {(senderId: string) => void} [opts.onFirstSender]
128
- * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
129
- */
130
- constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
131
- super({
132
- ctx,
133
- logger,
134
- config,
135
- platform: makePlatform(ctx.qq),
136
- onFirstSender,
137
- onActiveSessionChange,
138
- })
139
- this.gateway = ctx.qq
140
- this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
141
- // 当前对话 peer 信息(由 _handleInbound 在每次收到消息时刷新)
142
- this._lastPeer = null // { peerId, scope: 'c2c'|'group' }
143
- this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
144
-
145
- // 订阅网关入站事件
146
- this.disposers.push(this.ctx.on('qq/message', (event) => this._handleInbound(event)))
147
- this.disposers.push(this.ctx.on('qq/interaction', (event) => this._handleInteraction(event)))
148
- }
149
-
150
- // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
151
-
152
- /** 解析当前对话 peer 信息;无活动 peer 时返回 null */
153
- _currentPeer() {
154
- return this._lastPeer
155
- }
156
-
157
- async _sendTextNow(text, opts = {}) {
158
- // T2.3:轮次绑定的 outboundPeer 携带完整 { peerId, scope, replyMsgId } 时优先生效
159
- const bound = opts?.outboundPeer && opts.outboundPeer.scope ? opts.outboundPeer : null
160
- const peerInfo = bound ?? this._currentPeer()
161
- if (!peerInfo) return
162
- const { peerId, scope } = peerInfo
163
- const replyMsgId = (bound?.replyMsgId ?? this._replyMsgId) || undefined
164
-
165
- // 检测是否是提示用户开始新会话的消息
166
- const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
167
-
168
- if (isPromptMessage) {
169
- // 发送带按钮的消息,方便用户快速操作
170
- // 官方键盘结构:keyboard.content.rows;action.type=1(回调按钮,触发 INTERACTION_CREATE)
171
- // 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
172
- const keyboard = {
173
- content: {
174
- rows: [
175
- {
176
- buttons: [
177
- {
178
- id: 'new_conversation',
179
- render_data: { label: '🆕 新建会话', visited_label: '新建会话', style: 1 },
180
- action: { type: 1, permission: { type: 2 }, data: 'new', unsupport_tips: '请升级QQ客户端后使用' },
181
- },
182
- {
183
- id: 'list_sessions',
184
- render_data: { label: '📋 会话列表', visited_label: '会话列表', style: 1 },
185
- action: { type: 1, permission: { type: 2 }, data: 'list', unsupport_tips: '请升级QQ客户端后使用' },
186
- },
187
- ],
188
- },
189
- {
190
- buttons: [
191
- {
192
- id: 'help',
193
- render_data: { label: '❓ 帮助', visited_label: '帮助', style: 1 },
194
- action: { type: 1, permission: { type: 2 }, data: 'help', unsupport_tips: '请升级QQ客户端后使用' },
195
- },
196
- ],
197
- },
198
- ],
199
- },
200
- }
201
- // 官方按钮基于 markdown 消息(msg_type=2)挂载
202
- return this._sendMarkdown(peerId, text, { scope, msgId: replyMsgId, keyboard })
203
- }
204
-
205
- // 审批申请:发送带「✓ 批准执行」与「✕ 拒绝执行」交互按钮的 Markdown 卡片
206
- const isApprovalMessage = text.includes('操作权限确认')
207
- if (isApprovalMessage) {
208
- const keyboard = {
209
- content: {
210
- rows: [
211
- {
212
- buttons: [
213
- {
214
- id: 'approve_yes',
215
- render_data: { label: '✓ 批准执行', visited_label: '已批准', style: 1 },
216
- action: { type: 1, permission: { type: 2 }, data: '/yes', unsupport_tips: '请升级QQ客户端后使用' },
217
- },
218
- {
219
- id: 'approve_no',
220
- render_data: { label: '✕ 拒绝执行', visited_label: '已拒绝', style: 0 },
221
- action: { type: 1, permission: { type: 2 }, data: '/no', unsupport_tips: '请升级QQ客户端后使用' },
222
- },
223
- ],
224
- },
225
- ],
226
- },
227
- }
228
- return this._sendMarkdown(peerId, sanitizeQQMarkdown(text), { scope, keyboard })
229
- }
230
-
231
- // 审批决议通知(已批准/已拒绝):直接单条 Markdown 发送,无需流式分片
232
- const isResolutionMessage = text.includes('已批准执行') || text.includes('已拒绝执行')
233
- if (isResolutionMessage) {
234
- return this._sendMarkdown(peerId, sanitizeQQMarkdown(text), { scope })
235
- }
236
-
237
- // 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染
238
- const content = String(text || '').trim()
239
- if (content.length === 0) return { success: true }
240
- const md = sanitizeQQMarkdown(content)
241
-
242
- // 群消息不支持流式参数(官方文档明确说明),直接发送 Markdown
243
- // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_groups_group_openid_messages.post.html
244
- if (scope === 'group') {
245
- return this._sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
246
- }
247
-
248
- // 单聊:replace 模式(每片是全量前缀),服务端逐片覆盖 → 一条消息逐渐变长
249
- const maxChars = this.config.maxMessageChars || 2000
250
- const slices = splitIntoIncremental(md, maxChars)
251
-
252
- // 被动回复的 msg_id 每片都带(官方示例如此);主动消息(digest 等)无 msg_id 则不传
253
- const baseSeq = Number(this._msgSeq) || 0
254
- const streamCommon = {
255
- scope,
256
- contentType: 'markdown',
257
- inputMode: 'replace',
258
- }
259
- // 声明在 try 外:catch 的补发收尾路径也要读取,否则是 ReferenceError
260
- let streamMsgId = null
261
-
262
- try {
263
- for (let i = 0; i < slices.length; i++) {
264
- const isLast = i === slices.length - 1
265
- const result = await this.gateway.sendStream(peerId, slices[i], {
266
- ...streamCommon,
267
- msgId: replyMsgId, // 每片都带被动回复 msg_id(官方示例如此)
268
- msgSeq: replyMsgId ? baseSeq + 1 + i : undefined, // 递增避免去重(40054005)
269
- streamMsgId, // 后续片携带服务端返回的 stream_msg_id
270
- index: i, // 分片序号从 0 递增
271
- inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
272
- })
273
-
274
- // 首片返回 stream_msg_id,后续片需携带
275
- if (i === 0) {
276
- streamMsgId = result?.id || result?.message_id || result?.stream_msg_id
277
- if (streamMsgId) {
278
- this.lastMessageId = streamMsgId
279
- this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s, slices=%d', streamMsgId, slices.length)
280
- } else {
281
- this.logger?.warn?.('[dsh-bridge qq] stream first chunk returned no id: %o', result)
282
- }
283
- }
284
-
285
- if (result?.code !== undefined && result.code !== 0) {
286
- throw new Error(result.message || `QQ API error ${result.code}`)
287
- }
288
-
289
- // 使用用户配置的 sendChunkDelayMs(默认 1500ms),而非硬编码 120ms
290
- if (!isLast) {
291
- const delayMs = this.config.sendChunkDelayMs ?? 1500
292
- if (delayMs > 0) await sleep(delayMs)
293
- }
294
- }
295
- return { success: true }
296
- } catch (err) {
297
- // 流式失败 → 补发完整内容 replace 收尾(input_state=10),尽量合并成一条
298
- this.logger?.warn?.('[dsh-bridge qq] stream send failed, patch final replace: %s', err?.message ?? err)
299
- try {
300
- const result = await this.gateway.sendStream(peerId, md, {
301
- ...streamCommon,
302
- msgId: replyMsgId,
303
- msgSeq: replyMsgId ? baseSeq + slices.length + 1 : undefined,
304
- streamMsgId,
305
- index: slices.length,
306
- inputState: 10,
307
- })
308
- if (result?.id) this.lastMessageId = result.id
309
- return result
310
- } catch (fallbackErr1) {
311
- // 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
312
- return this._sendMarkdown(peerId, md, { scope })
313
- }
314
- }
315
- }
316
-
317
- /** 发送 Markdown 消息:先带被动回复 msg_id,失败则降级为主动消息。 */
318
- async _sendMarkdown(peerId, md, { scope, msgId, keyboard } = {}) {
319
- try {
320
- const result = await this.gateway.sendMarkdown(peerId, md, { scope, msgId, keyboard })
321
- if (result?.id) this.lastMessageId = result.id
322
- return result
323
- } catch (fallbackErr1) {
324
- // 被动回复失败(msg_id 过期等)→ 主动消息兜底
325
- this.logger?.warn?.('[dsh-bridge qq] markdown send failed with msg_id, retry as active: %s', fallbackErr1?.message ?? fallbackErr1)
326
- try {
327
- const result = await this.gateway.sendMarkdown(peerId, md, { scope, keyboard })
328
- if (result?.id) this.lastMessageId = result.id
329
- return result
330
- } catch (fallbackErr2) {
331
- this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
332
- return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
333
- }
334
- }
335
- }
336
-
337
- /** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒;群聊不支持) */
338
- async sendTyping(state) {
339
- const peerInfo = this._currentPeer()
340
- if (!peerInfo) return
341
- // 群聊消息类型列表不含 msg_type=6(输入状态),跳过
342
- if (peerInfo.scope === 'group') return { ok: true }
343
- // state=2(停止)时无需显式结束——input_second 到期自动消失
344
- if (Number(state) === 2) return { ok: true }
345
- return this.gateway.sendTyping(peerInfo.peerId, {
346
- scope: peerInfo.scope,
347
- durationSeconds: 8,
348
- msgId: this._replyMsgId || undefined,
349
- })
350
- }
351
-
352
- /** 发送多媒体附件,正确传递 c2c 或 group 范围 */
353
- async sendMediaFile(filePath, opts = {}) {
354
- const peerInfo = this._currentPeer()
355
- if (!peerInfo) return
356
- return this.gateway.sendMediaFile(peerInfo.peerId, filePath, { scope: peerInfo.scope, ...opts })
357
- }
358
-
359
- // ---- 入站 ----
360
-
361
- async _handleInbound(event) {
362
- if (this.gateway?.stopRequested) return
363
- const sender = String(event.senderId ?? '').trim()
364
- if (!sender) return
365
-
366
- // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
367
- const isGroup = event.scope === 'group' || event.scope === 'guild'
368
-
369
- // 记录当前 peer 信息与被动回复消息 ID(事件 d.id)、msg_seq,供出站使用
370
- const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
371
- // 授权主体:群聊按群维度(group_openid),单聊按用户(user_openid)
372
- const authId = isGroup ? (event.groupId || peerId || sender) : sender
373
-
374
- // 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
375
- // 群聊不在此拦截——交给 handleInbound 自动授权(首次 @机器人 即授权该群)
376
- if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
377
- this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${authId}`)
378
- return
379
- }
380
-
381
- const text = String(event.text ?? '').trim()
382
- const attachments = event.attachments || []
383
-
384
- let mediaFiles = []
385
- if (attachments.length > 0) {
386
- this.logger?.info?.(`[dsh-bridge qq] processing ${attachments.length} attachment(s) from ${sender}...`)
387
- mediaFiles = await this._processAttachments(attachments, this.config.cwd)
388
- this.logger?.info?.(`[dsh-bridge qq] downloaded ${mediaFiles.length} file(s)`)
389
- }
390
-
391
- if (!text && mediaFiles.length === 0) {
392
- this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
393
- return
394
- }
395
-
396
- if (peerId) {
397
- this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
398
- if (event.id) this._replyMsgId = String(event.id)
399
- if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
400
- }
401
-
402
- let fullText = text
403
- if (mediaFiles.length > 0) {
404
- const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
405
- fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
406
- }
407
-
408
- // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
409
- // 则自动创建新会话并发送消息
410
- const messageReference = event.messageReference
411
- if (messageReference && !this.activeSessionId && !fullText.startsWith('/')) {
412
- this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
413
- // 先创建新会话,再处理消息
414
- await this.handleInbound({ senderId: authId, text: '/new', isGroup, outboundPeer: this._lastPeer })
415
- // 等待一小段时间确保会话创建完成
416
- await new Promise(resolve => setTimeout(resolve, 100))
417
- }
418
-
419
- // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
420
- await this.handleInbound({ senderId: authId, text: fullText, isGroup, outboundPeer: this._lastPeer })
421
- }
422
-
423
- /** 下载附件(图片/文件)并保存到本地工作目录 */
424
- async _processAttachments(attachments, sessionCwd) {
425
- if (!Array.isArray(attachments) || attachments.length === 0) return []
426
- const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.qq-media')
427
- try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
428
-
429
- const downloaded = []
430
- for (const att of attachments) {
431
- if (!att?.url) continue
432
- try {
433
- const rawUrl = att.url.startsWith('http') ? att.url : `https://${att.url}`
434
- const res = await fetch(rawUrl, { signal: AbortSignal.timeout(15000) })
435
- if (!res.ok) continue
436
- const buf = Buffer.from(await res.arrayBuffer())
437
- const ext = att.filename ? path.extname(att.filename) : (att.content_type?.includes('image') ? '.png' : '.bin')
438
- const safeName = att.filename ? path.basename(att.filename) : `qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
439
- const filePath = path.join(mediaDir, safeName)
440
- await fs.promises.writeFile(filePath, buf)
441
- downloaded.push({ filename: safeName, path: filePath, size: buf.length })
442
- } catch (err) {
443
- this.logger?.warn?.('[dsh-bridge qq] download attachment error: %s', err?.message ?? err)
444
- }
445
- }
446
- return downloaded
447
- }
448
-
449
- // ---- 互动事件 ----
450
-
451
- async _handleInteraction(event) {
452
- if (this.gateway?.stopRequested) return
453
- const sender = String(event.senderId ?? '').trim()
454
- if (!sender) return
455
-
456
- // 群聊按钮点击:授权主体按群维度(group_openid),与消息处理一致
457
- const isGroup = event.scope === 'group'
458
- const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
459
- const authId = isGroup ? (event.groupId || peerId || sender) : sender
460
-
461
- // 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
462
- // 群聊不在此拦截——handleInbound 会按群自动授权
463
- if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
464
- this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${authId}`)
465
- return
466
- }
467
-
468
- const type = Number(event.interactionType ?? event?.data?.type ?? 0)
469
- // 仅消息按钮(11)与快捷菜单(12)需要回应;其他类型(反馈/清空会话/故事集/授权等)无需回应
470
- const needsRespond = type === 11 || type === 12
471
-
472
- // 记录 peer 信息,供命令回复使用
473
- if (peerId) {
474
- this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
475
- this._replyMsgId = null
476
- }
477
-
478
- // 回应互动:告知后台已收到,避免客户端一直 loading(同一 interaction_id 只能回应一次)
479
- if (needsRespond && event.interactionId) {
480
- try {
481
- await this.gateway.respondInteraction(event.interactionId, { code: 0 })
482
- } catch (err) {
483
- this.logger?.warn?.('[dsh-bridge qq] respond interaction failed: %s', err?.message ?? err)
484
- }
485
- }
486
-
487
- if (!needsRespond) {
488
- this.logger?.info?.(`[dsh-bridge qq] skip non-button interaction type=${type} from ${authId}`)
489
- return
490
- }
491
-
492
- const data = event.data || {}
493
- const resolved = data.resolved || {}
494
- const buttonId = resolved.button_id || ''
495
-
496
- // 根据按钮 ID 执行对应操作(消息按钮 11 与快捷菜单 12 都映射到命令)
497
- this.logger?.info?.('[dsh-bridge qq] button interaction type=%s button_id=%s from %s', type, buttonId, authId)
498
- if (buttonId === 'new_conversation') {
499
- await this.handleInbound({ senderId: authId, text: '/new', isGroup, outboundPeer: this._lastPeer })
500
- } else if (buttonId === 'list_sessions') {
501
- await this.handleInbound({ senderId: authId, text: '/list', isGroup, outboundPeer: this._lastPeer })
502
- } else if (buttonId === 'help') {
503
- await this.handleInbound({ senderId: authId, text: '/help', isGroup, outboundPeer: this._lastPeer })
504
- } else if (buttonId === 'approve_yes') {
505
- await this.handleInbound({ senderId: authId, text: '/yes', isGroup, outboundPeer: this._lastPeer })
506
- } else if (buttonId === 'approve_no') {
507
- await this.handleInbound({ senderId: authId, text: '/no', isGroup, outboundPeer: this._lastPeer })
508
- } else {
509
- this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
510
- }
511
- }
512
-
513
- dispose() {
514
- this._lastPeer = null
515
- this._replyMsgId = null
516
- this.lastMessageId = null
517
- super.dispose()
518
- }
519
- }
520
-
521
- // 导出,便于测试与复用
522
- export const qqNodeHelpers = {
523
- splitForQQ: conversationBridgeHelpers.splitForIM,
524
- digestLine: conversationBridgeHelpers.digestLine,
525
- textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
526
- listSessions: conversationBridgeHelpers.listSessions,
527
- sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
528
- sanitizeQQMarkdown,
529
- splitIntoChunks,
530
- splitIntoIncremental,
531
- STREAM_CHUNK_SIZE,
532
- }
1
+ // dsh-bridge QQ conversation node
2
+ // 把 QQ OpenAPI v2 的入站事件(C2C 私聊 / 群聊 @提及)解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 QqGateway 发送文本 / Markdown / 按钮。
4
+ // 平台特定部分:
5
+ // - 入站解析:event.scope 决定 c2c / group / guild,text 直接来自 content
6
+ // - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
7
+ // - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
8
+
9
+ import fs from 'node:fs'
10
+ import path from 'node:path'
11
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
12
+ import { cumulativeSlices } from '../platform/stream-slices.js'
13
+ import { gatewayConstants } from './gateway.js'
14
+
15
+ const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
17
+
18
+ // QQ 流式消息:markdown 内容每片最大长度(append 模式下每片为追加片段)
19
+ const STREAM_CHUNK_SIZE = 400
20
+
21
+ /**
22
+ * 将文本转成 QQ 安全 Markdown:
23
+ * - 标题降级:QQ 仅标准支持 # 与 ##,超出(###、####)降级为 ## 或加粗
24
+ * - 列表/表格前空行:QQ 规定列表前若为普通文本必须有空行隔开,自动补齐空行
25
+ * - markdown 图片语法 ![alt](url) 转为链接 [alt](url)(避免图片转存失败导致整条失败)
26
+ * - 代码块内容原样保留
27
+ */
28
+ function sanitizeQQMarkdown(text) {
29
+ if (!text) return ''
30
+ const lines = String(text).split('\n')
31
+ const out = []
32
+ let inFence = false
33
+ const isListOrTable = (l) => /^\s*(-|\*|\d+\.|\/|\|)\s+/.test(l) || /^\s*\|.*\|\s*$/.test(l)
34
+ const isHeading = (l) => /^\s*#{1,6}\s+/.test(l)
35
+
36
+ for (let i = 0; i < lines.length; i++) {
37
+ let line = lines[i]
38
+ if (/^\s*```/.test(line)) {
39
+ inFence = !inFence
40
+ out.push(line)
41
+ continue
42
+ }
43
+ if (inFence) { out.push(line); continue }
44
+
45
+ // 标题级别规范化:QQ 标准支持 # 和 ##,超出(###+)统一转为 ##
46
+ if (isHeading(line)) {
47
+ line = line.replace(/^\s*#{3,}\s+/, '## ')
48
+ }
49
+
50
+ // 图片语法转为链接
51
+ line = line.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)')
52
+
53
+ // 列表/表格前空行保障:若前一行是非空普通文本且不是标题/列表/引用/表格,则插入空行保证 QQ 正确解析
54
+ if (isListOrTable(line) && out.length > 0) {
55
+ const prev = out[out.length - 1]
56
+ if (prev && prev.trim() && !isListOrTable(prev) && !isHeading(prev) && !prev.startsWith('>')) {
57
+ out.push('')
58
+ }
59
+ }
60
+
61
+ out.push(line)
62
+ }
63
+ return out.join('\n')
64
+ }
65
+
66
+ /**
67
+ * replace 模式流式分片:把完整内容切成「递增前缀」序列。
68
+ * 官方要求 replace 模式下每片 content_raw 为当前全量正文,
69
+ * 且须以上游已下发内容开头;服务端逐片覆盖显示 → 手机端看到一条消息逐渐变长。
70
+ * @param {string} content
71
+ * @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
72
+ * @returns {string[]} 递增前缀数组,最后一片为完整内容
73
+ */
74
+ function splitIntoIncremental(content, maxChunk) {
75
+ return cumulativeSlices(splitIntoChunks(content, maxChunk))
76
+ }
77
+
78
+ /**
79
+ * 按段落边界切分内容(避免切断行内内容),单块时拆两片保证流式过渡
80
+ * @param {string} content - 要分片的内容
81
+ * @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
82
+ */
83
+ function splitIntoChunks(content, maxChunk) {
84
+ const chunks = []
85
+ let start = 0
86
+ while (start < content.length) {
87
+ if (content.length - start <= maxChunk) { chunks.push(content.slice(start)); break }
88
+ const windowStart = start + Math.floor(maxChunk * 0.6)
89
+ const windowEnd = start + maxChunk
90
+ let cut = content.lastIndexOf('\n', windowEnd)
91
+ if (cut <= windowStart || cut === -1) cut = windowEnd
92
+ chunks.push(content.slice(start, cut))
93
+ start = cut
94
+ }
95
+ // 单块时拆两片,保证「生成中 → 生成结束」的流式过渡
96
+ if (chunks.length === 1 && content.length > 0) {
97
+ const half = Math.ceil(content.length / 2)
98
+ chunks.length = 0
99
+ chunks.push(content.slice(0, half), content.slice(half))
100
+ }
101
+ return chunks
102
+ }
103
+
104
+ // 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
105
+ function makePlatform(gateway) {
106
+ return {
107
+ id: 'qq',
108
+ name: 'QQ',
109
+ get accountId() { return gateway.accountId ?? '' },
110
+ get capabilities() { return gateway.capabilities },
111
+ // 真实 status:bridge 的离线守卫依赖它,鸭子对象不再恒为 undefined(T3.3)
112
+ get status() { return gateway.status ?? 'idle' },
113
+ // sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
114
+ sendText: (peer, text) => gateway.sendText(peer, text, {}),
115
+ sendMediaFile: (peer, filePath, opts = {}) => gateway.sendMediaFile(peer, filePath, opts),
116
+ sendTyping: () => Promise.resolve({ ok: true }),
117
+ sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
118
+ }
119
+ }
120
+
121
+ export class QqConversationNode extends ConversationBridge {
122
+ /**
123
+ * @param {object} ctx Cordis 上下文(含 ctx.qq 网关服务)
124
+ * @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
125
+ * @param {object} logger 日志器
126
+ * @param {object} [opts]
127
+ * @param {(senderId: string) => void} [opts.onFirstSender]
128
+ * @param {(sessionId: string) => void} [opts.onActiveSessionChange]
129
+ */
130
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
131
+ super({
132
+ ctx,
133
+ logger,
134
+ config,
135
+ platform: makePlatform(ctx.qq),
136
+ onFirstSender,
137
+ onActiveSessionChange,
138
+ })
139
+ this.gateway = ctx.qq
140
+ this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
141
+ // 当前对话 peer 信息(由 _handleInbound 在每次收到消息时刷新)
142
+ this._lastPeer = null // { peerId, scope: 'c2c'|'group' }
143
+ this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
144
+
145
+ // 订阅网关入站事件
146
+ this.disposers.push(this.ctx.on('qq/message', (event) => this._handleInbound(event)))
147
+ this.disposers.push(this.ctx.on('qq/interaction', (event) => this._handleInteraction(event)))
148
+ }
149
+
150
+ // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
151
+
152
+ /** 解析当前对话 peer 信息;无活动 peer 时返回 null */
153
+ _currentPeer() {
154
+ return this._lastPeer
155
+ }
156
+
157
+ async _sendTextNow(text, opts = {}) {
158
+ // T2.3:轮次绑定的 outboundPeer 携带完整 { peerId, scope, replyMsgId } 时优先生效
159
+ const bound = opts?.outboundPeer && opts.outboundPeer.scope ? opts.outboundPeer : null
160
+ const peerInfo = bound ?? this._currentPeer()
161
+ if (!peerInfo) return
162
+ const { peerId, scope } = peerInfo
163
+ const replyMsgId = (bound?.replyMsgId ?? this._replyMsgId) || undefined
164
+
165
+ // 检测是否是提示用户开始新会话的消息
166
+ const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
167
+
168
+ if (isPromptMessage) {
169
+ // 发送带按钮的消息,方便用户快速操作
170
+ // 官方键盘结构:keyboard.content.rows;action.type=1(回调按钮,触发 INTERACTION_CREATE)
171
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
172
+ const keyboard = {
173
+ content: {
174
+ rows: [
175
+ {
176
+ buttons: [
177
+ {
178
+ id: 'new_conversation',
179
+ render_data: { label: '🆕 新建会话', visited_label: '新建会话', style: 1 },
180
+ action: { type: 1, permission: { type: 2 }, data: 'new', unsupport_tips: '请升级QQ客户端后使用' },
181
+ },
182
+ {
183
+ id: 'list_sessions',
184
+ render_data: { label: '📋 会话列表', visited_label: '会话列表', style: 1 },
185
+ action: { type: 1, permission: { type: 2 }, data: 'list', unsupport_tips: '请升级QQ客户端后使用' },
186
+ },
187
+ ],
188
+ },
189
+ {
190
+ buttons: [
191
+ {
192
+ id: 'help',
193
+ render_data: { label: '❓ 帮助', visited_label: '帮助', style: 1 },
194
+ action: { type: 1, permission: { type: 2 }, data: 'help', unsupport_tips: '请升级QQ客户端后使用' },
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ },
200
+ }
201
+ // 官方按钮基于 markdown 消息(msg_type=2)挂载
202
+ return this._sendMarkdown(peerId, text, { scope, msgId: replyMsgId, keyboard })
203
+ }
204
+
205
+ // 审批申请:发送带「✓ 批准执行」与「✕ 拒绝执行」交互按钮的 Markdown 卡片
206
+ const isApprovalMessage = text.includes('操作权限确认')
207
+ if (isApprovalMessage) {
208
+ const keyboard = {
209
+ content: {
210
+ rows: [
211
+ {
212
+ buttons: [
213
+ {
214
+ id: 'approve_yes',
215
+ render_data: { label: '✓ 批准执行', visited_label: '已批准', style: 1 },
216
+ action: { type: 1, permission: { type: 2 }, data: '/yes', unsupport_tips: '请升级QQ客户端后使用' },
217
+ },
218
+ {
219
+ id: 'approve_no',
220
+ render_data: { label: '✕ 拒绝执行', visited_label: '已拒绝', style: 0 },
221
+ action: { type: 1, permission: { type: 2 }, data: '/no', unsupport_tips: '请升级QQ客户端后使用' },
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ },
227
+ }
228
+ return this._sendMarkdown(peerId, sanitizeQQMarkdown(text), { scope, keyboard })
229
+ }
230
+
231
+ // 审批决议通知(已批准/已拒绝):直接单条 Markdown 发送,无需流式分片
232
+ const isResolutionMessage = text.includes('已批准执行') || text.includes('已拒绝执行')
233
+ if (isResolutionMessage) {
234
+ return this._sendMarkdown(peerId, sanitizeQQMarkdown(text), { scope })
235
+ }
236
+
237
+ // 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染
238
+ const content = String(text || '').trim()
239
+ if (content.length === 0) return { success: true }
240
+ const md = sanitizeQQMarkdown(content)
241
+
242
+ // 群消息不支持流式参数(官方文档明确说明),直接发送 Markdown
243
+ // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_groups_group_openid_messages.post.html
244
+ if (scope === 'group') {
245
+ return this._sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
246
+ }
247
+
248
+ // 单聊:replace 模式(每片是全量前缀),服务端逐片覆盖 → 一条消息逐渐变长
249
+ const maxChars = this.config.maxMessageChars || 2000
250
+ const slices = splitIntoIncremental(md, maxChars)
251
+
252
+ // 被动回复的 msg_id 每片都带(官方示例如此);主动消息(digest 等)无 msg_id 则不传
253
+ const baseSeq = Number(this._msgSeq) || 0
254
+ const streamCommon = {
255
+ scope,
256
+ contentType: 'markdown',
257
+ inputMode: 'replace',
258
+ }
259
+ // 声明在 try 外:catch 的补发收尾路径也要读取,否则是 ReferenceError
260
+ let streamMsgId = null
261
+
262
+ try {
263
+ for (let i = 0; i < slices.length; i++) {
264
+ const isLast = i === slices.length - 1
265
+ const result = await this.gateway.sendStream(peerId, slices[i], {
266
+ ...streamCommon,
267
+ msgId: replyMsgId, // 每片都带被动回复 msg_id(官方示例如此)
268
+ msgSeq: replyMsgId ? baseSeq + 1 + i : undefined, // 递增避免去重(40054005)
269
+ streamMsgId, // 后续片携带服务端返回的 stream_msg_id
270
+ index: i, // 分片序号从 0 递增
271
+ inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
272
+ })
273
+
274
+ // 首片返回 stream_msg_id,后续片需携带
275
+ if (i === 0) {
276
+ streamMsgId = result?.id || result?.message_id || result?.stream_msg_id
277
+ if (streamMsgId) {
278
+ this.lastMessageId = streamMsgId
279
+ this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s, slices=%d', streamMsgId, slices.length)
280
+ } else {
281
+ this.logger?.warn?.('[dsh-bridge qq] stream first chunk returned no id: %o', result)
282
+ }
283
+ }
284
+
285
+ if (result?.code !== undefined && result.code !== 0) {
286
+ throw new Error(result.message || `QQ API error ${result.code}`)
287
+ }
288
+
289
+ // 使用用户配置的 sendChunkDelayMs(默认 1500ms),而非硬编码 120ms
290
+ if (!isLast) {
291
+ const delayMs = this.config.sendChunkDelayMs ?? 1500
292
+ if (delayMs > 0) await sleep(delayMs)
293
+ }
294
+ }
295
+ return { success: true }
296
+ } catch (err) {
297
+ // 流式失败 → 收尾策略:
298
+ // 首片已成功(有 stream_msg_id):补发完整内容 replace 收尾(input_state=10),把半成品流补全;
299
+ // 首片即失败(无 stream_msg_id):没有可收尾的流消息,若仍发 stream 补发会开启一条
300
+ // "全新流式消息",随后 _sendMarkdown 兜底又发一条 用户收到重复内容。故直接降级单条 Markdown。
301
+ if (!streamMsgId) {
302
+ this.logger?.warn?.('[dsh-bridge qq] stream first chunk failed, fallback to single markdown: %s', err?.message ?? err)
303
+ return this._sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
304
+ }
305
+ this.logger?.warn?.('[dsh-bridge qq] stream send failed, patch final replace: %s', err?.message ?? err)
306
+ try {
307
+ const result = await this.gateway.sendStream(peerId, md, {
308
+ ...streamCommon,
309
+ msgId: replyMsgId,
310
+ msgSeq: replyMsgId ? baseSeq + slices.length + 1 : undefined,
311
+ streamMsgId,
312
+ index: slices.length,
313
+ inputState: 10,
314
+ })
315
+ if (result?.id) this.lastMessageId = result.id
316
+ return result
317
+ } catch (fallbackErr1) {
318
+ // 补发收尾也失败 最终兜底单条 Markdown(带 replyMsgId,内部失败再降级主动消息)
319
+ return this._sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
320
+ }
321
+ }
322
+ }
323
+
324
+ /** 发送 Markdown 消息:先带被动回复 msg_id,失败则降级为主动消息。 */
325
+ async _sendMarkdown(peerId, md, { scope, msgId, keyboard } = {}) {
326
+ try {
327
+ const result = await this.gateway.sendMarkdown(peerId, md, { scope, msgId, keyboard })
328
+ if (result?.id) this.lastMessageId = result.id
329
+ return result
330
+ } catch (fallbackErr1) {
331
+ // 被动回复失败(msg_id 过期等)→ 主动消息兜底
332
+ this.logger?.warn?.('[dsh-bridge qq] markdown send failed with msg_id, retry as active: %s', fallbackErr1?.message ?? fallbackErr1)
333
+ try {
334
+ const result = await this.gateway.sendMarkdown(peerId, md, { scope, keyboard })
335
+ if (result?.id) this.lastMessageId = result.id
336
+ return result
337
+ } catch (fallbackErr2) {
338
+ this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
339
+ return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
340
+ }
341
+ }
342
+ }
343
+
344
+ /** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒;群聊不支持) */
345
+ async sendTyping(state) {
346
+ const peerInfo = this._currentPeer()
347
+ if (!peerInfo) return
348
+ // 群聊消息类型列表不含 msg_type=6(输入状态),跳过
349
+ if (peerInfo.scope === 'group') return { ok: true }
350
+ // state=2(停止)时无需显式结束——input_second 到期自动消失
351
+ if (Number(state) === 2) return { ok: true }
352
+ return this.gateway.sendTyping(peerInfo.peerId, {
353
+ scope: peerInfo.scope,
354
+ durationSeconds: 8,
355
+ msgId: this._replyMsgId || undefined,
356
+ })
357
+ }
358
+
359
+ /** 发送多媒体附件,正确传递 c2c 或 group 范围 */
360
+ async sendMediaFile(filePath, opts = {}) {
361
+ const peerInfo = this._currentPeer()
362
+ if (!peerInfo) return
363
+ return this.gateway.sendMediaFile(peerInfo.peerId, filePath, { scope: peerInfo.scope, ...opts })
364
+ }
365
+
366
+ // ---- 入站 ----
367
+
368
+ async _handleInbound(event) {
369
+ if (this.gateway?.stopRequested) return
370
+ const sender = String(event.senderId ?? '').trim()
371
+ if (!sender) return
372
+
373
+ // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
374
+ const isGroup = event.scope === 'group' || event.scope === 'guild'
375
+
376
+ // 记录当前 peer 信息与被动回复消息 ID(事件 d.id)、msg_seq,供出站使用
377
+ const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
378
+ // 授权主体:群聊按群维度(group_openid),单聊按用户(user_openid)
379
+ const authId = isGroup ? (event.groupId || peerId || sender) : sender
380
+
381
+ // 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
382
+ // 群聊不在此拦截——交给 handleInbound 自动授权(首次 @机器人 即授权该群)
383
+ if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
384
+ this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${authId}`)
385
+ return
386
+ }
387
+
388
+ const text = String(event.text ?? '').trim()
389
+ const attachments = event.attachments || []
390
+
391
+ let mediaFiles = []
392
+ if (attachments.length > 0) {
393
+ this.logger?.info?.(`[dsh-bridge qq] processing ${attachments.length} attachment(s) from ${sender}...`)
394
+ mediaFiles = await this._processAttachments(attachments, this.config.cwd)
395
+ this.logger?.info?.(`[dsh-bridge qq] downloaded ${mediaFiles.length} file(s)`)
396
+ }
397
+
398
+ if (!text && mediaFiles.length === 0) {
399
+ this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
400
+ return
401
+ }
402
+
403
+ if (peerId) {
404
+ this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
405
+ if (event.id) this._replyMsgId = String(event.id)
406
+ if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
407
+ }
408
+
409
+ let fullText = text
410
+ if (mediaFiles.length > 0) {
411
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
412
+ fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
413
+ }
414
+
415
+ // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
416
+ // 则自动创建新会话并发送消息
417
+ const messageReference = event.messageReference
418
+ if (messageReference && !this.activeSessionId && !fullText.startsWith('/')) {
419
+ this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
420
+ // 先创建新会话(handleInbound 内部串行执行并 await,返回时 createSession 已完成、
421
+ // activeSessionId 已更新),再继续处理原消息。无需固定 sleep —— 固定等待既不保证
422
+ // 会话已建好(创建是异步重活),又在慢环境里无谓拖延。
423
+ await this.handleInbound({ senderId: authId, text: '/new', isGroup, outboundPeer: this._lastPeer })
424
+ }
425
+
426
+ // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
427
+ await this.handleInbound({ senderId: authId, text: fullText, isGroup, outboundPeer: this._lastPeer })
428
+ }
429
+
430
+ /** 下载附件(图片/文件)并保存到本地工作目录 */
431
+ async _processAttachments(attachments, sessionCwd) {
432
+ if (!Array.isArray(attachments) || attachments.length === 0) return []
433
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.qq-media')
434
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
435
+
436
+ const downloaded = []
437
+ for (const att of attachments) {
438
+ if (!att?.url) continue
439
+ try {
440
+ const rawUrl = att.url.startsWith('http') ? att.url : `https://${att.url}`
441
+ const res = await fetch(rawUrl, { signal: AbortSignal.timeout(15000) })
442
+ if (!res.ok) continue
443
+ const buf = Buffer.from(await res.arrayBuffer())
444
+ const ext = att.filename ? path.extname(att.filename) : (att.content_type?.includes('image') ? '.png' : '.bin')
445
+ const safeName = att.filename ? path.basename(att.filename) : `qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
446
+ const filePath = path.join(mediaDir, safeName)
447
+ await fs.promises.writeFile(filePath, buf)
448
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
449
+ } catch (err) {
450
+ this.logger?.warn?.('[dsh-bridge qq] download attachment error: %s', err?.message ?? err)
451
+ }
452
+ }
453
+ return downloaded
454
+ }
455
+
456
+ // ---- 互动事件 ----
457
+
458
+ async _handleInteraction(event) {
459
+ if (this.gateway?.stopRequested) return
460
+ const sender = String(event.senderId ?? '').trim()
461
+ if (!sender) return
462
+
463
+ // 群聊按钮点击:授权主体按群维度(group_openid),与消息处理一致
464
+ const isGroup = event.scope === 'group'
465
+ const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
466
+ const authId = isGroup ? (event.groupId || peerId || sender) : sender
467
+
468
+ // 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
469
+ // 群聊不在此拦截——handleInbound 会按群自动授权
470
+ if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
471
+ this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${authId}`)
472
+ return
473
+ }
474
+
475
+ const type = Number(event.interactionType ?? event?.data?.type ?? 0)
476
+ // 仅消息按钮(11)与快捷菜单(12)需要回应;其他类型(反馈/清空会话/故事集/授权等)无需回应
477
+ const needsRespond = type === 11 || type === 12
478
+
479
+ // 记录 peer 信息,供命令回复使用
480
+ if (peerId) {
481
+ this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
482
+ this._replyMsgId = null
483
+ }
484
+
485
+ // 回应互动:告知后台已收到,避免客户端一直 loading(同一 interaction_id 只能回应一次)
486
+ if (needsRespond && event.interactionId) {
487
+ try {
488
+ await this.gateway.respondInteraction(event.interactionId, { code: 0 })
489
+ } catch (err) {
490
+ this.logger?.warn?.('[dsh-bridge qq] respond interaction failed: %s', err?.message ?? err)
491
+ }
492
+ }
493
+
494
+ if (!needsRespond) {
495
+ this.logger?.info?.(`[dsh-bridge qq] skip non-button interaction type=${type} from ${authId}`)
496
+ return
497
+ }
498
+
499
+ const data = event.data || {}
500
+ const resolved = data.resolved || {}
501
+ const buttonId = resolved.button_id || ''
502
+
503
+ // 根据按钮 ID 执行对应操作(消息按钮 11 与快捷菜单 12 都映射到命令)
504
+ this.logger?.info?.('[dsh-bridge qq] button interaction type=%s button_id=%s from %s', type, buttonId, authId)
505
+ if (buttonId === 'new_conversation') {
506
+ await this.handleInbound({ senderId: authId, text: '/new', isGroup, outboundPeer: this._lastPeer })
507
+ } else if (buttonId === 'list_sessions') {
508
+ await this.handleInbound({ senderId: authId, text: '/list', isGroup, outboundPeer: this._lastPeer })
509
+ } else if (buttonId === 'help') {
510
+ await this.handleInbound({ senderId: authId, text: '/help', isGroup, outboundPeer: this._lastPeer })
511
+ } else if (buttonId === 'approve_yes') {
512
+ await this.handleInbound({ senderId: authId, text: '/yes', isGroup, outboundPeer: this._lastPeer })
513
+ } else if (buttonId === 'approve_no') {
514
+ await this.handleInbound({ senderId: authId, text: '/no', isGroup, outboundPeer: this._lastPeer })
515
+ } else {
516
+ this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
517
+ }
518
+ }
519
+
520
+ dispose() {
521
+ this._lastPeer = null
522
+ this._replyMsgId = null
523
+ this.lastMessageId = null
524
+ super.dispose()
525
+ }
526
+ }
527
+
528
+ // 导出,便于测试与复用
529
+ export const qqNodeHelpers = {
530
+ splitForQQ: conversationBridgeHelpers.splitForIM,
531
+ digestLine: conversationBridgeHelpers.digestLine,
532
+ textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
533
+ listSessions: conversationBridgeHelpers.listSessions,
534
+ sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
535
+ sanitizeQQMarkdown,
536
+ splitIntoChunks,
537
+ splitIntoIncremental,
538
+ STREAM_CHUNK_SIZE,
539
+ }