@wenbin_wb/dsh-bridge 2.1.2 → 2.2.2
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 +81 -13
- package/lib/qq/index.js +71 -4
- package/lib/qq/node.js +171 -51
- package/package.json +1 -1
package/lib/qq/gateway.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Service } from '@deepseek-ai/cordis'
|
|
|
5
5
|
import WebSocket from 'ws'
|
|
6
6
|
|
|
7
7
|
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
8
|
-
const API_BASE = 'https://api.
|
|
8
|
+
const API_BASE = 'https://api.bot.qq.com'
|
|
9
9
|
const DEFAULT_GATEWAY = 'wss://api.sgroup.qq.com/websocket/'
|
|
10
10
|
const MAX_MESSAGE_CHARS = 2000
|
|
11
11
|
const TOKEN_MARGIN_MS = 5 * 60_000
|
|
@@ -323,6 +323,7 @@ export class QqGateway extends Service {
|
|
|
323
323
|
method: 'POST',
|
|
324
324
|
body: {
|
|
325
325
|
markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
|
|
326
|
+
keyboard: opts.keyboard,
|
|
326
327
|
msg_type: 2,
|
|
327
328
|
msg_id: opts.msgId,
|
|
328
329
|
event_id: opts.eventId,
|
|
@@ -331,12 +332,14 @@ export class QqGateway extends Service {
|
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
335
|
+
// 官方文档:keyboard 为附加字段,配合 msg_type=0(content) 或 msg_type=2(markdown) 使用
|
|
336
|
+
// 纯文本 + 键盘时使用 msg_type=0
|
|
334
337
|
return this.api(this.endpoint(peerId, opts.scope), {
|
|
335
338
|
method: 'POST',
|
|
336
339
|
body: {
|
|
337
340
|
content: stringValue(content),
|
|
338
341
|
keyboard,
|
|
339
|
-
msg_type:
|
|
342
|
+
msg_type: 0,
|
|
340
343
|
msg_id: opts.msgId,
|
|
341
344
|
event_id: opts.eventId,
|
|
342
345
|
},
|
|
@@ -355,15 +358,19 @@ export class QqGateway extends Service {
|
|
|
355
358
|
}
|
|
356
359
|
|
|
357
360
|
async sendStream(peerId, content, opts = {}) {
|
|
358
|
-
const endpoint = this.endpoint(peerId, opts.scope, '
|
|
361
|
+
const endpoint = this.endpoint(peerId, opts.scope, 'stream_messages')
|
|
359
362
|
return this.api(endpoint, {
|
|
360
363
|
method: 'POST',
|
|
361
364
|
body: {
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
content_type: opts.contentType || 'text',
|
|
366
|
+
content_raw: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
367
|
+
input_mode: opts.inputMode || 'replace',
|
|
368
|
+
input_state: opts.inputState, // 1=生成中, 10=生成结束
|
|
369
|
+
index: opts.index, // 分片序号,从0递增
|
|
370
|
+
stream_msg_id: opts.streamMsgId, // 第一片由服务端返回,后续片需携带
|
|
364
371
|
msg_id: opts.msgId,
|
|
365
372
|
event_id: opts.eventId,
|
|
366
|
-
|
|
373
|
+
msg_seq: opts.msgSeq,
|
|
367
374
|
},
|
|
368
375
|
})
|
|
369
376
|
}
|
|
@@ -376,22 +383,83 @@ export class QqGateway extends Service {
|
|
|
376
383
|
}
|
|
377
384
|
|
|
378
385
|
async sendTyping(peerId, opts = {}) {
|
|
379
|
-
// QQ Bot API v2
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
|
|
386
|
+
// QQ Bot API v2 使用 msg_type: 6 发送输入状态通知
|
|
387
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages.post.html
|
|
388
|
+
const endpoint = this.endpoint(peerId, opts.scope)
|
|
383
389
|
return this.api(endpoint, {
|
|
384
390
|
method: 'POST',
|
|
385
391
|
body: {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
392
|
+
msg_type: 6,
|
|
393
|
+
input_notify: {
|
|
394
|
+
input_type: 1,
|
|
395
|
+
input_second: Math.min(opts.durationSeconds || 5, 60), // 最长60秒
|
|
396
|
+
},
|
|
389
397
|
msg_id: opts.msgId,
|
|
390
398
|
event_id: opts.eventId,
|
|
391
399
|
},
|
|
392
400
|
})
|
|
393
401
|
}
|
|
394
402
|
|
|
403
|
+
// ---- 自定义菜单(单聊底部菜单,全局生效)----
|
|
404
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_menu.get.html
|
|
405
|
+
// https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_menu.put.html
|
|
406
|
+
|
|
407
|
+
/** 查询全局自定义菜单 */
|
|
408
|
+
async getMenu() {
|
|
409
|
+
return this.api('/v2/menu', { method: 'GET' })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 修改全局自定义菜单(覆盖式)
|
|
414
|
+
* @param {Array} items 菜单项,最多 10 个
|
|
415
|
+
* { name, type: 'switch'|'send_message'|'link'|'menu', sub_menu_items?, send_message?, link?, switch? }
|
|
416
|
+
*/
|
|
417
|
+
async setMenu(items) {
|
|
418
|
+
return this.api('/v2/menu', {
|
|
419
|
+
method: 'PUT',
|
|
420
|
+
body: { menu: { items } },
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- 指令面板(c2c/group/channel/dm 场景)----
|
|
425
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/menu-panel/
|
|
426
|
+
// https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_panels.post.html
|
|
427
|
+
|
|
428
|
+
/** 查询指令面板列表(按场景筛选) */
|
|
429
|
+
async listPanels(scope) {
|
|
430
|
+
const query = scope ? `?scope=${encodeURIComponent(scope)}` : ''
|
|
431
|
+
return this.api(`/v2/panels${query}`, { method: 'GET' })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 创建指令面板
|
|
436
|
+
* @param {object} body { scope, target_type?, user_openids?, group_openids?, panel: { items, remark?, version? } }
|
|
437
|
+
* @returns {Promise<{panel_id: string}>}
|
|
438
|
+
*/
|
|
439
|
+
async createPanel(body) {
|
|
440
|
+
return this.api('/v2/panels', { method: 'POST', body })
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 查询指令面板详情 */
|
|
444
|
+
async getPanel(panelId) {
|
|
445
|
+
return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'GET' })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 修改指令面板 */
|
|
449
|
+
async updatePanel(panelId, body) {
|
|
450
|
+
return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'PUT', body })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 删除指令面板 */
|
|
454
|
+
async deletePanel(panelId) {
|
|
455
|
+
return this.api(`/v2/panels/${encodeURIComponent(panelId)}`, { method: 'DELETE' })
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 修改指令面板关联对象(增删指定用户/群) */
|
|
459
|
+
async updatePanelTarget(panelId, body) {
|
|
460
|
+
return this.api(`/v2/panels/${encodeURIComponent(panelId)}/target`, { method: 'PUT', body })
|
|
461
|
+
}
|
|
462
|
+
|
|
395
463
|
setCredentials(values = {}) {
|
|
396
464
|
for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
|
|
397
465
|
if (values[key] !== undefined) this.config[key] = values[key]
|
package/lib/qq/index.js
CHANGED
|
@@ -42,6 +42,16 @@ export class QqService extends Platform {
|
|
|
42
42
|
// 挂到 ctx 供会话节点读取
|
|
43
43
|
try { ctx.qq = this.gateway } catch { /* 挂载失败不致命 */ }
|
|
44
44
|
|
|
45
|
+
// 网关连接成功后自动配置指令面板与自定义菜单(一次性)
|
|
46
|
+
this._panelSetupDone = false
|
|
47
|
+
try {
|
|
48
|
+
ctx.on?.('qq/status', (status) => {
|
|
49
|
+
if (status === 'connected') {
|
|
50
|
+
void this._ensurePanelSetup().catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
} catch { /* 忽略事件订阅失败 */ }
|
|
54
|
+
|
|
45
55
|
this.node = new QqConversationNode(ctx, {
|
|
46
56
|
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
47
57
|
digestIntervalSec: config.digestIntervalSec,
|
|
@@ -79,8 +89,10 @@ export class QqService extends Platform {
|
|
|
79
89
|
return this.gateway.sendText(peerId, text, opts)
|
|
80
90
|
}
|
|
81
91
|
|
|
82
|
-
async sendTyping(peerId,
|
|
83
|
-
|
|
92
|
+
async sendTyping(peerId, opts = {}) {
|
|
93
|
+
// 兼容旧调用:sendTyping(peerId, durationSeconds)
|
|
94
|
+
const normalized = typeof opts === 'number' ? { durationSeconds: opts } : opts
|
|
95
|
+
return this.gateway.sendTyping(peerId, normalized)
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
async sendMedia(peerId, media, opts = {}) {
|
|
@@ -91,8 +103,8 @@ export class QqService extends Platform {
|
|
|
91
103
|
return this.gateway.sendMarkdown(peerId, markdown, opts)
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
async sendKeyboard(peerId, keyboard, opts = {}) {
|
|
95
|
-
return this.gateway.sendKeyboard(peerId, keyboard, opts)
|
|
106
|
+
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
107
|
+
return this.gateway.sendKeyboard(peerId, content, keyboard, opts)
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
/** 合并展示状态给浏览器 UI。 */
|
|
@@ -189,6 +201,58 @@ export class QqService extends Platform {
|
|
|
189
201
|
await this.persist({ allowFrom: clean })
|
|
190
202
|
}
|
|
191
203
|
|
|
204
|
+
// ---- 指令面板 / 自定义菜单(一次性自动配置)----
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 连接成功后自动创建 c2c + group 指令面板,并配置单聊自定义菜单。
|
|
208
|
+
* 幂等:已存在 remark 匹配的面板时跳过创建;失败仅记日志,不影响连接。
|
|
209
|
+
*/
|
|
210
|
+
async _ensurePanelSetup() {
|
|
211
|
+
if (this._panelSetupDone) return
|
|
212
|
+
this._panelSetupDone = true // 防并发重复执行
|
|
213
|
+
|
|
214
|
+
const gate = this.gateway
|
|
215
|
+
const remark = PANEL_REMARK
|
|
216
|
+
const items = [
|
|
217
|
+
{ name: '/new', desc: '新建对话', type: 'command' },
|
|
218
|
+
{ name: '/list', desc: '查看会话列表', type: 'command' },
|
|
219
|
+
{ name: '/resume', desc: '恢复会话', type: 'command' },
|
|
220
|
+
{ name: '/sessions', desc: '切换会话', type: 'command' },
|
|
221
|
+
{ name: '/help', desc: '命令帮助', type: 'command' },
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
// 指令面板:c2c(单聊)与 group(群聊)各建一个全局面板
|
|
225
|
+
for (const scope of ['c2c', 'group']) {
|
|
226
|
+
try {
|
|
227
|
+
const list = await gate.listPanels(scope)
|
|
228
|
+
const exists = (list?.records || []).some((r) => r?.panel?.remark === remark)
|
|
229
|
+
if (!exists) {
|
|
230
|
+
const res = await gate.createPanel({
|
|
231
|
+
scope,
|
|
232
|
+
target_type: 'all',
|
|
233
|
+
panel: { items, remark, version: 1 },
|
|
234
|
+
})
|
|
235
|
+
this.logger.info('[dsh-bridge qq] created %s command panel: %s', scope, res?.panel_id ?? '(no id)')
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
this.logger.warn('[dsh-bridge qq] command panel setup for %s skipped: %s', scope, err?.message ?? err)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 自定义菜单:单聊底部菜单(send_message 类型,点击自动填入命令)
|
|
243
|
+
try {
|
|
244
|
+
const menuItems = [
|
|
245
|
+
{ name: '新建', type: 'send_message', send_message: '/new' },
|
|
246
|
+
{ name: '列表', type: 'send_message', send_message: '/list' },
|
|
247
|
+
{ name: '帮助', type: 'send_message', send_message: '/help' },
|
|
248
|
+
]
|
|
249
|
+
await gate.setMenu(menuItems)
|
|
250
|
+
this.logger.info('[dsh-bridge qq] custom menu configured')
|
|
251
|
+
} catch (err) {
|
|
252
|
+
this.logger.warn('[dsh-bridge qq] custom menu setup skipped: %s', err?.message ?? err)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
192
256
|
/** 更新运行时配置并持久化。 */
|
|
193
257
|
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, appId, clientSecret } = {}) {
|
|
194
258
|
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
@@ -226,3 +290,6 @@ export class QqService extends Platform {
|
|
|
226
290
|
function gatewayMaxChars() {
|
|
227
291
|
return 2000
|
|
228
292
|
}
|
|
293
|
+
|
|
294
|
+
// 指令面板 remark 标识,用于幂等判断(区分本插件创建的面板)
|
|
295
|
+
const PANEL_REMARK = 'dsh-bridge 常用命令'
|
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) {
|
|
@@ -18,8 +85,9 @@ function makePlatform(gateway) {
|
|
|
18
85
|
name: 'QQ',
|
|
19
86
|
get accountId() { return gateway.accountId ?? '' },
|
|
20
87
|
get capabilities() { return gateway.capabilities },
|
|
88
|
+
// sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
|
|
21
89
|
sendText: (peer, text) => gateway.sendText(peer, text, {}),
|
|
22
|
-
sendTyping: (
|
|
90
|
+
sendTyping: () => Promise.resolve({ ok: true }),
|
|
23
91
|
sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
|
|
24
92
|
}
|
|
25
93
|
}
|
|
@@ -44,6 +112,9 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
44
112
|
})
|
|
45
113
|
this.gateway = ctx.qq
|
|
46
114
|
this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
|
|
115
|
+
// 当前对话 peer 信息(由 _handleInbound 在每次收到消息时刷新)
|
|
116
|
+
this._lastPeer = null // { peerId, scope: 'c2c'|'group' }
|
|
117
|
+
this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
|
|
47
118
|
|
|
48
119
|
// 订阅网关入站事件
|
|
49
120
|
this.ctx.on('qq/message', (event) => {
|
|
@@ -54,15 +125,22 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
54
125
|
})
|
|
55
126
|
}
|
|
56
127
|
|
|
57
|
-
// ---- 出站:覆盖 sendText
|
|
128
|
+
// ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
|
|
129
|
+
|
|
130
|
+
/** 解析当前对话 peer 信息;无活动 peer 时返回 null */
|
|
131
|
+
_currentPeer() {
|
|
132
|
+
return this._lastPeer
|
|
133
|
+
}
|
|
58
134
|
|
|
59
135
|
async sendText(text) {
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
62
|
-
|
|
136
|
+
const peerInfo = this._currentPeer()
|
|
137
|
+
if (!peerInfo) return
|
|
138
|
+
const { peerId, scope } = peerInfo
|
|
139
|
+
const replyMsgId = this._replyMsgId || undefined
|
|
140
|
+
|
|
63
141
|
// 检测是否是提示用户开始新会话的消息
|
|
64
142
|
const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
|
|
65
|
-
|
|
143
|
+
|
|
66
144
|
if (isPromptMessage) {
|
|
67
145
|
// 发送带按钮的消息,方便用户快速操作
|
|
68
146
|
const keyboard = {
|
|
@@ -80,57 +158,81 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
80
158
|
},
|
|
81
159
|
],
|
|
82
160
|
}
|
|
83
|
-
|
|
84
|
-
// 保存消息 ID
|
|
85
|
-
if (result?.data?.message_id) {
|
|
86
|
-
this.lastMessageId = result.data.message_id
|
|
87
|
-
}
|
|
88
|
-
return result
|
|
161
|
+
return this.gateway.sendKeyboard(peerId, text, keyboard, { scope, msgId: replyMsgId })
|
|
89
162
|
}
|
|
90
|
-
|
|
91
|
-
//
|
|
163
|
+
|
|
164
|
+
// 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染,append 模式逐段追加
|
|
92
165
|
const content = String(text || '').trim()
|
|
93
166
|
if (content.length === 0) return { success: true }
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
|
|
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 && result?.id) {
|
|
189
|
+
streamMsgId = result.id
|
|
190
|
+
this.lastMessageId = result.id // 保存消息 ID 用于消息引用
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (result?.code !== undefined && result.code !== 0) {
|
|
194
|
+
throw new Error(result.message || `QQ API error ${result.code}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 使用用户配置的 sendChunkDelayMs(默认 1500ms),而非硬编码 120ms
|
|
198
|
+
if (!isLast) {
|
|
199
|
+
const delayMs = this.config.sendChunkDelayMs ?? 1500
|
|
200
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
201
|
+
}
|
|
125
202
|
}
|
|
126
|
-
|
|
127
|
-
|
|
203
|
+
return { success: true }
|
|
204
|
+
} catch (err) {
|
|
205
|
+
// 流式失败(如被动回复 msg_id 过期)→ 依次回退:带 msg_id 的 Markdown → 主动 Markdown
|
|
206
|
+
this.logger?.warn?.('[dsh-bridge qq] stream send failed, fallback to markdown: %s', err?.message ?? err)
|
|
207
|
+
try {
|
|
208
|
+
const result = await this.gateway.sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
|
|
209
|
+
if (result?.id) this.lastMessageId = result.id
|
|
210
|
+
return result
|
|
211
|
+
} catch (fallbackErr1) {
|
|
212
|
+
try {
|
|
213
|
+
// 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
|
|
214
|
+
const result = await this.gateway.sendMarkdown(peerId, md, { scope })
|
|
215
|
+
if (result?.id) this.lastMessageId = result.id
|
|
216
|
+
return result
|
|
217
|
+
} catch (fallbackErr2) {
|
|
218
|
+
this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
|
|
219
|
+
return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
|
|
220
|
+
}
|
|
128
221
|
}
|
|
129
|
-
// 流式发送间隔稍短,避免刷屏
|
|
130
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
131
222
|
}
|
|
132
|
-
|
|
133
|
-
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒) */
|
|
226
|
+
async sendTyping(state) {
|
|
227
|
+
const peerInfo = this._currentPeer()
|
|
228
|
+
if (!peerInfo) return
|
|
229
|
+
// state=2(停止)时无需显式结束——input_second 到期自动消失
|
|
230
|
+
if (Number(state) === 2) return { ok: true }
|
|
231
|
+
return this.gateway.sendTyping(peerInfo.peerId, {
|
|
232
|
+
scope: peerInfo.scope,
|
|
233
|
+
durationSeconds: 8,
|
|
234
|
+
msgId: this._replyMsgId || undefined,
|
|
235
|
+
})
|
|
134
236
|
}
|
|
135
237
|
|
|
136
238
|
// ---- 入站 ----
|
|
@@ -154,6 +256,13 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
154
256
|
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
155
257
|
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
156
258
|
|
|
259
|
+
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id),供出站使用
|
|
260
|
+
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
|
|
261
|
+
if (peerId) {
|
|
262
|
+
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
263
|
+
if (event.id) this._replyMsgId = String(event.id)
|
|
264
|
+
}
|
|
265
|
+
|
|
157
266
|
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
158
267
|
// 则自动创建新会话并发送消息
|
|
159
268
|
const messageReference = event.messageReference
|
|
@@ -181,6 +290,14 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
181
290
|
return
|
|
182
291
|
}
|
|
183
292
|
|
|
293
|
+
// 记录 peer 信息,供命令回复使用
|
|
294
|
+
const isGroup = event.scope === 'group'
|
|
295
|
+
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
|
|
296
|
+
if (peerId) {
|
|
297
|
+
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
298
|
+
this._replyMsgId = null
|
|
299
|
+
}
|
|
300
|
+
|
|
184
301
|
const data = event.data || {}
|
|
185
302
|
const resolved = data.resolved || {}
|
|
186
303
|
const buttonId = resolved.button_id || ''
|
|
@@ -210,4 +327,7 @@ export const qqNodeHelpers = {
|
|
|
210
327
|
textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
|
|
211
328
|
listSessions: conversationBridgeHelpers.listSessions,
|
|
212
329
|
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
330
|
+
sanitizeQQMarkdown,
|
|
331
|
+
splitIntoChunks,
|
|
332
|
+
STREAM_CHUNK_SIZE,
|
|
213
333
|
}
|
package/package.json
CHANGED