@wenbin_wb/dsh-bridge 2.2.0 → 2.2.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/gateway.js +59 -54
- package/lib/qq/node.js +135 -44
- package/package.json +1 -1
package/lib/qq/gateway.js
CHANGED
|
@@ -27,6 +27,8 @@ async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUE
|
|
|
27
27
|
const controller = new AbortController()
|
|
28
28
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
29
29
|
try {
|
|
30
|
+
// DEBUG: 记录请求详情
|
|
31
|
+
console.log('[QQ API DEBUG] Request:', { method, url, body })
|
|
30
32
|
const response = await fetch(url, {
|
|
31
33
|
method,
|
|
32
34
|
headers: {
|
|
@@ -40,6 +42,8 @@ async function requestJson(url, { method = 'GET', token, body, timeoutMs = REQUE
|
|
|
40
42
|
const raw = await response.text()
|
|
41
43
|
let value = {}
|
|
42
44
|
try { value = raw ? JSON.parse(raw) : {} } catch { value = { message: raw } }
|
|
45
|
+
// DEBUG: 记录响应详情
|
|
46
|
+
console.log('[QQ API DEBUG] Response:', { status: response.status, ok: response.ok, value })
|
|
43
47
|
if (!response.ok) {
|
|
44
48
|
const error = new Error(`QQ API ${response.status}: ${value?.message || value?.msg || value?.code || response.statusText}`)
|
|
45
49
|
error.status = response.status
|
|
@@ -62,6 +66,7 @@ function normalizeEvent(payload) {
|
|
|
62
66
|
text: stringValue(data.content).trim(),
|
|
63
67
|
message: data,
|
|
64
68
|
messageReference: data.message_reference,
|
|
69
|
+
msgSeq: data.msg_seq, // 用于避免去重
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
if (event === 'GROUP_AT_MESSAGE_CREATE') {
|
|
@@ -74,6 +79,7 @@ function normalizeEvent(payload) {
|
|
|
74
79
|
text: stringValue(data.content).trim(),
|
|
75
80
|
message: data,
|
|
76
81
|
messageReference: data.message_reference,
|
|
82
|
+
msgSeq: data.msg_seq, // 用于避免去重
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
if (event === 'AT_MESSAGE_CREATE') {
|
|
@@ -307,43 +313,43 @@ export class QqGateway extends Service {
|
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
async sendText(peerId, content, opts = {}) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
})
|
|
316
|
+
const body = {
|
|
317
|
+
content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
318
|
+
msg_type: 0,
|
|
319
|
+
}
|
|
320
|
+
// 只添加有值的可选字段
|
|
321
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
322
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
323
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
324
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
319
325
|
}
|
|
320
326
|
|
|
321
327
|
async sendMarkdown(peerId, markdown, opts = {}) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
})
|
|
328
|
+
const body = {
|
|
329
|
+
markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
|
|
330
|
+
keyboard: opts.keyboard,
|
|
331
|
+
msg_type: 2,
|
|
332
|
+
}
|
|
333
|
+
// 只添加有值的可选字段
|
|
334
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
335
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
336
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
337
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
335
341
|
// 官方文档:keyboard 为附加字段,配合 msg_type=0(content) 或 msg_type=2(markdown) 使用
|
|
336
342
|
// 纯文本 + 键盘时使用 msg_type=0
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
})
|
|
343
|
+
const body = {
|
|
344
|
+
content: stringValue(content),
|
|
345
|
+
keyboard,
|
|
346
|
+
msg_type: 0,
|
|
347
|
+
}
|
|
348
|
+
// 只添加有值的可选字段
|
|
349
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
350
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
351
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
352
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
async sendMedia(peerId, media, opts = {}) {
|
|
@@ -359,20 +365,19 @@ export class QqGateway extends Service {
|
|
|
359
365
|
|
|
360
366
|
async sendStream(peerId, content, opts = {}) {
|
|
361
367
|
const endpoint = this.endpoint(peerId, opts.scope, 'stream_messages')
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
})
|
|
368
|
+
const body = {
|
|
369
|
+
content_type: opts.contentType || 'text',
|
|
370
|
+
content_raw: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
371
|
+
input_mode: opts.inputMode || 'replace',
|
|
372
|
+
input_state: opts.inputState, // 1=生成中, 10=生成结束
|
|
373
|
+
index: opts.index, // 分片序号,从0递增
|
|
374
|
+
}
|
|
375
|
+
// 只添加有值的可选字段,避免 undefined 被序列化
|
|
376
|
+
if (opts.streamMsgId !== undefined) body.stream_msg_id = opts.streamMsgId
|
|
377
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
378
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
379
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
380
|
+
return this.api(endpoint, { method: 'POST', body })
|
|
376
381
|
}
|
|
377
382
|
|
|
378
383
|
async respondInteraction(interactionId, response) {
|
|
@@ -386,18 +391,18 @@ export class QqGateway extends Service {
|
|
|
386
391
|
// QQ Bot API v2 使用 msg_type: 6 发送输入状态通知
|
|
387
392
|
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages.post.html
|
|
388
393
|
const endpoint = this.endpoint(peerId, opts.scope)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
input_type: 1,
|
|
395
|
-
input_second: Math.min(opts.durationSeconds || 5, 60), // 最长60秒
|
|
396
|
-
},
|
|
397
|
-
msg_id: opts.msgId,
|
|
398
|
-
event_id: opts.eventId,
|
|
394
|
+
const body = {
|
|
395
|
+
msg_type: 6,
|
|
396
|
+
input_notify: {
|
|
397
|
+
input_type: 1,
|
|
398
|
+
input_second: Math.min(opts.durationSeconds || 5, 60), // 最长60秒
|
|
399
399
|
},
|
|
400
|
-
}
|
|
400
|
+
}
|
|
401
|
+
// 只添加有值的可选字段
|
|
402
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
403
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
404
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
405
|
+
return this.api(endpoint, { method: 'POST', body })
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
// ---- 自定义菜单(单聊底部菜单,全局生效)----
|
package/lib/qq/node.js
CHANGED
|
@@ -10,6 +10,73 @@ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conve
|
|
|
10
10
|
import { gatewayConstants } from './gateway.js'
|
|
11
11
|
|
|
12
12
|
const MAX_MESSAGE_CHARS = gatewayConstants.MAX_MESSAGE_CHARS
|
|
13
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
14
|
+
|
|
15
|
+
// QQ 流式消息:markdown 内容每片最大长度(append 模式下每片为追加片段)
|
|
16
|
+
const STREAM_CHUNK_SIZE = 400
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 将文本转成 QQ 安全 Markdown:
|
|
20
|
+
* - 表格(| a | b | / |---|)降级为纯文本(QQ Markdown 不支持表格,会截断/报错)
|
|
21
|
+
* - markdown 图片语法  转为链接 [alt](url)(避免图片转存失败导致整条失败)
|
|
22
|
+
* - 代码块内容原样保留
|
|
23
|
+
*/
|
|
24
|
+
function sanitizeQQMarkdown(text) {
|
|
25
|
+
if (!text) return ''
|
|
26
|
+
const lines = String(text).split('\n')
|
|
27
|
+
const out = []
|
|
28
|
+
let inFence = false
|
|
29
|
+
let inTable = false
|
|
30
|
+
const isSep = (l) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l) && l.includes('-')
|
|
31
|
+
const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(l)
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i]
|
|
34
|
+
if (/^\s*```/.test(line)) {
|
|
35
|
+
inFence = !inFence
|
|
36
|
+
out.push(line)
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (inFence) { out.push(line); continue }
|
|
40
|
+
const nextIsSep = i + 1 < lines.length && isSep(lines[i + 1])
|
|
41
|
+
if (isTableRow(line) && (inTable || nextIsSep)) {
|
|
42
|
+
inTable = true
|
|
43
|
+
if (!isSep(line)) {
|
|
44
|
+
// 去掉首尾 |,内部 | 转空格
|
|
45
|
+
out.push(line.replace(/^\s*\|\s*/, '').replace(/\s*\|\s*$/, '').replace(/\s*\|\s*/g, ' '))
|
|
46
|
+
}
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
inTable = false
|
|
50
|
+
out.push(line.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)'))
|
|
51
|
+
}
|
|
52
|
+
return out.join('\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 按段落边界切分内容(避免切断行内内容),单块时拆两片保证流式过渡
|
|
57
|
+
* @param {string} content - 要分片的内容
|
|
58
|
+
* @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
|
|
59
|
+
*/
|
|
60
|
+
function splitIntoChunks(content, maxChunk) {
|
|
61
|
+
const chunks = []
|
|
62
|
+
let start = 0
|
|
63
|
+
while (start < content.length) {
|
|
64
|
+
if (content.length - start <= maxChunk) { chunks.push(content.slice(start)); break }
|
|
65
|
+
const windowStart = start + Math.floor(maxChunk * 0.6)
|
|
66
|
+
const windowEnd = start + maxChunk
|
|
67
|
+
let cut = content.lastIndexOf('\n', windowEnd)
|
|
68
|
+
if (cut <= windowStart || cut === -1) cut = windowEnd
|
|
69
|
+
chunks.push(content.slice(start, cut))
|
|
70
|
+
start = cut
|
|
71
|
+
}
|
|
72
|
+
// 单块时拆两片,保证「生成中 → 生成结束」的流式过渡
|
|
73
|
+
if (chunks.length === 1 && content.length > 0) {
|
|
74
|
+
const half = Math.ceil(content.length / 2)
|
|
75
|
+
chunks.length = 0
|
|
76
|
+
chunks.push(content.slice(0, half), content.slice(half))
|
|
77
|
+
}
|
|
78
|
+
return chunks
|
|
79
|
+
}
|
|
13
80
|
|
|
14
81
|
// 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口
|
|
15
82
|
function makePlatform(gateway) {
|
|
@@ -94,55 +161,75 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
94
161
|
return this.gateway.sendKeyboard(peerId, text, keyboard, { scope, msgId: replyMsgId })
|
|
95
162
|
}
|
|
96
163
|
|
|
97
|
-
//
|
|
164
|
+
// 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染,append 模式逐段追加
|
|
98
165
|
const content = String(text || '').trim()
|
|
99
166
|
if (content.length === 0) return { success: true }
|
|
100
167
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
168
|
+
const md = sanitizeQQMarkdown(content)
|
|
169
|
+
// 使用用户配置的 maxMessageChars(默认 2000),而非硬编码 200
|
|
170
|
+
const maxChars = this.config.maxMessageChars || 2000
|
|
171
|
+
const chunks = splitIntoChunks(md, maxChars)
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
let streamMsgId = null
|
|
175
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
176
|
+
const isLast = i === chunks.length - 1
|
|
177
|
+
const result = await this.gateway.sendStream(peerId, chunks[i], {
|
|
178
|
+
scope,
|
|
179
|
+
contentType: 'markdown', // 流式 Markdown,手机端正常渲染
|
|
180
|
+
msgId: i === 0 ? replyMsgId : undefined, // 首片带被动回复 msg_id
|
|
181
|
+
streamMsgId, // 后续片携带服务端返回的 stream_msg_id
|
|
182
|
+
index: i, // 分片序号从 0 递增
|
|
183
|
+
inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
|
|
184
|
+
inputMode: 'append', // 追加模式:服务端拼接到同一条消息
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// 首片返回 stream_msg_id,后续片需携带
|
|
188
|
+
if (i === 0) {
|
|
189
|
+
// 尝试多个可能的字段名
|
|
190
|
+
streamMsgId = result?.id || result?.message_id || result?.stream_msg_id
|
|
191
|
+
if (streamMsgId) {
|
|
192
|
+
this.lastMessageId = streamMsgId
|
|
193
|
+
this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s, chunks=%d', streamMsgId, chunks.length)
|
|
194
|
+
} else {
|
|
195
|
+
this.logger?.warn?.('[dsh-bridge qq] stream first chunk returned no id: %o', result)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (result?.code !== undefined && result.code !== 0) {
|
|
200
|
+
throw new Error(result.message || `QQ API error ${result.code}`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 使用用户配置的 sendChunkDelayMs(默认 1500ms),而非硬编码 120ms
|
|
204
|
+
if (!isLast) {
|
|
205
|
+
const delayMs = this.config.sendChunkDelayMs ?? 1500
|
|
206
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
207
|
+
}
|
|
133
208
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
209
|
+
return { success: true }
|
|
210
|
+
} catch (err) {
|
|
211
|
+
// 流式失败(如被动回复 msg_id 过期)→ 依次回退:带 msg_id 的 Markdown → 主动 Markdown
|
|
212
|
+
this.logger?.warn?.('[dsh-bridge qq] stream send failed, fallback to markdown: %s', err?.message ?? err)
|
|
213
|
+
try {
|
|
214
|
+
const result = await this.gateway.sendMarkdown(peerId, md, {
|
|
215
|
+
scope,
|
|
216
|
+
msgId: replyMsgId,
|
|
217
|
+
msgSeq: this._msgSeq !== undefined ? this._msgSeq + 1 : undefined, // 使用递增的 msg_seq 避免去重
|
|
218
|
+
})
|
|
219
|
+
if (result?.id) this.lastMessageId = result.id
|
|
220
|
+
return result
|
|
221
|
+
} catch (fallbackErr1) {
|
|
222
|
+
try {
|
|
223
|
+
// 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
|
|
224
|
+
const result = await this.gateway.sendMarkdown(peerId, md, { scope })
|
|
225
|
+
if (result?.id) this.lastMessageId = result.id
|
|
226
|
+
return result
|
|
227
|
+
} catch (fallbackErr2) {
|
|
228
|
+
this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
|
|
229
|
+
return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
|
|
230
|
+
}
|
|
142
231
|
}
|
|
143
232
|
}
|
|
144
|
-
|
|
145
|
-
return { success: true }
|
|
146
233
|
}
|
|
147
234
|
|
|
148
235
|
/** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒) */
|
|
@@ -179,11 +266,12 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
179
266
|
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
180
267
|
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
181
268
|
|
|
182
|
-
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id
|
|
269
|
+
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id)、msg_seq,供出站使用
|
|
183
270
|
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
|
|
184
271
|
if (peerId) {
|
|
185
272
|
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
186
273
|
if (event.id) this._replyMsgId = String(event.id)
|
|
274
|
+
if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
|
|
187
275
|
}
|
|
188
276
|
|
|
189
277
|
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
@@ -250,4 +338,7 @@ export const qqNodeHelpers = {
|
|
|
250
338
|
textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
|
|
251
339
|
listSessions: conversationBridgeHelpers.listSessions,
|
|
252
340
|
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
341
|
+
sanitizeQQMarkdown,
|
|
342
|
+
splitIntoChunks,
|
|
343
|
+
STREAM_CHUNK_SIZE,
|
|
253
344
|
}
|
package/package.json
CHANGED