| 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 |