返回 DeepSeek-Reasonix
desktop.go
根目录 / internal / bot / desktop.go
1 package bot
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/event"
9 )
10
11 // DesktopSessionInfo 是一个桌面 live 会话(tab)的快照,用于 /desktop status。
12 type DesktopSessionInfo struct {
13 TabID string
14 Label string
15 Workspace string
16 Topic string
17 Ready bool
18 Running bool
19 PendingPrompt bool
20 // Detached 标记后台运行的会话(已从可见 tab 分离,controller 仍存活)。
21 Detached bool
22 // Pending 列出该会话当前待处理的审批/问答,便于用户在推送丢失时仍能
23 // 用 /desktop approve|answer <id> 处理。
24 Pending []DesktopPendingInfo
25 }
26
27 // DesktopPendingInfo 是一条待处理的审批或问答的摘要。
28 type DesktopPendingInfo struct {
29 ID string
30 Kind string // "approval" | "ask"
31 Tool string
32 }
33
34 // DesktopWatchRoute 标识一个订阅了桌面事件的 bot 聊天。
35 type DesktopWatchRoute struct {
36 ConnectionID string
37 Domain string
38 Platform Platform
39 ChatType ChatType
40 ChatID string
41 }
42
43 // Key 返回订阅表的稳定键。
44 func (r DesktopWatchRoute) Key() string {
45 return fmt.Sprintf("%s|%s|%s|%s", r.Platform, r.ConnectionID, r.Domain, r.ChatID)
46 }
47
48 // DesktopBridge 由桌面端进程实现,让 bot 聊天获得对整个桌面端的上帝视角:
49 // 全局会话清单、事件订阅、以及对任意桌面 live 会话的远程审批/问答。
50 //
51 // 语义约定:
52 // - 审批应答与桌面 UI 是"先到者赢"(controller 侧幂等,重复应答被静默忽略),
53 // Approve/Answer 的返回文案应体现"以先到者为准"。
54 // - 接管是"显式"的:/desktop takeover 绑定聊天与会话,桌面端会在会话
55 // transcript 里看到提示;桌面用户在该会话本地发送任意消息即自动收回
56 // 控制并通知远端。接管期间桌面输入不被锁定(controller 侧先到者赢)。
57 type DesktopBridge interface {
58 // Sessions 枚举当前所有桌面 live 会话(含后台 detached)。
59 Sessions() []DesktopSessionInfo
60 // SetWatch 订阅/退订当前聊天的桌面事件推送。
61 SetWatch(route DesktopWatchRoute, enable bool) error
62 // Watching 返回该聊天当前是否在订阅。
63 Watching(route DesktopWatchRoute) bool
64 // Approve 应答任意桌面会话的待审批项,返回用户可读的结果文案。
65 Approve(approvalID string, allow bool) (string, error)
66 // AskQuestions 返回某个待回答 ask 的问题列表(用于把 IM 文本解析成选项)。
67 AskQuestions(askID string) ([]event.AskQuestion, bool)
68 // Answer 应答任意桌面会话的待回答 ask,返回用户可读的结果文案。
69 Answer(askID string, answers []event.AskAnswer) (string, error)
70 // Takeover 把该聊天绑定为某个桌面会话的远程驾驶者,返回用户可读文案。
71 Takeover(route DesktopWatchRoute, tabID string) (string, error)
72 // Release 解除该聊天的接管绑定,返回用户可读文案。
73 Release(route DesktopWatchRoute) (string, error)
74 // TakeoverTab 返回该聊天当前接管的 tabID,未接管时为空串。
75 TakeoverTab(route DesktopWatchRoute) string
76 // DriveInput 把一条文本作为新 turn 提交到该聊天接管的桌面会话。
77 // 成功时输出会经事件转发流回聊天;返回的文案(可为空)用于即时回执。
78 DriveInput(route DesktopWatchRoute, text string) (string, error)
79 }
80
81 func desktopRouteFromMessage(msg InboundMessage) DesktopWatchRoute {
82 return DesktopWatchRoute{
83 ConnectionID: msg.ConnectionID,
84 Domain: msg.Domain,
85 Platform: msg.Platform,
86 ChatType: msg.ChatType,
87 ChatID: msg.ChatID,
88 }
89 }
90
91 const desktopCommandUsage = "用法:\n" +
92 "/desktop status - 查看桌面端所有 live 会话\n" +
93 "/desktop watch on|off|status - 订阅/退订桌面事件推送(审批请求、任务完成/出错)\n" +
94 "/desktop approve <id> - 批准桌面会话的待审批操作\n" +
95 "/desktop deny <id> - 拒绝桌面会话的待审批操作\n" +
96 "/desktop answer <id> <选项编号或文本> - 回答桌面会话的提问\n" +
97 "/desktop takeover <tab> - 接管桌面会话,后续消息直接驱动它\n" +
98 "/desktop release - 解除接管,回到普通 bot 会话"
99
100 // handleDesktopCommand 处理 /desktop 系列命令(上帝视角:观察 + 遥控审批)。
101 // 调用方已完成 admin 角色门控。
102 func (gw *BotGateway) handleDesktopCommand(msg InboundMessage) string {
103 bridge := gw.cfg.Desktop
104 if bridge == nil {
105 return "此 bot 未运行在桌面端进程内,/desktop 命令不可用。请在桌面端设置里启用 bot。"
106 }
107 fields := strings.Fields(msg.Text)
108 sub := ""
109 if len(fields) > 1 {
110 sub = strings.ToLower(fields[1])
111 }
112 switch sub {
113 case "", "status", "sessions":
114 return formatDesktopSessions(bridge.Sessions())
115 case "watch":
116 arg := ""
117 if len(fields) > 2 {
118 arg = strings.ToLower(fields[2])
119 }
120 route := desktopRouteFromMessage(msg)
121 switch arg {
122 case "on":
123 if err := bridge.SetWatch(route, true); err != nil {
124 return "已在本次运行中订阅桌面事件,但保存订阅失败;桌面端重启后可能需要重新订阅。"
125 }
126 return "已订阅桌面事件:审批请求、任务完成/出错会推送到本聊天。订阅已保存,桌面端重启后仍会生效。用 /desktop watch off 退订。"
127 case "off":
128 if err := bridge.SetWatch(route, false); err != nil {
129 return "已在本次运行中退订桌面事件,但保存失败;桌面端重启后订阅可能恢复。"
130 }
131 return "已退订桌面事件推送,持久化订阅也已移除。"
132 case "", "state":
133 if bridge.Watching(route) {
134 return "本聊天正在订阅桌面事件推送。用 /desktop watch off 退订。"
135 }
136 return "本聊天未订阅桌面事件推送。用 /desktop watch on 订阅。"
137 default:
138 return desktopCommandUsage
139 }
140 case "approve", "deny":
141 if len(fields) < 3 {
142 return desktopCommandUsage
143 }
144 feedback, err := bridge.Approve(fields[2], sub == "approve")
145 if err != nil {
146 return err.Error()
147 }
148 return feedback
149 case "answer":
150 if len(fields) < 4 {
151 return desktopCommandUsage
152 }
153 askID := fields[2]
154 questions, ok := bridge.AskQuestions(askID)
155 if !ok {
156 return fmt.Sprintf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
157 }
158 raw := strings.Join(fields[3:], " ")
159 answers := parseAskAnswers(questions, raw)
160 feedback, err := bridge.Answer(askID, answers)
161 if err != nil {
162 return err.Error()
163 }
164 return feedback
165 case "takeover":
166 if len(fields) < 3 {
167 return desktopCommandUsage
168 }
169 feedback, err := bridge.Takeover(desktopRouteFromMessage(msg), fields[2])
170 if err != nil {
171 return err.Error()
172 }
173 return feedback
174 case "release":
175 feedback, err := bridge.Release(desktopRouteFromMessage(msg))
176 if err != nil {
177 return err.Error()
178 }
179 return feedback
180 default:
181 return desktopCommandUsage
182 }
183 }
184
185 // divertToDesktopTakeover 把已接管聊天的普通消息改道到桌面会话。斜杠命令
186 // 不经过这里(仍走 handleSlashCommand),所以 /desktop release 永远可用。
187 // 返回 true 表示消息已被桌面接管通道消费。
188 func (gw *BotGateway) divertToDesktopTakeover(ctx context.Context, adapter Adapter, msg InboundMessage) bool {
189 if strings.HasPrefix(strings.TrimSpace(msg.Text), "/") {
190 return false
191 }
192 bridge := gw.cfg.Desktop
193 if bridge == nil {
194 return false
195 }
196 route := desktopRouteFromMessage(msg)
197 if bridge.TakeoverTab(route) == "" {
198 return false
199 }
200 // Re-check the continuing capability, not only the command that created it.
201 // A runtime refresh can revoke admin while leaving this process-local binding
202 // alive; base allowlist admission alone must not preserve takeover power.
203 if !gw.checkCommandRole(msg.Platform, msg, "admin") {
204 _, _ = bridge.Release(route)
205 _ = gw.sendText(ctx, adapter, msg, "桌面接管已解除:当前账号不再具有 bot 管理员权限。")
206 return true
207 }
208 if strings.TrimSpace(msg.Text) == "" {
209 _ = gw.sendText(ctx, adapter, msg, "接管模式暂不支持转发附件,请发送文本。")
210 return true
211 }
212 feedback, err := bridge.DriveInput(route, msg.Text)
213 if err != nil {
214 _ = gw.sendText(ctx, adapter, msg, err.Error())
215 return true
216 }
217 if strings.TrimSpace(feedback) != "" {
218 _ = gw.sendText(ctx, adapter, msg, feedback)
219 }
220 return true
221 }
222
223 func formatDesktopSessions(sessions []DesktopSessionInfo) string {
224 if len(sessions) == 0 {
225 return "桌面端当前没有 live 会话。"
226 }
227 var b strings.Builder
228 fmt.Fprintf(&b, "桌面端 live 会话(%d 个):\n", len(sessions))
229 for _, s := range sessions {
230 state := "空闲"
231 switch {
232 case s.PendingPrompt:
233 state = "⚠️ 等待审批/回答"
234 case s.Running:
235 state = "▶️ 执行中"
236 case !s.Ready:
237 state = "启动中"
238 }
239 label := strings.TrimSpace(s.Label)
240 if label == "" {
241 label = strings.TrimSpace(s.Topic)
242 }
243 if label == "" {
244 label = "(未命名)"
245 }
246 if s.Detached {
247 state += "·后台"
248 }
249 fmt.Fprintf(&b, "\n- %s [%s]", label, state)
250 if ws := strings.TrimSpace(s.Workspace); ws != "" {
251 fmt.Fprintf(&b, "\n 项目: %s", ws)
252 }
253 fmt.Fprintf(&b, "\n tab: %s", s.TabID)
254 for _, p := range s.Pending {
255 kind := "审批"
256 if p.Kind == "ask" {
257 kind = "提问"
258 }
259 line := fmt.Sprintf("\n 待%s: %s", kind, p.ID)
260 if tool := strings.TrimSpace(p.Tool); tool != "" {
261 line += " (" + tool + ")"
262 }
263 b.WriteString(line)
264 }
265 }
266 b.WriteString("\n\n用 /desktop watch on 订阅审批与完成事件;/desktop takeover <tab> 接管某个会话。")
267 return b.String()
268 }
269
269 lines GO