@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 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.sgroup.qq.com'
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: 2,
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, 'stream-messages')
361
+ const endpoint = this.endpoint(peerId, opts.scope, 'stream_messages')
359
362
  return this.api(endpoint, {
360
363
  method: 'POST',
361
364
  body: {
362
- content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
363
- msg_type: 0,
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
- input_state: opts.inputState, // 0=结束输入, 1=输入中
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 通过流式消息的 input_state 参数控制输入状态
380
- // input_state: 0=结束输入, 1=输入中
381
- // 参考:https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_stream_messages.post.html
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
- content: '', // 仅设置输入状态,不发送内容
387
- msg_type: 0,
388
- input_state: 1, // 1=输入中
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, state) {
83
- return this.gateway.sendTyping(peerId, state)
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) 转为链接 [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: (peer, opts) => gateway.sendTyping(peer, opts),
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 peer = this.peerId
61
- if (!peer) return
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
- const result = await this.platform.sendKeyboard(peer, text, keyboard)
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 STREAM_CHUNK_SIZE = 200
96
-
97
- // 如果消息较短,直接发送普通消息
98
- if (content.length <= STREAM_CHUNK_SIZE) {
99
- const result = await this.gateway.sendText(peer, content, {})
100
- // 保存消息 ID
101
- if (result?.data?.message_id) {
102
- this.lastMessageId = result.data.message_id
103
- }
104
- return result
105
- }
106
-
107
- // 流式发送:把内容切分成多段
108
- const chunks = []
109
- for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
110
- chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
111
- }
112
-
113
- // 逐段发送流式消息
114
- let firstMsgId = null
115
- for (let i = 0; i < chunks.length; i++) {
116
- const chunk = chunks[i]
117
- const isLast = i === chunks.length - 1
118
- const result = await this.gateway.sendStream(peer, chunk, {
119
- msgId: firstMsgId, // 后续段关联到第一段
120
- inputState: isLast ? 0 : 1, // 最后一段结束输入状态,其他段显示输入中
121
- })
122
- if (!firstMsgId && result?.data?.message_id) {
123
- firstMsgId = result.data.message_id
124
- this.lastMessageId = firstMsgId // 保存第一段消息 ID
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
- if (result?.error) {
127
- return { success: false, error: result.error }
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
- return { success: true }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.1.2",
3
+ "version": "2.2.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",