| 1 | package bot |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | ) |
| 11 | |
| 12 | type fakeDesktopBridge struct { |
| 13 | sessions []DesktopSessionInfo |
| 14 | watching map[string]bool |
| 15 | approved []string |
| 16 | denied []string |
| 17 | answered map[string][]event.AskAnswer |
| 18 | questions map[string][]event.AskQuestion |
| 19 | takeovers map[string]string // routeKey -> tabID |
| 20 | driven []string |
| 21 | driveErr error |
| 22 | watchErr error |
| 23 | } |
| 24 | |
| 25 | func newFakeDesktopBridge() *fakeDesktopBridge { |
| 26 | return &fakeDesktopBridge{ |
| 27 | watching: make(map[string]bool), |
| 28 | answered: make(map[string][]event.AskAnswer), |
| 29 | questions: make(map[string][]event.AskQuestion), |
| 30 | takeovers: make(map[string]string), |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func (f *fakeDesktopBridge) Sessions() []DesktopSessionInfo { return f.sessions } |
| 35 | func (f *fakeDesktopBridge) SetWatch(route DesktopWatchRoute, enable bool) error { |
| 36 | f.watching[route.Key()] = enable |
| 37 | return f.watchErr |
| 38 | } |
| 39 | func (f *fakeDesktopBridge) Watching(route DesktopWatchRoute) bool { |
| 40 | return f.watching[route.Key()] |
| 41 | } |
| 42 | func (f *fakeDesktopBridge) Approve(id string, allow bool) (string, error) { |
| 43 | if id == "gone" { |
| 44 | return "", fmt.Errorf("未找到待处理的审批 %s", id) |
| 45 | } |
| 46 | if allow { |
| 47 | f.approved = append(f.approved, id) |
| 48 | } else { |
| 49 | f.denied = append(f.denied, id) |
| 50 | } |
| 51 | return "已提交", nil |
| 52 | } |
| 53 | func (f *fakeDesktopBridge) AskQuestions(id string) ([]event.AskQuestion, bool) { |
| 54 | qs, ok := f.questions[id] |
| 55 | return qs, ok |
| 56 | } |
| 57 | func (f *fakeDesktopBridge) Answer(id string, answers []event.AskAnswer) (string, error) { |
| 58 | f.answered[id] = answers |
| 59 | return "已提交回答", nil |
| 60 | } |
| 61 | |
| 62 | func (f *fakeDesktopBridge) Takeover(route DesktopWatchRoute, tabID string) (string, error) { |
| 63 | for _, s := range f.sessions { |
| 64 | if s.TabID == tabID { |
| 65 | f.takeovers[route.Key()] = tabID |
| 66 | return "已接管", nil |
| 67 | } |
| 68 | } |
| 69 | return "", fmt.Errorf("未找到会话 %s", tabID) |
| 70 | } |
| 71 | |
| 72 | func (f *fakeDesktopBridge) Release(route DesktopWatchRoute) (string, error) { |
| 73 | if _, ok := f.takeovers[route.Key()]; !ok { |
| 74 | return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。") |
| 75 | } |
| 76 | delete(f.takeovers, route.Key()) |
| 77 | return "已解除接管", nil |
| 78 | } |
| 79 | |
| 80 | func (f *fakeDesktopBridge) TakeoverTab(route DesktopWatchRoute) string { |
| 81 | return f.takeovers[route.Key()] |
| 82 | } |
| 83 | |
| 84 | func (f *fakeDesktopBridge) DriveInput(route DesktopWatchRoute, text string) (string, error) { |
| 85 | if f.driveErr != nil { |
| 86 | return "", f.driveErr |
| 87 | } |
| 88 | f.driven = append(f.driven, text) |
| 89 | return "", nil |
| 90 | } |
| 91 | |
| 92 | func desktopTestMessage(text string) InboundMessage { |
| 93 | return InboundMessage{ |
| 94 | Platform: PlatformFeishu, |
| 95 | ConnectionID: "feishu-main", |
| 96 | Domain: "feishu", |
| 97 | ChatType: ChatDM, |
| 98 | ChatID: "chat-god", |
| 99 | UserID: "admin-user", |
| 100 | Text: text, |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestHandleDesktopCommandWithoutBridge(t *testing.T) { |
| 105 | gw := &BotGateway{cfg: GatewayConfig{}} |
| 106 | got := gw.handleDesktopCommand(desktopTestMessage("/desktop status")) |
| 107 | if !strings.Contains(got, "未运行在桌面端进程内") { |
| 108 | t.Fatalf("reply = %q, want standalone-mode notice", got) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestHandleDesktopCommandStatusListsSessions(t *testing.T) { |
| 113 | bridge := newFakeDesktopBridge() |
| 114 | bridge.sessions = []DesktopSessionInfo{ |
| 115 | {TabID: "tab-1", Label: "修复登录", Workspace: "blade", Running: true, Ready: true}, |
| 116 | {TabID: "tab-2", Label: "", Topic: "周报", Ready: true, PendingPrompt: true}, |
| 117 | } |
| 118 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 119 | |
| 120 | got := gw.handleDesktopCommand(desktopTestMessage("/desktop status")) |
| 121 | for _, want := range []string{"2 个", "修复登录", "▶️ 执行中", "周报", "⚠️ 等待审批/回答", "tab-1", "blade"} { |
| 122 | if !strings.Contains(got, want) { |
| 123 | t.Fatalf("status reply = %q, want it to contain %q", got, want) |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestHandleDesktopCommandStatusListsPendingIDs(t *testing.T) { |
| 129 | bridge := newFakeDesktopBridge() |
| 130 | bridge.sessions = []DesktopSessionInfo{{ |
| 131 | TabID: "tab-1", Label: "修复登录", Ready: true, PendingPrompt: true, |
| 132 | Pending: []DesktopPendingInfo{{ID: "appr-9", Kind: "approval", Tool: "bash"}}, |
| 133 | }} |
| 134 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 135 | got := gw.handleDesktopCommand(desktopTestMessage("/desktop status")) |
| 136 | // The pending id must be visible so a user whose push was dropped can still |
| 137 | // run /desktop approve <id>. |
| 138 | if !strings.Contains(got, "appr-9") { |
| 139 | t.Fatalf("status = %q, want it to list the pending approval id", got) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestHandleDesktopCommandStatusEmpty(t *testing.T) { |
| 144 | gw := &BotGateway{cfg: GatewayConfig{Desktop: newFakeDesktopBridge()}} |
| 145 | got := gw.handleDesktopCommand(desktopTestMessage("/desktop")) |
| 146 | if !strings.Contains(got, "没有 live 会话") { |
| 147 | t.Fatalf("reply = %q, want empty-sessions notice", got) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestHandleDesktopCommandWatchLifecycle(t *testing.T) { |
| 152 | bridge := newFakeDesktopBridge() |
| 153 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 154 | msg := desktopTestMessage("/desktop watch on") |
| 155 | |
| 156 | got := gw.handleDesktopCommand(msg) |
| 157 | if !strings.Contains(got, "已订阅") { |
| 158 | t.Fatalf("watch on reply = %q", got) |
| 159 | } |
| 160 | route := desktopRouteFromMessage(msg) |
| 161 | if !bridge.watching[route.Key()] { |
| 162 | t.Fatal("watch on did not subscribe the message route") |
| 163 | } |
| 164 | |
| 165 | msg.Text = "/desktop watch off" |
| 166 | got = gw.handleDesktopCommand(msg) |
| 167 | if !strings.Contains(got, "已退订") { |
| 168 | t.Fatalf("watch off reply = %q", got) |
| 169 | } |
| 170 | if bridge.watching[route.Key()] { |
| 171 | t.Fatal("watch off did not unsubscribe the message route") |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestHandleDesktopCommandWatchReportsPersistenceFailure(t *testing.T) { |
| 176 | bridge := newFakeDesktopBridge() |
| 177 | bridge.watchErr = fmt.Errorf("disk unavailable") |
| 178 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 179 | msg := desktopTestMessage("/desktop watch on") |
| 180 | |
| 181 | got := gw.handleDesktopCommand(msg) |
| 182 | if !strings.Contains(got, "本次运行中订阅") || !strings.Contains(got, "保存订阅失败") { |
| 183 | t.Fatalf("watch persistence failure reply = %q", got) |
| 184 | } |
| 185 | if !bridge.Watching(desktopRouteFromMessage(msg)) { |
| 186 | t.Fatal("runtime subscription should remain active after persistence failure") |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestHandleDesktopCommandApproveAndDeny(t *testing.T) { |
| 191 | bridge := newFakeDesktopBridge() |
| 192 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 193 | |
| 194 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve appr-1")); !strings.Contains(got, "已提交") { |
| 195 | t.Fatalf("approve reply = %q", got) |
| 196 | } |
| 197 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop deny appr-2")); !strings.Contains(got, "已提交") { |
| 198 | t.Fatalf("deny reply = %q", got) |
| 199 | } |
| 200 | if len(bridge.approved) != 1 || bridge.approved[0] != "appr-1" { |
| 201 | t.Fatalf("approved = %v, want [appr-1]", bridge.approved) |
| 202 | } |
| 203 | if len(bridge.denied) != 1 || bridge.denied[0] != "appr-2" { |
| 204 | t.Fatalf("denied = %v, want [appr-2]", bridge.denied) |
| 205 | } |
| 206 | |
| 207 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve gone")); !strings.Contains(got, "未找到") { |
| 208 | t.Fatalf("missing-approval reply = %q", got) |
| 209 | } |
| 210 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve")); got != desktopCommandUsage { |
| 211 | t.Fatalf("missing-arg reply = %q, want usage", got) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestHandleDesktopCommandAnswerParsesSelection(t *testing.T) { |
| 216 | bridge := newFakeDesktopBridge() |
| 217 | bridge.questions["ask-1"] = []event.AskQuestion{{ |
| 218 | ID: "q1", |
| 219 | Prompt: "选一个", |
| 220 | Options: []event.AskOption{{Label: "方案 A"}, {Label: "方案 B"}}, |
| 221 | }} |
| 222 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 223 | |
| 224 | got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-1 2")) |
| 225 | if !strings.Contains(got, "已提交回答") { |
| 226 | t.Fatalf("answer reply = %q", got) |
| 227 | } |
| 228 | answers := bridge.answered["ask-1"] |
| 229 | if len(answers) != 1 || answers[0].QuestionID != "q1" { |
| 230 | t.Fatalf("answers = %+v, want one answer for q1", answers) |
| 231 | } |
| 232 | if len(answers[0].Selected) != 1 || answers[0].Selected[0] != "方案 B" { |
| 233 | t.Fatalf("selected = %v, want numeric index resolved to 方案 B", answers[0].Selected) |
| 234 | } |
| 235 | |
| 236 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-gone 1")); !strings.Contains(got, "未找到") { |
| 237 | t.Fatalf("missing-ask reply = %q", got) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestHandleDesktopCommandTakeoverAndRelease(t *testing.T) { |
| 242 | bridge := newFakeDesktopBridge() |
| 243 | bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}} |
| 244 | gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}} |
| 245 | msg := desktopTestMessage("/desktop takeover tab-1") |
| 246 | |
| 247 | if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已接管") { |
| 248 | t.Fatalf("takeover reply = %q", got) |
| 249 | } |
| 250 | route := desktopRouteFromMessage(msg) |
| 251 | if bridge.takeovers[route.Key()] != "tab-1" { |
| 252 | t.Fatalf("takeovers = %v, want route bound to tab-1", bridge.takeovers) |
| 253 | } |
| 254 | |
| 255 | msg.Text = "/desktop release" |
| 256 | if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已解除") { |
| 257 | t.Fatalf("release reply = %q", got) |
| 258 | } |
| 259 | if got := gw.handleDesktopCommand(desktopTestMessage("/desktop takeover missing")); !strings.Contains(got, "未找到") { |
| 260 | t.Fatalf("missing-tab reply = %q", got) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestDivertToDesktopTakeover(t *testing.T) { |
| 265 | bridge := newFakeDesktopBridge() |
| 266 | bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}} |
| 267 | gw := &BotGateway{ |
| 268 | cfg: GatewayConfig{Desktop: bridge}, |
| 269 | logger: discardLogger(), |
| 270 | adapterHealth: map[string]*AdapterHealthSnapshot{}, |
| 271 | } |
| 272 | adapter := newFakeAdapter(PlatformFeishu, "fake-feishu") |
| 273 | msg := desktopTestMessage("帮我跑一下测试") |
| 274 | |
| 275 | // 未接管:不分流。 |
| 276 | if gw.divertToDesktopTakeover(context.Background(), adapter, msg) { |
| 277 | t.Fatal("message should not divert without a takeover binding") |
| 278 | } |
| 279 | |
| 280 | route := desktopRouteFromMessage(msg) |
| 281 | bridge.takeovers[route.Key()] = "tab-1" |
| 282 | msg.Text = "/desktop status" |
| 283 | if gw.divertToDesktopTakeover(context.Background(), adapter, msg) { |
| 284 | t.Fatal("slash commands must remain in the bot command path during takeover") |
| 285 | } |
| 286 | msg.Text = "帮我跑一下测试" |
| 287 | if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) { |
| 288 | t.Fatal("message should divert to the taken-over session") |
| 289 | } |
| 290 | if len(bridge.driven) != 1 || bridge.driven[0] != "帮我跑一下测试" { |
| 291 | t.Fatalf("driven = %v, want the plain message text", bridge.driven) |
| 292 | } |
| 293 | |
| 294 | // 驱动失败:错误文案回给用户。 |
| 295 | bridge.driveErr = fmt.Errorf("会话正在执行中") |
| 296 | if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) { |
| 297 | t.Fatal("drive failure should still consume the message") |
| 298 | } |
| 299 | sent := adapter.sentMessages() |
| 300 | if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "正在执行中") { |
| 301 | t.Fatalf("sent = %+v, want drive error relayed to the chat", sent) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestDivertToDesktopTakeoverRevokesFormerAdmin(t *testing.T) { |
| 306 | bridge := newFakeDesktopBridge() |
| 307 | bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}} |
| 308 | gw := &BotGateway{ |
| 309 | cfg: GatewayConfig{ |
| 310 | Desktop: bridge, |
| 311 | Allowlist: AllowlistConfig{Admins: map[Platform][]string{ |
| 312 | PlatformFeishu: {"current-admin"}, |
| 313 | }}, |
| 314 | }, |
| 315 | logger: discardLogger(), |
| 316 | adapterHealth: map[string]*AdapterHealthSnapshot{}, |
| 317 | } |
| 318 | adapter := newFakeAdapter(PlatformFeishu, "fake-feishu") |
| 319 | msg := desktopTestMessage("run tests") |
| 320 | route := desktopRouteFromMessage(msg) |
| 321 | bridge.takeovers[route.Key()] = "tab-1" |
| 322 | |
| 323 | if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) { |
| 324 | t.Fatal("revoked takeover message should be consumed with an explanation") |
| 325 | } |
| 326 | if bridge.TakeoverTab(route) != "" || len(bridge.driven) != 0 { |
| 327 | t.Fatalf("revoked takeover remained active or drove input: tab=%q driven=%v", bridge.TakeoverTab(route), bridge.driven) |
| 328 | } |
| 329 | sent := adapter.sentMessages() |
| 330 | if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "不再具有") { |
| 331 | t.Fatalf("sent = %+v, want admin-revocation explanation", sent) |
| 332 | } |
| 333 | } |
| 334 |