@wenbin_wb/dsh-bridge 2.2.3 → 2.2.5
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 +70 -6
- package/README.zh-CN.md +70 -6
- package/client/client.js +310 -128
- package/client/index.js +265 -113
- package/docs/qq-usage.md +385 -0
- package/lib/index.js +39 -0
- package/lib/platform/conversation-bridge.js +36 -18
- package/lib/qq/gateway.js +31 -11
- package/lib/qq/index.js +14 -3
- package/lib/qq/node.js +155 -66
- package/lib/tunnel-client.mjs +0 -3
- package/lib/wechat/index.js +1 -1
- package/lib/wechat/node.js +42 -16
- package/package.json +3 -2
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
|
|
@@ -141,6 +141,8 @@ export class QqService extends Platform {
|
|
|
141
141
|
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
142
142
|
maxMessageChars: this.node.config.maxMessageChars,
|
|
143
143
|
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
144
|
+
appId: this.gateway.config.appId,
|
|
145
|
+
clientSecret: '',
|
|
144
146
|
}
|
|
145
147
|
}
|
|
146
148
|
|
|
@@ -240,14 +242,23 @@ export class QqService extends Platform {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
// 自定义菜单:单聊底部菜单(send_message 类型,点击自动填入命令)
|
|
245
|
+
// 幂等:先查询,内容一致则跳过 PUT,避免每次重启都覆盖(version 递增)
|
|
243
246
|
try {
|
|
244
247
|
const menuItems = [
|
|
245
248
|
{ name: '新建', type: 'send_message', send_message: '/new' },
|
|
246
249
|
{ name: '列表', type: 'send_message', send_message: '/list' },
|
|
247
250
|
{ name: '帮助', type: 'send_message', send_message: '/help' },
|
|
248
251
|
]
|
|
249
|
-
await gate.
|
|
250
|
-
|
|
252
|
+
const existing = await gate.getMenu()
|
|
253
|
+
const existingItems = existing?.menu?.items || []
|
|
254
|
+
const same = existingItems.length === menuItems.length &&
|
|
255
|
+
menuItems.every((item, i) => existingItems[i]?.name === item.name && existingItems[i]?.send_message === item.send_message)
|
|
256
|
+
if (!same) {
|
|
257
|
+
await gate.setMenu(menuItems)
|
|
258
|
+
this.logger.info('[dsh-bridge qq] custom menu configured')
|
|
259
|
+
} else {
|
|
260
|
+
this.logger.info('[dsh-bridge qq] custom menu already configured')
|
|
261
|
+
}
|
|
251
262
|
} catch (err) {
|
|
252
263
|
this.logger.warn('[dsh-bridge qq] custom menu setup skipped: %s', err?.message ?? err)
|
|
253
264
|
}
|
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,54 +162,83 @@ 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
237
|
if (i === 0) {
|
|
189
|
-
// 尝试多个可能的字段名
|
|
190
238
|
streamMsgId = result?.id || result?.message_id || result?.stream_msg_id
|
|
191
239
|
if (streamMsgId) {
|
|
192
240
|
this.lastMessageId = streamMsgId
|
|
193
|
-
this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s,
|
|
241
|
+
this.logger?.info?.('[dsh-bridge qq] stream started, stream_msg_id=%s, slices=%d', streamMsgId, slices.length)
|
|
194
242
|
} else {
|
|
195
243
|
this.logger?.warn?.('[dsh-bridge qq] stream first chunk returned no id: %o', result)
|
|
196
244
|
}
|
|
@@ -208,34 +256,52 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
208
256
|
}
|
|
209
257
|
return { success: true }
|
|
210
258
|
} catch (err) {
|
|
211
|
-
//
|
|
212
|
-
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)
|
|
213
261
|
try {
|
|
214
|
-
const result = await this.gateway.
|
|
215
|
-
|
|
262
|
+
const result = await this.gateway.sendStream(peerId, md, {
|
|
263
|
+
...streamCommon,
|
|
216
264
|
msgId: replyMsgId,
|
|
217
|
-
msgSeq:
|
|
265
|
+
msgSeq: replyMsgId ? baseSeq + slices.length + 1 : undefined,
|
|
266
|
+
streamMsgId,
|
|
267
|
+
index: slices.length,
|
|
268
|
+
inputState: 10,
|
|
218
269
|
})
|
|
219
270
|
if (result?.id) this.lastMessageId = result.id
|
|
220
271
|
return result
|
|
221
272
|
} catch (fallbackErr1) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const result = await this.gateway.sendMarkdown(peerId, md, { scope })
|
|
225
|
-
if (result?.id) this.lastMessageId = result.id
|
|
226
|
-
return result
|
|
227
|
-
} catch (fallbackErr2) {
|
|
228
|
-
this.logger?.error?.('[dsh-bridge qq] markdown send failed: %s', fallbackErr2?.message ?? fallbackErr2)
|
|
229
|
-
return { success: false, error: fallbackErr2?.message ?? String(fallbackErr2) }
|
|
230
|
-
}
|
|
273
|
+
// 主动消息兜底(digest 心跳等非回复场景,msg_id 已过期或无)
|
|
274
|
+
return this._sendMarkdown(peerId, md, { scope })
|
|
231
275
|
}
|
|
232
276
|
}
|
|
233
277
|
}
|
|
234
278
|
|
|
235
|
-
/** 发送
|
|
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 秒;群聊不支持) */
|
|
236
300
|
async sendTyping(state) {
|
|
237
301
|
const peerInfo = this._currentPeer()
|
|
238
302
|
if (!peerInfo) return
|
|
303
|
+
// 群聊消息类型列表不含 msg_type=6(输入状态),跳过
|
|
304
|
+
if (peerInfo.scope === 'group') return { ok: true }
|
|
239
305
|
// state=2(停止)时无需显式结束——input_second 到期自动消失
|
|
240
306
|
if (Number(state) === 2) return { ok: true }
|
|
241
307
|
return this.gateway.sendTyping(peerInfo.peerId, {
|
|
@@ -251,9 +317,18 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
251
317
|
const sender = String(event.senderId ?? '').trim()
|
|
252
318
|
if (!sender) return
|
|
253
319
|
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
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}`)
|
|
257
332
|
return
|
|
258
333
|
}
|
|
259
334
|
|
|
@@ -263,11 +338,6 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
263
338
|
return
|
|
264
339
|
}
|
|
265
340
|
|
|
266
|
-
// 群聊:仅在群聊 @ 机器人事件中处理(GROUP_AT_MESSAGE_CREATE 已由网关归一化)
|
|
267
|
-
const isGroup = event.scope === 'group' || event.scope === 'guild'
|
|
268
|
-
|
|
269
|
-
// 记录当前 peer 信息与被动回复消息 ID(事件 d.id)、msg_seq,供出站使用
|
|
270
|
-
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || event.senderId
|
|
271
341
|
if (peerId) {
|
|
272
342
|
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
273
343
|
if (event.id) this._replyMsgId = String(event.id)
|
|
@@ -280,13 +350,13 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
280
350
|
if (messageReference && !this.activeSessionId && !text.startsWith('/')) {
|
|
281
351
|
this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
|
|
282
352
|
// 先创建新会话,再处理消息
|
|
283
|
-
await this.handleInbound({ senderId:
|
|
353
|
+
await this.handleInbound({ senderId: authId, text: '/new', isGroup })
|
|
284
354
|
// 等待一小段时间确保会话创建完成
|
|
285
355
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
286
356
|
}
|
|
287
357
|
|
|
288
358
|
// 交给平台无关核心:白名单/群消息/命令路由/agent 分发
|
|
289
|
-
await this.handleInbound({ senderId:
|
|
359
|
+
await this.handleInbound({ senderId: authId, text, isGroup })
|
|
290
360
|
}
|
|
291
361
|
|
|
292
362
|
// ---- 互动事件 ----
|
|
@@ -295,36 +365,54 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
295
365
|
const sender = String(event.senderId ?? '').trim()
|
|
296
366
|
if (!sender) return
|
|
297
367
|
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
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}`)
|
|
301
377
|
return
|
|
302
378
|
}
|
|
303
379
|
|
|
380
|
+
const type = Number(event.interactionType ?? event?.data?.type ?? 0)
|
|
381
|
+
// 仅消息按钮(11)与快捷菜单(12)需要回应;其他类型(反馈/清空会话/故事集/授权等)无需回应
|
|
382
|
+
const needsRespond = type === 11 || type === 12
|
|
383
|
+
|
|
304
384
|
// 记录 peer 信息,供命令回复使用
|
|
305
|
-
const isGroup = event.scope === 'group'
|
|
306
|
-
const peerId = isGroup ? event.groupId || event.peerId : event.peerId || sender
|
|
307
385
|
if (peerId) {
|
|
308
386
|
this._lastPeer = { peerId: String(peerId), scope: isGroup ? 'group' : 'c2c' }
|
|
309
387
|
this._replyMsgId = null
|
|
310
388
|
}
|
|
311
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
|
+
|
|
312
404
|
const data = event.data || {}
|
|
313
405
|
const resolved = data.resolved || {}
|
|
314
406
|
const buttonId = resolved.button_id || ''
|
|
315
407
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
code: 0,
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
// 根据按钮 ID 执行对应操作
|
|
408
|
+
// 根据按钮 ID 执行对应操作(消息按钮 11 与快捷菜单 12 都映射到命令)
|
|
409
|
+
this.logger?.info?.('[dsh-bridge qq] button interaction type=%s button_id=%s from %s', type, buttonId, authId)
|
|
322
410
|
if (buttonId === 'new_conversation') {
|
|
323
|
-
await this.handleInbound({ senderId:
|
|
411
|
+
await this.handleInbound({ senderId: authId, text: '/new', isGroup })
|
|
324
412
|
} else if (buttonId === 'list_sessions') {
|
|
325
|
-
await this.handleInbound({ senderId:
|
|
413
|
+
await this.handleInbound({ senderId: authId, text: '/list', isGroup })
|
|
326
414
|
} else if (buttonId === 'help') {
|
|
327
|
-
await this.handleInbound({ senderId:
|
|
415
|
+
await this.handleInbound({ senderId: authId, text: '/help', isGroup })
|
|
328
416
|
} else {
|
|
329
417
|
this.logger?.info?.(`[dsh-bridge qq] unknown button interaction: ${buttonId}`)
|
|
330
418
|
}
|
|
@@ -340,5 +428,6 @@ export const qqNodeHelpers = {
|
|
|
340
428
|
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
341
429
|
sanitizeQQMarkdown,
|
|
342
430
|
splitIntoChunks,
|
|
431
|
+
splitIntoIncremental,
|
|
343
432
|
STREAM_CHUNK_SIZE,
|
|
344
433
|
}
|
package/lib/tunnel-client.mjs
CHANGED
|
@@ -22,7 +22,6 @@ export class CustomTunnelClient {
|
|
|
22
22
|
this.reconnectAttempts = 0;
|
|
23
23
|
this.reconnectTimer = null;
|
|
24
24
|
this.heartbeatTimer = null;
|
|
25
|
-
this.pendingRequests = new Map();
|
|
26
25
|
this.localWsSockets = new Map(); // wsId -> net.Socket
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -276,8 +275,6 @@ export class CustomTunnelClient {
|
|
|
276
275
|
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
277
276
|
this.connected = false;
|
|
278
277
|
this.publicUrl = null;
|
|
279
|
-
for (const [, p] of this.pendingRequests) p.reject(new Error('Disconnected'));
|
|
280
|
-
this.pendingRequests.clear();
|
|
281
278
|
this.logger?.info('Tunnel disconnected');
|
|
282
279
|
}
|
|
283
280
|
}
|
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/lib/wechat/node.js
CHANGED
|
@@ -147,7 +147,7 @@ export class WechatConversationNode extends ConversationBridge {
|
|
|
147
147
|
continue
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
const aesKey = img.aeskey || img.aes_key || img.media?.aes_key
|
|
150
|
+
const aesKey = img.aeskey || img.aes_key || img.media?.aes_key || img.media?.aeskey
|
|
151
151
|
if (!aesKey) {
|
|
152
152
|
const fields = Object.keys(img).join(', ')
|
|
153
153
|
const msg = `图片缺 aes_key,字段: ${fields}`
|
|
@@ -156,14 +156,20 @@ export class WechatConversationNode extends ConversationBridge {
|
|
|
156
156
|
continue
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// 从 image_item.media 对象提取 CDN 下载参数
|
|
159
|
+
// 从 image_item 或 image_item.media 对象提取 CDN 下载参数
|
|
160
160
|
const mediaObj = img.media
|
|
161
|
-
const encryptedParam =
|
|
162
|
-
|
|
161
|
+
const encryptedParam = img.encrypt_query_param
|
|
162
|
+
|| img.encrypted_query_param
|
|
163
|
+
|| mediaObj?.encrypt_query_param
|
|
164
|
+
|| mediaObj?.encrypted_query_param
|
|
165
|
+
|| mediaObj?.encrypt_query_param_full
|
|
166
|
+
|| (typeof mediaObj === 'string' ? mediaObj : undefined)
|
|
167
|
+
const fullUrl = img.full_url || img.url || mediaObj?.full_url || mediaObj?.url
|
|
168
|
+
debugInfo.push(`media 类型: ${typeof mediaObj}, param=${!!encryptedParam}`)
|
|
163
169
|
|
|
164
170
|
const file = await this._downloadMediaItem({
|
|
165
|
-
encryptedQueryParam:
|
|
166
|
-
fullUrl
|
|
171
|
+
encryptedQueryParam: encryptedParam,
|
|
172
|
+
fullUrl,
|
|
167
173
|
aesKeyBase64: aesKey,
|
|
168
174
|
sender,
|
|
169
175
|
mediaType: 'image',
|
|
@@ -181,15 +187,25 @@ export class WechatConversationNode extends ConversationBridge {
|
|
|
181
187
|
debugInfo.push(`文件项缺少 file_item`)
|
|
182
188
|
continue
|
|
183
189
|
}
|
|
184
|
-
const aesKey = fileItem.aes_key || fileItem.media?.aes_key
|
|
185
|
-
const encryptedParam = fileItem.
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
const aesKey = fileItem.aes_key || fileItem.aeskey || fileItem.media?.aes_key || fileItem.media?.aeskey
|
|
191
|
+
const encryptedParam = fileItem.encrypt_query_param
|
|
192
|
+
|| fileItem.encrypted_query_param
|
|
193
|
+
|| fileItem.media?.encrypt_query_param
|
|
194
|
+
|| fileItem.media?.encrypted_query_param
|
|
195
|
+
|| (typeof fileItem.media === 'string' ? fileItem.media : undefined)
|
|
196
|
+
const fullUrl = fileItem.full_url || fileItem.url || fileItem.media?.full_url || fileItem.media?.url
|
|
197
|
+
|
|
198
|
+
if (!encryptedParam && !fullUrl) {
|
|
199
|
+
debugInfo.push(`文件缺 media/encrypt_query_param,字段: ${Object.keys(fileItem).join(', ')}`)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (!aesKey) {
|
|
203
|
+
debugInfo.push(`文件缺 aes_key,字段: ${Object.keys(fileItem).join(', ')}`)
|
|
188
204
|
continue
|
|
189
205
|
}
|
|
190
206
|
const file = await this._downloadMediaItem({
|
|
191
207
|
encryptedQueryParam: encryptedParam,
|
|
192
|
-
fullUrl
|
|
208
|
+
fullUrl,
|
|
193
209
|
aesKeyBase64: aesKey,
|
|
194
210
|
sender,
|
|
195
211
|
mediaType: 'file',
|
|
@@ -210,15 +226,25 @@ export class WechatConversationNode extends ConversationBridge {
|
|
|
210
226
|
debugInfo.push(`视频项缺少 video_item`)
|
|
211
227
|
continue
|
|
212
228
|
}
|
|
213
|
-
const aesKey = video.aes_key || video.media?.aes_key
|
|
214
|
-
const encryptedParam = video.
|
|
215
|
-
|
|
216
|
-
|
|
229
|
+
const aesKey = video.aes_key || video.aeskey || video.media?.aes_key || video.media?.aeskey
|
|
230
|
+
const encryptedParam = video.encrypt_query_param
|
|
231
|
+
|| video.encrypted_query_param
|
|
232
|
+
|| video.media?.encrypt_query_param
|
|
233
|
+
|| video.media?.encrypted_query_param
|
|
234
|
+
|| (typeof video.media === 'string' ? video.media : undefined)
|
|
235
|
+
const fullUrl = video.full_url || video.url || video.media?.full_url || video.media?.url
|
|
236
|
+
|
|
237
|
+
if (!encryptedParam && !fullUrl) {
|
|
238
|
+
debugInfo.push(`视频缺 media/encrypt_query_param,字段: ${Object.keys(video).join(', ')}`)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (!aesKey) {
|
|
242
|
+
debugInfo.push(`视频缺 aes_key,字段: ${Object.keys(video).join(', ')}`)
|
|
217
243
|
continue
|
|
218
244
|
}
|
|
219
245
|
const file = await this._downloadMediaItem({
|
|
220
246
|
encryptedQueryParam: encryptedParam,
|
|
221
|
-
fullUrl
|
|
247
|
+
fullUrl,
|
|
222
248
|
aesKeyBase64: aesKey,
|
|
223
249
|
sender,
|
|
224
250
|
mediaType: 'video',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.5",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 Bot(多工作区/会话持久化/媒体/审批),无需自己搭公网服务器。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"LICENSE",
|
|
21
21
|
"docs/banner.jpg",
|
|
22
22
|
"docs/custom-tunnel.md",
|
|
23
|
-
"docs/wechat-usage.md"
|
|
23
|
+
"docs/wechat-usage.md",
|
|
24
|
+
"docs/qq-usage.md"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
27
|
"build:client": "node client/build.mjs",
|