@wenbin_wb/dsh-bridge 2.1.0 → 2.1.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/README.md CHANGED
@@ -18,6 +18,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
18
18
  - **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere without a public server of your own — keep working even when you're away from home
19
19
  - **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
20
20
  - **WeChat Bot (ClawBot / iLink)**: Scan a QR code to log in a WeChat personal account, then chat with, control, and approve your DeepSeek Harness agents right inside WeChat. **Multi-workspace selection, restart-persistent sessions, grouped session listing with titles, media (image/file/voice) transfer, and permission approvals** — over Tencent's official iLink Bot API, no public server or tunnel required ([Usage Guide](docs/wechat-usage.md))
21
+ - **QQ Bot (OpenAPI v2)**: Connect your QQ Bot to receive private/group messages, send Markdown, button keyboards, and rich media. **Full event coverage (C2C / GROUP_AT_MESSAGE_CREATE), auto token refresh, reconnection with backoff, message deduplication** — over Tencent's official QQ Bot OpenAPI v2 ([Usage Guide](docs/qq-usage.md))
21
22
  - **IM Integration (More Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
22
23
  - **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
23
24
  - **Auto Version Check**: Automatic update detection when entering the panel
@@ -35,7 +36,7 @@ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public
35
36
  |--------|-------------|--------|
36
37
  | **Platform Abstraction** | Platform-agnostic core (sessions / approvals / commands / digest) shared across IM channels | ✅ **Completed** (v2.0.0) |
37
38
  | **WeChat** | Chat with your Agent directly in WeChat | ✅ Supported (workspaces / persisted sessions / media / approvals) |
38
- | **QQ Bot** | QQ bot integration for group/private chat | 🚧 Next (platform adapter implementation) |
39
+ | **QQ Bot** | QQ bot integration for group/private chat | **Completed** (v2.1.0) Markdown / buttons / rich media |
39
40
  | **Feishu** | Feishu message/bot integration for workplace scenarios | Planned |
40
41
  | **OpenClaw** | Integration with OpenClaw ecosystem | Planned |
41
42
  | **Telegram** | Self-hosted IM channel | Planned |
package/README.zh-CN.md CHANGED
@@ -18,7 +18,8 @@
18
18
  - **Cloudflare 隧道**:一键暴露公网地址,随时随地连接,出差在外、不在家也能接着干,无需自建公网服务器
19
19
  - **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
20
20
  - **微信 Bot(ClawBot / iLink)**:扫码登录微信个人号后,直接在微信里对话、控制 DeepSeek Harness 的 agent。**支持多工作区选择、会话跨重启持久化、按工作区分组查看、媒体(图片/文件/语音)收发、权限审批**——走腾讯官方 iLink Bot API,无需公网([使用说明](docs/wechat-usage.md))
21
- - **IM 集成(更多平台规划中)**:QQ / 飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
21
+ - **QQ Bot(OpenAPI v2)**:接入 QQ 机器人,私聊/群聊接收消息,发送 Markdown、按钮键盘和富媒体。**完整事件覆盖(C2C / GROUP_AT_MESSAGE_CREATE)、Token 自动刷新、断线重连、消息去重**——走腾讯官方 QQ Bot OpenAPI v2([使用说明](docs/qq-usage.md))
22
+ - **IM 集成(更多平台规划中)**:飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
22
23
  - **安全提示**:URL 和二维码带访问警告,防止误分享
23
24
  - **版本检查**:进入面板自动检测是否有新版本
24
25
 
@@ -35,7 +36,7 @@
35
36
  |------|------|------|
36
37
  | **平台抽象层** | 平台无关的核心(会话/审批/命令/digest)跨 IM 渠道复用 | ✅ **已完成**(v2.0.0) |
37
38
  | **微信** | 在微信里直接与你的 Agent 对话 | ✅ 已支持(多工作区 / 会话持久化 / 媒体 / 审批) |
38
- | **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | 🚧 下一步(平台适配器实现) |
39
+ | **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | **已完成**(v2.1.0)— Markdown / 按钮 / 富媒体 |
39
40
  | **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
40
41
  | **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
41
42
  | **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
package/lib/index.js CHANGED
@@ -545,6 +545,9 @@ function apply(ctx, config = {}) {
545
545
  accountId: cfg.accountId,
546
546
  });
547
547
  logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
548
+ await qq.start().catch((err) => {
549
+ logger.error('dsh-bridge: qq auto-start failed: %s', err?.message ?? err);
550
+ });
548
551
  }
549
552
  }
550
553
  }).catch(() => {});
package/lib/qq/gateway.js CHANGED
@@ -14,6 +14,7 @@ const RECONNECT_DELAY_MS = 3000
14
14
 
15
15
  export const QQ_INTENTS = {
16
16
  C2C_MESSAGE_CREATE: 1 << 25,
17
+ INTERACTION_CREATE: 1 << 26,
17
18
  GROUP_AT_MESSAGE_CREATE: 1 << 30,
18
19
  PUBLIC_GUILD_MESSAGES: 1 << 9,
19
20
  DIRECT_MESSAGE: 1 << 12,
@@ -60,6 +61,7 @@ function normalizeEvent(payload) {
60
61
  peerId: data.author?.user_openid || data.user_openid,
61
62
  text: stringValue(data.content).trim(),
62
63
  message: data,
64
+ messageReference: data.message_reference,
63
65
  }
64
66
  }
65
67
  if (event === 'GROUP_AT_MESSAGE_CREATE') {
@@ -71,6 +73,7 @@ function normalizeEvent(payload) {
71
73
  groupId: data.group_openid || data.group_id,
72
74
  text: stringValue(data.content).trim(),
73
75
  message: data,
76
+ messageReference: data.message_reference,
74
77
  }
75
78
  }
76
79
  if (event === 'AT_MESSAGE_CREATE') {
@@ -82,6 +85,19 @@ function normalizeEvent(payload) {
82
85
  guildId: data.guild_id,
83
86
  text: stringValue(data.content).trim(),
84
87
  message: data,
88
+ messageReference: data.message_reference,
89
+ }
90
+ }
91
+ if (event === 'INTERACTION_CREATE') {
92
+ return {
93
+ type: 'interaction', scope: data.group_openid ? 'group' : 'c2c', event,
94
+ id: data.id,
95
+ interactionId: data.id,
96
+ senderId: data.group_member_openid || data.user_openid,
97
+ peerId: data.group_openid || data.user_openid,
98
+ groupId: data.group_openid,
99
+ data: data.data,
100
+ message: data,
85
101
  }
86
102
  }
87
103
  return { type: 'event', event, message: data }
@@ -98,7 +114,7 @@ export class QqGateway extends Service {
98
114
  accessToken: config.accessToken || '',
99
115
  accessTokenExpiresAt: Number(config.accessTokenExpiresAt || 0),
100
116
  gatewayUrl: config.gatewayUrl || '',
101
- intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE)),
117
+ intents: Number(config.intents ?? (QQ_INTENTS.C2C_MESSAGE_CREATE | QQ_INTENTS.GROUP_AT_MESSAGE_CREATE | QQ_INTENTS.INTERACTION_CREATE)),
102
118
  reconnectDelayMs: Number(config.reconnectDelayMs ?? RECONNECT_DELAY_MS),
103
119
  apiTimeoutMs: Number(config.apiTimeoutMs ?? REQUEST_TIMEOUT_MS),
104
120
  }
@@ -253,6 +269,9 @@ export class QqGateway extends Service {
253
269
  if (event.type === 'message' && event.id && !this.seen(event.id)) {
254
270
  this.ctx.emit?.('qq/message', event)
255
271
  }
272
+ if (event.type === 'interaction' && event.interactionId) {
273
+ this.ctx.emit?.('qq/interaction', event)
274
+ }
256
275
  return
257
276
  }
258
277
  if (op === 7) { try { ws.close() } catch {}; return }
@@ -311,11 +330,11 @@ export class QqGateway extends Service {
311
330
  })
312
331
  }
313
332
 
314
- async sendKeyboard(peerId, keyboard, opts = {}) {
333
+ async sendKeyboard(peerId, content, keyboard, opts = {}) {
315
334
  return this.api(this.endpoint(peerId, opts.scope), {
316
335
  method: 'POST',
317
336
  body: {
318
- content: opts.content || '',
337
+ content: stringValue(content),
319
338
  keyboard,
320
339
  msg_type: 2,
321
340
  msg_id: opts.msgId,
@@ -335,7 +354,43 @@ export class QqGateway extends Service {
335
354
  })
336
355
  }
337
356
 
338
- async sendTyping() { return { ok: false, unsupported: true } }
357
+ async sendStream(peerId, content, opts = {}) {
358
+ const endpoint = this.endpoint(peerId, opts.scope, 'stream-messages')
359
+ return this.api(endpoint, {
360
+ method: 'POST',
361
+ body: {
362
+ content: stringValue(content).slice(0, MAX_MESSAGE_CHARS),
363
+ msg_type: 0,
364
+ msg_id: opts.msgId,
365
+ event_id: opts.eventId,
366
+ input_state: opts.inputState, // 0=结束输入, 1=输入中
367
+ },
368
+ })
369
+ }
370
+
371
+ async respondInteraction(interactionId, response) {
372
+ return this.api(`/interactions/${encodeURIComponent(interactionId)}`, {
373
+ method: 'PUT',
374
+ body: response,
375
+ })
376
+ }
377
+
378
+ 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')
383
+ return this.api(endpoint, {
384
+ method: 'POST',
385
+ body: {
386
+ content: '', // 仅设置输入状态,不发送内容
387
+ msg_type: 0,
388
+ input_state: 1, // 1=输入中
389
+ msg_id: opts.msgId,
390
+ event_id: opts.eventId,
391
+ },
392
+ })
393
+ }
339
394
 
340
395
  setCredentials(values = {}) {
341
396
  for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
package/lib/qq/node.js CHANGED
@@ -19,7 +19,8 @@ function makePlatform(gateway) {
19
19
  get accountId() { return gateway.accountId ?? '' },
20
20
  get capabilities() { return gateway.capabilities },
21
21
  sendText: (peer, text) => gateway.sendText(peer, text, {}),
22
- sendTyping: (peer, state) => gateway.sendTyping(peer, state),
22
+ sendTyping: (peer, opts) => gateway.sendTyping(peer, opts),
23
+ sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
23
24
  }
24
25
  }
25
26
 
@@ -42,11 +43,94 @@ export class QqConversationNode extends ConversationBridge {
42
43
  onActiveSessionChange,
43
44
  })
44
45
  this.gateway = ctx.qq
46
+ this.lastMessageId = null // 存储最后发送的消息 ID,用于消息引用
45
47
 
46
48
  // 订阅网关入站事件
47
49
  this.ctx.on('qq/message', (event) => {
48
50
  void this._handleInbound(event)
49
51
  })
52
+ this.ctx.on('qq/interaction', (event) => {
53
+ void this._handleInteraction(event)
54
+ })
55
+ }
56
+
57
+ // ---- 出站:覆盖 sendText,在提示消息中添加按钮 ----
58
+
59
+ async sendText(text) {
60
+ const peer = this.peerId
61
+ if (!peer) return
62
+
63
+ // 检测是否是提示用户开始新会话的消息
64
+ const isPromptMessage = text.includes('没有活动会话') || text.includes('恢复会话失败')
65
+
66
+ if (isPromptMessage) {
67
+ // 发送带按钮的消息,方便用户快速操作
68
+ const keyboard = {
69
+ rows: [
70
+ {
71
+ buttons: [
72
+ { id: 'new_conversation', render_data: { label: '🆕 新建会话', visited_label: '新建会话' }, action: { type: 2, permission: { type: 2 } } },
73
+ { id: 'list_sessions', render_data: { label: '📋 会话列表', visited_label: '会话列表' }, action: { type: 2, permission: { type: 2 } } },
74
+ ],
75
+ },
76
+ {
77
+ buttons: [
78
+ { id: 'help', render_data: { label: '❓ 帮助', visited_label: '帮助' }, action: { type: 2, permission: { type: 2 } } },
79
+ ],
80
+ },
81
+ ],
82
+ }
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
89
+ }
90
+
91
+ // 其他消息使用流式发送
92
+ const content = String(text || '').trim()
93
+ 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
125
+ }
126
+ if (result?.error) {
127
+ return { success: false, error: result.error }
128
+ }
129
+ // 流式发送间隔稍短,避免刷屏
130
+ await new Promise(resolve => setTimeout(resolve, 100))
131
+ }
132
+
133
+ return { success: true }
50
134
  }
51
135
 
52
136
  // ---- 入站 ----
@@ -70,9 +154,53 @@ export class QqConversationNode extends ConversationBridge {
70
154
  // 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
71
155
  const isGroup = event.scope === 'group' || event.scope === 'guild'
72
156
 
157
+ // 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
158
+ // 则自动创建新会话并发送消息
159
+ const messageReference = event.messageReference
160
+ if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
161
+ this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
162
+ // 先创建新会话,再处理消息
163
+ await this.handleInbound({ senderId: sender, text: '/new', isGroup })
164
+ // 等待一小段时间确保会话创建完成
165
+ await new Promise(resolve => setTimeout(resolve, 100))
166
+ }
167
+
73
168
  // 交给平台无关核心:白名单/群消息/命令路由/agent 分发
74
169
  await this.handleInbound({ senderId: sender, text, isGroup })
75
170
  }
171
+
172
+ // ---- 互动事件 ----
173
+
174
+ async _handleInteraction(event) {
175
+ const sender = String(event.senderId ?? '').trim()
176
+ if (!sender) return
177
+
178
+ // 快速预检查:白名单非空时,未授权发件人直接忽略
179
+ if (!this.isAllowed(sender) && this.config.allowFrom.length > 0) {
180
+ this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${sender}`)
181
+ return
182
+ }
183
+
184
+ const data = event.data || {}
185
+ const resolved = data.resolved || {}
186
+ const buttonId = resolved.button_id || ''
187
+
188
+ // 响应互动
189
+ await this.gateway.respondInteraction(event.interactionId, {
190
+ code: 0,
191
+ })
192
+
193
+ // 根据按钮 ID 执行对应操作
194
+ if (buttonId === 'new_conversation') {
195
+ await this.handleInbound({ senderId: sender, text: '/new', isGroup: event.scope === 'group' })
196
+ } else if (buttonId === 'list_sessions') {
197
+ await this.handleInbound({ senderId: sender, text: '/list', isGroup: event.scope === 'group' })
198
+ } else if (buttonId === 'help') {
199
+ await this.handleInbound({ senderId: sender, text: '/help', isGroup: event.scope === 'group' })
200
+ } else {
201
+ this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
202
+ }
203
+ }
76
204
  }
77
205
 
78
206
  // 导出,便于测试与复用
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,6 +22,10 @@
22
22
  "docs/custom-tunnel.md",
23
23
  "docs/wechat-usage.md"
24
24
  ],
25
+ "scripts": {
26
+ "build:client": "node client/build.mjs",
27
+ "test": "node --test test/*.test.mjs"
28
+ },
25
29
  "dependencies": {
26
30
  "qrcode": "^1.5.3",
27
31
  "ws": "^8.18.0",
@@ -81,9 +85,5 @@
81
85
  "publishConfig": {
82
86
  "access": "public",
83
87
  "registry": "https://registry.npmjs.org/"
84
- },
85
- "scripts": {
86
- "build:client": "node client/build.mjs",
87
- "test": "node --test test/*.test.mjs"
88
88
  }
89
- }
89
+ }