@wenbin_wb/dsh-bridge 2.3.2 → 2.4.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/README.en.md +41 -1
- package/README.md +41 -1
- package/client/client.js +102 -45
- package/client/index.js +103 -46
- package/docs/telegram-usage.md +89 -0
- package/lib/feishu/gateway.js +166 -7
- package/lib/feishu/node.js +55 -10
- package/lib/index.js +56 -1
- package/lib/platform/base.js +13 -0
- package/lib/platform/conversation-bridge.js +32 -4
- package/lib/qq/gateway.js +94 -14
- package/lib/qq/node.js +49 -9
- package/lib/telegram/gateway.js +619 -0
- package/lib/telegram/index.js +210 -0
- package/lib/telegram/node.js +342 -0
- package/lib/wechat/gateway.js +54 -0
- package/lib/wechat/node.js +1 -0
- package/package.json +4 -3
package/lib/feishu/node.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
|
|
3
3
|
// ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
5
7
|
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
6
8
|
|
|
7
9
|
function makePlatform(gateway) {
|
|
@@ -12,6 +14,9 @@ function makePlatform(gateway) {
|
|
|
12
14
|
async sendText(peerId, text, opts = {}) {
|
|
13
15
|
return gateway?.sendMarkdownCard(peerId, text, opts)
|
|
14
16
|
},
|
|
17
|
+
async sendMediaFile(peerId, filePath, opts = {}) {
|
|
18
|
+
return gateway?.sendMediaFile(peerId, filePath, opts)
|
|
19
|
+
},
|
|
15
20
|
async sendTyping() {},
|
|
16
21
|
dispose() {},
|
|
17
22
|
}
|
|
@@ -70,14 +75,10 @@ export class FeishuConversationNode extends ConversationBridge {
|
|
|
70
75
|
this._inTurn = false
|
|
71
76
|
|
|
72
77
|
// 订阅网关入站消息事件
|
|
73
|
-
this.ctx.on('feishu/message', (event) =>
|
|
74
|
-
void this._handleInbound(event)
|
|
75
|
-
})
|
|
78
|
+
this.ctx.on('feishu/message', (event) => this._handleInbound(event))
|
|
76
79
|
|
|
77
80
|
// 订阅卡片交互事件(审批按钮点击)
|
|
78
|
-
this.ctx.on('feishu/action', (event) =>
|
|
79
|
-
void this._handleAction(event)
|
|
80
|
-
})
|
|
81
|
+
this.ctx.on('feishu/action', (event) => this._handleAction(event))
|
|
81
82
|
|
|
82
83
|
// 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
|
|
83
84
|
this.ctx.on('session/event', (session, event) => {
|
|
@@ -106,10 +107,26 @@ export class FeishuConversationNode extends ConversationBridge {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
async _handleInbound(event) {
|
|
109
|
-
const { peerId, senderId, isGroup, text } = event
|
|
110
|
+
const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
|
|
110
111
|
this._lastPeer = { peerId, senderId, isGroup }
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
let mediaFiles = []
|
|
114
|
+
if (messageType && messageType !== 'text') {
|
|
115
|
+
mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!text && mediaFiles.length === 0) {
|
|
119
|
+
this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let fullText = text || ''
|
|
124
|
+
if (mediaFiles.length > 0) {
|
|
125
|
+
const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
|
|
126
|
+
fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
|
|
113
130
|
|
|
114
131
|
// 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
|
|
115
132
|
if (!this.activeSessionId) {
|
|
@@ -117,14 +134,42 @@ export class FeishuConversationNode extends ConversationBridge {
|
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
// 转交通用 ConversationBridge 路由
|
|
120
|
-
return
|
|
137
|
+
return this.handleInbound({
|
|
121
138
|
senderId: isGroup ? peerId : senderId,
|
|
122
139
|
peerId,
|
|
123
140
|
isGroup,
|
|
124
|
-
text,
|
|
141
|
+
text: fullText,
|
|
125
142
|
})
|
|
126
143
|
}
|
|
127
144
|
|
|
145
|
+
async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
|
|
146
|
+
if (!messageId || !messageType) return []
|
|
147
|
+
const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
|
|
148
|
+
try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
|
|
149
|
+
|
|
150
|
+
const downloaded = []
|
|
151
|
+
const fileKey = contentObj.image_key || contentObj.file_key
|
|
152
|
+
const fileName = contentObj.file_name
|
|
153
|
+
|
|
154
|
+
if (fileKey) {
|
|
155
|
+
try {
|
|
156
|
+
const type = messageType === 'image' ? 'image' : 'file'
|
|
157
|
+
const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
|
|
158
|
+
if (buf && buf.length > 0) {
|
|
159
|
+
const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
|
|
160
|
+
const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
|
|
161
|
+
const filePath = path.join(mediaDir, safeName)
|
|
162
|
+
await fs.promises.writeFile(filePath, buf)
|
|
163
|
+
downloaded.push({ filename: safeName, path: filePath, size: buf.length })
|
|
164
|
+
this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return downloaded
|
|
171
|
+
}
|
|
172
|
+
|
|
128
173
|
async _handleAction(event) {
|
|
129
174
|
const { operatorId, value } = event
|
|
130
175
|
const approvalId = Number(value.approvalId)
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { PlatformManager } from './platform/manager.js';
|
|
|
21
21
|
import { WechatService } from './wechat/index.js';
|
|
22
22
|
import { QqService } from './qq/index.js';
|
|
23
23
|
import { FeishuService } from './feishu/index.js';
|
|
24
|
+
import { TelegramService } from './telegram/index.js';
|
|
24
25
|
|
|
25
26
|
const name = 'dsh-bridge';
|
|
26
27
|
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
@@ -569,6 +570,19 @@ function apply(ctx, config = {}) {
|
|
|
569
570
|
});
|
|
570
571
|
platformManager.register(feishu);
|
|
571
572
|
|
|
573
|
+
// Telegram Bot(官方 Long Polling + 代理支持)—— 作为 Platform 子类注册进平台管理器
|
|
574
|
+
const telegram = new TelegramService({
|
|
575
|
+
ctx,
|
|
576
|
+
logger,
|
|
577
|
+
config: config.telegram ?? {},
|
|
578
|
+
onPersist: async (patch) => {
|
|
579
|
+
const stored = await loadConfig();
|
|
580
|
+
stored.telegram = { ...(stored.telegram ?? {}), ...patch };
|
|
581
|
+
await saveConfig(stored);
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
platformManager.register(telegram);
|
|
585
|
+
|
|
572
586
|
// 启动时读取已保存的微信 Bot 配置(凭证 + 白名单 + 活动会话)
|
|
573
587
|
loadConfig().then(async (stored) => {
|
|
574
588
|
if (stored?.wechat) {
|
|
@@ -681,11 +695,50 @@ function apply(ctx, config = {}) {
|
|
|
681
695
|
}
|
|
682
696
|
}).catch(() => {});
|
|
683
697
|
|
|
698
|
+
// 启动时读取已保存的 Telegram Bot 配置(凭证 + 代理 + 白名单 + 活动会话)
|
|
699
|
+
loadConfig().then(async (stored) => {
|
|
700
|
+
if (stored?.telegram) {
|
|
701
|
+
const cfg = stored.telegram;
|
|
702
|
+
telegram.node.config.allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : [];
|
|
703
|
+
if (cfg.digestIntervalSec != null) telegram.node.config.digestIntervalSec = Number(cfg.digestIntervalSec);
|
|
704
|
+
if (cfg.approvalTimeoutSec != null) telegram.node.config.approvalTimeoutSec = Number(cfg.approvalTimeoutSec);
|
|
705
|
+
if (cfg.maxMessageChars != null) telegram.node.config.maxMessageChars = Number(cfg.maxMessageChars);
|
|
706
|
+
if (cfg.sendChunkDelayMs != null) telegram.node.config.sendChunkDelayMs = Number(cfg.sendChunkDelayMs);
|
|
707
|
+
|
|
708
|
+
telegram.node._restoringConfig = (async () => {
|
|
709
|
+
try {
|
|
710
|
+
if (cfg.activeSessionId) {
|
|
711
|
+
telegram.node.activeSessionId = cfg.activeSessionId;
|
|
712
|
+
logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
|
|
713
|
+
} else {
|
|
714
|
+
await telegram.node._pickDefaultSession().catch(() => {});
|
|
715
|
+
}
|
|
716
|
+
} finally {
|
|
717
|
+
telegram.node._configRestored = true;
|
|
718
|
+
}
|
|
719
|
+
})();
|
|
720
|
+
|
|
721
|
+
await telegram.node._restoringConfig;
|
|
722
|
+
|
|
723
|
+
if (cfg.botToken) {
|
|
724
|
+
telegram.gateway.setCredentials({
|
|
725
|
+
botToken: cfg.botToken,
|
|
726
|
+
proxy: cfg.proxy || '',
|
|
727
|
+
});
|
|
728
|
+
logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
|
|
729
|
+
await telegram.start().catch((err) => {
|
|
730
|
+
logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}).catch(() => {});
|
|
735
|
+
|
|
684
736
|
const disposeRpc = installBridgeRpc(ctx, {
|
|
685
737
|
service,
|
|
686
738
|
wechat,
|
|
687
739
|
qq,
|
|
688
740
|
feishu,
|
|
741
|
+
telegram,
|
|
689
742
|
platformManager,
|
|
690
743
|
logger,
|
|
691
744
|
saveCustomTunnelConfig: async (serverUrl, accessToken) => {
|
|
@@ -705,9 +758,11 @@ function apply(ctx, config = {}) {
|
|
|
705
758
|
try { disposeRpc(); } catch {}
|
|
706
759
|
await wechat.destroy();
|
|
707
760
|
await qq.destroy();
|
|
761
|
+
await feishu.destroy();
|
|
762
|
+
await telegram.destroy();
|
|
708
763
|
platformManager.dispose();
|
|
709
764
|
await service.dispose();
|
|
710
|
-
}, 'dsh-bridge: stop wechat, qq, proxy and tunnels');
|
|
765
|
+
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy and tunnels');
|
|
711
766
|
}
|
|
712
767
|
|
|
713
768
|
export { name, inject, apply };
|
package/lib/platform/base.js
CHANGED
|
@@ -140,4 +140,17 @@ export class Platform {
|
|
|
140
140
|
this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
dispose() {
|
|
145
|
+
for (const d of this.disposers) {
|
|
146
|
+
try { d() } catch {}
|
|
147
|
+
}
|
|
148
|
+
this.disposers = []
|
|
149
|
+
this.bridge?.dispose?.()
|
|
150
|
+
this.bridge = null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async destroy() {
|
|
154
|
+
this.dispose()
|
|
155
|
+
}
|
|
143
156
|
}
|
|
@@ -582,12 +582,13 @@ export class ConversationBridge {
|
|
|
582
582
|
}
|
|
583
583
|
const onEvent = (session, event) => {
|
|
584
584
|
if (session.id !== this.activeSessionId) return
|
|
585
|
-
const state = digestState.get(session.id) ?? { startedTurns: new Set() }
|
|
585
|
+
const state = digestState.get(session.id) ?? { startedTurns: new Set(), createdFiles: new Set() }
|
|
586
586
|
digestState.set(session.id, state)
|
|
587
587
|
|
|
588
588
|
if (event.type === 'turn/start') {
|
|
589
|
-
const turn = event.data
|
|
590
|
-
|
|
589
|
+
const turn = event.data?.turn
|
|
590
|
+
state.createdFiles = new Set()
|
|
591
|
+
if (turn != null && !state.startedTurns.has(turn)) {
|
|
591
592
|
state.startedTurns.add(turn)
|
|
592
593
|
// 不发送"[OK] 收到,开始处理…",改用 typing 指示 + 心跳进度。
|
|
593
594
|
if (this.peerId) this.sendTyping(1).catch(() => {})
|
|
@@ -595,6 +596,14 @@ export class ConversationBridge {
|
|
|
595
596
|
startHeartbeat(session, state)
|
|
596
597
|
return
|
|
597
598
|
}
|
|
599
|
+
if (event.type === 'tool/call') {
|
|
600
|
+
const args = event.data?.parameters || event.data?.args || {}
|
|
601
|
+
const target = args.TargetFile || args.targetFile || args.target_file || args.path || args.filePath
|
|
602
|
+
if (target && typeof target === 'string') {
|
|
603
|
+
state.createdFiles.add(target)
|
|
604
|
+
}
|
|
605
|
+
return
|
|
606
|
+
}
|
|
598
607
|
if (event.type === 'assistant/message') {
|
|
599
608
|
const text = textOfAssistantMessage(event.data.message)
|
|
600
609
|
if (text.trim()) void this.sendText(text)
|
|
@@ -603,7 +612,7 @@ export class ConversationBridge {
|
|
|
603
612
|
if (event.type === 'turn/end') {
|
|
604
613
|
stopHeartbeat(state)
|
|
605
614
|
if (this.peerId) this.sendTyping(2).catch(() => {})
|
|
606
|
-
const reason = event.data
|
|
615
|
+
const reason = event.data?.reason || {}
|
|
607
616
|
if (reason.kind === 'error') {
|
|
608
617
|
void this.sendText(`❌ **处理出错**:${summarizeError(reason.error)}`)
|
|
609
618
|
} else if (reason.kind === 'aborted') {
|
|
@@ -611,6 +620,25 @@ export class ConversationBridge {
|
|
|
611
620
|
} else if (reason.kind === 'max-tokens') {
|
|
612
621
|
void this.sendText(`⚠️ **达到模型单轮输出上限,本轮内容已截断**`)
|
|
613
622
|
}
|
|
623
|
+
// 如果本轮生成/修改了产物文件,下发产物清单通知并尝试直接上传文件至聊天窗口
|
|
624
|
+
if (state.createdFiles && state.createdFiles.size > 0) {
|
|
625
|
+
const files = Array.from(state.createdFiles)
|
|
626
|
+
const fileLines = files.map((f) => `- \`${f}\``).join('\n')
|
|
627
|
+
void this.sendText(`📦 **本轮已生成产物文件**:\n${fileLines}`)
|
|
628
|
+
|
|
629
|
+
// 如果平台支持 sendMediaFile,自动尝试直接发送文件/图片到聊天窗口
|
|
630
|
+
if (typeof this.platform?.sendMediaFile === 'function' && this.peerId) {
|
|
631
|
+
for (const f of files) {
|
|
632
|
+
try {
|
|
633
|
+
if (statSync(f).isFile()) {
|
|
634
|
+
void this.platform.sendMediaFile(this.peerId, f)
|
|
635
|
+
}
|
|
636
|
+
} catch {}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
state.createdFiles.clear()
|
|
641
|
+
}
|
|
614
642
|
return
|
|
615
643
|
}
|
|
616
644
|
}
|
package/lib/qq/gateway.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// QQ Bot OpenAPI v2 gateway
|
|
2
2
|
// Official API: https://bot.q.qq.com/wiki/develop/api-v2/
|
|
3
3
|
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
4
6
|
import { Service } from '@deepseek-ai/cordis'
|
|
5
7
|
import WebSocket from 'ws'
|
|
6
8
|
|
|
@@ -66,6 +68,7 @@ function normalizeEvent(payload) {
|
|
|
66
68
|
senderId: data.author?.user_openid || data.user_openid,
|
|
67
69
|
peerId: data.author?.user_openid || data.user_openid,
|
|
68
70
|
text: stringValue(data.content).trim(),
|
|
71
|
+
attachments: Array.isArray(data.attachments) ? data.attachments : [],
|
|
69
72
|
message: data,
|
|
70
73
|
messageReference: data.message_reference,
|
|
71
74
|
msgSeq: data.msg_seq, // 用于避免去重
|
|
@@ -81,6 +84,7 @@ function normalizeEvent(payload) {
|
|
|
81
84
|
peerId: data.group_openid || data.group_id,
|
|
82
85
|
groupId: data.group_openid || data.group_id,
|
|
83
86
|
text: stringValue(data.content).trim(),
|
|
87
|
+
attachments: Array.isArray(data.attachments) ? data.attachments : [],
|
|
84
88
|
message: data,
|
|
85
89
|
messageReference: data.message_reference,
|
|
86
90
|
msgSeq: data.msg_seq, // 用于避免去重
|
|
@@ -137,6 +141,8 @@ export class QqGateway extends Service {
|
|
|
137
141
|
this.heartbeatTimer = null
|
|
138
142
|
this.heartbeatInterval = 30_000
|
|
139
143
|
this.sequence = null
|
|
144
|
+
this.sessionId = null
|
|
145
|
+
this.unackedHeartbeats = 0
|
|
140
146
|
this.tokenPromise = null
|
|
141
147
|
this.dedup = new Map()
|
|
142
148
|
}
|
|
@@ -213,6 +219,7 @@ export class QqGateway extends Service {
|
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
async runLoop() {
|
|
222
|
+
let backoffMs = this.config.reconnectDelayMs
|
|
216
223
|
while (!this.stopRequested) {
|
|
217
224
|
try {
|
|
218
225
|
this.setStatus('starting')
|
|
@@ -223,11 +230,13 @@ export class QqGateway extends Service {
|
|
|
223
230
|
|| ((await requestJson(`${API_BASE}/gateway/bot`, { token, timeoutMs: this.config.apiTimeoutMs })).url)
|
|
224
231
|
|| DEFAULT_GATEWAY
|
|
225
232
|
await this.connect(gateway, token)
|
|
233
|
+
backoffMs = this.config.reconnectDelayMs
|
|
226
234
|
} catch (error) {
|
|
227
235
|
if (this.stopRequested) break
|
|
228
236
|
this.setStatus('reconnecting')
|
|
229
|
-
this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s', error?.message ?? error)
|
|
230
|
-
await sleep(
|
|
237
|
+
this.logger?.warn?.('[dsh-bridge qq] gateway disconnected: %s, retrying in %dms...', error?.message ?? error, backoffMs)
|
|
238
|
+
await sleep(backoffMs)
|
|
239
|
+
backoffMs = Math.min(Math.round(backoffMs * 1.5), 30_000)
|
|
231
240
|
}
|
|
232
241
|
}
|
|
233
242
|
this.setStatus('idle')
|
|
@@ -262,22 +271,48 @@ export class QqGateway extends Service {
|
|
|
262
271
|
if (op === 10) {
|
|
263
272
|
this.heartbeatInterval = Number(payload?.d?.heartbeat_interval || 30_000)
|
|
264
273
|
this.startHeartbeat(ws)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
if (this.sessionId && this.sequence != null) {
|
|
275
|
+
// 快速恢复模式(Resume):携带 sessionId 与 sequence 避免重新全量鉴权与丢失消息
|
|
276
|
+
this.logger?.info?.('[dsh-bridge qq] attempting session resume (sessionId=%s, seq=%s)', this.sessionId, this.sequence)
|
|
277
|
+
ws.send(JSON.stringify({
|
|
278
|
+
op: 6,
|
|
279
|
+
d: {
|
|
280
|
+
token: `QQBot ${token}`,
|
|
281
|
+
session_id: this.sessionId,
|
|
282
|
+
seq: this.sequence,
|
|
283
|
+
},
|
|
284
|
+
}))
|
|
285
|
+
} else {
|
|
286
|
+
// 全量鉴权模式(Identify)
|
|
287
|
+
ws.send(JSON.stringify({
|
|
288
|
+
op: 2,
|
|
289
|
+
d: {
|
|
290
|
+
token: `QQBot ${token}`,
|
|
291
|
+
intents: this.config.intents,
|
|
292
|
+
shard: [0, 1],
|
|
293
|
+
properties: { $os: process.platform, $browser: 'dsh-bridge', $device: 'dsh-bridge' },
|
|
294
|
+
},
|
|
295
|
+
}))
|
|
296
|
+
}
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (op === 11) {
|
|
300
|
+
// 心跳确认(Heartbeat ACK):重置未确认计数器
|
|
301
|
+
this.unackedHeartbeats = 0
|
|
274
302
|
return
|
|
275
303
|
}
|
|
276
304
|
if (op === 0) {
|
|
277
305
|
if (payload.t === 'READY') {
|
|
306
|
+
this.sessionId = payload.d?.session_id || this.sessionId
|
|
278
307
|
this.accountId = payload.d?.user?.id || payload.d?.user?.username || this.accountId
|
|
308
|
+
this.unackedHeartbeats = 0
|
|
279
309
|
await this.persist({ accountId: this.accountId })
|
|
280
310
|
this.setStatus('connected')
|
|
311
|
+
this.logger?.info?.('[dsh-bridge qq] gateway READY (session_id=%s)', this.sessionId)
|
|
312
|
+
} else if (payload.t === 'RESUMED') {
|
|
313
|
+
this.unackedHeartbeats = 0
|
|
314
|
+
this.setStatus('connected')
|
|
315
|
+
this.logger?.info?.('[dsh-bridge qq] gateway RESUMED successfully')
|
|
281
316
|
}
|
|
282
317
|
const event = normalizeEvent(payload)
|
|
283
318
|
if (event.type === 'message' && event.id && !this.seen(event.id)) {
|
|
@@ -288,14 +323,31 @@ export class QqGateway extends Service {
|
|
|
288
323
|
}
|
|
289
324
|
return
|
|
290
325
|
}
|
|
291
|
-
if (op === 7) {
|
|
292
|
-
|
|
326
|
+
if (op === 7) {
|
|
327
|
+
this.logger?.info?.('[dsh-bridge qq] gateway requested reconnect (OpCode 7)')
|
|
328
|
+
try { ws.close() } catch {}
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (op === 9) {
|
|
332
|
+
this.sessionId = null
|
|
333
|
+
this.sequence = null
|
|
334
|
+
throw new Error('QQ gateway invalid session (OpCode 9)')
|
|
335
|
+
}
|
|
293
336
|
}
|
|
294
337
|
|
|
295
338
|
startHeartbeat(ws) {
|
|
296
339
|
this.clearHeartbeat()
|
|
340
|
+
this.unackedHeartbeats = 0
|
|
297
341
|
const beat = () => {
|
|
298
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
342
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
343
|
+
if (this.unackedHeartbeats >= 2) {
|
|
344
|
+
this.logger?.warn?.('[dsh-bridge qq] heartbeat ACK missed (count=%d), dead link detected, reconnecting...', this.unackedHeartbeats)
|
|
345
|
+
try { ws.terminate() } catch {}
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
this.unackedHeartbeats += 1
|
|
349
|
+
ws.send(JSON.stringify({ op: 1, d: this.sequence }))
|
|
350
|
+
}
|
|
299
351
|
}
|
|
300
352
|
beat()
|
|
301
353
|
this.heartbeatTimer = setInterval(beat, this.heartbeatInterval)
|
|
@@ -304,6 +356,7 @@ export class QqGateway extends Service {
|
|
|
304
356
|
clearHeartbeat() {
|
|
305
357
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
|
|
306
358
|
this.heartbeatTimer = null
|
|
359
|
+
this.unackedHeartbeats = 0
|
|
307
360
|
}
|
|
308
361
|
|
|
309
362
|
seen(id) {
|
|
@@ -485,6 +538,33 @@ export class QqGateway extends Service {
|
|
|
485
538
|
return this.api(`/v2/panels/${encodeURIComponent(panelId)}/target`, { method: 'PUT', body })
|
|
486
539
|
}
|
|
487
540
|
|
|
541
|
+
/**
|
|
542
|
+
* 上传并发送本地媒体文件(图片等)
|
|
543
|
+
*/
|
|
544
|
+
async sendMediaFile(peerId, filePath, opts = {}) {
|
|
545
|
+
if (!fs.existsSync(filePath)) return null
|
|
546
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
547
|
+
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext)
|
|
548
|
+
const fileType = isImage ? 1 : 1
|
|
549
|
+
try {
|
|
550
|
+
const buf = await fs.promises.readFile(filePath)
|
|
551
|
+
const base64Data = buf.toString('base64')
|
|
552
|
+
const ep = this.endpoint(peerId, opts.scope, 'files')
|
|
553
|
+
const res = await this.api(ep, {
|
|
554
|
+
method: 'POST',
|
|
555
|
+
body: {
|
|
556
|
+
file_type: fileType,
|
|
557
|
+
file_data: base64Data,
|
|
558
|
+
srv_send_msg: true,
|
|
559
|
+
},
|
|
560
|
+
})
|
|
561
|
+
return res
|
|
562
|
+
} catch (err) {
|
|
563
|
+
this.logger?.warn?.('[dsh-bridge qq] sendMediaFile error: %s', err?.message ?? err)
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
488
568
|
setCredentials(values = {}) {
|
|
489
569
|
for (const key of ['appId', 'clientSecret', 'accessToken', 'accessTokenExpiresAt', 'gatewayUrl', 'intents']) {
|
|
490
570
|
if (values[key] !== undefined) this.config[key] = values[key]
|
package/lib/qq/node.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// - 出站:sendText 走 gateway.sendText;长文本用 Markdown 分块
|
|
7
7
|
// - 群聊:@提及消息仅在命中机器人才处理(GROUP_AT_MESSAGE_CREATE 已保证)
|
|
8
8
|
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
9
11
|
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
10
12
|
import { gatewayConstants } from './gateway.js'
|
|
11
13
|
|
|
@@ -114,6 +116,7 @@ function makePlatform(gateway) {
|
|
|
114
116
|
get capabilities() { return gateway.capabilities },
|
|
115
117
|
// sendText / sendTyping 由 QqConversationNode 覆盖,这里仅提供兜底
|
|
116
118
|
sendText: (peer, text) => gateway.sendText(peer, text, {}),
|
|
119
|
+
sendMediaFile: (peer, filePath, opts = {}) => gateway.sendMediaFile(peer, filePath, opts),
|
|
117
120
|
sendTyping: () => Promise.resolve({ ok: true }),
|
|
118
121
|
sendKeyboard: (peer, content, keyboard) => gateway.sendKeyboard(peer, content, keyboard, {}),
|
|
119
122
|
}
|
|
@@ -144,12 +147,8 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
144
147
|
this._replyMsgId = null // 被动回复用的用户消息 ID(事件 d.id)
|
|
145
148
|
|
|
146
149
|
// 订阅网关入站事件
|
|
147
|
-
this.ctx.on('qq/message', (event) =>
|
|
148
|
-
|
|
149
|
-
})
|
|
150
|
-
this.ctx.on('qq/interaction', (event) => {
|
|
151
|
-
void this._handleInteraction(event)
|
|
152
|
-
})
|
|
150
|
+
this.ctx.on('qq/message', (event) => this._handleInbound(event))
|
|
151
|
+
this.ctx.on('qq/interaction', (event) => this._handleInteraction(event))
|
|
153
152
|
}
|
|
154
153
|
|
|
155
154
|
// ---- 出站:覆盖 sendText / sendTyping,正确处理 scope 与被动回复 ----
|
|
@@ -373,7 +372,16 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
373
372
|
}
|
|
374
373
|
|
|
375
374
|
const text = String(event.text ?? '').trim()
|
|
376
|
-
|
|
375
|
+
const attachments = event.attachments || []
|
|
376
|
+
|
|
377
|
+
let mediaFiles = []
|
|
378
|
+
if (attachments.length > 0) {
|
|
379
|
+
this.logger?.info?.(`[dsh-bridge qq] processing ${attachments.length} attachment(s) from ${sender}...`)
|
|
380
|
+
mediaFiles = await this._processAttachments(attachments, this.config.cwd)
|
|
381
|
+
this.logger?.info?.(`[dsh-bridge qq] downloaded ${mediaFiles.length} file(s)`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!text && mediaFiles.length === 0) {
|
|
377
385
|
this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${sender}`)
|
|
378
386
|
return
|
|
379
387
|
}
|
|
@@ -384,10 +392,16 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
384
392
|
if (event.msgSeq !== undefined) this._msgSeq = event.msgSeq
|
|
385
393
|
}
|
|
386
394
|
|
|
395
|
+
let fullText = text
|
|
396
|
+
if (mediaFiles.length > 0) {
|
|
397
|
+
const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
|
|
398
|
+
fullText = fullText ? `${text}\n\n${mediaDesc}` : mediaDesc
|
|
399
|
+
}
|
|
400
|
+
|
|
387
401
|
// 检测消息引用(文本交互):如果用户回复了机器人的消息,且当前没有活动会话或消息以 /new 开头,
|
|
388
402
|
// 则自动创建新会话并发送消息
|
|
389
403
|
const messageReference = event.messageReference
|
|
390
|
-
if (messageReference && !this.activeSessionId && !
|
|
404
|
+
if (messageReference && !this.activeSessionId && !fullText.startsWith('/')) {
|
|
391
405
|
this.logger?.info?.(`[dsh-bridge qq] detected message reference from ${sender}, auto-starting conversation`)
|
|
392
406
|
// 先创建新会话,再处理消息
|
|
393
407
|
await this.handleInbound({ senderId: authId, text: '/new', isGroup })
|
|
@@ -396,7 +410,33 @@ export class QqConversationNode extends ConversationBridge {
|
|
|
396
410
|
}
|
|
397
411
|
|
|
398
412
|
// 交给平台无关核心:白名单/群消息/命令路由/agent 分发
|
|
399
|
-
await this.handleInbound({ senderId: authId, text, isGroup })
|
|
413
|
+
await this.handleInbound({ senderId: authId, text: fullText, isGroup })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 下载附件(图片/文件)并保存到本地工作目录 */
|
|
417
|
+
async _processAttachments(attachments, sessionCwd) {
|
|
418
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return []
|
|
419
|
+
const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.qq-media')
|
|
420
|
+
try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
|
|
421
|
+
|
|
422
|
+
const downloaded = []
|
|
423
|
+
for (const att of attachments) {
|
|
424
|
+
if (!att?.url) continue
|
|
425
|
+
try {
|
|
426
|
+
const rawUrl = att.url.startsWith('http') ? att.url : `https://${att.url}`
|
|
427
|
+
const res = await fetch(rawUrl, { signal: AbortSignal.timeout(15000) })
|
|
428
|
+
if (!res.ok) continue
|
|
429
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
430
|
+
const ext = att.filename ? path.extname(att.filename) : (att.content_type?.includes('image') ? '.png' : '.bin')
|
|
431
|
+
const safeName = att.filename ? path.basename(att.filename) : `qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
|
|
432
|
+
const filePath = path.join(mediaDir, safeName)
|
|
433
|
+
await fs.promises.writeFile(filePath, buf)
|
|
434
|
+
downloaded.push({ filename: safeName, path: filePath, size: buf.length })
|
|
435
|
+
} catch (err) {
|
|
436
|
+
this.logger?.warn?.('[dsh-bridge qq] download attachment error: %s', err?.message ?? err)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return downloaded
|
|
400
440
|
}
|
|
401
441
|
|
|
402
442
|
// ---- 互动事件 ----
|