@wenbin_wb/dsh-bridge 2.8.7 → 2.9.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.
@@ -1,241 +1,244 @@
1
- // dsh-bridge WeChat platform adapter
2
- //
3
- // 编排 WechatGateway(iLink 网关,注册为 ctx.wechat 服务)+ WechatConversationNode
4
- // (微信⇄DSH 会话桥)。作为 Platform 子类,可注册进 PlatformManager 统一管理。
5
- //
6
- // 对外暴露给 bridge-rpc 的接口(与 v1.x 完全兼容):getStatus / login / stop /
7
- // setAllowFrom / setConfig / unbind / destroy。
8
- //
9
- // 持久化策略:凭证(token/accountId/baseUrl)与配置(allowFrom/间隔)由主插件通过回调
10
- // `onPersist` 保存在 $DSH_HOME/dsh-bridge/config.json。本服务不直接碰文件系统。
11
-
12
- import { Platform } from '../platform/base.js'
13
- import { WechatGateway, gatewayConstants } from './gateway.js'
14
- import { WechatConversationNode } from './node.js'
15
-
16
- export class WechatService extends Platform {
17
- /**
18
- * @param {object} opts
19
- * @param {object} opts.ctx Cordis 上下文
20
- * @param {object} opts.logger 日志器
21
- * @param {object} [opts.config] 已持久化的 wechat 配置(凭证 + allowFrom + 间隔)
22
- * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
23
- */
24
- constructor({ ctx, logger, config = {}, onPersist }) {
25
- super({ ctx, logger, config, onPersist })
26
- this.id = 'wechat'
27
- this.name = '微信'
28
-
29
- this.gateway = new WechatGateway({
30
- ctx,
31
- logger,
32
- config: {
33
- token: config.token ?? '',
34
- accountId: config.accountId ?? '',
35
- baseUrl: config.baseUrl ?? undefined,
36
- },
37
- })
38
-
39
- this.node = new WechatConversationNode(ctx, {
40
- allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
41
- digestIntervalSec: config.digestIntervalSec,
42
- approvalTimeoutSec: config.approvalTimeoutSec,
43
- maxMessageChars: config.maxMessageChars,
44
- sendChunkDelayMs: config.sendChunkDelayMs,
45
- activeSessionId: config.activeSessionId, // v0.2.1:恢复活动会话
46
- }, logger, {
47
- onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
48
- onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }), // v0.2.1:持久化活动会话
49
- })
50
- this.bridge = this.node
51
-
52
- if (this.gateway.configured) {
53
- void this.start().catch((err) => {
54
- this.logger.error('[dsh-bridge wechat] start failed: %s', err?.message ?? err)
55
- })
56
- }
57
- }
58
-
59
- // ---- Platform 接口 ----
60
-
61
- get configured() { return this.gateway.configured }
62
- get accountId() { return this.gateway.accountId }
63
- get baseUrl() { return this.gateway.baseUrl }
64
-
65
- get capabilities() {
66
- return {
67
- supportsGroup: false, // v0.1 不处理群消息
68
- supportsMedia: true,
69
- supportsVoice: true,
70
- supportsTyping: true,
71
- maxMessageChars: this.node.config.maxMessageChars ?? gatewayConstants.MAX_MESSAGE_CHARS,
72
- }
73
- }
74
-
75
- /** 消息抽象:委托 gateway(供 PlatformManager/未来多平台统一调用)。 */
76
- async sendText(peerId, text, opts = {}) {
77
- return this.gateway.sendText(peerId, text, opts.clientId)
78
- }
79
-
80
- async sendTyping(peerId, state) {
81
- return this.gateway.sendTyping(peerId, state)
82
- }
83
-
84
- async sendMedia(peerId, media, opts = {}) {
85
- return this.gateway.sendMedia({ ...media, to: peerId, ...opts })
86
- }
87
-
88
- async sendMediaFile(peerId, filePath, opts = {}) {
89
- return this.gateway.sendMediaFile(peerId, filePath)
90
- }
91
-
92
- /** 合并展示状态给浏览器 UI(保持 v1.x 字段结构,新增 id/name/capabilities)。 */
93
- getStatus() {
94
- const allowFrom = [...(this.node.config.allowFrom ?? [])]
95
- return {
96
- id: this.id,
97
- name: this.name,
98
- status: this.gateway.status,
99
- configured: this.gateway.configured,
100
- accountId: this.gateway.accountId,
101
- allowFrom,
102
- peerId: this.node.peerId,
103
- sessionId: this.node.activeSessionId,
104
- baseUrl: this.gateway.baseUrl,
105
- login: { ...this.loginState },
106
- capabilities: { ...this.capabilities },
107
- // 可编辑配置(供 UI 设置面板读取)
108
- config: {
109
- digestIntervalSec: this.node.config.digestIntervalSec,
110
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
111
- maxMessageChars: this.node.config.maxMessageChars,
112
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
113
- },
114
- }
115
- }
116
-
117
- /** 可编辑配置(供通用 PlatformPanel 读取)。 */
118
- getEditableConfig() {
119
- return {
120
- digestIntervalSec: this.node.config.digestIntervalSec,
121
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
122
- maxMessageChars: this.node.config.maxMessageChars,
123
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
124
- }
125
- }
126
-
127
- /** 启动网关轮询(凭证已配置时生效)。 */
128
- async start() {
129
- if (!this.gateway.configured) {
130
- this.setStatus('idle')
131
- return
132
- }
133
- await this.gateway.start()
134
- }
135
-
136
- /** 停止网关轮询(保留凭证与配置)。 */
137
- async stop() {
138
- await this.gateway.stop()
139
- }
140
-
141
- /** 完整关停(dispose 时调用)。 */
142
- async dispose() {
143
- await this.gateway.stop()
144
- this.gateway.dispose()
145
- super.dispose() // 清理 node (bridge) + disposers
146
- }
147
-
148
- /** 兼容 v1.x 的别名(主插件 ctx.effect 调用)。 */
149
- async destroy() {
150
- await this.dispose()
151
- }
152
-
153
- /**
154
- * 发起扫码登录(后台执行,状态通过 getStatus/loginState 轮询读取)。
155
- * @param {object} [opts]
156
- * @param {string} [opts.qrType] 'img' 时优先用 qrcode_img_content,否则用 scanData
157
- * @returns {Promise<{ok: boolean, error?: string}>} 立即返回(已启动)
158
- */
159
- async login({ qrType } = {}) {
160
- // 已有状态/正在登录则先清空
161
- this.loginState = { phase: 'idle', qrPayload: null, qrKind: null, error: null }
162
- void this._doLogin({ qrType })
163
- return { ok: true }
164
- }
165
-
166
- async _doLogin({ qrType } = {}) {
167
- const update = (phase, patch = {}) => {
168
- this.loginState = { ...this.loginState, phase, ...patch }
169
- }
170
- const result = await this.gateway.loginQr({
171
- onQr: (qr) => {
172
- const img = qr.imgContent
173
- if (img && /^data:/i.test(img)) {
174
- // 服务端已给图片 dataURL
175
- update('qr', { qrPayload: img, qrKind: 'img' })
176
- } else if (qrType === 'img' && img) {
177
- // 服务端给了图片内容但非 dataURL(可能是 base64)——RPC 层拼接
178
- update('qr', { qrPayload: img, qrKind: 'img' })
179
- } else {
180
- // 纯文本/短链:RPC 层用 qrcode 库渲染成 dataURL
181
- update('qr', { qrPayload: qr.scanData || qr.value, qrKind: 'text' })
182
- }
183
- },
184
- onStatus: (state) => {
185
- if (state === 'scaned' || state === 'scaned_but_redirect') update('scaned')
186
- else if (state === 'confirmed') update('confirmed')
187
- else if (state === 'expired') update('qr', { error: '二维码已过期,正在刷新…' })
188
- else if (state === 'wait') update('qr')
189
- },
190
- }).catch((err) => {
191
- this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: err?.message ?? String(err) }
192
- return null
193
- })
194
- if (!result || !result.success) {
195
- this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: result?.error ?? '登录失败或超时' }
196
- return
197
- }
198
- const creds = result.credentials
199
- await this.persist({
200
- token: creds.token,
201
- accountId: creds.accountId,
202
- baseUrl: creds.baseUrl,
203
- })
204
- void this.gateway.start().catch((err) => {
205
- this.logger.error('[dsh-bridge wechat] login start failed: %s', err?.message ?? err)
206
- })
207
- this.loginState = { phase: 'done', qrPayload: null, qrKind: null, error: null }
208
- }
209
-
210
- /** 解绑:停止网关并清除凭证(token/accountId),下次重启不再自动重连。 */
211
- async unbind() {
212
- await this.gateway.stop()
213
- this.gateway.setCredentials({ token: '', accountId: '', baseUrl: undefined })
214
- await this.persist({ token: '', accountId: '', baseUrl: '' })
215
- this.logger.info('[dsh-bridge wechat] unbound: credentials cleared')
216
- }
217
-
218
- /** 更新白名单并发起持久化。 */
219
- async setAllowFrom(list) {
220
- const clean = Array.isArray(list) ? [...new Set(list.map((x) => String(x).trim()).filter(Boolean))] : []
221
- this.node.config.allowFrom = clean
222
- await this.persist({ allowFrom: clean })
223
- }
224
-
225
- /** 更新运行时配置(心跳间隔/审批超时/每气泡字数/分块延迟)并持久化。 */
226
- async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs } = {}) {
227
- if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
228
- if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
229
- if (maxMessageChars != null) {
230
- const val = Number(maxMessageChars)
231
- this.node.config.maxMessageChars = (val >= 200) ? val : gatewayConstants.MAX_MESSAGE_CHARS
232
- }
233
- if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
234
- await this.persist({
235
- digestIntervalSec: this.node.config.digestIntervalSec,
236
- approvalTimeoutSec: this.node.config.approvalTimeoutSec,
237
- maxMessageChars: this.node.config.maxMessageChars,
238
- sendChunkDelayMs: this.node.config.sendChunkDelayMs,
239
- })
240
- }
241
- }
1
+ // dsh-bridge WeChat platform adapter
2
+ //
3
+ // 编排 WechatGateway(iLink 网关,注册为 ctx.wechat 服务)+ WechatConversationNode
4
+ // (微信⇄DSH 会话桥)。作为 Platform 子类,可注册进 PlatformManager 统一管理。
5
+ //
6
+ // 对外暴露给 bridge-rpc 的接口(与 v1.x 完全兼容):getStatus / login / stop /
7
+ // setAllowFrom / setConfig / unbind / destroy。
8
+ //
9
+ // 持久化策略:凭证(token/accountId/baseUrl)与配置(allowFrom/间隔)由主插件通过回调
10
+ // `onPersist` 保存在 $DSH_HOME/dsh-bridge/config.json。本服务不直接碰文件系统。
11
+
12
+ import { Platform } from '../platform/base.js'
13
+ import { WechatGateway, gatewayConstants } from './gateway.js'
14
+ import { WechatConversationNode } from './node.js'
15
+
16
+ export class WechatService extends Platform {
17
+ /**
18
+ * @param {object} opts
19
+ * @param {object} opts.ctx Cordis 上下文
20
+ * @param {object} opts.logger 日志器
21
+ * @param {object} [opts.config] 已持久化的 wechat 配置(凭证 + allowFrom + 间隔)
22
+ * @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
23
+ */
24
+ constructor({ ctx, logger, config = {}, onPersist }) {
25
+ super({ ctx, logger, config, onPersist })
26
+ this.id = 'wechat'
27
+ this.name = '微信'
28
+
29
+ this.gateway = new WechatGateway({
30
+ ctx,
31
+ logger,
32
+ config: {
33
+ token: config.token ?? '',
34
+ accountId: config.accountId ?? '',
35
+ baseUrl: config.baseUrl ?? undefined,
36
+ },
37
+ })
38
+
39
+ this.node = new WechatConversationNode(ctx, {
40
+ allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
41
+ groupAutoApprove: config.groupAutoApprove,
42
+ digestIntervalSec: config.digestIntervalSec,
43
+ approvalTimeoutSec: config.approvalTimeoutSec,
44
+ maxMessageChars: config.maxMessageChars,
45
+ sendChunkDelayMs: config.sendChunkDelayMs,
46
+ activeSessionId: config.activeSessionId, // v0.2.1:恢复活动会话
47
+ }, logger, {
48
+ onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
49
+ onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }), // v0.2.1:持久化活动会话
50
+ })
51
+ this.bridge = this.node
52
+
53
+ if (this.gateway.configured) {
54
+ void this.start().catch((err) => {
55
+ this.logger.error('[dsh-bridge wechat] start failed: %s', err?.message ?? err)
56
+ })
57
+ }
58
+ }
59
+
60
+ // ---- Platform 接口 ----
61
+
62
+ get configured() { return this.gateway.configured }
63
+ get accountId() { return this.gateway.accountId }
64
+ get baseUrl() { return this.gateway.baseUrl }
65
+
66
+ get capabilities() {
67
+ return {
68
+ supportsGroup: false, // v0.1 不处理群消息
69
+ supportsMedia: true,
70
+ supportsVoice: true,
71
+ supportsTyping: true,
72
+ maxMessageChars: this.node.config.maxMessageChars ?? gatewayConstants.MAX_MESSAGE_CHARS,
73
+ }
74
+ }
75
+
76
+ /** 消息抽象:委托 gateway(供 PlatformManager/未来多平台统一调用)。 */
77
+ async sendText(peerId, text, opts = {}) {
78
+ return this.gateway.sendText(peerId, text, opts.clientId)
79
+ }
80
+
81
+ async sendTyping(peerId, state) {
82
+ return this.gateway.sendTyping(peerId, state)
83
+ }
84
+
85
+ async sendMedia(peerId, media, opts = {}) {
86
+ return this.gateway.sendMedia({ ...media, to: peerId, ...opts })
87
+ }
88
+
89
+ async sendMediaFile(peerId, filePath, opts = {}) {
90
+ return this.gateway.sendMediaFile(peerId, filePath)
91
+ }
92
+
93
+ /** 合并展示状态给浏览器 UI(保持 v1.x 字段结构,新增 id/name/capabilities)。 */
94
+ getStatus() {
95
+ const allowFrom = [...(this.node.config.allowFrom ?? [])]
96
+ return {
97
+ id: this.id,
98
+ name: this.name,
99
+ status: this.gateway.status,
100
+ configured: this.gateway.configured,
101
+ accountId: this.gateway.accountId,
102
+ allowFrom,
103
+ peerId: this.node.peerId,
104
+ sessionId: this.node.activeSessionId,
105
+ baseUrl: this.gateway.baseUrl,
106
+ login: { ...this.loginState },
107
+ capabilities: { ...this.capabilities },
108
+ // 可编辑配置(供 UI 设置面板读取)
109
+ config: {
110
+ digestIntervalSec: this.node.config.digestIntervalSec,
111
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
112
+ maxMessageChars: this.node.config.maxMessageChars,
113
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
114
+ },
115
+ }
116
+ }
117
+
118
+ /** 可编辑配置(供通用 PlatformPanel 读取)。 */
119
+ getEditableConfig() {
120
+ return {
121
+ digestIntervalSec: this.node.config.digestIntervalSec,
122
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
123
+ maxMessageChars: this.node.config.maxMessageChars,
124
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
125
+ groupAutoApprove: this.node.config.groupAutoApprove === true,
126
+ }
127
+ }
128
+
129
+ /** 启动网关轮询(凭证已配置时生效)。 */
130
+ async start() {
131
+ if (!this.gateway.configured) {
132
+ this.setStatus('idle')
133
+ return
134
+ }
135
+ await this.gateway.start()
136
+ }
137
+
138
+ /** 停止网关轮询(保留凭证与配置)。 */
139
+ async stop() {
140
+ await this.gateway.stop()
141
+ }
142
+
143
+ /** 完整关停(dispose 时调用)。 */
144
+ async dispose() {
145
+ await this.gateway.stop()
146
+ this.gateway.dispose()
147
+ super.dispose() // 清理 node (bridge) + disposers
148
+ }
149
+
150
+ /** 兼容 v1.x 的别名(主插件 ctx.effect 调用)。 */
151
+ async destroy() {
152
+ await this.dispose()
153
+ }
154
+
155
+ /**
156
+ * 发起扫码登录(后台执行,状态通过 getStatus/loginState 轮询读取)。
157
+ * @param {object} [opts]
158
+ * @param {string} [opts.qrType] 'img' 时优先用 qrcode_img_content,否则用 scanData
159
+ * @returns {Promise<{ok: boolean, error?: string}>} 立即返回(已启动)
160
+ */
161
+ async login({ qrType } = {}) {
162
+ // 已有状态/正在登录则先清空
163
+ this.loginState = { phase: 'idle', qrPayload: null, qrKind: null, error: null }
164
+ void this._doLogin({ qrType })
165
+ return { ok: true }
166
+ }
167
+
168
+ async _doLogin({ qrType } = {}) {
169
+ const update = (phase, patch = {}) => {
170
+ this.loginState = { ...this.loginState, phase, ...patch }
171
+ }
172
+ const result = await this.gateway.loginQr({
173
+ onQr: (qr) => {
174
+ const img = qr.imgContent
175
+ if (img && /^data:/i.test(img)) {
176
+ // 服务端已给图片 dataURL
177
+ update('qr', { qrPayload: img, qrKind: 'img' })
178
+ } else if (qrType === 'img' && img) {
179
+ // 服务端给了图片内容但非 dataURL(可能是 base64)——RPC 层拼接
180
+ update('qr', { qrPayload: img, qrKind: 'img' })
181
+ } else {
182
+ // 纯文本/短链:RPC 层用 qrcode 库渲染成 dataURL
183
+ update('qr', { qrPayload: qr.scanData || qr.value, qrKind: 'text' })
184
+ }
185
+ },
186
+ onStatus: (state) => {
187
+ if (state === 'scaned' || state === 'scaned_but_redirect') update('scaned')
188
+ else if (state === 'confirmed') update('confirmed')
189
+ else if (state === 'expired') update('qr', { error: '二维码已过期,正在刷新…' })
190
+ else if (state === 'wait') update('qr')
191
+ },
192
+ }).catch((err) => {
193
+ this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: err?.message ?? String(err) }
194
+ return null
195
+ })
196
+ if (!result || !result.success) {
197
+ this.loginState = { phase: 'error', qrPayload: null, qrKind: null, error: result?.error ?? '登录失败或超时' }
198
+ return
199
+ }
200
+ const creds = result.credentials
201
+ await this.persist({
202
+ token: creds.token,
203
+ accountId: creds.accountId,
204
+ baseUrl: creds.baseUrl,
205
+ })
206
+ void this.gateway.start().catch((err) => {
207
+ this.logger.error('[dsh-bridge wechat] login start failed: %s', err?.message ?? err)
208
+ })
209
+ this.loginState = { phase: 'done', qrPayload: null, qrKind: null, error: null }
210
+ }
211
+
212
+ /** 解绑:停止网关并清除凭证(token/accountId),下次重启不再自动重连。 */
213
+ async unbind() {
214
+ await this.gateway.stop()
215
+ this.gateway.setCredentials({ token: '', accountId: '', baseUrl: undefined })
216
+ await this.persist({ token: '', accountId: '', baseUrl: '' })
217
+ this.logger.info('[dsh-bridge wechat] unbound: credentials cleared')
218
+ }
219
+
220
+ /** 更新白名单并发起持久化。 */
221
+ async setAllowFrom(list) {
222
+ const clean = Array.isArray(list) ? [...new Set(list.map((x) => String(x).trim()).filter(Boolean))] : []
223
+ this.node.config.allowFrom = clean
224
+ await this.persist({ allowFrom: clean })
225
+ }
226
+
227
+ /** 更新运行时配置(心跳间隔/审批超时/每气泡字数/分块延迟)并持久化。 */
228
+ async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove } = {}) {
229
+ if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
230
+ if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
231
+ if (maxMessageChars != null) {
232
+ const val = Number(maxMessageChars)
233
+ this.node.config.maxMessageChars = (val >= 200) ? val : gatewayConstants.MAX_MESSAGE_CHARS
234
+ }
235
+ if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
236
+ await this.persist({
237
+ digestIntervalSec: this.node.config.digestIntervalSec,
238
+ approvalTimeoutSec: this.node.config.approvalTimeoutSec,
239
+ maxMessageChars: this.node.config.maxMessageChars,
240
+ sendChunkDelayMs: this.node.config.sendChunkDelayMs,
241
+ groupAutoApprove: this.node.config.groupAutoApprove === true,
242
+ })
243
+ }
244
+ }