@qzsy/dsh-bridge 0.0.0-stage → 2.18.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/CHANGELOG.md +1107 -0
- package/LICENSE +21 -0
- package/README.en.md +488 -0
- package/README.md +509 -2
- package/client/build.mjs +43 -0
- package/client/client.js +9864 -0
- package/client/host-families.js +442 -0
- package/client/index.js +6883 -0
- package/client/locales.mjs +242 -0
- package/client/mobile-styles.js +1299 -0
- package/client/mobile-yield.js +219 -0
- package/client/panel-locales.mjs +777 -0
- package/client/picker-yield.js +77 -0
- package/client/resource-url-compat.js +90 -0
- package/client/settings-nav-icon.js +105 -0
- package/client/theme-tokens.js +49 -0
- package/client/tunnel-guard.js +46 -0
- package/client/unlock-manager.js +144 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/auth/login-template.js +516 -0
- package/lib/auth/manager.js +596 -0
- package/lib/bridge-rpc-constants.js +66 -0
- package/lib/bridge-rpc.js +722 -0
- package/lib/cloudflared-manager.mjs +1277 -0
- package/lib/cloudflared-network-args.js +99 -0
- package/lib/cloudflared-process-scan.js +153 -0
- package/lib/compat.js +172 -0
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/comment.js +50 -0
- package/lib/feishu/gateway.js +644 -0
- package/lib/feishu/index.js +432 -0
- package/lib/feishu/lark-bundled.mjs +131929 -0
- package/lib/feishu/markdown.js +148 -0
- package/lib/feishu/node.js +1152 -0
- package/lib/feishu/slash-commands.js +255 -0
- package/lib/index.js +3554 -0
- package/lib/platform/account-ops.js +248 -0
- package/lib/platform/activity.js +169 -0
- package/lib/platform/ask-user.js +293 -0
- package/lib/platform/base.js +147 -0
- package/lib/platform/changes-digest.js +93 -0
- package/lib/platform/commands.js +267 -0
- package/lib/platform/config-restore.js +78 -0
- package/lib/platform/conversation-bridge.js +1544 -0
- package/lib/platform/dsh-storage.js +222 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/instances.js +458 -0
- package/lib/platform/manager.js +130 -0
- package/lib/platform/message-split.js +229 -0
- package/lib/platform/session-catalog.js +405 -0
- package/lib/platform/session-config.js +52 -0
- package/lib/platform/stream-slices.js +21 -0
- package/lib/qq/gateway.js +1107 -0
- package/lib/qq/index.js +561 -0
- package/lib/qq/node.js +1153 -0
- package/lib/qq/qqbot-bundled.mjs +8273 -0
- package/lib/restart-helper.mjs +133 -0
- package/lib/saas/device-auth.mjs +366 -0
- package/lib/saas/tunnel-client.mjs +695 -0
- package/lib/security/log-sanitize.js +50 -0
- package/lib/security/path-validator.js +195 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/session-strip.js +100 -0
- package/lib/sse-keepalive.js +115 -0
- package/lib/telegram/gateway.js +643 -0
- package/lib/telegram/index.js +255 -0
- package/lib/telegram/node.js +410 -0
- package/lib/tunnel-client.mjs +537 -0
- package/lib/wechat/gateway.js +1000 -0
- package/lib/wechat/index.js +282 -0
- package/lib/wechat/media.js +302 -0
- package/lib/wechat/node.js +360 -0
- package/llms.txt +36 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -4
package/lib/qq/index.js
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// dsh-bridge QQ platform adapter
|
|
2
|
+
//
|
|
3
|
+
// 编排 QqGateway(官方 @tencent-connect/qqbot-nodejs 网关)+ QqConversationNode(QQ⇄DSH 会话桥)。
|
|
4
|
+
// 作为 Platform 子类,注册进 PlatformManager 统一管理。
|
|
5
|
+
//
|
|
6
|
+
// 凭证获取支持两条路径(与飞书一致):
|
|
7
|
+
// 1. 一键扫码绑定(官方 @tencent-connect/qqbot-connector)——推荐,
|
|
8
|
+
// 手机 QQ 扫码后在开放平台侧自动创建/绑定机器人并回传 AppID + AppSecret;
|
|
9
|
+
// 2. 手动填写已有机器人的 AppID + AppSecret。
|
|
10
|
+
//
|
|
11
|
+
// 对外暴露给 bridge-rpc 的接口:getStatus / login / stop / setAllowFrom / setConfig /
|
|
12
|
+
// unbind / start / dispose。
|
|
13
|
+
//
|
|
14
|
+
// 持久化策略:凭证(appId/appSecret)与配置由主插件通过回调 `onPersist` 保存在
|
|
15
|
+
// $DSH_HOME/dsh-bridge/config.json。
|
|
16
|
+
|
|
17
|
+
import { Platform } from '../platform/base.js'
|
|
18
|
+
import { applySessionConfig, readSessionConfig } from '../platform/session-config.js'
|
|
19
|
+
import { QqGateway, gatewayConstants } from './gateway.js'
|
|
20
|
+
import { QqConversationNode } from './node.js'
|
|
21
|
+
|
|
22
|
+
/** 等待二维码就绪的上限(create_bind_task 通常 <1s;超时则前端轮询后续状态) */
|
|
23
|
+
const BIND_QR_TIMEOUT_MS = 10_000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 可被 unref 的等待。
|
|
27
|
+
*
|
|
28
|
+
* 该定时器只是"二维码就绪的兜底超时",不应阻止进程退出——
|
|
29
|
+
* 否则测试/CLI 场景下进程会白等满 10 秒(复核实测退出耗时 9999ms)。
|
|
30
|
+
*/
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const timer = setTimeout(resolve, ms)
|
|
34
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class QqService extends Platform {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
42
|
+
* @param {object} opts.logger 日志器
|
|
43
|
+
* @param {object} [opts.config] 已持久化的 qq 配置(凭证 + allowFrom + 间隔)
|
|
44
|
+
* @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
|
|
45
|
+
* @param {string} [opts.instanceId]
|
|
46
|
+
* @param {string} [opts.serviceName]
|
|
47
|
+
* @param {string} [opts.dataDir] SDK 会话持久化目录
|
|
48
|
+
*/
|
|
49
|
+
constructor({ ctx, logger, config = {}, onPersist, instanceId, serviceName, dataDir }) {
|
|
50
|
+
super({ ctx, logger, config, onPersist })
|
|
51
|
+
this.id = 'qq'
|
|
52
|
+
// issue #78:同类型可并行多实例;缺省回退类型 id 保持单实例旧行为
|
|
53
|
+
this.instanceId = instanceId || this.id
|
|
54
|
+
this.displayName = null
|
|
55
|
+
this.name = 'QQ'
|
|
56
|
+
/** 扫码绑定流程运行期状态(一次流程 = 一个 run 对象) */
|
|
57
|
+
this._bindRun = null
|
|
58
|
+
this._bindSeq = 0
|
|
59
|
+
|
|
60
|
+
this.gateway = new QqGateway({
|
|
61
|
+
ctx,
|
|
62
|
+
logger,
|
|
63
|
+
// issue #78:instanceId 用于事件隔离;serviceName 是唯一 cordis 服务名(D2)
|
|
64
|
+
instanceId: this.instanceId,
|
|
65
|
+
serviceName,
|
|
66
|
+
dataDir,
|
|
67
|
+
config: {
|
|
68
|
+
appId: config.appId ?? '',
|
|
69
|
+
appSecret: config.appSecret ?? '',
|
|
70
|
+
markdownSupport: config.markdownSupport,
|
|
71
|
+
requireMentionInGroup: config.requireMentionInGroup,
|
|
72
|
+
maxMessageChars: config.maxMessageChars,
|
|
73
|
+
// 被动回复限额(单聊 4 / 群聊 5,官方上限);留空走 gateway 默认值
|
|
74
|
+
passiveReplyLimit: config.passiveReplyLimit,
|
|
75
|
+
passiveReplyLimitGroup: config.passiveReplyLimitGroup,
|
|
76
|
+
// 流式节流(平台建议 ≥300ms)
|
|
77
|
+
streamThrottleMs: config.streamThrottleMs,
|
|
78
|
+
},
|
|
79
|
+
onPersist: (patch) => this.persist(patch),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 仅首个实例占用 ctx.qq 槽位(兼容外部读取方);节点已改为显式接收网关
|
|
83
|
+
try { if (!ctx.qq) ctx.qq = this.gateway } catch { /* 挂载失败不致命 */ }
|
|
84
|
+
|
|
85
|
+
// 网关连接成功后自动配置指令面板与自定义菜单(一次性)
|
|
86
|
+
this._panelSetupDone = false
|
|
87
|
+
try {
|
|
88
|
+
ctx.on?.('qq/status', (event) => {
|
|
89
|
+
// issue #78:status 事件带 instanceId(兼容旧的裸字符串 payload)
|
|
90
|
+
if (event?.instanceId && event.instanceId !== this.instanceId) return
|
|
91
|
+
const status = typeof event === 'string' ? event : event?.status
|
|
92
|
+
if (status === 'connected') {
|
|
93
|
+
void this._ensurePanelSetup().catch(() => {})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
} catch { /* 忽略事件订阅失败 */ }
|
|
97
|
+
|
|
98
|
+
this.node = new QqConversationNode(ctx, {
|
|
99
|
+
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
100
|
+
groupAutoApprove: config.groupAutoApprove,
|
|
101
|
+
changesDigest: config.changesDigest,
|
|
102
|
+
steerMode: config.steerMode,
|
|
103
|
+
digestIntervalSec: config.digestIntervalSec,
|
|
104
|
+
approvalTimeoutSec: config.approvalTimeoutSec,
|
|
105
|
+
maxMessageChars: config.maxMessageChars,
|
|
106
|
+
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
107
|
+
activeSessionId: config.activeSessionId,
|
|
108
|
+
// 会话级配置:工作区 / Agent 预设 / 模型路由(构造期来自 cordis 配置,运行期由设置页写入)
|
|
109
|
+
cwd: config.cwd,
|
|
110
|
+
agentPreset: config.agentPreset,
|
|
111
|
+
agentProvider: config.agentProvider,
|
|
112
|
+
agentModel: config.agentModel,
|
|
113
|
+
// 单聊流式输出(平台只对 C2C 开放 stream_messages)
|
|
114
|
+
streamReply: config.streamReply,
|
|
115
|
+
}, logger, {
|
|
116
|
+
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
117
|
+
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
|
|
118
|
+
gateway: this.gateway,
|
|
119
|
+
instanceId: this.instanceId,
|
|
120
|
+
})
|
|
121
|
+
this.bridge = this.node
|
|
122
|
+
|
|
123
|
+
if (this.gateway.configured) {
|
|
124
|
+
void this.start().catch((err) => {
|
|
125
|
+
this.logger.error?.('[dsh-bridge qq] start failed:', err?.message ?? err)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- Platform 接口 ----
|
|
131
|
+
|
|
132
|
+
get configured() { return this.gateway.configured }
|
|
133
|
+
get accountId() { return this.gateway.accountId }
|
|
134
|
+
|
|
135
|
+
get capabilities() {
|
|
136
|
+
return {
|
|
137
|
+
...this.gateway.capabilities,
|
|
138
|
+
maxMessageChars: this.node.config.maxMessageChars ?? gatewayConstants.MAX_MESSAGE_CHARS,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 消息抽象:委托 gateway(供 PlatformManager/未来多平台统一调用)。 */
|
|
143
|
+
async sendText(peerId, text, opts = {}) {
|
|
144
|
+
return this.gateway.sendText(peerId, text, opts)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async sendTyping(peerId, opts = {}) {
|
|
148
|
+
// 兼容旧调用:sendTyping(peerId, durationSeconds)
|
|
149
|
+
const normalized = typeof opts === 'number' ? { durationSeconds: opts } : opts
|
|
150
|
+
return this.gateway.sendTyping(peerId, normalized)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async sendMedia(peerId, media, opts = {}) {
|
|
154
|
+
return this.gateway.sendMedia(peerId, media, opts)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async sendMarkdown(peerId, markdown, opts = {}) {
|
|
158
|
+
return this.gateway.sendMarkdown(peerId, markdown, opts)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async sendKeyboard(peerId, content, keyboard, opts = {}) {
|
|
162
|
+
return this.gateway.sendKeyboard(peerId, content, keyboard, opts)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 合并展示状态给浏览器 UI。 */
|
|
166
|
+
getStatus() {
|
|
167
|
+
const allowFrom = [...(this.node.config.allowFrom ?? [])]
|
|
168
|
+
return {
|
|
169
|
+
id: this.id,
|
|
170
|
+
// issue #78:实例标识(instanceId 为全局唯一键,platformId 为平台类型)
|
|
171
|
+
instanceId: this.instanceId,
|
|
172
|
+
platformId: this.id,
|
|
173
|
+
displayName: this.displayName ?? this.name,
|
|
174
|
+
name: this.name,
|
|
175
|
+
status: this.gateway.status,
|
|
176
|
+
configured: this.gateway.configured,
|
|
177
|
+
accountId: this.gateway.accountId,
|
|
178
|
+
allowFrom,
|
|
179
|
+
peerId: this.node.peerId,
|
|
180
|
+
sessionId: this.node.activeSessionId,
|
|
181
|
+
login: { ...this.loginState },
|
|
182
|
+
capabilities: { ...this.capabilities },
|
|
183
|
+
config: {
|
|
184
|
+
name: this.config.name ?? '',
|
|
185
|
+
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
186
|
+
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
187
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
188
|
+
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
189
|
+
groupAutoApprove: this.node.config.groupAutoApprove === true,
|
|
190
|
+
changesDigest: this.node.config.changesDigest !== false,
|
|
191
|
+
steerMode: this.node.config.steerMode === true,
|
|
192
|
+
streamReply: this.node.config.streamReply !== false,
|
|
193
|
+
appId: this.gateway.config.appId,
|
|
194
|
+
// 密钥不回传到浏览器;设置页留空时保持已保存密钥
|
|
195
|
+
appSecret: '',
|
|
196
|
+
accountId: this.gateway.accountId,
|
|
197
|
+
...readSessionConfig(this.node.config),
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 可编辑配置(供通用 PlatformPanel 读取)。 */
|
|
203
|
+
getEditableConfig() {
|
|
204
|
+
return {
|
|
205
|
+
name: this.config.name ?? '',
|
|
206
|
+
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
207
|
+
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
208
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
209
|
+
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
210
|
+
groupAutoApprove: this.node.config.groupAutoApprove === true,
|
|
211
|
+
changesDigest: this.node.config.changesDigest !== false,
|
|
212
|
+
steerMode: this.node.config.steerMode === true,
|
|
213
|
+
streamReply: this.node.config.streamReply !== false,
|
|
214
|
+
appId: this.gateway.config.appId,
|
|
215
|
+
appSecret: '',
|
|
216
|
+
...readSessionConfig(this.node.config),
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 启动网关(凭证已配置时生效)。 */
|
|
221
|
+
async start() {
|
|
222
|
+
if (!this.gateway.configured) {
|
|
223
|
+
this.setStatus('idle')
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
return this.gateway.start()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 停止网关(保留凭证与配置)。 */
|
|
230
|
+
async stop() {
|
|
231
|
+
this.cancelBind()
|
|
232
|
+
await this.gateway.stop()
|
|
233
|
+
this.setStatus('idle')
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 完整关停(dispose 时调用)。 */
|
|
238
|
+
async dispose() {
|
|
239
|
+
this.cancelBind()
|
|
240
|
+
await this.gateway.stop()
|
|
241
|
+
this.gateway.dispose()
|
|
242
|
+
super.dispose()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 兼容 v1.x 的别名。 */
|
|
246
|
+
async destroy() {
|
|
247
|
+
await this.dispose()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 登录。
|
|
252
|
+
* @param {object} [opts]
|
|
253
|
+
* @param {string} [opts.qrType] 传 'bind' 走「官方扫码绑定机器人」流程
|
|
254
|
+
* @param {boolean} [opts.refresh] true = 作废当前二维码并重新生成
|
|
255
|
+
* @param {string} [opts.appId] / [opts.appSecret] 手动凭证
|
|
256
|
+
*/
|
|
257
|
+
async login({ qrType, refresh, appId, appSecret } = {}) {
|
|
258
|
+
if (qrType === 'bind') return this.startBindBot({ refresh })
|
|
259
|
+
|
|
260
|
+
const patch = {}
|
|
261
|
+
if (appId !== undefined) patch.appId = String(appId).trim()
|
|
262
|
+
if (appSecret !== undefined) patch.appSecret = String(appSecret).trim()
|
|
263
|
+
if (Object.keys(patch).length > 0) {
|
|
264
|
+
this.gateway.setCredentials(patch)
|
|
265
|
+
await this.persist(patch)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!this.gateway.configured) {
|
|
269
|
+
return { ok: false, error: '请填写 QQ 机器人 AppID 与 AppSecret,或使用扫码一键绑定' }
|
|
270
|
+
}
|
|
271
|
+
this.setLoginState({ phase: 'done', qrPayload: null, qrKind: null, error: null })
|
|
272
|
+
void this.gateway.start().catch((err) => {
|
|
273
|
+
this.logger.error?.('[dsh-bridge qq] login start failed: %s', err?.message ?? err)
|
|
274
|
+
})
|
|
275
|
+
return { ok: true }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- 扫码绑定机器人(官方 @tencent-connect/qqbot-connector) ----
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 发起「扫码绑定 QQ 机器人」:立刻返回,二维码经 loginState 暴露给前端轮询。
|
|
282
|
+
* 用户用手机 QQ 扫码确认后,AppID + AppSecret 自动落盘并连接。
|
|
283
|
+
*
|
|
284
|
+
* @param {object} [opts]
|
|
285
|
+
* @param {boolean} [opts.refresh] true = 中止当前流程并重新生成二维码
|
|
286
|
+
*/
|
|
287
|
+
async startBindBot({ refresh = false } = {}) {
|
|
288
|
+
const current = this._bindRun
|
|
289
|
+
if (current && !refresh) return { ok: true } // 已在扫码中:复用当前二维码
|
|
290
|
+
if (current) {
|
|
291
|
+
this.logger?.info?.('[dsh-bridge qq] 重新生成扫码绑定二维码')
|
|
292
|
+
this.cancelBind()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const run = { seq: (this._bindSeq += 1), stopped: false }
|
|
296
|
+
run.ready = new Promise((resolve) => { run.resolveReady = resolve })
|
|
297
|
+
run.settled = new Promise((resolve) => { run.resolveSettled = resolve })
|
|
298
|
+
this._bindRun = run
|
|
299
|
+
this.setLoginState({ phase: 'qr', qrPayload: null, qrKind: 'text', error: null })
|
|
300
|
+
|
|
301
|
+
void this._runBindBot(run)
|
|
302
|
+
|
|
303
|
+
// 二维码就绪(或流程已提前结束/超时)后再返回,避免前端首帧空白
|
|
304
|
+
await Promise.race([run.ready, run.settled, sleep(this._bindQrTimeoutMs())])
|
|
305
|
+
return { ok: true }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 等待二维码就绪的上限(测试可注入更短的值)。 */
|
|
309
|
+
_bindQrTimeoutMs() {
|
|
310
|
+
const value = Number(this.config.bindQrTimeoutMs)
|
|
311
|
+
return Number.isFinite(value) && value > 0 ? value : BIND_QR_TIMEOUT_MS
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 是否正在扫码绑定流程中(状态快照与外部判断使用) */
|
|
315
|
+
get binding() {
|
|
316
|
+
return Boolean(this._bindRun)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
cancelBind() {
|
|
320
|
+
const run = this._bindRun
|
|
321
|
+
if (!run) return
|
|
322
|
+
this.logger?.info?.('[dsh-bridge qq] 取消扫码绑定')
|
|
323
|
+
run.stopped = true
|
|
324
|
+
if (this._bindRun === run) this._bindRun = null
|
|
325
|
+
try { run.stopFn?.() } catch { /* 忽略 */ }
|
|
326
|
+
try { run.resolveSettled?.() } catch { /* 忽略 */ }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 官方扫码绑定工厂(测试注入点)。
|
|
331
|
+
*
|
|
332
|
+
* `@tencent-connect/qqbot-connector` 的 npm 包声明为 **UNLICENSED**(无 LICENSE 文件),
|
|
333
|
+
* 不能内联进本仓的 MIT 产物。因此这里按需从**用户自己的 node_modules** 动态加载:
|
|
334
|
+
* - 未安装 → 抛出可读错误,前端提示"改为手动填写 AppID/AppSecret";
|
|
335
|
+
* - 已安装 → 正常走官方扫码流程。
|
|
336
|
+
* 这样既尊重上游许可,又不影响插件主功能(收发消息完全不依赖它)。
|
|
337
|
+
*/
|
|
338
|
+
async _resolveQrConnect() {
|
|
339
|
+
// 测试注入点优先(单测用假实现,避免依赖可选包)
|
|
340
|
+
if (typeof this._qrConnectOverride === 'function') return this._qrConnectOverride
|
|
341
|
+
let mod
|
|
342
|
+
try {
|
|
343
|
+
mod = await import('@tencent-connect/qqbot-connector')
|
|
344
|
+
} catch (err) {
|
|
345
|
+
const hint = '扫码绑定需要可选依赖 @tencent-connect/qqbot-connector(该包为 UNLICENSED,未随插件分发)。'
|
|
346
|
+
+ '请执行 `npm i @tencent-connect/qqbot-connector` 后重试,或直接手动填写 AppID 与 AppSecret。'
|
|
347
|
+
const error = new Error(hint)
|
|
348
|
+
error.cause = err
|
|
349
|
+
error.code = 'CONNECTOR_NOT_INSTALLED'
|
|
350
|
+
throw error
|
|
351
|
+
}
|
|
352
|
+
const start = mod.startQrConnect ?? mod.default?.startQrConnect
|
|
353
|
+
if (typeof start !== 'function') {
|
|
354
|
+
throw new Error('@tencent-connect/qqbot-connector 未导出 startQrConnect,请升级该依赖')
|
|
355
|
+
}
|
|
356
|
+
return start
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async _runBindBot(run) {
|
|
360
|
+
// 每次状态写入前确认自己仍是"当前流程":刷新用的新流程启动后,
|
|
361
|
+
// 旧流程的回调与结果都必须丢弃,避免覆盖新流程
|
|
362
|
+
const isCurrent = () => this._bindRun === run && !run.stopped
|
|
363
|
+
try {
|
|
364
|
+
// 先解析出 start 函数(可选依赖动态加载,可能抛"未安装")
|
|
365
|
+
const startFn = await this._resolveQrConnect()
|
|
366
|
+
if (!isCurrent()) return
|
|
367
|
+
const credentials = await new Promise((resolve, reject) => {
|
|
368
|
+
const stop = startFn({
|
|
369
|
+
onSuccess: (creds) => resolve(Array.isArray(creds) ? creds : []),
|
|
370
|
+
onFailure: (err) => reject(err instanceof Error ? err : new Error(String(err))),
|
|
371
|
+
onQrDisplayed: (url) => {
|
|
372
|
+
if (!isCurrent()) return
|
|
373
|
+
this.setLoginState({ phase: 'qr', qrPayload: url, qrKind: 'text' })
|
|
374
|
+
run.resolveReady()
|
|
375
|
+
},
|
|
376
|
+
onQrExpired: () => {
|
|
377
|
+
if (!isCurrent()) return
|
|
378
|
+
this.logger?.info?.('[dsh-bridge qq] 绑定二维码已过期,正在刷新…')
|
|
379
|
+
},
|
|
380
|
+
}, {
|
|
381
|
+
// 不往插件进程的控制台打二维码:二维码由设置面板渲染(终端渲染在 Web 场景是噪音)
|
|
382
|
+
displayQrCodeToConsole: false,
|
|
383
|
+
source: 'dsh-bridge',
|
|
384
|
+
})
|
|
385
|
+
run.stopFn = stop
|
|
386
|
+
})
|
|
387
|
+
if (!isCurrent()) return // 已被刷新流程取代:结果作废
|
|
388
|
+
const first = Array.isArray(credentials) ? credentials[0] : null
|
|
389
|
+
const appId = String(first?.appId ?? '').trim()
|
|
390
|
+
const appSecret = String(first?.appSecret ?? '').trim()
|
|
391
|
+
if (!appId || !appSecret) throw new Error('扫码绑定未返回完整凭据')
|
|
392
|
+
this.gateway.setCredentials({ appId, appSecret })
|
|
393
|
+
await this.persist({ appId, appSecret })
|
|
394
|
+
this.logger?.info?.('[dsh-bridge qq] 扫码绑定成功:%s', appId)
|
|
395
|
+
this.setLoginState({ phase: 'done', qrPayload: null, qrKind: null, error: null })
|
|
396
|
+
await this.start()
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (!isCurrent()) return // 旧流程被取消:静默退出
|
|
399
|
+
const message = err?.message ?? String(err)
|
|
400
|
+
this.logger?.warn?.('[dsh-bridge qq] 扫码绑定失败:%s', message)
|
|
401
|
+
this.setLoginState({ phase: 'error', qrPayload: null, qrKind: null, error: message })
|
|
402
|
+
} finally {
|
|
403
|
+
if (isCurrent()) this._bindRun = null
|
|
404
|
+
run.resolveSettled()
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
setLoginState(state) {
|
|
409
|
+
this.loginState = {
|
|
410
|
+
phase: 'idle', qrPayload: null, qrKind: null, error: null,
|
|
411
|
+
...state,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 解绑:停止网关并清除凭证,下次重启不再自动重连。 */
|
|
416
|
+
async unbind() {
|
|
417
|
+
this.cancelBind()
|
|
418
|
+
await this.gateway.stop()
|
|
419
|
+
this.gateway.setCredentials({ appId: '', appSecret: '' })
|
|
420
|
+
this.gateway.markUnbound()
|
|
421
|
+
await this.persist({ appId: '', appSecret: '', allowFrom: [] })
|
|
422
|
+
if (this.node) this.node.config.allowFrom = []
|
|
423
|
+
this.setLoginState({ phase: 'idle', qrPayload: null, qrKind: null, error: null })
|
|
424
|
+
this.logger?.info?.('[dsh-bridge qq] unbound: credentials cleared')
|
|
425
|
+
return { success: true }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 更新白名单并发起持久化。 */
|
|
429
|
+
async setAllowFrom(list) {
|
|
430
|
+
const clean = Array.isArray(list) ? [...new Set(list.map((x) => String(x).trim()).filter(Boolean))] : []
|
|
431
|
+
this.node.config.allowFrom = clean
|
|
432
|
+
await this.persist({ allowFrom: clean })
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---- 指令面板 / 自定义菜单(一次性自动配置)----
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 连接成功后自动创建 c2c + group 指令面板,并配置单聊自定义菜单。
|
|
439
|
+
* 幂等:已存在 remark 匹配的面板时跳过创建;失败仅记日志,不影响连接。
|
|
440
|
+
*/
|
|
441
|
+
async _ensurePanelSetup() {
|
|
442
|
+
if (this._panelSetupDone) return
|
|
443
|
+
this._panelSetupDone = true // 防并发重复执行
|
|
444
|
+
|
|
445
|
+
const gate = this.gateway
|
|
446
|
+
const remark = PANEL_REMARK
|
|
447
|
+
const items = [
|
|
448
|
+
{ name: '/new', desc: '新建对话', type: 'command' },
|
|
449
|
+
{ name: '/list', desc: '查看会话列表', type: 'command' },
|
|
450
|
+
{ name: '/resume', desc: '恢复会话', type: 'command' },
|
|
451
|
+
{ name: '/sessions', desc: '切换会话', type: 'command' },
|
|
452
|
+
{ name: '/help', desc: '命令帮助', type: 'command' },
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
// 指令面板:c2c(单聊)与 group(群聊)各建一个全局面板
|
|
456
|
+
for (const scope of ['c2c', 'group']) {
|
|
457
|
+
try {
|
|
458
|
+
const list = await gate.apiCall('GET', `/v2/panels?scope=${encodeURIComponent(scope)}`)
|
|
459
|
+
const exists = (list?.records || []).some((r) => r?.panel?.remark === remark)
|
|
460
|
+
if (!exists) {
|
|
461
|
+
const res = await gate.apiCall('POST', '/v2/panels', {
|
|
462
|
+
scope,
|
|
463
|
+
target_type: 'all',
|
|
464
|
+
panel: { items, remark, version: 1 },
|
|
465
|
+
})
|
|
466
|
+
this.logger.info?.('[dsh-bridge qq] created %s command panel: %s', scope, res?.panel_id ?? '(no id)')
|
|
467
|
+
}
|
|
468
|
+
} catch (err) {
|
|
469
|
+
this.logger.warn?.('[dsh-bridge qq] command panel setup for %s skipped: %s', scope, err?.message ?? err)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 自定义菜单:单聊底部菜单(send_message 类型,点击自动填入命令)
|
|
474
|
+
// 幂等:先查询,内容一致则跳过 PUT,避免每次重启都覆盖(version 递增)
|
|
475
|
+
try {
|
|
476
|
+
const menuItems = [
|
|
477
|
+
{ name: '新建', type: 'send_message', send_message: '/new' },
|
|
478
|
+
{ name: '列表', type: 'send_message', send_message: '/list' },
|
|
479
|
+
{ name: '帮助', type: 'send_message', send_message: '/help' },
|
|
480
|
+
]
|
|
481
|
+
const existing = await gate.apiCall('GET', '/v2/menu')
|
|
482
|
+
const existingItems = existing?.menu?.items || []
|
|
483
|
+
const same = existingItems.length === menuItems.length &&
|
|
484
|
+
menuItems.every((item, i) => existingItems[i]?.name === item.name && existingItems[i]?.send_message === item.send_message)
|
|
485
|
+
if (!same) {
|
|
486
|
+
await gate.apiCall('PUT', '/v2/menu', { menu: { items: menuItems } })
|
|
487
|
+
this.logger.info?.('[dsh-bridge qq] custom menu configured')
|
|
488
|
+
} else {
|
|
489
|
+
this.logger.info?.('[dsh-bridge qq] custom menu already configured')
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
this.logger.warn?.('[dsh-bridge qq] custom menu setup skipped: %s', err?.message ?? err)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 更新运行时配置并持久化。 */
|
|
497
|
+
async setConfig({
|
|
498
|
+
digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs,
|
|
499
|
+
groupAutoApprove, changesDigest, steerMode, streamReply,
|
|
500
|
+
appId, appSecret, clientSecret, name, agentPreset, cwd, agentProvider, agentModel,
|
|
501
|
+
} = {}) {
|
|
502
|
+
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
503
|
+
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
504
|
+
if (maxMessageChars != null) {
|
|
505
|
+
const val = Number(maxMessageChars)
|
|
506
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : 2000
|
|
507
|
+
}
|
|
508
|
+
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
509
|
+
if (groupAutoApprove != null) this.node.config.groupAutoApprove = groupAutoApprove === true
|
|
510
|
+
if (changesDigest != null) this.node.config.changesDigest = changesDigest === true
|
|
511
|
+
if (steerMode != null) this.node.config.steerMode = steerMode === true
|
|
512
|
+
if (streamReply != null) this.node.config.streamReply = streamReply === true
|
|
513
|
+
applySessionConfig(this.node.config, { agentPreset, cwd, agentProvider, agentModel })
|
|
514
|
+
// issue #78:账号备注名(面板回显 + displayName 来源),随 setConfig 持久化
|
|
515
|
+
if (name !== undefined) this.config.name = String(name).trim()
|
|
516
|
+
|
|
517
|
+
// 密钥字段兼容两种命名(旧面板传 clientSecret,新面板传 appSecret);
|
|
518
|
+
// 空字符串表示 UI 未修改密钥,避免误覆盖已保存的 secret
|
|
519
|
+
const secretInput = appSecret !== undefined ? appSecret : clientSecret
|
|
520
|
+
const credentialsTouched = appId !== undefined || secretInput !== undefined
|
|
521
|
+
if (credentialsTouched) {
|
|
522
|
+
this.gateway.setCredentials({
|
|
523
|
+
appId: appId !== undefined ? appId : this.gateway.config.appId,
|
|
524
|
+
appSecret: secretInput?.trim() ? secretInput.trim() : this.gateway.config.appSecret,
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.gateway.setCredentials({
|
|
529
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const patch = {
|
|
533
|
+
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
534
|
+
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
535
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
536
|
+
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
537
|
+
groupAutoApprove: this.node.config.groupAutoApprove === true,
|
|
538
|
+
changesDigest: this.node.config.changesDigest !== false,
|
|
539
|
+
steerMode: this.node.config.steerMode === true,
|
|
540
|
+
streamReply: this.node.config.streamReply !== false,
|
|
541
|
+
...readSessionConfig(this.node.config),
|
|
542
|
+
}
|
|
543
|
+
if (name !== undefined) patch.name = this.config.name ?? ''
|
|
544
|
+
if (credentialsTouched) {
|
|
545
|
+
patch.appId = this.gateway.config.appId
|
|
546
|
+
patch.appSecret = this.gateway.config.appSecret
|
|
547
|
+
}
|
|
548
|
+
await this.persist(patch)
|
|
549
|
+
|
|
550
|
+
// 凭证配置完成后自动启动网关(前端「保存并连接」无需二次点击)
|
|
551
|
+
if (this.gateway.configured && credentialsTouched) {
|
|
552
|
+
await this.gateway.start().catch((err) => {
|
|
553
|
+
this.logger?.warn?.('[dsh-bridge qq] auto-start failed: %s', err?.message ?? err)
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 指令面板 remark 标识,用于幂等判断(区分本插件创建的面板)
|
|
561
|
+
const PANEL_REMARK = 'dsh-bridge 常用命令'
|