@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/CHANGELOG.md +27 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +25 -1
- package/client/index.js +13 -0
- package/lib/feishu/node.js +10 -4
- package/lib/index.js +91 -14
- package/lib/platform/conversation-bridge.js +8 -3
- package/lib/platform/message-split.js +39 -1
- package/lib/qq/node.js +539 -532
- package/lib/session-strip.js +57 -0
- package/lib/telegram/node.js +0 -27
- package/lib/tunnel-client.mjs +8 -29
- package/lib/wechat/gateway.js +17 -4
- package/package.json +2 -2
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)(避免图片转存失败导致整条失败)
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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)(避免图片转存失败导致整条失败)
|
|
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
|
+
}
|