返回 DeepSeek-Reasonix
bot_test.go
根目录 / internal / cli / bot_test.go
1 package cli
2
3 import (
4 "io"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "slices"
9 "strings"
10 "testing"
11
12 "reasonix/internal/bot"
13 "reasonix/internal/botruntime"
14 "reasonix/internal/config"
15 )
16
17 func TestRememberBotRemoteStoresIncomingChatID(t *testing.T) {
18 isolateBotUserConfig(t)
19 cfg := config.Default()
20 cfg.Bot.Connections = []config.BotConnectionConfig{
21 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
22 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
23 }
24 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
25 t.Fatalf("save config: %v", err)
26 }
27
28 msg := bot.InboundMessage{
29 Platform: bot.PlatformWeixin,
30 ChatType: bot.ChatDM,
31 ChatID: "wx-chat-1",
32 UserID: "wx-user-1",
33 }
34 if err := botruntime.RememberInbound(msg); err != nil {
35 t.Fatalf("rememberBotInbound: %v", err)
36 }
37 if err := botruntime.RememberInbound(msg); err != nil {
38 t.Fatalf("rememberBotRemote duplicate: %v", err)
39 }
40
41 got := config.LoadForEdit(config.UserConfigPath())
42 if len(got.Bot.Connections) != 2 {
43 t.Fatalf("connections = %d, want 2", len(got.Bot.Connections))
44 }
45 var wx config.BotConnectionConfig
46 var fs config.BotConnectionConfig
47 for _, conn := range got.Bot.Connections {
48 switch conn.ID {
49 case "weixin-weixin":
50 wx = conn
51 case "feishu-feishu":
52 fs = conn
53 }
54 }
55 if len(fs.SessionMappings) != 0 {
56 t.Fatalf("feishu mappings = %+v, want none", fs.SessionMappings)
57 }
58 if len(wx.SessionMappings) != 1 {
59 t.Fatalf("weixin mappings = %+v, want one", wx.SessionMappings)
60 }
61 if m := wx.SessionMappings[0]; m.RemoteID != "wx-chat-1" || m.Scope != "global" || m.WorkspaceRoot != "" || m.UpdatedAt == "" {
62 t.Fatalf("weixin mapping = %+v, want global wx-chat-1 with timestamp", m)
63 }
64 if got := got.Bot.Allowlist.WeixinUsers; len(got) != 1 || got[0] != "wx-user-1" {
65 t.Fatalf("weixin users = %+v, want wx-user-1", got)
66 }
67 }
68
69 func TestRememberBotRemoteKeepsProjectScopedConnection(t *testing.T) {
70 isolateBotUserConfig(t)
71 workspace := filepath.Join(t.TempDir(), "project")
72 cfg := config.Default()
73 cfg.Bot.Connections = []config.BotConnectionConfig{{
74 ID: "feishu-project",
75 Provider: "feishu",
76 Domain: "feishu",
77 Label: "飞书",
78 Enabled: true,
79 Status: "connected",
80 WorkspaceRoot: workspace,
81 }}
82 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
83 t.Fatalf("save config: %v", err)
84 }
85
86 if err := botruntime.RememberInbound(bot.InboundMessage{
87 Platform: bot.PlatformFeishu,
88 ChatType: bot.ChatDM,
89 ChatID: "oc-chat-1",
90 UserID: "ou-user-1",
91 }); err != nil {
92 t.Fatalf("rememberBotInbound: %v", err)
93 }
94
95 got := config.LoadForEdit(config.UserConfigPath())
96 if len(got.Bot.Connections) != 1 || len(got.Bot.Connections[0].SessionMappings) != 1 {
97 t.Fatalf("connections = %+v, want one project mapping", got.Bot.Connections)
98 }
99 if m := got.Bot.Connections[0].SessionMappings[0]; m.RemoteID != "oc-chat-1" || m.Scope != "project" || m.WorkspaceRoot != workspace {
100 t.Fatalf("mapping = %+v, want project scoped remote", m)
101 }
102 if got := got.Bot.Allowlist.FeishuUsers; len(got) != 1 || got[0] != "ou-user-1" {
103 t.Fatalf("feishu users = %+v, want ou-user-1", got)
104 }
105 }
106
107 func TestRememberBotInboundStoresGroupAllowlist(t *testing.T) {
108 isolateBotUserConfig(t)
109 cfg := config.Default()
110 cfg.Bot.Connections = []config.BotConnectionConfig{
111 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
112 }
113 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
114 t.Fatalf("save config: %v", err)
115 }
116
117 msg := bot.InboundMessage{
118 Platform: bot.PlatformFeishu,
119 ChatType: bot.ChatGroup,
120 ChatID: "oc-group-1",
121 UserID: "ou-user-1",
122 }
123 if err := botruntime.RememberInbound(msg); err != nil {
124 t.Fatalf("rememberBotInbound: %v", err)
125 }
126 if err := botruntime.RememberInbound(msg); err != nil {
127 t.Fatalf("rememberBotInbound duplicate: %v", err)
128 }
129
130 got := config.LoadForEdit(config.UserConfigPath())
131 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 1 || users[0] != "ou-user-1" {
132 t.Fatalf("feishu users = %+v, want one ou-user-1", users)
133 }
134 if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
135 t.Fatalf("feishu groups = %+v, want one oc-group-1", groups)
136 }
137 }
138
139 func TestBotDoctorReportsSessionMappingCounts(t *testing.T) {
140 isolateBotUserConfig(t)
141 cfg := config.Default()
142 cfg.Bot.Connections = []config.BotConnectionConfig{
143 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
144 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
145 }
146 cfg.Bot.Connections[0].SessionMappings = []config.BotConnectionSessionMapping{{RemoteID: "oc-chat-1", Scope: "global"}}
147 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
148 t.Fatalf("save config: %v", err)
149 }
150
151 out := captureStdout(t, func() {
152 if rc := botDoctor([]string{"--json"}); rc != 0 {
153 t.Fatalf("botDoctor rc = %d, want 0", rc)
154 }
155 })
156 for _, want := range []string{
157 `"name":"bot.connections","status":"ok","detail":"enabled=2 total=2"`,
158 `"name":"bot.connection.feishu-feishu.session_mappings","status":"ok","detail":"provider=feishu mappings=1"`,
159 `"name":"bot.connection.weixin-weixin.session_mappings","status":"missing","detail":"provider=weixin mappings=0"`,
160 } {
161 if !strings.Contains(out, want) {
162 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
163 }
164 }
165 }
166
167 func TestBotDoctorDeepReportsPairingAndRoles(t *testing.T) {
168 isolateBotUserConfig(t)
169 cfg := config.Default()
170 cfg.Bot.Enabled = true
171 cfg.Bot.Pairing.Enabled = true
172 cfg.Bot.Allowlist.Enabled = true
173 cfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
174 cfg.Bot.Allowlist.FeishuApprovers = []string{"ou-approver"}
175 cfg.Bot.Allowlist.FeishuAdmins = []string{"ou-admin"}
176 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
177 t.Fatalf("save config: %v", err)
178 }
179
180 if _, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
181 Platform: bot.PlatformFeishu,
182 ConnectionID: "feishu-feishu",
183 ChatType: bot.ChatDM,
184 ChatID: "chat",
185 UserID: "pending-user",
186 }, bot.PairingConfig{Enabled: true}); err != nil {
187 t.Fatalf("create pairing: %v", err)
188 }
189
190 out := captureStdout(t, func() {
191 if rc := botDoctor([]string{"--json", "--deep"}); rc != 0 {
192 t.Fatalf("botDoctor rc = %d, want 0", rc)
193 }
194 })
195 for _, want := range []string{
196 `"name":"bot.pairing.pending","status":"ok","detail":"1 pending"`,
197 `"name":"bot.roles","status":"ok","detail":"approvers=1 admins=1"`,
198 `"name":"bot.config.user","status":"ok"`,
199 } {
200 if !strings.Contains(out, want) {
201 t.Fatalf("bot doctor deep output missing %s:\n%s", want, out)
202 }
203 }
204 }
205
206 func TestBotPairingApproveAddsAllowlistAndFirstAdmin(t *testing.T) {
207 isolateBotUserConfig(t)
208 cfg := config.Default()
209 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
210 t.Fatalf("save config: %v", err)
211 }
212 req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
213 Platform: bot.PlatformWeixin,
214 ChatType: bot.ChatDM,
215 ChatID: "wx-chat",
216 UserID: "wx-user",
217 }, bot.PairingConfig{Enabled: true})
218 if err != nil {
219 t.Fatalf("create pairing: %v", err)
220 }
221
222 if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
223 t.Fatalf("botPairing approve rc = %d, want 0", rc)
224 }
225 got := config.LoadForEdit(config.UserConfigPath())
226 if users := got.Bot.Allowlist.WeixinUsers; len(users) != 1 || users[0] != "wx-user" {
227 t.Fatalf("weixin users = %+v, want wx-user", users)
228 }
229 if admins := got.Bot.Allowlist.WeixinAdmins; len(admins) != 1 || admins[0] != "wx-user" {
230 t.Fatalf("weixin admins = %+v, want first paired admin", admins)
231 }
232 if approvers := got.Bot.Allowlist.WeixinApprovers; len(approvers) != 1 || approvers[0] != "wx-user" {
233 t.Fatalf("weixin approvers = %+v, want first paired approver", approvers)
234 }
235 }
236
237 func TestBotPairingApproveAddsUserToConnectionAccess(t *testing.T) {
238 isolateBotUserConfig(t)
239 cfg := config.Default()
240 cfg.Bot.Connections = []config.BotConnectionConfig{{
241 ID: "feishu-lark",
242 Provider: "feishu",
243 Domain: "lark",
244 Label: "Lark",
245 Enabled: true,
246 Status: "connected",
247 Access: config.BotAccessConfig{
248 Enabled: true,
249 PairingEnabled: true,
250 Users: []string{"ou-existing"},
251 },
252 }}
253 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
254 t.Fatalf("save config: %v", err)
255 }
256 req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
257 Platform: bot.PlatformFeishu,
258 ConnectionID: "feishu-lark",
259 Domain: "lark",
260 ChatType: bot.ChatDM,
261 ChatID: "oc-chat",
262 UserID: "ou-new",
263 }, bot.PairingConfig{Enabled: true})
264 if err != nil {
265 t.Fatalf("create pairing: %v", err)
266 }
267
268 if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
269 t.Fatalf("botPairing approve rc = %d, want 0", rc)
270 }
271 got := config.LoadForEdit(config.UserConfigPath())
272 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 0 {
273 t.Fatalf("global feishu users = %+v, want unchanged global allowlist", users)
274 }
275 if len(got.Bot.Connections) != 1 {
276 t.Fatalf("connections = %+v, want one connection", got.Bot.Connections)
277 }
278 access := got.Bot.Connections[0].Access
279 if !access.Enabled {
280 t.Fatal("connection access disabled after approval, want enabled")
281 }
282 for _, want := range []string{"ou-existing", "ou-new"} {
283 if !hasTestString(access.Users, want) {
284 t.Fatalf("connection users = %+v, want %s", access.Users, want)
285 }
286 }
287 }
288
289 func TestBotDoctorPrefersUserBotSettingsOverProjectBotConfig(t *testing.T) {
290 isolateBotUserConfig(t)
291 userCfg := config.Default()
292 userCfg.Bot.Enabled = true
293 userCfg.Bot.Allowlist.Enabled = true
294 userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
295 userCfg.Bot.Connections = []config.BotConnectionConfig{
296 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
297 }
298 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
299 t.Fatalf("save user config: %v", err)
300 }
301
302 project := t.TempDir()
303 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
304 [bot]
305 enabled = false
306 `), 0o644); err != nil {
307 t.Fatalf("write project config: %v", err)
308 }
309 t.Chdir(project)
310
311 out := captureStdout(t, func() {
312 if rc := botDoctor([]string{"--json"}); rc != 0 {
313 t.Fatalf("botDoctor rc = %d, want 0", rc)
314 }
315 })
316 for _, want := range []string{
317 `"name":"bot.enabled","status":"ok"`,
318 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
319 `"name":"bot.connection.feishu-lark.session_mappings","status":"missing","detail":"provider=feishu mappings=0"`,
320 } {
321 if !strings.Contains(out, want) {
322 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
323 }
324 }
325 }
326
327 func TestBotDoctorUsesProjectBotConfigWhenUserBotIsUnconfigured(t *testing.T) {
328 isolateBotUserConfig(t)
329 projectCfg := config.Default()
330 projectCfg.Bot.Enabled = true
331 projectCfg.Bot.Allowlist.AllowAll = true
332 projectCfg.Bot.Connections = []config.BotConnectionConfig{
333 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
334 }
335 if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
336 t.Fatalf("save project config: %v", err)
337 }
338
339 out := captureStdout(t, func() {
340 if rc := botDoctor([]string{"--json"}); rc != 0 {
341 t.Fatalf("botDoctor rc = %d, want 0", rc)
342 }
343 })
344 for _, want := range []string{
345 `"name":"bot.enabled","status":"ok"`,
346 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
347 `"name":"bot.allowlist","status":"open"`,
348 } {
349 if !strings.Contains(out, want) {
350 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
351 }
352 }
353 }
354
355 func TestBotDoctorUsesProjectBotConfigWhenUserConfigOnlyHasBotDefaults(t *testing.T) {
356 isolateBotUserConfig(t)
357 userCfg := config.Default()
358 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
359 t.Fatalf("save user config: %v", err)
360 }
361 projectCfg := config.Default()
362 projectCfg.Bot.Enabled = true
363 projectCfg.Bot.Allowlist.AllowAll = true
364 projectCfg.Bot.Connections = []config.BotConnectionConfig{
365 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
366 }
367 if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
368 t.Fatalf("save project config: %v", err)
369 }
370
371 out := captureStdout(t, func() {
372 if rc := botDoctor([]string{"--json"}); rc != 0 {
373 t.Fatalf("botDoctor rc = %d, want 0", rc)
374 }
375 })
376 for _, want := range []string{
377 `"name":"bot.enabled","status":"ok"`,
378 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
379 `"name":"bot.allowlist","status":"open"`,
380 } {
381 if !strings.Contains(out, want) {
382 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
383 }
384 }
385 }
386
387 func TestBotConnectionChannelConfigsKeepFeishuAndLarkSeparate(t *testing.T) {
388 connections := []config.BotConnectionConfig{
389 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Model: "feishu-model", WorkspaceRoot: "/feishu"},
390 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Model: "lark-model", WorkspaceRoot: "/lark"},
391 }
392 channels := botruntime.ConnectionChannelConfigs(connections, true, true)
393 if channels["feishu-feishu"].Model != "feishu-model" || channels["feishu-feishu"].WorkspaceRoot != "/feishu" {
394 t.Fatalf("feishu channel = %+v, want feishu override", channels["feishu-feishu"])
395 }
396 if channels["feishu-lark"].Model != "lark-model" || channels["feishu-lark"].WorkspaceRoot != "/lark" {
397 t.Fatalf("lark channel = %+v, want lark override", channels["feishu-lark"])
398 }
399 }
400
401 func TestBotAdapterBindingsCreateSeparateFeishuAndLarkInstances(t *testing.T) {
402 cfg := config.Default()
403 cfg.Bot.Connections = []config.BotConnectionConfig{
404 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
405 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
406 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Credential: config.BotConnectionCredential{AccountID: "wx-account", TokenEnv: "WEIXIN_BOT_TOKEN"}},
407 }
408 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
409 bindings := botruntime.AdapterBindings(cfg, map[bot.Platform]bool{bot.PlatformFeishu: true, bot.PlatformWeixin: true}, nil, logger)
410
411 got := map[string]bot.AdapterBinding{}
412 for _, binding := range bindings {
413 got[binding.ID] = binding
414 }
415 for _, id := range []string{"feishu-feishu", "feishu-lark", "weixin-weixin"} {
416 if got[id].Adapter == nil {
417 t.Fatalf("binding %s missing from %+v", id, bindings)
418 }
419 }
420 if got["feishu-feishu"].Domain != "feishu" || got["feishu-lark"].Domain != "lark" {
421 t.Fatalf("domains = feishu:%q lark:%q, want separate domains", got["feishu-feishu"].Domain, got["feishu-lark"].Domain)
422 }
423 }
424
425 func TestBotAdapterBindingsIsolateRequestedFeishuDomain(t *testing.T) {
426 cfg := config.Default()
427 cfg.Bot.Connections = []config.BotConnectionConfig{
428 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
429 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
430 }
431 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
432 enabled := map[bot.Platform]bool{bot.PlatformFeishu: true}
433
434 larkOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"lark"}), logger)
435 if len(larkOnly) != 1 || larkOnly[0].ID != "feishu-lark" {
436 t.Fatalf("--channels lark bindings = %+v, want only feishu-lark", larkOnly)
437 }
438
439 feishuOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"feishu"}), logger)
440 if len(feishuOnly) != 1 || feishuOnly[0].ID != "feishu-feishu" {
441 t.Fatalf("--channels feishu bindings = %+v, want only feishu-feishu", feishuOnly)
442 }
443 }
444
445 func TestRememberBotInboundUsesConnectionID(t *testing.T) {
446 isolateBotUserConfig(t)
447 cfg := config.Default()
448 cfg.Bot.Connections = []config.BotConnectionConfig{
449 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
450 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
451 }
452 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
453 t.Fatalf("save config: %v", err)
454 }
455
456 if err := botruntime.RememberInbound(bot.InboundMessage{
457 Platform: bot.PlatformFeishu,
458 ConnectionID: "feishu-lark",
459 Domain: "lark",
460 ChatType: bot.ChatDM,
461 ChatID: "oc-lark-chat",
462 UserID: "ou-lark-user",
463 }); err != nil {
464 t.Fatalf("rememberBotInbound: %v", err)
465 }
466
467 got := config.LoadForEdit(config.UserConfigPath())
468 var feishuConn, larkConn config.BotConnectionConfig
469 for _, conn := range got.Bot.Connections {
470 switch conn.ID {
471 case "feishu-feishu":
472 feishuConn = conn
473 case "feishu-lark":
474 larkConn = conn
475 }
476 }
477 if len(feishuConn.SessionMappings) != 0 {
478 t.Fatalf("feishu mappings = %+v, want none", feishuConn.SessionMappings)
479 }
480 if len(larkConn.SessionMappings) != 1 || larkConn.SessionMappings[0].RemoteID != "oc-lark-chat" {
481 t.Fatalf("lark mappings = %+v, want lark chat only", larkConn.SessionMappings)
482 }
483 }
484
485 func isolateBotUserConfig(t *testing.T) {
486 t.Helper()
487 home := t.TempDir()
488 t.Setenv("HOME", home)
489 t.Setenv("USERPROFILE", home)
490 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
491 t.Setenv("AppData", filepath.Join(home, "AppData"))
492 t.Chdir(t.TempDir())
493 }
494
495 func hasTestString(values []string, want string) bool {
496 return slices.Contains(values, want)
497 }
498
498 lines GO