@wenbin_wb/dsh-bridge 2.10.8 → 2.10.9
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 +12 -0
- package/README.en.md +27 -0
- package/README.md +27 -0
- package/docs/telegram-usage.md +1 -1
- package/lib/auth/dsh-native-cookie.js +148 -0
- package/lib/bridge-rpc.js +9 -2
- package/lib/compat.js +129 -129
- package/lib/connection-compat.js +115 -0
- package/lib/feishu/index.js +225 -225
- package/lib/feishu/node.js +439 -439
- package/lib/index.js +5 -26
- package/lib/platform/base.js +147 -147
- package/lib/platform/commands.js +221 -221
- package/lib/platform/conversation-bridge.js +821 -821
- package/lib/platform/dsh-storage.js +117 -117
- package/lib/platform/index.js +10 -10
- package/lib/platform/message-split.js +229 -229
- package/lib/platform/session-catalog.js +372 -372
- package/lib/platform/stream-slices.js +21 -21
- package/lib/qq/index.js +312 -312
- package/lib/telegram/index.js +216 -216
- package/lib/telegram/node.js +322 -322
- package/lib/wechat/gateway.js +986 -986
- package/lib/wechat/index.js +244 -244
- package/lib/wechat/media.js +285 -285
- package/lib/wechat/node.js +352 -352
- package/package.json +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 运行时兼容垫片:DSH ≥ 0.1.5-alpha.1 的 connection RPC 通道注册回归
|
|
2
|
+
// (lib/connection-compat.js)
|
|
3
|
+
//
|
|
4
|
+
// 背景(上游回归,非本插件声明问题)
|
|
5
|
+
// ---------------------------------------------------------------
|
|
6
|
+
// @deepseek-ai/dsh-client-connection 在 0.1.5-alpha.1 改了插件级服务声明:
|
|
7
|
+
// ≤ 0.1.3-alpha.2 const inject = ["webServer", "credentials"];
|
|
8
|
+
// ≥ 0.1.5-alpha.1 const inject = ["credentials"]; // webServer 改为可选
|
|
9
|
+
// 同时把 /api 路由改用作用域注入 ctx.inject(["webServer"], (webCtx) => …)。
|
|
10
|
+
// 但 HostConnectionService.register() 里那一句没有同步改(各版本行号均为 618):
|
|
11
|
+
// return owner.effect(() => owner.webServer.register(route),
|
|
12
|
+
// `client-connection: ${channel} rpc channel`);
|
|
13
|
+
// 其中 owner === this.ctx,即 connection 插件自己的 ctx。该 ctx 的 fiber 不再把
|
|
14
|
+
// webServer 记入 inject,于是 cordis 4 的服务守卫直接抛:
|
|
15
|
+
// cannot get property "webServer" without inject
|
|
16
|
+
// 注册发生在插件树加载期,异常向上冒泡 → 整棵插件树加载失败,`dsh web` 起不来:
|
|
17
|
+
// Error: dsh: plugin tree failed to load: failed to apply loader entry
|
|
18
|
+
// dsh-bridge (@wenbin_wb/dsh-bridge): cannot get property "webServer" without inject
|
|
19
|
+
//
|
|
20
|
+
// 影响面:任何调用 ctx.connection.rpc.handle() 的插件都会踩中(dsh-bridge 只是第一个撞上的)。
|
|
21
|
+
// 已逐一核对 npm 上的版本:0.1.5-alpha.1 / alpha.2 / rc.1 / rc.2 全部未修
|
|
22
|
+
// (0.1.5-rc.2 为当前已发布的最新版),0.1.3-alpha.2 及更早正常。
|
|
23
|
+
//
|
|
24
|
+
// 本垫片的做法
|
|
25
|
+
// ---------------------------------------------------------------
|
|
26
|
+
// 不改上游代码、不猜内部私有方法,只走两个公开面:
|
|
27
|
+
// 1. 正常路径:ctx.connection.rpc.handle(channel, handler)(公开 API,未来上游修好即恢复);
|
|
28
|
+
// 2. 兜底路径:仅当该调用抛出 "webServer … without inject" 时,在 root 的
|
|
29
|
+
// `internal/get` 瀑布事件上临时挂一个监听器 —— 先让默认解析跑,默认解析失败
|
|
30
|
+
// (抛出的正是同一个 error 对象)才补上本插件已通过 inject 拿到的 webServer 引用。
|
|
31
|
+
// webServer 是单例服务,语义等价;默认解析成功时零干预。
|
|
32
|
+
// 监听器只在这一次同步注册调用期间存在,返回后立即摘除(cordis 的 effect 同步执行,
|
|
33
|
+
// 注册完成后 owner.webServer 不再被访问),因此对宿主其余部分完全无副作用。
|
|
34
|
+
//
|
|
35
|
+
// 上游修好后本模块自动空转:首次调用不再抛错,兜底分支根本不会进入。
|
|
36
|
+
|
|
37
|
+
const MISSING_INJECT = /without inject/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 判断错误是否为"跨服务取属性缺 inject 作用域"的守卫错误,且对象是 webServer。
|
|
41
|
+
* @param {unknown} err 捕获到的异常
|
|
42
|
+
* @returns {boolean} 是否命中该回归
|
|
43
|
+
*/
|
|
44
|
+
export function isMissingWebServerInjectError(err) {
|
|
45
|
+
if (!(err instanceof Error)) return false;
|
|
46
|
+
return MISSING_INJECT.test(err.message) && /webServer/.test(err.message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 在 root 的 `internal/get` 瀑布事件上挂一个临时兜底:默认解析失败时提供 webServer。
|
|
51
|
+
*
|
|
52
|
+
* cordis 4 的 `ctx.events` 是所有子 ctx 共享的同一个实例,且 `internal/get` 派发时
|
|
53
|
+
* 不带 context 过滤条件(thisArg 为 null),因此在本插件 ctx 上注册的监听器同样会对
|
|
54
|
+
* connection 插件 ctx 的查找生效。监听器由 root fiber 持有,须手工摘除。
|
|
55
|
+
*
|
|
56
|
+
* @param {object} ctx 本插件的 ctx
|
|
57
|
+
* @returns {() => void} 摘除函数
|
|
58
|
+
*/
|
|
59
|
+
export function installWebServerResolutionFallback(ctx) {
|
|
60
|
+
const webServer = ctx?.webServer;
|
|
61
|
+
if (webServer === undefined) return () => {};
|
|
62
|
+
|
|
63
|
+
const listener = function (lookupCtx, prop, error, next) {
|
|
64
|
+
if (prop !== 'webServer') return next();
|
|
65
|
+
try {
|
|
66
|
+
return next();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// 默认解析抛的不是本次守卫错误 → 原样上抛,绝不掩盖真实故障
|
|
69
|
+
if (err !== error) throw err;
|
|
70
|
+
}
|
|
71
|
+
// 默认解析确认失败:补上本插件已 inject 的单例引用(语义等价)
|
|
72
|
+
return webServer;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const off = ctx.on?.('internal/get', listener, { global: true });
|
|
76
|
+
return typeof off === 'function' ? off : () => {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 注册 connection RPC 通道,并兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归。
|
|
81
|
+
*
|
|
82
|
+
* @param {object} ctx 本插件的 ctx(须已 inject `connection` 与 `webServer`)
|
|
83
|
+
* @param {string} channel RPC 通道名(如 `/dsh-bridge`)
|
|
84
|
+
* @param {Function} handler 通道处理器
|
|
85
|
+
* @param {object} [rpcOptions] 透传给 connection 的通道选项。
|
|
86
|
+
* DSH 0.1.0/0.1.1 的 `rpc.handle(channel, handler, options)` 支持
|
|
87
|
+
* `{ authority: 'loopback' }`(仅回环可达,非回环请求 403);
|
|
88
|
+
* 0.1.2-rc.1 起该第三参数已被移除(更高版本忽略它,传入无副作用)。
|
|
89
|
+
* 本插件依赖它来做回环加固,因此必须原样透传、不得丢失。
|
|
90
|
+
* @param {{warn?: Function}} [logger] 可选日志器
|
|
91
|
+
* @returns {Function} 由 connection 服务返回的处置函数
|
|
92
|
+
*/
|
|
93
|
+
export function registerRpcChannel(ctx, channel, handler, rpcOptions, logger) {
|
|
94
|
+
try {
|
|
95
|
+
return ctx.connection.rpc.handle(channel, handler, rpcOptions);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (!isMissingWebServerInjectError(err)) throw err;
|
|
98
|
+
|
|
99
|
+
// 上游回归:owner(connection 插件自己的 ctx)解析不到 webServer。
|
|
100
|
+
// 该调用在 owner.webServer 处、于 webServer.register() 之前就抛,未产生任何副作用,
|
|
101
|
+
// 因此可以安全地补上兜底后重试一次。
|
|
102
|
+
const dispose = installWebServerResolutionFallback(ctx);
|
|
103
|
+
try {
|
|
104
|
+
const registered = ctx.connection.rpc.handle(channel, handler, rpcOptions);
|
|
105
|
+
logger?.warn?.(
|
|
106
|
+
'dsh-bridge: 检测到宿主 DSH 的 connection RPC 注册回归(cannot get property "webServer" without inject,'
|
|
107
|
+
+ '见 dsh-client-connection 的 register());已用 webServer 解析兜底完成通道注册。'
|
|
108
|
+
+ '此为宿主侧问题(DSH 0.1.5-alpha.1 起),插件侧垫片会在上游修复后自动空转。'
|
|
109
|
+
);
|
|
110
|
+
return registered;
|
|
111
|
+
} finally {
|
|
112
|
+
dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
package/lib/feishu/index.js
CHANGED
|
@@ -1,225 +1,225 @@
|
|
|
1
|
-
// dsh-bridge Feishu / Lark platform adapter
|
|
2
|
-
// 编排 FeishuGateway(官方 OpenAPI/WSClient 网关)+ FeishuConversationNode(飞书⇄DSH 会话桥)。
|
|
3
|
-
// 作为 Platform 子类,注册进 PlatformManager 统一管理。
|
|
4
|
-
|
|
5
|
-
import QRCode from 'qrcode'
|
|
6
|
-
import { Platform } from '../platform/base.js'
|
|
7
|
-
import { FeishuGateway } from './gateway.js'
|
|
8
|
-
import { FeishuConversationNode } from './node.js'
|
|
9
|
-
|
|
10
|
-
export class FeishuService extends Platform {
|
|
11
|
-
/**
|
|
12
|
-
* @param {object} opts
|
|
13
|
-
* @param {object} opts.ctx Cordis 上下文
|
|
14
|
-
* @param {object} opts.logger 日志器
|
|
15
|
-
* @param {object} [opts.config] 已持久化的 feishu 配置(凭证 + allowFrom + 间隔)
|
|
16
|
-
* @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
|
|
17
|
-
*/
|
|
18
|
-
constructor({ ctx, logger, config = {}, onPersist }) {
|
|
19
|
-
super({ ctx, logger, config, onPersist })
|
|
20
|
-
this.id = 'feishu'
|
|
21
|
-
this.name = 'Feishu'
|
|
22
|
-
this._botQrCache = { appId: '', qr: '' }
|
|
23
|
-
|
|
24
|
-
this.gateway = new FeishuGateway(ctx, {
|
|
25
|
-
appId: config.appId ?? '',
|
|
26
|
-
appSecret: config.appSecret ?? '',
|
|
27
|
-
domain: config.domain ?? 'feishu',
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
// 挂到 ctx 供会话节点读取
|
|
31
|
-
try { ctx.feishu = this.gateway } catch { /* 挂载失败不致命 */ }
|
|
32
|
-
|
|
33
|
-
this.node = new FeishuConversationNode(ctx, {
|
|
34
|
-
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
35
|
-
groupAutoApprove: config.groupAutoApprove,
|
|
36
|
-
digestIntervalSec: config.digestIntervalSec,
|
|
37
|
-
approvalTimeoutSec: config.approvalTimeoutSec,
|
|
38
|
-
maxMessageChars: config.maxMessageChars || 2000,
|
|
39
|
-
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
40
|
-
activeSessionId: config.activeSessionId,
|
|
41
|
-
}, logger, {
|
|
42
|
-
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
43
|
-
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
|
|
44
|
-
})
|
|
45
|
-
this.bridge = this.node
|
|
46
|
-
|
|
47
|
-
if (this.gateway.configured) {
|
|
48
|
-
void this.start().catch((err) => {
|
|
49
|
-
this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
|
|
50
|
-
})
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---- Platform 接口 ----
|
|
55
|
-
|
|
56
|
-
get configured() { return this.gateway.configured }
|
|
57
|
-
get accountId() { return this.gateway.botInfo?.openId || '' }
|
|
58
|
-
|
|
59
|
-
get capabilities() {
|
|
60
|
-
return {
|
|
61
|
-
group: true,
|
|
62
|
-
media: true,
|
|
63
|
-
approvals: true,
|
|
64
|
-
maxMessageChars: this.node.config.maxMessageChars || 2000,
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async sendText(peerId, text, opts = {}) {
|
|
69
|
-
return this.gateway.sendMarkdownCard(peerId, text, opts)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async sendTyping(peerId, opts = {}) {
|
|
73
|
-
return this.gateway.sendTyping?.(peerId, opts)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ---- 生命周期控制 ----
|
|
77
|
-
|
|
78
|
-
async start() {
|
|
79
|
-
if (!this.gateway.configured) {
|
|
80
|
-
this.setStatus('idle')
|
|
81
|
-
return { success: false, error: 'App ID 与 App Secret 未配置' }
|
|
82
|
-
}
|
|
83
|
-
this.setStatus('starting')
|
|
84
|
-
const ok = await this.gateway.start()
|
|
85
|
-
if (ok) {
|
|
86
|
-
this.setStatus('connected')
|
|
87
|
-
return { success: true }
|
|
88
|
-
} else {
|
|
89
|
-
this.setStatus('error', this.gateway.error || '连接失败')
|
|
90
|
-
return { success: false, error: this.gateway.error }
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async stop() {
|
|
95
|
-
await this.gateway.stop()
|
|
96
|
-
this.setStatus('offline')
|
|
97
|
-
return { success: true }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 配置或登录
|
|
102
|
-
* @param {object} creds - { appId, appSecret, domain }
|
|
103
|
-
*/
|
|
104
|
-
async login(creds = {}) {
|
|
105
|
-
const patch = {}
|
|
106
|
-
if (creds.appId !== undefined) patch.appId = String(creds.appId).trim()
|
|
107
|
-
if (creds.appSecret !== undefined) patch.appSecret = String(creds.appSecret).trim()
|
|
108
|
-
if (creds.domain !== undefined) patch.domain = creds.domain === 'lark' ? 'lark' : 'feishu'
|
|
109
|
-
|
|
110
|
-
this.gateway.updateConfig(patch)
|
|
111
|
-
this.persist(patch)
|
|
112
|
-
|
|
113
|
-
if (!this.gateway.configured) {
|
|
114
|
-
await this.stop()
|
|
115
|
-
return { success: false, error: '请填写完整的 App ID 和 App Secret' }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return this.start()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async unbind() {
|
|
122
|
-
await this.stop()
|
|
123
|
-
this.gateway.updateConfig({ appId: '', appSecret: '' })
|
|
124
|
-
this.persist({ appId: '', appSecret: '', allowFrom: [] })
|
|
125
|
-
if (this.node) this.node.config.allowFrom = []
|
|
126
|
-
return { success: true }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
getStatus() {
|
|
130
|
-
const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
|
|
131
|
-
const appId = this.gateway.config.appId || ''
|
|
132
|
-
const botLink = appId ? `https://applink.feishu.cn/client/bot/open?appId=${encodeURIComponent(appId)}` : null
|
|
133
|
-
if (botLink && this._botQrCache.appId !== appId) {
|
|
134
|
-
this._botQrCache.appId = appId
|
|
135
|
-
void QRCode.toDataURL(botLink, {
|
|
136
|
-
width: 260,
|
|
137
|
-
margin: 2,
|
|
138
|
-
color: { dark: '#1F2421', light: '#FFFFFF' },
|
|
139
|
-
}).then((qr) => {
|
|
140
|
-
this._botQrCache.qr = qr
|
|
141
|
-
}).catch(() => {})
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
id: this.id,
|
|
145
|
-
name: this.name,
|
|
146
|
-
status: this.status === 'connected' ? 'connected' : this.gateway.status,
|
|
147
|
-
configured: this.gateway.configured,
|
|
148
|
-
accountId: this.gateway.botInfo?.appName
|
|
149
|
-
? `${this.gateway.botInfo.appName} (${this.gateway.botInfo.openId || this.gateway.config.appId})`
|
|
150
|
-
: (this.gateway.botInfo?.openId || this.gateway.config.appId || ''),
|
|
151
|
-
allowFrom,
|
|
152
|
-
peerId: this.node?.peerId,
|
|
153
|
-
sessionId: this.node?.activeSessionId,
|
|
154
|
-
login: {
|
|
155
|
-
phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
|
|
156
|
-
error: this.gateway.error,
|
|
157
|
-
},
|
|
158
|
-
capabilities: { ...this.capabilities },
|
|
159
|
-
config: {
|
|
160
|
-
digestIntervalSec: this.node?.config?.digestIntervalSec,
|
|
161
|
-
approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
|
|
162
|
-
maxMessageChars: this.node?.config?.maxMessageChars,
|
|
163
|
-
sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
|
|
164
|
-
appId: this.gateway.config.appId,
|
|
165
|
-
appSecret: '',
|
|
166
|
-
domain: this.gateway.config.domain,
|
|
167
|
-
},
|
|
168
|
-
botInfo: this.gateway.botInfo,
|
|
169
|
-
botLink,
|
|
170
|
-
botQr: this._botQrCache.qr,
|
|
171
|
-
error: this.gateway.error,
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
setAllowFrom(allowFrom) {
|
|
176
|
-
const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
|
|
177
|
-
if (this.node) this.node.config.allowFrom = list
|
|
178
|
-
this.persist({ allowFrom: list })
|
|
179
|
-
return { success: true, allowFrom: list }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, appId, appSecret, domain } = {}) {
|
|
183
|
-
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
184
|
-
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
185
|
-
if (maxMessageChars != null) {
|
|
186
|
-
const val = Number(maxMessageChars)
|
|
187
|
-
this.node.config.maxMessageChars = (val >= 200) ? val : 2000
|
|
188
|
-
}
|
|
189
|
-
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
190
|
-
if (groupAutoApprove != null) this.node.config.groupAutoApprove = groupAutoApprove === true
|
|
191
|
-
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
192
|
-
this.gateway.updateConfig({
|
|
193
|
-
appId: appId !== undefined ? appId.trim() : this.gateway.config.appId,
|
|
194
|
-
appSecret: appSecret?.trim() ? appSecret.trim() : this.gateway.config.appSecret,
|
|
195
|
-
domain: domain || this.gateway.config.domain || 'feishu',
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
const patch = {
|
|
199
|
-
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
200
|
-
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
201
|
-
maxMessageChars: this.node.config.maxMessageChars,
|
|
202
|
-
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
203
|
-
groupAutoApprove: this.node.config.groupAutoApprove === true,
|
|
204
|
-
}
|
|
205
|
-
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
206
|
-
patch.appId = this.gateway.config.appId
|
|
207
|
-
patch.appSecret = this.gateway.config.appSecret
|
|
208
|
-
patch.domain = this.gateway.config.domain
|
|
209
|
-
}
|
|
210
|
-
await this.persist(patch)
|
|
211
|
-
|
|
212
|
-
// 凭证配置完成后自动启动网关(前端「保存并连接」自动连接)
|
|
213
|
-
if (this.gateway.configured && (appId !== undefined || appSecret !== undefined)) {
|
|
214
|
-
await this.start().catch((err) => {
|
|
215
|
-
this.logger?.warn?.('[dsh-bridge feishu] auto-start failed: %s', err?.message ?? err)
|
|
216
|
-
})
|
|
217
|
-
}
|
|
218
|
-
return { success: true }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
dispose() {
|
|
222
|
-
super.dispose()
|
|
223
|
-
this.gateway?.dispose?.()
|
|
224
|
-
}
|
|
225
|
-
}
|
|
1
|
+
// dsh-bridge Feishu / Lark platform adapter
|
|
2
|
+
// 编排 FeishuGateway(官方 OpenAPI/WSClient 网关)+ FeishuConversationNode(飞书⇄DSH 会话桥)。
|
|
3
|
+
// 作为 Platform 子类,注册进 PlatformManager 统一管理。
|
|
4
|
+
|
|
5
|
+
import QRCode from 'qrcode'
|
|
6
|
+
import { Platform } from '../platform/base.js'
|
|
7
|
+
import { FeishuGateway } from './gateway.js'
|
|
8
|
+
import { FeishuConversationNode } from './node.js'
|
|
9
|
+
|
|
10
|
+
export class FeishuService extends Platform {
|
|
11
|
+
/**
|
|
12
|
+
* @param {object} opts
|
|
13
|
+
* @param {object} opts.ctx Cordis 上下文
|
|
14
|
+
* @param {object} opts.logger 日志器
|
|
15
|
+
* @param {object} [opts.config] 已持久化的 feishu 配置(凭证 + allowFrom + 间隔)
|
|
16
|
+
* @param {(patch: object) => (void|Promise<void>)} opts.onPersist 主插件保存回调
|
|
17
|
+
*/
|
|
18
|
+
constructor({ ctx, logger, config = {}, onPersist }) {
|
|
19
|
+
super({ ctx, logger, config, onPersist })
|
|
20
|
+
this.id = 'feishu'
|
|
21
|
+
this.name = 'Feishu'
|
|
22
|
+
this._botQrCache = { appId: '', qr: '' }
|
|
23
|
+
|
|
24
|
+
this.gateway = new FeishuGateway(ctx, {
|
|
25
|
+
appId: config.appId ?? '',
|
|
26
|
+
appSecret: config.appSecret ?? '',
|
|
27
|
+
domain: config.domain ?? 'feishu',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// 挂到 ctx 供会话节点读取
|
|
31
|
+
try { ctx.feishu = this.gateway } catch { /* 挂载失败不致命 */ }
|
|
32
|
+
|
|
33
|
+
this.node = new FeishuConversationNode(ctx, {
|
|
34
|
+
allowFrom: Array.isArray(config.allowFrom) ? config.allowFrom : [],
|
|
35
|
+
groupAutoApprove: config.groupAutoApprove,
|
|
36
|
+
digestIntervalSec: config.digestIntervalSec,
|
|
37
|
+
approvalTimeoutSec: config.approvalTimeoutSec,
|
|
38
|
+
maxMessageChars: config.maxMessageChars || 2000,
|
|
39
|
+
sendChunkDelayMs: config.sendChunkDelayMs,
|
|
40
|
+
activeSessionId: config.activeSessionId,
|
|
41
|
+
}, logger, {
|
|
42
|
+
onFirstSender: () => this.persist({ allowFrom: [...(this.node?.config?.allowFrom ?? [])] }),
|
|
43
|
+
onActiveSessionChange: (sessionId) => this.persist({ activeSessionId: sessionId }),
|
|
44
|
+
})
|
|
45
|
+
this.bridge = this.node
|
|
46
|
+
|
|
47
|
+
if (this.gateway.configured) {
|
|
48
|
+
void this.start().catch((err) => {
|
|
49
|
+
this.logger.error?.('[dsh-bridge feishu] start failed:', err?.message ?? err)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Platform 接口 ----
|
|
55
|
+
|
|
56
|
+
get configured() { return this.gateway.configured }
|
|
57
|
+
get accountId() { return this.gateway.botInfo?.openId || '' }
|
|
58
|
+
|
|
59
|
+
get capabilities() {
|
|
60
|
+
return {
|
|
61
|
+
group: true,
|
|
62
|
+
media: true,
|
|
63
|
+
approvals: true,
|
|
64
|
+
maxMessageChars: this.node.config.maxMessageChars || 2000,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async sendText(peerId, text, opts = {}) {
|
|
69
|
+
return this.gateway.sendMarkdownCard(peerId, text, opts)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sendTyping(peerId, opts = {}) {
|
|
73
|
+
return this.gateway.sendTyping?.(peerId, opts)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- 生命周期控制 ----
|
|
77
|
+
|
|
78
|
+
async start() {
|
|
79
|
+
if (!this.gateway.configured) {
|
|
80
|
+
this.setStatus('idle')
|
|
81
|
+
return { success: false, error: 'App ID 与 App Secret 未配置' }
|
|
82
|
+
}
|
|
83
|
+
this.setStatus('starting')
|
|
84
|
+
const ok = await this.gateway.start()
|
|
85
|
+
if (ok) {
|
|
86
|
+
this.setStatus('connected')
|
|
87
|
+
return { success: true }
|
|
88
|
+
} else {
|
|
89
|
+
this.setStatus('error', this.gateway.error || '连接失败')
|
|
90
|
+
return { success: false, error: this.gateway.error }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async stop() {
|
|
95
|
+
await this.gateway.stop()
|
|
96
|
+
this.setStatus('offline')
|
|
97
|
+
return { success: true }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 配置或登录
|
|
102
|
+
* @param {object} creds - { appId, appSecret, domain }
|
|
103
|
+
*/
|
|
104
|
+
async login(creds = {}) {
|
|
105
|
+
const patch = {}
|
|
106
|
+
if (creds.appId !== undefined) patch.appId = String(creds.appId).trim()
|
|
107
|
+
if (creds.appSecret !== undefined) patch.appSecret = String(creds.appSecret).trim()
|
|
108
|
+
if (creds.domain !== undefined) patch.domain = creds.domain === 'lark' ? 'lark' : 'feishu'
|
|
109
|
+
|
|
110
|
+
this.gateway.updateConfig(patch)
|
|
111
|
+
this.persist(patch)
|
|
112
|
+
|
|
113
|
+
if (!this.gateway.configured) {
|
|
114
|
+
await this.stop()
|
|
115
|
+
return { success: false, error: '请填写完整的 App ID 和 App Secret' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return this.start()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async unbind() {
|
|
122
|
+
await this.stop()
|
|
123
|
+
this.gateway.updateConfig({ appId: '', appSecret: '' })
|
|
124
|
+
this.persist({ appId: '', appSecret: '', allowFrom: [] })
|
|
125
|
+
if (this.node) this.node.config.allowFrom = []
|
|
126
|
+
return { success: true }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getStatus() {
|
|
130
|
+
const allowFrom = [...(this.node?.config?.allowFrom ?? [])]
|
|
131
|
+
const appId = this.gateway.config.appId || ''
|
|
132
|
+
const botLink = appId ? `https://applink.feishu.cn/client/bot/open?appId=${encodeURIComponent(appId)}` : null
|
|
133
|
+
if (botLink && this._botQrCache.appId !== appId) {
|
|
134
|
+
this._botQrCache.appId = appId
|
|
135
|
+
void QRCode.toDataURL(botLink, {
|
|
136
|
+
width: 260,
|
|
137
|
+
margin: 2,
|
|
138
|
+
color: { dark: '#1F2421', light: '#FFFFFF' },
|
|
139
|
+
}).then((qr) => {
|
|
140
|
+
this._botQrCache.qr = qr
|
|
141
|
+
}).catch(() => {})
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
id: this.id,
|
|
145
|
+
name: this.name,
|
|
146
|
+
status: this.status === 'connected' ? 'connected' : this.gateway.status,
|
|
147
|
+
configured: this.gateway.configured,
|
|
148
|
+
accountId: this.gateway.botInfo?.appName
|
|
149
|
+
? `${this.gateway.botInfo.appName} (${this.gateway.botInfo.openId || this.gateway.config.appId})`
|
|
150
|
+
: (this.gateway.botInfo?.openId || this.gateway.config.appId || ''),
|
|
151
|
+
allowFrom,
|
|
152
|
+
peerId: this.node?.peerId,
|
|
153
|
+
sessionId: this.node?.activeSessionId,
|
|
154
|
+
login: {
|
|
155
|
+
phase: this.status === 'connected' ? 'done' : this.status === 'error' ? 'error' : 'idle',
|
|
156
|
+
error: this.gateway.error,
|
|
157
|
+
},
|
|
158
|
+
capabilities: { ...this.capabilities },
|
|
159
|
+
config: {
|
|
160
|
+
digestIntervalSec: this.node?.config?.digestIntervalSec,
|
|
161
|
+
approvalTimeoutSec: this.node?.config?.approvalTimeoutSec,
|
|
162
|
+
maxMessageChars: this.node?.config?.maxMessageChars,
|
|
163
|
+
sendChunkDelayMs: this.node?.config?.sendChunkDelayMs,
|
|
164
|
+
appId: this.gateway.config.appId,
|
|
165
|
+
appSecret: '',
|
|
166
|
+
domain: this.gateway.config.domain,
|
|
167
|
+
},
|
|
168
|
+
botInfo: this.gateway.botInfo,
|
|
169
|
+
botLink,
|
|
170
|
+
botQr: this._botQrCache.qr,
|
|
171
|
+
error: this.gateway.error,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setAllowFrom(allowFrom) {
|
|
176
|
+
const list = Array.isArray(allowFrom) ? allowFrom.map((s) => String(s).trim()).filter(Boolean) : []
|
|
177
|
+
if (this.node) this.node.config.allowFrom = list
|
|
178
|
+
this.persist({ allowFrom: list })
|
|
179
|
+
return { success: true, allowFrom: list }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, groupAutoApprove, appId, appSecret, domain } = {}) {
|
|
183
|
+
if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
|
|
184
|
+
if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
|
|
185
|
+
if (maxMessageChars != null) {
|
|
186
|
+
const val = Number(maxMessageChars)
|
|
187
|
+
this.node.config.maxMessageChars = (val >= 200) ? val : 2000
|
|
188
|
+
}
|
|
189
|
+
if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
|
|
190
|
+
if (groupAutoApprove != null) this.node.config.groupAutoApprove = groupAutoApprove === true
|
|
191
|
+
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
192
|
+
this.gateway.updateConfig({
|
|
193
|
+
appId: appId !== undefined ? appId.trim() : this.gateway.config.appId,
|
|
194
|
+
appSecret: appSecret?.trim() ? appSecret.trim() : this.gateway.config.appSecret,
|
|
195
|
+
domain: domain || this.gateway.config.domain || 'feishu',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
const patch = {
|
|
199
|
+
digestIntervalSec: this.node.config.digestIntervalSec,
|
|
200
|
+
approvalTimeoutSec: this.node.config.approvalTimeoutSec,
|
|
201
|
+
maxMessageChars: this.node.config.maxMessageChars,
|
|
202
|
+
sendChunkDelayMs: this.node.config.sendChunkDelayMs,
|
|
203
|
+
groupAutoApprove: this.node.config.groupAutoApprove === true,
|
|
204
|
+
}
|
|
205
|
+
if (appId !== undefined || appSecret !== undefined || domain !== undefined) {
|
|
206
|
+
patch.appId = this.gateway.config.appId
|
|
207
|
+
patch.appSecret = this.gateway.config.appSecret
|
|
208
|
+
patch.domain = this.gateway.config.domain
|
|
209
|
+
}
|
|
210
|
+
await this.persist(patch)
|
|
211
|
+
|
|
212
|
+
// 凭证配置完成后自动启动网关(前端「保存并连接」自动连接)
|
|
213
|
+
if (this.gateway.configured && (appId !== undefined || appSecret !== undefined)) {
|
|
214
|
+
await this.start().catch((err) => {
|
|
215
|
+
this.logger?.warn?.('[dsh-bridge feishu] auto-start failed: %s', err?.message ?? err)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
return { success: true }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
dispose() {
|
|
222
|
+
super.dispose()
|
|
223
|
+
this.gateway?.dispose?.()
|
|
224
|
+
}
|
|
225
|
+
}
|