@wenbin_wb/dsh-bridge 2.2.2 → 2.2.4
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/platform/conversation-bridge.js +31 -15
- package/lib/qq/gateway.js +84 -59
- package/lib/qq/index.js +12 -3
- package/lib/qq/node.js +165 -65
- package/lib/wechat/index.js +1 -1
- package/package.json +1 -1
|
@@ -416,20 +416,24 @@ export class ConversationBridge {
|
|
|
416
416
|
if (!sender) return 'ignored'
|
|
417
417
|
|
|
418
418
|
if (!this.isAllowed(sender)) {
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
this.
|
|
419
|
+
// 自动授权:
|
|
420
|
+
// - 单聊:白名单为空时,首个发消息的真实用户自动纳入白名单
|
|
421
|
+
// - 群聊:首次 @机器人 的群自动纳入(群维度授权,群内成员均可使用)
|
|
422
|
+
// 这是"登录后第一条消息/首次被 @即完成授权"的一步到位体验。
|
|
423
|
+
const shouldAutoApprove = Boolean(text?.trim()) && (
|
|
424
|
+
this.config.allowFrom.length === 0 || // 白名单为空:单聊/群聊都自动授权
|
|
425
|
+
isGroup // 群聊:始终自动授权群
|
|
426
|
+
)
|
|
427
|
+
if (shouldAutoApprove) {
|
|
428
|
+
this.config.allowFrom = Array.from(new Set([...this.config.allowFrom, sender]))
|
|
429
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] auto-approved ${isGroup ? 'group' : 'sender'} ${sender} into allowlist`)
|
|
430
|
+
try {
|
|
431
|
+
await this.onFirstSender?.(sender)
|
|
432
|
+
} catch (err) {
|
|
433
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] failed to persist first sender: ${err instanceof Error ? err.message : String(err)}`)
|
|
432
434
|
}
|
|
435
|
+
} else {
|
|
436
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] media-only first message from ${sender} not auto-approved (waiting for text)`)
|
|
433
437
|
}
|
|
434
438
|
|
|
435
439
|
// 如果仍未通过白名单,拒绝处理(防止绕过白名单)
|
|
@@ -439,8 +443,9 @@ export class ConversationBridge {
|
|
|
439
443
|
}
|
|
440
444
|
}
|
|
441
445
|
|
|
442
|
-
|
|
443
|
-
|
|
446
|
+
// 仅在不支持群聊的平台忽略群消息(QQ 等支持群聊的平台放行)
|
|
447
|
+
if (isGroup && !this.platform?.capabilities?.supportsGroup) {
|
|
448
|
+
this.logger?.info?.(`[dsh-bridge ${this.platform.id}] ignore group message from ${sender} (no group support)`)
|
|
444
449
|
return 'ignored'
|
|
445
450
|
}
|
|
446
451
|
|
|
@@ -852,6 +857,16 @@ async function routeCommand(node, text) {
|
|
|
852
857
|
}
|
|
853
858
|
return true
|
|
854
859
|
}
|
|
860
|
+
case 'end': {
|
|
861
|
+
// 结束当前会话:停止 agent 并清除活动会话(进入"无活动会话"状态)
|
|
862
|
+
const agent = node.activeAgent()
|
|
863
|
+
if (agent) agent.cancel({ kind: 'user' })
|
|
864
|
+
node.activeSessionId = null
|
|
865
|
+
await node.onActiveSessionChange?.(null)
|
|
866
|
+
// 文本含"没有活动会话"→ 有按钮权限时附带快捷按钮;无按钮时文字指引也能操作
|
|
867
|
+
await node.sendText(`${node.mark.ok} 已结束当前会话(没有活动会话)。\n\n发送以下任一命令开始:\n/new <提示词> — 新建会话\n/sessions — 查看已有会话\n/help — 命令帮助`)
|
|
868
|
+
return true
|
|
869
|
+
}
|
|
855
870
|
case 'status': {
|
|
856
871
|
const agent = node.activeAgent()
|
|
857
872
|
const session = node.activeSession()
|
|
@@ -954,6 +969,7 @@ function helpText() {
|
|
|
954
969
|
'/new <提示词> @路径 — 在指定目录新建会话',
|
|
955
970
|
'/new <提示词> @N — 用编号选择工作区(/workspaces)',
|
|
956
971
|
'/stop — 停止当前任务',
|
|
972
|
+
'/end — 结束当前会话(回到无活动会话状态)',
|
|
957
973
|
'/status — 查看状态',
|
|
958
974
|
'/yes /no 或 1/2 — 回应权限请求',
|
|
959
975
|
'/help — 本帮助',
|
package/lib/qq/gateway.js
CHANGED
|
@@ -6,16 +6,22 @@ import WebSocket from 'ws'
|
|
|
6
6
|
|
|
7
7
|
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
|
|
8
8
|
const API_BASE = 'https://api.bot.qq.com'
|
|
9
|
-
|
|
9
|
+
// 官方 WebSocket 网关地址(2026-08-10 起域名统一为 api.bot.qq.com)
|
|
10
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
|
|
11
|
+
const DEFAULT_GATEWAY = 'wss://api.bot.qq.com/websocket/'
|
|
10
12
|
const MAX_MESSAGE_CHARS = 2000
|
|
11
13
|
const TOKEN_MARGIN_MS = 5 * 60_000
|
|
12
14
|
const REQUEST_TIMEOUT_MS = 15_000
|
|
13
15
|
const RECONNECT_DELAY_MS = 3000
|
|
14
16
|
|
|
15
17
|
export const QQ_INTENTS = {
|
|
18
|
+
// 官方 Intent 表:C2C_MESSAGE_CREATE 与 GROUP_AT_MESSAGE_CREATE 同属 GROUP_AND_C2C_EVENT (1<<25)
|
|
19
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/event/c2c_message_create.html
|
|
20
|
+
// https://bot.q.qq.com/wiki/develop/api-v2/autogen/event/group_at_message_create.html
|
|
21
|
+
GROUP_AND_C2C_EVENT: 1 << 25,
|
|
16
22
|
C2C_MESSAGE_CREATE: 1 << 25,
|
|
23
|
+
GROUP_AT_MESSAGE_CREATE: 1 << 25, // 之前误标 1<<30(无效值)
|
|
17
24
|
INTERACTION_CREATE: 1 << 26,
|
|
18
|
-
GROUP_AT_MESSAGE_CREATE: 1 << 30,
|
|
19
25
|
PUBLIC_GUILD_MESSAGES: 1 << 9,
|
|
20
26
|
DIRECT_MESSAGE: 1 << 12,
|
|
21
27
|
}
|
|
@@ -62,9 +68,12 @@ function normalizeEvent(payload) {
|
|
|
62
68
|
text: stringValue(data.content).trim(),
|
|
63
69
|
message: data,
|
|
64
70
|
messageReference: data.message_reference,
|
|
71
|
+
msgSeq: data.msg_seq, // 用于避免去重
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
if (event === 'GROUP_AT_MESSAGE_CREATE') {
|
|
74
|
+
if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
|
|
75
|
+
// GROUP_AT_MESSAGE_CREATE:用户@机器人触发;GROUP_MESSAGE_CREATE:开启"接收所有消息"后每条群消息
|
|
76
|
+
// 两者字段结构完全一致(官方文档),content 已去除 @机器人 前缀
|
|
68
77
|
return {
|
|
69
78
|
type: 'message', scope: 'group', event,
|
|
70
79
|
id: data.id || data.msg_id,
|
|
@@ -74,6 +83,7 @@ function normalizeEvent(payload) {
|
|
|
74
83
|
text: stringValue(data.content).trim(),
|
|
75
84
|
message: data,
|
|
76
85
|
messageReference: data.message_reference,
|
|
86
|
+
msgSeq: data.msg_seq, // 用于避免去重
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
if (event === 'AT_MESSAGE_CREATE') {
|
|
@@ -93,6 +103,7 @@ function normalizeEvent(payload) {
|
|
|
93
103
|
type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
|
|
94
104
|
id: data.id,
|
|
95
105
|
interactionId: data.id,
|
|
106
|
+
interactionType: Number(data.type), // 11=消息按钮回调, 12=快捷菜单回调, 13=消息反馈...
|
|
96
107
|
senderId: data.group_member_openid || data.user_openid,
|
|
97
108
|
peerId: data.group_openid || data.user_openid,
|
|
98
109
|
groupId: data.group_openid,
|
|
@@ -133,7 +144,8 @@ export class QqGateway extends Service {
|
|
|
133
144
|
get status() { return this.statusValue }
|
|
134
145
|
get configured() { return Boolean(this.config.appId && this.config.clientSecret) }
|
|
135
146
|
get capabilities() {
|
|
136
|
-
|
|
147
|
+
// QQ 支持输入状态(msg_type=6 + input_notify)、群聊、富媒体
|
|
148
|
+
return { supportsGroup: true, supportsMedia: true, supportsVoice: true, supportsTyping: true, maxMessageChars: MAX_MESSAGE_CHARS }
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
setStatus(status) {
|
|
@@ -205,8 +217,10 @@ export class QqGateway extends Service {
|
|
|
205
217
|
try {
|
|
206
218
|
this.setStatus('starting')
|
|
207
219
|
const token = await this.refreshAccessToken()
|
|
220
|
+
// 官方「获取带分片 WSS 接入点」接口,返回网关地址与建议分片数
|
|
221
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/event-emit/websocket.html
|
|
208
222
|
const gateway = this.config.gatewayUrl
|
|
209
|
-
|| ((await requestJson(`${API_BASE}/gateway`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
|
|
223
|
+
|| ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
|
|
210
224
|
|| DEFAULT_GATEWAY
|
|
211
225
|
await this.connect(gateway, token)
|
|
212
226
|
} catch (error) {
|
|
@@ -307,43 +321,43 @@ export class QqGateway extends Service {
|
|
|
307
321
|
}
|
|
308
322
|
|
|
309
323
|
async sendText(peerId, content, opts = {}) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
})
|
|
324
|
+
const body = {
|
|
325
|
+
content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
|
|
326
|
+
msg_type: 0,
|
|
327
|
+
}
|
|
328
|
+
// 只添加有值的可选字段
|
|
329
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
330
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
331
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
332
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
async sendMarkdown(peerId, markdown, opts = {}) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
})
|
|
336
|
+
const body = {
|
|
337
|
+
markdown: typeof markdown === 'string' ? { content: markdown } : markdown,
|
|
338
|
+
msg_type: 2,
|
|
339
|
+
}
|
|
340
|
+
// 只添加有值的可选字段
|
|
341
|
+
if (opts.keyboard !== undefined) body.keyboard = opts.keyboard
|
|
342
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
343
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
344
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
345
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
332
346
|
}
|
|
333
347
|
|
|
334
348
|
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
335
349
|
// 官方文档:keyboard 为附加字段,配合 msg_type=0(content) 或 msg_type=2(markdown) 使用
|
|
336
350
|
// 纯文本 + 键盘时使用 msg_type=0
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
})
|
|
351
|
+
const body = {
|
|
352
|
+
content: stringValue(content),
|
|
353
|
+
keyboard,
|
|
354
|
+
msg_type: 0,
|
|
355
|
+
}
|
|
356
|
+
// 只添加有值的可选字段
|
|
357
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
358
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
359
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
360
|
+
return this.api(this.endpoint(peerId, opts.scope), { method: 'POST', body })
|
|
347
361
|
}
|
|
348
362
|
|
|
349
363
|
async sendMedia(peerId, media, opts = {}) {
|
|
@@ -359,20 +373,19 @@ export class QqGateway extends Service {
|
|
|
359
373
|
|
|
360
374
|
async sendStream(peerId, content, opts = {}) {
|
|
361
375
|
const endpoint = this.endpoint(peerId, opts.scope, 'stream_messages')
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
})
|
|
376
|
+
const body = {
|
|
377
|
+
content_type: opts.contentType || 'text',
|
|
378
|
+
content_raw: stringValue(content), // 流式 replace 模式每片是全量前缀,不截断
|
|
379
|
+
input_mode: opts.inputMode || 'replace',
|
|
380
|
+
input_state: opts.inputState, // 1=生成中, 10=生成结束
|
|
381
|
+
index: opts.index, // 分片序号,从0递增
|
|
382
|
+
}
|
|
383
|
+
// 只添加有值的可选字段,避免 undefined 被序列化
|
|
384
|
+
if (opts.streamMsgId !== undefined) body.stream_msg_id = opts.streamMsgId
|
|
385
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
386
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
387
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
388
|
+
return this.api(endpoint, { method: 'POST', body })
|
|
376
389
|
}
|
|
377
390
|
|
|
378
391
|
async respondInteraction(interactionId, response) {
|
|
@@ -386,18 +399,30 @@ export class QqGateway extends Service {
|
|
|
386
399
|
// QQ Bot API v2 使用 msg_type: 6 发送输入状态通知
|
|
387
400
|
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages.post.html
|
|
388
401
|
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,
|
|
402
|
+
const body = {
|
|
403
|
+
msg_type: 6,
|
|
404
|
+
input_notify: {
|
|
405
|
+
input_type: 1,
|
|
406
|
+
input_second: Math.min(opts.durationSeconds || 5, 60), // 最长60秒
|
|
399
407
|
},
|
|
400
|
-
}
|
|
408
|
+
}
|
|
409
|
+
// 只添加有值的可选字段
|
|
410
|
+
if (opts.msgId !== undefined) body.msg_id = opts.msgId
|
|
411
|
+
if (opts.eventId !== undefined) body.event_id = opts.eventId
|
|
412
|
+
if (opts.msgSeq !== undefined) body.msg_seq = opts.msgSeq
|
|
413
|
+
return this.api(endpoint, { method: 'POST', body })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 撤回机器人发送的消息(发送超过 2 分钟不可撤回)。
|
|
418
|
+
* 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_messages_message_id.delete.html
|
|
419
|
+
* @param {string} peerId 用户/群 OpenID(u_xxx / g_xxx)
|
|
420
|
+
* @param {string} messageId 要撤回的消息 ID
|
|
421
|
+
* @param {object} [opts] { scope }
|
|
422
|
+
*/
|
|
423
|
+
async withdrawMessage(peerId, messageId, opts = {}) {
|
|
424
|
+
const endpoint = `${this.endpoint(peerId, opts.scope)}/${encodeURIComponent(messageId)}`
|
|
425
|
+
return this.api(endpoint, { method: 'DELETE' })
|
|
401
426
|
}
|
|
402
427
|
|
|
403
428
|
// ---- 自定义菜单(单聊底部菜单,全局生效)----
|
package/lib/qq/index.js
CHANGED
|
@@ -60,7 +60,7 @@ export class QqService extends Platform {
|
|
|
60
60
|
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
61
61
|
activeSessionId: config.activeSessionId,
|
|
62
62
|
}, logger, {
|
|
63
|
-
onFirstSender: (
|
|
63
|
+
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
64
64
|
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
|
|
65
65
|
})
|
|
66
66
|
this.bridge = this.node
|
|
@@ -240,14 +240,23 @@ export class QqService extends Platform {
|
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
// 自定义菜单:单聊底部菜单(send_message 类型,点击自动填入命令)
|
|
243
|
+
// 幂等:先查询,内容一致则跳过 PUT,避免每次重启都覆盖(version 递增)
|
|
243
244
|
try {
|
|
244
245
|
const menuItems = [
|
|
245
246
|
{ name: '新建', type: 'send_message', send_message: '/new' },
|
|
246
247
|
{ name: '列表', type: 'send_message', send_message: '/list' },
|
|
247
248
|
{ name: '帮助', type: 'send_message', send_message: '/help' },
|
|
248
249
|
]
|
|
249
|
-
await gate.
|
|
250
|
-
|
|
250
|
+
const existing = await gate.getMenu()
|
|
251
|
+
const existingItems = existing?.menu?.items || []
|
|
252
|
+
const same = existingItems.length === menuItems.length &&
|
|
253
|
+
menuItems.every((item, i) => existingItems[i]?.name === item.name && existingItems[i]?.send_message === item.send_message)
|
|
254
|
+
if (!same) {
|
|
255
|
+
await gate.setMenu(menuItems)
|
|
256
|
+
this.logger.info('[dsh-bridge qq] custom menu configured')
|
|
257
|
+
} else {
|
|
258
|
+
this.logger.info('[dsh-bridge qq] custom menu already configured')
|
|
259
|
+
}
|
|
251
260
|
} catch (err) {
|
|
252
261
|
this.logger.warn('[dsh-bridge qq] custom menu setup skipped: %s', err?.message ?? err)
|
|
253
262
|
}
|
package/lib/qq/node.js
CHANGED
|
@@ -52,6 +52,25 @@ function sanitizeQQMarkdown(text) {
|
|
|
52
52
|
return out.join('\n')
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* replace 模式流式分片:把完整内容切成「递增前缀」序列。
|
|
57
|
+
* 官方要求 replace 模式下每片 content_raw 为当前全量正文,
|
|
58
|
+
* 且须以上游已下发内容开头;服务端逐片覆盖显示 → 手机端看到一条消息逐渐变长。
|
|
59
|
+
* @param {string} content
|
|
60
|
+
* @param {number} maxChunk - 单片最大字符数(用户配置的 maxMessageChars)
|
|
61
|
+
* @returns {string[]} 递增前缀数组,最后一片为完整内容
|
|
62
|
+
*/
|
|
63
|
+
function splitIntoIncremental(content, maxChunk) {
|
|
64
|
+
const segs = splitIntoChunks(content, maxChunk)
|
|
65
|
+
const slices = []
|
|
66
|
+
let acc = ''
|
|
67
|
+
for (const s of segs) {
|
|
68
|
+
acc += s
|
|
69
|
+
slices.push(acc)
|
|
70
|
+
}
|
|
71
|
+
return slices
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
/**
|
|
56
75
|
* 按段落边界切分内容(避免切断行内内容),单块时拆两片保证流式过渡
|
|
57
76
|
* @param {string} content - 要分片的内容
|
|
@@ -143,51 +162,86 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
143
162
|
|
|
144
163
|
if (isPromptMessage) {
|
|
145
164
|
// 发送带按钮的消息,方便用户快速操作
|
|
165
|
+
// 官方键盘结构:keyboard.content.rows;action.type=1(回调按钮,触发 INTERACTION_CREATE)
|
|
166
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
|
|
146
167
|
const keyboard = {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
content: {
|
|
169
|
+
rows: [
|
|
170
|
+
{
|
|
171
|
+
buttons: [
|
|
172
|
+
{
|
|
173
|
+
id: 'new_conversation',
|
|
174
|
+
render_data: { label: '🆕 新建会话', visited_label: '新建会话', style: 1 },
|
|
175
|
+
action: { type: 1, permission: { type: 2 }, data: 'new', unsupport_tips: '请升级QQ客户端后使用' },
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'list_sessions',
|
|
179
|
+
render_data: { label: '📋 会话列表', visited_label: '会话列表', style: 1 },
|
|
180
|
+
action: { type: 1, permission: { type: 2 }, data: 'list', unsupport_tips: '请升级QQ客户端后使用' },
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
buttons: [
|
|
186
|
+
{
|
|
187
|
+
id: 'help',
|
|
188
|
+
render_data: { label: '❓ 帮助', visited_label: '帮助', style: 1 },
|
|
189
|
+
action: { type: 1, permission: { type: 2 }, data: 'help', unsupport_tips: '请升级QQ客户端后使用' },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
160
195
|
}
|
|
161
|
-
|
|
196
|
+
// 官方按钮基于 markdown 消息(msg_type=2)挂载
|
|
197
|
+
return this._sendMarkdown(peerId, text, { scope, msgId: replyMsgId, keyboard })
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
// 其他回复统一走「流式 Markdown」:content_type=markdown
|
|
200
|
+
// 其他回复统一走「流式 Markdown」:content_type=markdown 让手机端渲染
|
|
165
201
|
const content = String(text || '').trim()
|
|
166
202
|
if (content.length === 0) return { success: true }
|
|
167
|
-
|
|
168
203
|
const md = sanitizeQQMarkdown(content)
|
|
169
|
-
|
|
204
|
+
|
|
205
|
+
// 群消息不支持流式参数(官方文档明确说明),直接发送 Markdown
|
|
206
|
+
// 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_groups_group_openid_messages.post.html
|
|
207
|
+
if (scope === 'group') {
|
|
208
|
+
return this._sendMarkdown(peerId, md, { scope, msgId: replyMsgId })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 单聊:replace 模式(每片是全量前缀),服务端逐片覆盖 → 一条消息逐渐变长
|
|
170
212
|
const maxChars = this.config.maxMessageChars || 2000
|
|
171
|
-
const
|
|
213
|
+
const slices = splitIntoIncremental(md, maxChars)
|
|
214
|
+
|
|
215
|
+
// 被动回复的 msg_id 每片都带(官方示例如此);主动消息(digest 等)无 msg_id 则不传
|
|
216
|
+
const baseSeq = Number(this._msgSeq) || 0
|
|
217
|
+
const streamCommon = {
|
|
218
|
+
scope,
|
|
219
|
+
contentType: 'markdown',
|
|
220
|
+
inputMode: 'replace',
|
|
221
|
+
}
|
|
172
222
|
|
|
173
223
|
try {
|
|
174
224
|
let streamMsgId = null
|
|
175
|
-
for (let i = 0; i <
|
|
176
|
-
const isLast = i ===
|
|
177
|
-
const result = await this.gateway.sendStream(peerId,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
225
|
+
for (let i = 0; i < slices.length; i++) {
|
|
226
|
+
const isLast = i === slices.length - 1
|
|
227
|
+
const result = await this.gateway.sendStream(peerId, slices[i], {
|
|
228
|
+
...streamCommon,
|
|
229
|
+
msgId: replyMsgId, // 每片都带被动回复 msg_id(官方示例如此)
|
|
230
|
+
msgSeq: replyMsgId ? baseSeq + 1 + i : undefined, // 递增避免去重(40054005)
|
|
181
231
|
streamMsgId, // 后续片携带服务端返回的 stream_msg_id
|
|
182
232
|
index: i, // 分片序号从 0 递增
|
|
183
233
|
inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
|
|
184
|
-
inputMode: 'append', // 追加模式:服务端拼接到同一条消息
|
|
185
234
|
})
|
|
186
235
|
|
|
187
236
|
// 首片返回 stream_msg_id,后续片需携带
|
|
188
|
-
if (i === 0
|
|
189
|
-
streamMsgId = result
|
|
190
|
-
|
|
237
|
+
if (i === 0) {
|
|
238
|
+
streamMsgId = result?.id || result?.message_id || result?.stream_msg_id
|
|
239
|
+
if (streamMsgId) {
|
|
240
|
+
this.lastMessageId = streamMsgId
|
|
241
|
+
this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s, slices=%d', streamMsgId, slices.length)
|
|
242
|
+
} else {
|
|
243
|
+
this.logger?.warn?.('[dsh-bridge qq] stream first chunk returned no id: %o', result)
|
|
244
|
+
}
|
|
191
245
|
}
|
|
192
246
|
|
|
193
247
|
if (result?.code !== undefined && result.code !== 0) {
|
|
@@ -202,30 +256,52 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
202
256
|
}
|
|
203
257
|
return { success: true }
|
|
204
258
|
} catch (err) {
|
|
205
|
-
//
|
|
206
|
-
this.logger?.warn?.('[dsh-bridge qq] stream send failed,
|
|
259
|
+
// 流式失败 → 补发完整内容 replace 收尾(input_state=10),尽量合并成一条
|
|
260
|
+
this.logger?.warn?.('[dsh-bridge qq] stream send failed, patch final replace: %s', err?.message ?? err)
|
|
207
261
|
try {
|
|
208
|
-
const result = await this.gateway.
|
|
262
|
+
const result = await this.gateway.sendStream(peerId, md, {
|
|
263
|
+
...streamCommon,
|
|
264
|
+
msgId: replyMsgId,
|
|
265
|
+
msgSeq: replyMsgId ? baseSeq + slices.length + 1 : undefined,
|
|
266
|
+
streamMsgId,
|
|
267
|
+
index: slices.length,
|
|
268
|
+
inputState: 10,
|
|
269
|
+
})
|
|
209
270
|
if (result?.id) this.lastMessageId = result.id
|
|
210
271
|
return result
|
|
211
272
|
} catch (fallbackErr1) {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
}
|
|
273
|
+
// 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
|
|
274
|
+
return this._sendMarkdown(peerId, md, { scope })
|
|
221
275
|
}
|
|
222
276
|
}
|
|
223
277
|
}
|
|
224
278
|
|
|
225
|
-
/** 发送
|
|
279
|
+
/** 发送 Markdown 消息:先带被动回复 msg_id,失败则降级为主动消息。 */
|
|
280
|
+
async _sendMarkdown(peerId, md, { scope, msgId, keyboard } = {}) {
|
|
281
|
+
try {
|
|
282
|
+
const result = await this.gateway.sendMarkdown(peerId, md, { scope, msgId, keyboard })
|
|
283
|
+
if (result?.id) this.lastMessageId = result.id
|
|
284
|
+
return result
|
|
285
|
+
} catch (fallbackErr1) {
|
|
286
|
+
// 被动回复失败(msg_id 过期等)→ 主动消息兜底
|
|
287
|
+
this.logger?.warn?.('[dsh-bridge qq] markdown send failed with msg_id, retry as active: %s', fallbackErr1?.message ?? fallbackErr1)
|
|
288
|
+
try {
|
|
289
|
+
const result = await this.gateway.sendMarkdown(peerId, md, { scope, keyboard })
|
|
290
|
+
if (result?.id) this.lastMessageId = result.id
|
|
291
|
+
return result
|
|
292
|
+
} catch (fallbackErr2) {
|
|
293
|
+
this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
|
|
294
|
+
return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒;群聊不支持) */
|
|
226
300
|
async sendTyping(state) {
|
|
227
301
|
const peerInfo = this._currentPeer()
|
|
228
302
|
if (!peerInfo) return
|
|
303
|
+
// 群聊消息类型列表不含 msg_type=6(输入状态),跳过
|
|
304
|
+
if (peerInfo.scope === 'group') return { ok: true }
|
|
229
305
|
// state=2(停止)时无需显式结束——input_second 到期自动消失
|
|
230
306
|
if (Number(state) === 2) return { ok: true }
|
|
231
307
|
return this.gateway.sendTyping(peerInfo.peerId, {
|
|
@@ -241,9 +317,18 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
241
317
|
const sender = String(event.senderId ?? '').trim()
|
|
242
318
|
if (!sender) return
|
|
243
319
|
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
320
|
+
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
321
|
+
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
322
|
+
|
|
323
|
+
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id)、msg_seq,供出站使用
|
|
324
|
+
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
|
|
325
|
+
// 授权主体:群聊按群维度(group_openid),单聊按用户(user_openid)
|
|
326
|
+
const authId = isGroup ? (event.groupId || peerId || sender) : sender
|
|
327
|
+
|
|
328
|
+
// 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
|
|
329
|
+
// 群聊不在此拦截——交给 handleInbound 自动授权(首次 @机器人 即授权该群)
|
|
330
|
+
if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
|
|
331
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${authId}`)
|
|
247
332
|
return
|
|
248
333
|
}
|
|
249
334
|
|
|
@@ -253,14 +338,10 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
253
338
|
return
|
|
254
339
|
}
|
|
255
340
|
|
|
256
|
-
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
257
|
-
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
258
|
-
|
|
259
|
-
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id),供出站使用
|
|
260
|
-
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
|
|
261
341
|
if (peerId) {
|
|
262
342
|
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
263
343
|
if (event.id) this._replyMsgId = String(event.id)
|
|
344
|
+
if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
|
|
264
345
|
}
|
|
265
346
|
|
|
266
347
|
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
@@ -269,13 +350,13 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
269
350
|
if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
|
|
270
351
|
this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
|
|
271
352
|
// 先创建新会话,再处理消息
|
|
272
|
-
await this.handleInbound({ senderId:
|
|
353
|
+
await this.handleInbound({ senderId: authId, text: '/new', isGroup })
|
|
273
354
|
// 等待一小段时间确保会话创建完成
|
|
274
355
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
275
356
|
}
|
|
276
357
|
|
|
277
358
|
// 交给平台无关核心:白名单/群消息/命令路由/agent 分发
|
|
278
|
-
await this.handleInbound({ senderId:
|
|
359
|
+
await this.handleInbound({ senderId: authId, text, isGroup })
|
|
279
360
|
}
|
|
280
361
|
|
|
281
362
|
// ---- 互动事件 ----
|
|
@@ -284,36 +365,54 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
284
365
|
const sender = String(event.senderId ?? '').trim()
|
|
285
366
|
if (!sender) return
|
|
286
367
|
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
368
|
+
// 群聊按钮点击:授权主体按群维度(group_openid),与消息处理一致
|
|
369
|
+
const isGroup = event.scope === 'group'
|
|
370
|
+
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
|
|
371
|
+
const authId = isGroup ? (event.groupId || peerId || sender) : sender
|
|
372
|
+
|
|
373
|
+
// 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
|
|
374
|
+
// 群聊不在此拦截——handleInbound 会按群自动授权
|
|
375
|
+
if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
|
|
376
|
+
this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${authId}`)
|
|
290
377
|
return
|
|
291
378
|
}
|
|
292
379
|
|
|
380
|
+
const type = Number(event.interactionType ?? event?.data?.type ?? 0)
|
|
381
|
+
// 仅消息按钮(11)与快捷菜单(12)需要回应;其他类型(反馈/清空会话/故事集/授权等)无需回应
|
|
382
|
+
const needsRespond = type === 11 || type === 12
|
|
383
|
+
|
|
293
384
|
// 记录 peer 信息,供命令回复使用
|
|
294
|
-
const isGroup = event.scope === 'group'
|
|
295
|
-
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
|
|
296
385
|
if (peerId) {
|
|
297
386
|
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
298
387
|
this._replyMsgId = null
|
|
299
388
|
}
|
|
300
389
|
|
|
390
|
+
// 回应互动:告知后台已收到,避免客户端一直 loading(同一 interaction_id 只能回应一次)
|
|
391
|
+
if (needsRespond && event.interactionId) {
|
|
392
|
+
try {
|
|
393
|
+
await this.gateway.respondInteraction(event.interactionId, { code: 0 })
|
|
394
|
+
} catch (err) {
|
|
395
|
+
this.logger?.warn?.('[dsh-bridge qq] respond interaction failed: %s', err?.message ?? err)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!needsRespond) {
|
|
400
|
+
this.logger?.info?.(`[dsh-bridge qq] skip non-button interaction type=${type} from ${authId}`)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
301
404
|
const data = event.data || {}
|
|
302
405
|
const resolved = data.resolved || {}
|
|
303
406
|
const buttonId = resolved.button_id || ''
|
|
304
407
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
code: 0,
|
|
308
|
-
})
|
|
309
|
-
|
|
310
|
-
// 根据按钮 ID 执行对应操作
|
|
408
|
+
// 根据按钮 ID 执行对应操作(消息按钮 11 与快捷菜单 12 都映射到命令)
|
|
409
|
+
this.logger?.info?.('[dsh-bridge qq] button interaction type=%s button_id=%s from %s', type, buttonId, authId)
|
|
311
410
|
if (buttonId === 'new_conversation') {
|
|
312
|
-
await this.handleInbound({ senderId:
|
|
411
|
+
await this.handleInbound({ senderId: authId, text: '/new', isGroup })
|
|
313
412
|
} else if (buttonId === 'list_sessions') {
|
|
314
|
-
await this.handleInbound({ senderId:
|
|
413
|
+
await this.handleInbound({ senderId: authId, text: '/list', isGroup })
|
|
315
414
|
} else if (buttonId === 'help') {
|
|
316
|
-
await this.handleInbound({ senderId:
|
|
415
|
+
await this.handleInbound({ senderId: authId, text: '/help', isGroup })
|
|
317
416
|
} else {
|
|
318
417
|
this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
|
|
319
418
|
}
|
|
@@ -329,5 +428,6 @@ export const qqNodeHelpers = {
|
|
|
329
428
|
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
330
429
|
sanitizeQQMarkdown,
|
|
331
430
|
splitIntoChunks,
|
|
431
|
+
splitIntoIncremental,
|
|
332
432
|
STREAM_CHUNK_SIZE,
|
|
333
433
|
}
|
package/lib/wechat/index.js
CHANGED
|
@@ -44,7 +44,7 @@ export class WechatService extends Platform {
|
|
|
44
44
|
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
45
45
|
activeSessionId: config.activeSessionId, // v0.2.1:恢复活动会话
|
|
46
46
|
}, logger, {
|
|
47
|
-
onFirstSender: (
|
|
47
|
+
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
48
48
|
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }), // v0.2.1:持久化活动会话
|
|
49
49
|
})
|
|
50
50
|
this.bridge = this.node
|
package/package.json
CHANGED