@wenbin_wb/dsh-bridge 2.1.2 → 2.2.0

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
@@ -18,8 +18,9 @@ function makePlatform(gateway) {
18
18
  name: 'QQ',
19
19
  get accountId() { return gateway.accountId ?? '' },
20
20
  get capabilities() { return gateway.capabilities },
21
+ // sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
21
22
  sendText: (peer, text) => gateway.sendText(peer, text, {}),
22
- sendTyping: (peer, opts) => gateway.sendTyping(peer, opts),
23
+ sendTyping: () => Promise.resolve({ ok: true }),
23
24
  sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
24
25
  }
25
26
  }
@@ -44,6 +45,9 @@ export class QqConversationNode extends ConversationBridge {
44
45
  })
45
46
  this.gateway = ctx.qq
46
47
  this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
48
+ // 当前对话 peer 信息(由 _handleInbound 在每次收到消息时刷新)
49
+ this._lastPeer = null // { peerId, scope: 'c2c'|'group' }
50
+ this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
47
51
 
48
52
  // 订阅网关入站事件
49
53
  this.ctx.on('qq/message', (event) => {
@@ -54,15 +58,22 @@ export class QqConversationNode extends ConversationBridge {
54
58
  })
55
59
  }
56
60
 
57
- // ---- 出站:覆盖 sendText,在提示消息中添加按钮 ----
61
+ // ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
62
+
63
+ /** 解析当前对话 peer 信息;无活动 peer 时返回 null */
64
+ _currentPeer() {
65
+ return this._lastPeer
66
+ }
58
67
 
59
68
  async sendText(text) {
60
- const peer = this.peerId
61
- if (!peer) return
62
-
69
+ const peerInfo = this._currentPeer()
70
+ if (!peerInfo) return
71
+ const { peerId, scope } = peerInfo
72
+ const replyMsgId = this._replyMsgId || undefined
73
+
63
74
  // 检测是否是提示用户开始新会话的消息
64
75
  const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
65
-
76
+
66
77
  if (isPromptMessage) {
67
78
  // 发送带按钮的消息,方便用户快速操作
68
79
  const keyboard = {
@@ -80,59 +91,73 @@ export class QqConversationNode extends ConversationBridge {
80
91
  },
81
92
  ],
82
93
  }
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
94
+ return this.gateway.sendKeyboard(peerId, text, keyboard, { scope, msgId: replyMsgId })
89
95
  }
90
-
96
+
91
97
  // 其他消息使用流式发送
92
98
  const content = String(text || '').trim()
93
99
  if (content.length === 0) return { success: true }
94
-
95
- const STREAM_CHUNK_SIZE = 200
96
-
100
+
101
+ const STREAM_CHUNK_SIZE = 500
102
+
97
103
  // 如果消息较短,直接发送普通消息
98
104
  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
- }
105
+ const result = await this.gateway.sendText(peerId, content, { scope, msgId: replyMsgId })
106
+ if (result?.id) this.lastMessageId = result.id
104
107
  return result
105
108
  }
106
-
107
- // 流式发送:把内容切分成多段
109
+
110
+ // 流式发送:把内容切分成多段(append 模式,服务端拼接为同一条消息)
108
111
  const chunks = []
109
112
  for (let i = 0; i < content.length; i += STREAM_CHUNK_SIZE) {
110
113
  chunks.push(content.slice(i, i + STREAM_CHUNK_SIZE))
111
114
  }
112
-
113
- // 逐段发送流式消息
114
- let firstMsgId = null
115
+
116
+ let streamMsgId = null
115
117
  for (let i = 0; i < chunks.length; i++) {
116
118
  const chunk = chunks[i]
117
119
  const isLast = i === chunks.length - 1
118
- const result = await this.gateway.sendStream(peer, chunk, {
119
- msgId: firstMsgId, // 后续段关联到第一段
120
- inputState: isLast ? 0 : 1, // 最后一段结束输入状态,其他段显示输入中
120
+ const result = await this.gateway.sendStream(peerId, chunk, {
121
+ scope,
122
+ msgId: i === 0 ? replyMsgId : undefined, // 首片带被动回复 msg_id
123
+ streamMsgId, // 后续片携带服务端返回的 stream_msg_id
124
+ index: i, // 分片序号从 0 递增
125
+ inputState: isLast ? 10 : 1, // 1=生成中, 10=生成结束
126
+ inputMode: 'append', // 追加模式:服务端拼接到同一条消息
121
127
  })
122
- if (!firstMsgId && result?.data?.message_id) {
123
- firstMsgId = result.data.message_id
124
- this.lastMessageId = firstMsgId // 保存第一段消息 ID
128
+
129
+ // 首片返回 stream_msg_id,后续片需携带
130
+ if (i === 0 && result?.id) {
131
+ streamMsgId = result.id
132
+ this.lastMessageId = result.id // 保存消息 ID 用于消息引用
125
133
  }
126
- if (result?.error) {
127
- return { success: false, error: result.error }
134
+
135
+ if (result?.code !== undefined && result.code !== 0) {
136
+ return { success: false, error: result.message || `QQ API error ${result.code}` }
128
137
  }
138
+
129
139
  // 流式发送间隔稍短,避免刷屏
130
- await new Promise(resolve => setTimeout(resolve, 100))
140
+ if (!isLast) {
141
+ await new Promise(resolve => setTimeout(resolve, 100))
142
+ }
131
143
  }
132
-
144
+
133
145
  return { success: true }
134
146
  }
135
147
 
148
+ /** 发送"正在输入"状态(QQ 通过 msg_type=6 + input_notify 显示 N 秒) */
149
+ async sendTyping(state) {
150
+ const peerInfo = this._currentPeer()
151
+ if (!peerInfo) return
152
+ // state=2(停止)时无需显式结束——input_second 到期自动消失
153
+ if (Number(state) === 2) return { ok: true }
154
+ return this.gateway.sendTyping(peerInfo.peerId, {
155
+ scope: peerInfo.scope,
156
+ durationSeconds: 8,
157
+ msgId: this._replyMsgId || undefined,
158
+ })
159
+ }
160
+
136
161
  // ---- 入站 ----
137
162
 
138
163
  async _handleInbound(event) {
@@ -154,6 +179,13 @@ export class QqConversationNode extends ConversationBridge {
154
179
  // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
155
180
  const isGroup = event.scope === 'group' || event.scope === 'guild'
156
181
 
182
+ // 记录当前 peer 信息与被动回复消息 ID(事件 d.id),供出站使用
183
+ const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
184
+ if (peerId) {
185
+ this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
186
+ if (event.id) this._replyMsgId = String(event.id)
187
+ }
188
+
157
189
  // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
158
190
  // 则自动创建新会话并发送消息
159
191
  const messageReference = event.messageReference
@@ -181,6 +213,14 @@ export class QqConversationNode extends ConversationBridge {
181
213
  return
182
214
  }
183
215
 
216
+ // 记录 peer 信息,供命令回复使用
217
+ const isGroup = event.scope === 'group'
218
+ const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
219
+ if (peerId) {
220
+ this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
221
+ this._replyMsgId = null
222
+ }
223
+
184
224
  const data = event.data || {}
185
225
  const resolved = data.resolved || {}
186
226
  const buttonId = resolved.button_id || ''
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.0",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",