@wenbin_wb/dsh-bridge 2.8.6 → 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,156 +1,147 @@
1
- // dsh-bridge 平台抽象基类
2
- //
3
- // 定义 IM 平台适配器的统一接口。每个平台(微信/QQ/飞书/Telegram…)继承本类,
4
- // 实现协议层(登录、收发消息、typing)。平台无关的会话桥逻辑在 ConversationBridge
5
- // (lib/platform/conversation-bridge.js)中实现,通过 platform 注入到 bridge。
6
- //
7
- // 生命周期:constructor → start() → stop() → dispose()
8
- // 消息抽象:sendText / sendTyping / sendMedia(由子类实现)
9
-
10
- export class Platform {
11
- /**
12
- * @param {object} opts
13
- * @param {object} opts.ctx Cordis 上下文
14
- * @param {object} opts.logger 日志器
15
- * @param {object} [opts.config] 已持久化的平台配置(凭证等)
16
- * @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 主插件保存回调
17
- * @param {import('./conversation-bridge.js').ConversationBridge} [opts.bridge] 会话桥实例
18
- */
19
- constructor({ ctx, logger, config = {}, onPersist, bridge } = {}) {
20
- this.ctx = ctx
21
- this.logger = logger
22
- this.config = { ...config }
23
- this.onPersist = onPersist ?? (() => {})
24
- this.bridge = bridge ?? null
25
-
26
- // 平台标识(子类必须设置)
27
- this.id = ''
28
- this.name = ''
29
-
30
- // 连接状态与账号:子类可能用 getter 覆盖(如委托给 gateway),
31
- // 因此仅在未被子类覆盖时才初始化默认值。
32
- if (!('status' in this)) this.status = 'idle'
33
- if (!('accountId' in this)) this.accountId = null
34
-
35
- // 扫码/登录的流式状态(RPC 轮询读取)
36
- this.loginState = {
37
- phase: 'idle', // idle | qr | scaned | confirmed | done | error
38
- qrPayload: null, // 待渲染内容:dataURL 图片 或 二维码文本
39
- qrKind: null, // 'img' | 'text'
40
- error: null,
41
- }
42
-
43
- this.disposers = []
44
- }
45
-
46
- // ---- 平台能力声明(子类可覆盖)----
47
-
48
- get capabilities() {
49
- return {
50
- supportsGroup: false, // 是否支持群聊
51
- supportsMedia: false, // 是否支持媒体收发
52
- supportsVoice: false, // 是否支持语音
53
- supportsTyping: false, // 是否支持 typing 状态
54
- maxMessageChars: 2000, // 单条消息最大字符数
55
- }
56
- }
57
-
58
- get configured() {
59
- return false
60
- }
61
-
62
- // ---- 生命周期(子类必须实现 start/stop;dispose 已提供默认实现)----
63
-
64
- async start() {
65
- throw new Error(`${this.id || 'platform'}: start() not implemented`)
66
- }
67
-
68
- async stop() {
69
- throw new Error(`${this.id || 'platform'}: stop() not implemented`)
70
- }
71
-
72
- dispose() {
73
- for (const disposer of this.disposers) {
74
- try { disposer() } catch { /* 忽略 */ }
75
- }
76
- this.disposers = []
77
- this.bridge?.dispose?.()
78
- this.bridge = null
79
- }
80
-
81
- // ---- 消息抽象(子类必须实现)----
82
-
83
- async sendText(peerId, text, opts = {}) {
84
- throw new Error(`${this.id || 'platform'}: sendText() not implemented`)
85
- }
86
-
87
- async sendTyping(peerId, state) {
88
- return Promise.resolve()
89
- }
90
-
91
- async sendMedia(peerId, media, opts = {}) {
92
- throw new Error(`${this.id || 'platform'}: sendMedia() not implemented`)
93
- }
94
-
95
- // ---- 登录(子类必须实现 login;getLoginState 已提供默认)----
96
-
97
- async login(opts = {}) {
98
- throw new Error(`${this.id || 'platform'}: login() not implemented`)
99
- }
100
-
101
- getLoginState() {
102
- return { ...this.loginState }
103
- }
104
-
105
- // ---- 状态汇总(供 RPC/UI 读取)----
106
-
107
- getStatus() {
108
- return {
109
- id: this.id,
110
- name: this.name,
111
- status: this.status,
112
- configured: this.configured,
113
- accountId: this.accountId,
114
- login: this.getLoginState(),
115
- peerId: this.bridge?.peerId ?? null,
116
- sessionId: this.bridge?.activeSessionId ?? null,
117
- config: this.getEditableConfig?.(),
118
- }
119
- }
120
-
121
- /** 可编辑配置(供 UI 设置面板读取);子类可覆盖返回具体字段。 */
122
- getEditableConfig() {
123
- return {}
124
- }
125
-
126
- // ---- 工具 ----
127
-
128
- setStatus(status) {
129
- if (this.status === status) return
130
- this.status = status
131
- try {
132
- this.ctx.emit?.(`${this.id}/status`, status)
133
- } catch { /* emit 失败不致命 */ }
134
- }
135
-
136
- async persist(patch) {
137
- try {
138
- await this.onPersist(patch)
139
- } catch (err) {
140
- this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
141
- }
142
- }
143
-
144
- dispose() {
145
- for (const d of this.disposers) {
146
- try { d() } catch {}
147
- }
148
- this.disposers = []
149
- this.bridge?.dispose?.()
150
- this.bridge = null
151
- }
152
-
153
- async destroy() {
154
- this.dispose()
155
- }
156
- }
1
+ // dsh-bridge 平台抽象基类
2
+ //
3
+ // 定义 IM 平台适配器的统一接口。每个平台(微信/QQ/飞书/Telegram…)继承本类,
4
+ // 实现协议层(登录、收发消息、typing)。平台无关的会话桥逻辑在 ConversationBridge
5
+ // (lib/platform/conversation-bridge.js)中实现,通过 platform 注入到 bridge。
6
+ //
7
+ // 生命周期:constructor → start() → stop() → dispose()
8
+ // 消息抽象:sendText / sendTyping / sendMedia(由子类实现)
9
+
10
+ export class Platform {
11
+ /**
12
+ * @param {object} opts
13
+ * @param {object} opts.ctx Cordis 上下文
14
+ * @param {object} opts.logger 日志器
15
+ * @param {object} [opts.config] 已持久化的平台配置(凭证等)
16
+ * @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 主插件保存回调
17
+ * @param {import('./conversation-bridge.js').ConversationBridge} [opts.bridge] 会话桥实例
18
+ */
19
+ constructor({ ctx, logger, config = {}, onPersist, bridge } = {}) {
20
+ this.ctx = ctx
21
+ this.logger = logger
22
+ this.config = { ...config }
23
+ this.onPersist = onPersist ?? (() => {})
24
+ this.bridge = bridge ?? null
25
+
26
+ // 平台标识(子类必须设置)
27
+ this.id = ''
28
+ this.name = ''
29
+
30
+ // 连接状态与账号:子类可能用 getter 覆盖(如委托给 gateway),
31
+ // 因此仅在未被子类覆盖时才初始化默认值。
32
+ if (!('status' in this)) this.status = 'idle'
33
+ if (!('accountId' in this)) this.accountId = null
34
+
35
+ // 扫码/登录的流式状态(RPC 轮询读取)
36
+ this.loginState = {
37
+ phase: 'idle', // idle | qr | scaned | confirmed | done | error
38
+ qrPayload: null, // 待渲染内容:dataURL 图片 或 二维码文本
39
+ qrKind: null, // 'img' | 'text'
40
+ error: null,
41
+ }
42
+
43
+ this.disposers = []
44
+ }
45
+
46
+ // ---- 平台能力声明(子类可覆盖)----
47
+
48
+ get capabilities() {
49
+ return {
50
+ supportsGroup: false, // 是否支持群聊
51
+ supportsMedia: false, // 是否支持媒体收发
52
+ supportsVoice: false, // 是否支持语音
53
+ supportsTyping: false, // 是否支持 typing 状态
54
+ maxMessageChars: 2000, // 单条消息最大字符数
55
+ }
56
+ }
57
+
58
+ get configured() {
59
+ return false
60
+ }
61
+
62
+ // ---- 生命周期(子类必须实现 start/stop;dispose 已提供默认实现)----
63
+
64
+ async start() {
65
+ throw new Error(`${this.id || 'platform'}: start() not implemented`)
66
+ }
67
+
68
+ async stop() {
69
+ throw new Error(`${this.id || 'platform'}: stop() not implemented`)
70
+ }
71
+
72
+ dispose() {
73
+ for (const disposer of this.disposers) {
74
+ try { disposer() } catch { /* 忽略 */ }
75
+ }
76
+ this.disposers = []
77
+ this.bridge?.dispose?.()
78
+ this.bridge = null
79
+ }
80
+
81
+ // ---- 消息抽象(子类必须实现)----
82
+
83
+ async sendText(peerId, text, opts = {}) {
84
+ throw new Error(`${this.id || 'platform'}: sendText() not implemented`)
85
+ }
86
+
87
+ async sendTyping(peerId, state) {
88
+ return Promise.resolve()
89
+ }
90
+
91
+ async sendMedia(peerId, media, opts = {}) {
92
+ throw new Error(`${this.id || 'platform'}: sendMedia() not implemented`)
93
+ }
94
+
95
+ // ---- 登录(子类必须实现 login;getLoginState 已提供默认)----
96
+
97
+ async login(opts = {}) {
98
+ throw new Error(`${this.id || 'platform'}: login() not implemented`)
99
+ }
100
+
101
+ getLoginState() {
102
+ return { ...this.loginState }
103
+ }
104
+
105
+ // ---- 状态汇总(供 RPC/UI 读取)----
106
+
107
+ getStatus() {
108
+ return {
109
+ id: this.id,
110
+ name: this.name,
111
+ status: this.status,
112
+ configured: this.configured,
113
+ accountId: this.accountId,
114
+ login: this.getLoginState(),
115
+ peerId: this.bridge?.peerId ?? null,
116
+ sessionId: this.bridge?.activeSessionId ?? null,
117
+ config: this.getEditableConfig?.(),
118
+ }
119
+ }
120
+
121
+ /** 可编辑配置(供 UI 设置面板读取);子类可覆盖返回具体字段。 */
122
+ getEditableConfig() {
123
+ return {}
124
+ }
125
+
126
+ // ---- 工具 ----
127
+
128
+ setStatus(status) {
129
+ if (this.status === status) return
130
+ this.status = status
131
+ try {
132
+ this.ctx.emit?.(`${this.id}/status`, status)
133
+ } catch { /* emit 失败不致命 */ }
134
+ }
135
+
136
+ async persist(patch) {
137
+ try {
138
+ await this.onPersist(patch)
139
+ } catch (err) {
140
+ this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
141
+ }
142
+ }
143
+
144
+ async destroy() {
145
+ this.dispose()
146
+ }
147
+ }
@@ -0,0 +1,221 @@
1
+ // 斜杠命令解释器(/sessions /use /new /workspaces /status /help …)
2
+ // 自 conversation-bridge.js 拆出。routeCommand(node, text, senderId) 只通过 node 参数
3
+ // 访问会话桥能力,目录/渲染逻辑来自 session-catalog.js。
4
+ import {
5
+ listSessions, listWorkspaces, validateWorkspacePath, renderSessions,
6
+ sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId,
7
+ sessionLabel, getWorkspaceBasename,
8
+ } from './session-catalog.js'
9
+ import { isSafeWorkspacePath } from '../security/path-validator.js'
10
+ import { basename, normalize } from 'node:path'
11
+
12
+ export async function routeCommand(node, text, senderId = null) {
13
+ const trimmed = text.trim()
14
+
15
+ if (trimmed === '/yes' || trimmed === '/no' || /^[12]$/.test(trimmed)) {
16
+ if (node.resolveApproval(trimmed, senderId)) return true
17
+ }
18
+
19
+ if (!trimmed.startsWith('/')) return false
20
+
21
+ const [command, ...rest] = trimmed.slice(1).split(/\s+/)
22
+ switch (command) {
23
+ case 'help':
24
+ await node.sendText(helpText())
25
+ return true
26
+ case 'sessions':
27
+ case 'list':
28
+ await node.sendText(await renderSessions(node))
29
+ return true
30
+ case 'use':
31
+ case 'resume': {
32
+ const index = Number(rest[0])
33
+ const sessions = sessionsInDisplayOrder(await listSessions(node))
34
+ if (!Number.isInteger(index) || index < 1 || index > sessions.length) {
35
+ await node.sendText(`❌ **无效会话编号**:\`${rest[0] ?? ''}\`\n\n> 可用编号范围:\`1 – ${sessions.length}\`(发送 \`/sessions\` 查看会话列表)`)
36
+ return true
37
+ }
38
+ const session = sessions[index - 1]
39
+ node.setActiveSessionById(session.id)
40
+ const title = session.title || (session.events ? sessionLabel(session) : '')
41
+ const titleLine = title ? `\n- **标题**:${title}` : ''
42
+ await node.sendText(`✓ **已切换到会话 #${index}**${titleLine}\n- **会话 ID**:\`${fmtSessionId(session.id)}\``)
43
+ return true
44
+ }
45
+ case 'rename': {
46
+ if (!node.activeSessionId) {
47
+ await node.sendText(`❌ **当前没有活动会话**\n\n> 请先使用 \`/sessions\` 查看会话列表并通过 \`/use 编号\` 切换到目标会话,或通过 \`/new <提示词>\` 创建新会话。`)
48
+ return true
49
+ }
50
+ const newTitle = rest.join(' ').trim()
51
+ if (!newTitle) {
52
+ await node.sendText(`❌ **缺少新标题参数**\n\n> 用法:\`/rename <新标题>\`\n> 示例:\`/rename 优化登录交互逻辑\``)
53
+ return true
54
+ }
55
+
56
+ try {
57
+ const session = node.activeSession()
58
+ if (session) {
59
+ session.title = newTitle
60
+ }
61
+ if (node.ctx.sessionPersistence?.update) {
62
+ await node.ctx.sessionPersistence.update(node.activeSessionId, { title: newTitle }).catch(() => {})
63
+ }
64
+ await node.sendText(`✓ **会话重命名成功**\n- **会话 ID**:\`${fmtSessionId(node.activeSessionId)}\`\n- **新标题**:${newTitle}`)
65
+ } catch (err) {
66
+ await node.sendText(`❌ **重命名失败**:${err instanceof Error ? err.message : String(err)}`)
67
+ }
68
+ return true
69
+ }
70
+ case 'workspaces': {
71
+ const workspaces = await listWorkspaces(node)
72
+ if (workspaces.length === 0) {
73
+ await node.sendText(`## 🗂️ 可用工作区\n\n> 当前没有已注册的工作区。可使用 \`/new <提示词> @<路径>\` 指定项目目录。`)
74
+ return true
75
+ }
76
+ const rows = workspaces.map((w, i) => {
77
+ const titleStr = w.title && w.title !== w.path ? w.title : getWorkspaceBasename(w.path)
78
+ const safeTitle = titleStr.replace(/\|/g, '|')
79
+ return `| **@${i + 1}** | ${safeTitle} | \`${w.path}\` |`
80
+ })
81
+ await node.sendText([
82
+ `## 🗂️ 可用工作区 (共 ${workspaces.length} 个)`,
83
+ `> 新建会话:发送 \`/new <提示词> @序号\` 或 \`/new <提示词> @路径\``,
84
+ '',
85
+ '| 序号 | 工作区名称 | 目录路径 |',
86
+ '| :--- | :--- | :--- |',
87
+ ...rows,
88
+ ].join('\n'))
89
+ return true
90
+ }
91
+ case 'addworkspace': {
92
+ const targetPath = rest.join(' ').trim()
93
+ if (!targetPath) {
94
+ await node.sendText(`❌ **缺少工作区路径**\n\n> 用法:\`/addworkspace <电脑绝对路径>\`\n> 示例:\`/addworkspace D:\\IdeaProjects\\my-app\``)
95
+ return true
96
+ }
97
+ try {
98
+ const safetyCheck = await isSafeWorkspacePath(targetPath)
99
+ if (!safetyCheck.valid) {
100
+ await node.sendText(`⚠️ **${safetyCheck.error || '路径安全校验未通过'}**:\`${targetPath}\`\n\n> 出于安全考虑,禁止将系统关键目录或敏感配置文件所在路径登记为工作区。`)
101
+ return true
102
+ }
103
+ const resolved = safetyCheck.path
104
+ const title = basename(resolved) || resolved
105
+ if (node.ctx.workspaceRegistry?.add) {
106
+ await node.ctx.workspaceRegistry.add({ path: resolved, title }).catch(() => {})
107
+ } else if (node.ctx.workspaceRegistry?.register) {
108
+ await node.ctx.workspaceRegistry.register({ path: resolved, title }).catch(() => {})
109
+ }
110
+ const workspaces = await listWorkspaces(node)
111
+ const foundIndex = workspaces.findIndex(w => normalize(w.path) === normalize(resolved))
112
+ const numStr = foundIndex >= 0 ? `@${foundIndex + 1}` : ''
113
+ await node.sendText([
114
+ `✓ **工作区添加成功**!`,
115
+ `- **名称**:${title}`,
116
+ `- **路径**:\`${resolved}\``,
117
+ foundIndex >= 0 ? `- **快捷编号**:\`${numStr}\`` : '',
118
+ '',
119
+ `> 发送 \`/new <提示词> ${numStr || '@' + resolved}\` 即可直接在此工作区创建会话。`,
120
+ ].filter(Boolean).join('\n'))
121
+ } catch (err) {
122
+ await node.sendText(`❌ **添加工作区失败**:${err instanceof Error ? err.message : String(err)}`)
123
+ }
124
+ return true
125
+ }
126
+ case 'new': {
127
+ // 解析尾部 @N 或 @路径 作为工作区 cwd
128
+ const args = rest.join(' ').trim()
129
+ let cwd
130
+ let prompt = args
131
+ const atMatch = args.match(/\s+@(\S+)$/)
132
+ if (atMatch) {
133
+ prompt = args.slice(0, atMatch.index).trim()
134
+ const sel = atMatch[1]
135
+ const workspaces = await listWorkspaces(node)
136
+ if (/^\d+$/.test(sel)) {
137
+ const idx = Number(sel)
138
+ const ws = workspaces[idx - 1]
139
+ if (ws) cwd = ws.path
140
+ else {
141
+ await node.sendText(`❌ **无效工作区编号**:\`${sel}\`\n\n> 请发送 \`/workspaces\` 查看可用工作区列表与编号。`)
142
+ return true
143
+ }
144
+ } else {
145
+ // 直接指定路径时,规范化并校验(必须完全匹配已注册工作区)
146
+ const validation = await validateWorkspacePath(node, sel)
147
+ if (!validation.valid) {
148
+ await node.sendText(validation.error)
149
+ return true
150
+ }
151
+ cwd = validation.path
152
+ }
153
+ }
154
+ await node.createSession(prompt, cwd)
155
+ return true
156
+ }
157
+ case 'stop': {
158
+ node.stopAllHeartbeats()
159
+ const agent = node.activeAgent()
160
+ if (!agent) {
161
+ await node.sendText(`ℹ️ **当前没有正在运行的 Agent 任务**`)
162
+ } else {
163
+ agent.cancel({ kind: 'user' })
164
+ await node.sendText(`⏹ **已请求停止当前任务**`)
165
+ }
166
+ return true
167
+ }
168
+ case 'end': {
169
+ node.stopAllHeartbeats()
170
+ // 结束当前会话:停止 agent 并清除活动会话(进入"没有活动会话"状态)
171
+ const agent = node.activeAgent()
172
+ if (agent) agent.cancel({ kind: 'user' })
173
+ node.activeSessionId = null
174
+ await node.onActiveSessionChange?.(null)
175
+ await node.sendText(`✓ **已结束当前会话**(没有活动会话)。\n\n> **后续操作**:\n> - \`/new <提示词>\` — 新建会话并开始\n> - \`/sessions\` — 查看历史会话列表\n> - \`/help\` — 查看常用指令帮助`)
176
+ return true
177
+ }
178
+ case 'status': {
179
+ const agent = node.activeAgent()
180
+ const session = node.activeSession()
181
+ if (!session) {
182
+ await node.sendText(`## 📊 Agent 状态看板\n\n> 当前没有活动会话。\n> 发送 \`/new <提示词>\` 开始新任务,或发送 \`/sessions\` 查看已有会话。`)
183
+ return true
184
+ }
185
+ const statusMap = {
186
+ idle: '空闲 (idle)',
187
+ running: '运行中 (running)',
188
+ paused: '已暂停 (paused)',
189
+ error: '异常 (error)',
190
+ }
191
+ const status = statusMap[agent?.status] || (agent?.status ?? '空闲 (idle)')
192
+ const lastTurn = [...(session.events ?? [])].reverse().find((e) => e.type === 'turn/end')
193
+ const reason = lastTurn ? describeTurnEnd(lastTurn.data.reason) : '尚未运行'
194
+ const title = session.title || (session.events ? sessionLabel(session) : '')
195
+ const shortId = fmtSessionId(session.id)
196
+ const cwd = session.header?.cwd || node.config?.cwd || ''
197
+
198
+ const content = [
199
+ `## 📊 Agent 状态看板`,
200
+ '',
201
+ '| 属性 | 当前状态 / 参数 |',
202
+ '| :--- | :--- |',
203
+ `| **会话 ID** | \`${shortId}\` |`,
204
+ ...(title ? [`| **会话标题** | ${title.replace(/\|/g, '|')} |`] : []),
205
+ ...(cwd ? [`| **工作区** | \`${cwd}\` |`] : []),
206
+ `| **Agent 状态** | ${status} |`,
207
+ `| **累计事件** | ${session.seq ?? 0} 条 |`,
208
+ `| **最近执行** | ${reason} |`,
209
+ ].join('\n')
210
+
211
+ await node.sendText(content)
212
+ return true
213
+ }
214
+ case 'start': // 别名:首次扫码自动开始一个会话
215
+ await node.createSession('')
216
+ return true
217
+ default:
218
+ await node.sendText(`❌ **未知指令**:\`/${command}\`\n\n${helpText()}`)
219
+ return true
220
+ }
221
+ }