@qzsy/dsh-bridge 2.18.0 → 3.0.1

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -1094
  2. package/LICENSE +21 -21
  3. package/README.md +73 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +414 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +303 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
package/lib/qq/index.js DELETED
@@ -1,561 +0,0 @@
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 常用命令'