返回 DeepSeek-Reasonix
bot.go
根目录 / internal / cli / bot.go
1 package cli
2
3 import (
4 "context"
5 "flag"
6 "fmt"
7 "log/slog"
8 "os"
9 "os/signal"
10 "strings"
11 "syscall"
12 "time"
13
14 "reasonix/internal/bot"
15 "reasonix/internal/bot/weixin"
16 "reasonix/internal/botruntime"
17 "reasonix/internal/config"
18 )
19
20 func botCommand(args []string, version string) int {
21 if len(args) < 1 {
22 botUsage()
23 return 2
24 }
25
26 sub := args[0]
27 rest := args[1:]
28
29 switch sub {
30 case "start":
31 return botStart(rest, version)
32 case "doctor":
33 return botDoctor(rest)
34 case "pairing":
35 return botPairing(rest)
36 case "weixin-login":
37 return botWeixinLogin(rest)
38 case "help", "--help", "-h":
39 botUsage()
40 return 0
41 default:
42 fmt.Fprintf(os.Stderr, "unknown bot subcommand %q\n\n", sub)
43 botUsage()
44 return 2
45 }
46 }
47
48 func botStart(args []string, version string) int {
49 fs := flag.NewFlagSet("bot start", flag.ContinueOnError)
50 channels := fs.String("channels", "", "启用的平台,逗号分隔:qq,feishu,lark,weixin")
51 dir := fs.String("dir", "", "工作目录")
52 model := fs.String("model", "", "模型名(空则用 default_model)")
53
54 if code, ok := parseCommandFlags(fs, args); !ok {
55 return code
56 }
57
58 ctx, cancel := context.WithCancel(context.Background())
59 defer cancel()
60
61 cfg, err := loadBotCommandConfig()
62 if err != nil {
63 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
64 return 1
65 }
66
67 if !cfg.Bot.Enabled {
68 fmt.Fprintln(os.Stderr, "error: bot is not enabled in config — set [bot] enabled = true")
69 return 1
70 }
71 if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
72 fmt.Fprintln(os.Stderr, "error: bot requires explicit access control; set per-connection access, enable pairing, configure [bot.allowlist], or set allow_all = true intentionally")
73 return 1
74 }
75
76 workspaceRoot := *dir
77 if workspaceRoot == "" {
78 if wd, err := os.Getwd(); err == nil {
79 workspaceRoot = wd
80 }
81 }
82
83 requestedChannels := splitBotChannels(*channels)
84 enabledPlatforms, unknownChannels := botruntime.EnabledPlatforms(cfg, requestedChannels)
85 for _, ch := range unknownChannels {
86 fmt.Fprintf(os.Stderr, "warning: unknown channel %q\n", ch)
87 }
88 if !botruntime.HasEnabledPlatform(enabledPlatforms) {
89 fmt.Fprintln(os.Stderr, "error: no bot channels enabled — enable at least one in config")
90 return 1
91 }
92
93 modelName := botruntime.ModelName(cfg, *model)
94
95 logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
96 rememberInboundRemote := botruntime.NewRemoteRememberer(logger)
97
98 // 构建网关配置
99 gwCfg := bot.GatewayConfig{
100 Model: modelName,
101 ToolApprovalMode: cfg.Bot.ToolApprovalMode,
102 MaxSteps: cfg.Bot.MaxSteps,
103 QueueMode: cfg.Bot.QueueMode,
104 QueueCap: cfg.Bot.QueueCap,
105 QueueDrop: cfg.Bot.QueueDrop,
106 PairingEnabled: cfg.Bot.Pairing.Enabled,
107 PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
108 PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
109 IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
110 SelfUserIDs: map[bot.Platform][]string{
111 bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
112 bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
113 bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
114 },
115 ControlEnabled: cfg.Bot.Control.Enabled,
116 ControlAddr: cfg.Bot.Control.Addr,
117 ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
118 WorkspaceRoot: workspaceRoot,
119 Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""),
120 ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""),
121 Routes: botruntime.RouteConfigs(cfg.Bot.Routes, *model == "", *dir == ""),
122 ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
123 Enabled: enabledPlatforms,
124 Allowlist: bot.AllowlistConfig{
125 Enabled: cfg.Bot.Allowlist.Enabled,
126 AllowAll: cfg.Bot.Allowlist.AllowAll,
127 Users: map[bot.Platform][]string{
128 bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
129 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
130 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
131 },
132 Approvers: map[bot.Platform][]string{
133 bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
134 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
135 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
136 },
137 Admins: map[bot.Platform][]string{
138 bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
139 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
140 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
141 },
142 Groups: map[bot.Platform][]string{
143 bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
144 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
145 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
146 },
147 },
148 Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
149 OnInbound: rememberInboundRemote,
150 OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
151 }
152
153 feishuDomains := botruntime.RequestedFeishuDomains(requestedChannels)
154 gw := bot.NewGatewayWithAdapterBindings(gwCfg, botruntime.AdapterBindings(cfg, enabledPlatforms, feishuDomains, logger), logger)
155
156 // 信号处理
157 sigCh := make(chan os.Signal, 1)
158 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
159
160 go func() {
161 <-sigCh
162 fmt.Fprintln(os.Stderr, "\nshutting down...")
163 cancel()
164 }()
165
166 fmt.Fprintf(os.Stderr, "reasonix bot starting (model: %s, channels: %s)...\n", modelName, *channels)
167 fmt.Fprintf(os.Stderr, "version: %s\n", version)
168
169 if err := gw.Start(ctx); err != nil {
170 gw.Stop()
171 fmt.Fprintf(os.Stderr, "error: start gateway: %v\n", err)
172 return 1
173 }
174 defer gw.Stop()
175
176 // 等待信号或 context 取消
177 <-ctx.Done()
178 return 0
179 }
180
181 func splitBotChannels(raw string) []string {
182 raw = strings.TrimSpace(raw)
183 if raw == "" {
184 return nil
185 }
186 return strings.Split(raw, ",")
187 }
188
189 func botDoctor(args []string) int {
190 fs := flag.NewFlagSet("bot doctor", flag.ContinueOnError)
191 jsonOut := fs.Bool("json", false, "JSON 格式输出")
192 deep := fs.Bool("deep", false, "执行更详细的本机诊断")
193
194 if code, ok := parseCommandFlags(fs, args); !ok {
195 return code
196 }
197
198 cfg, err := loadBotCommandConfig()
199 if err != nil {
200 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
201 return 1
202 }
203
204 bc := cfg.Bot
205
206 type checkResult struct {
207 Name string `json:"name"`
208 Status string `json:"status"`
209 Detail string `json:"detail,omitempty"`
210 }
211
212 var results []checkResult
213
214 addCheck := func(name, status, detail string) {
215 results = append(results, checkResult{Name: name, Status: status, Detail: detail})
216 }
217
218 // 基础检查
219 if bc.Enabled {
220 addCheck("bot.enabled", "ok", "")
221 } else {
222 addCheck("bot.enabled", "disabled", "bot is not enabled in config")
223 }
224 if *deep {
225 if path := config.UserConfigPath(); path != "" {
226 if _, err := os.Stat(path); err == nil {
227 addCheck("bot.config.user", "ok", path)
228 } else {
229 addCheck("bot.config.user", "missing", path)
230 }
231 }
232 if dir := config.SessionDir(); dir != "" {
233 addCheck("bot.sessions.dir", "ok", dir)
234 }
235 }
236 queueMode := bot.NormalizeQueueMode(bc.QueueMode)
237 queueCap := bc.QueueCap
238 if queueCap <= 0 {
239 queueCap = bot.DefaultQueueCap
240 }
241 addCheck("bot.queue", "ok", fmt.Sprintf("mode=%s cap=%d drop=%s", queueMode, queueCap, bot.NormalizeQueueDrop(bc.QueueDrop)))
242 if bc.Pairing.Enabled {
243 addCheck("bot.pairing", "enabled", fmt.Sprintf("ttl=%dm max_pending=%d", bc.Pairing.RequestTTLMinutes, bc.Pairing.MaxPendingPerPlatform))
244 } else {
245 addCheck("bot.pairing", "disabled", "")
246 }
247 if *deep {
248 reqs, err := bot.ListPairingRequests()
249 if err != nil {
250 addCheck("bot.pairing.pending", "error", err.Error())
251 } else {
252 addCheck("bot.pairing.pending", "ok", fmt.Sprintf("%d pending", len(reqs)))
253 }
254 if path := bot.PairingStorePath(); path != "" {
255 if info, err := os.Stat(path); err == nil {
256 addCheck("bot.pairing.store", "ok", fmt.Sprintf("%s mode=%s", path, info.Mode().Perm()))
257 } else {
258 addCheck("bot.pairing.store", "missing", path)
259 }
260 }
261 }
262 if *deep {
263 selfStatus := "disabled"
264 if bc.IgnoreSelfMessages {
265 selfStatus = "enabled"
266 }
267 addCheck("bot.self_protection", selfStatus,
268 fmt.Sprintf("self_ids=%d", len(bc.SelfUserIDs.QQ)+len(bc.SelfUserIDs.Feishu)+len(bc.SelfUserIDs.Weixin)))
269 controlStatus := "disabled"
270 controlDetail := ""
271 if bc.Control.Enabled {
272 controlStatus = "enabled"
273 tokenStatus := "missing_token"
274 if strings.TrimSpace(bc.Control.TokenEnv) != "" && os.Getenv(strings.TrimSpace(bc.Control.TokenEnv)) != "" {
275 tokenStatus = "token_set"
276 }
277 addr := strings.TrimSpace(bc.Control.Addr)
278 if addr == "" {
279 addr = "127.0.0.1:37913"
280 }
281 controlDetail = fmt.Sprintf("addr=%s token_env=%s %s", addr, bc.Control.TokenEnv, tokenStatus)
282 }
283 addCheck("bot.control", controlStatus, controlDetail)
284 addCheck("bot.routes", "ok", fmt.Sprintf("%d routes", len(bc.Routes)))
285 }
286
287 // QQ 检查
288 if bc.QQ.Enabled {
289 addCheck("bot.qq.enabled", "ok", "")
290 secret := os.Getenv(bc.QQ.AppSecretEnv)
291 if secret == "" {
292 addCheck("bot.qq.app_secret", "missing", bc.QQ.AppSecretEnv+" is not set")
293 } else {
294 addCheck("bot.qq.app_secret", "ok", bc.QQ.AppSecretEnv+" is set")
295 }
296 if bc.QQ.AppID == "" {
297 addCheck("bot.qq.app_id", "missing", "app_id is empty")
298 } else {
299 addCheck("bot.qq.app_id", "ok", "app_id configured")
300 }
301 } else {
302 addCheck("bot.qq", "disabled", "")
303 }
304
305 // 飞书检查
306 if bc.Feishu.Enabled {
307 addCheck("bot.feishu.enabled", "ok", "")
308 secret := os.Getenv(bc.Feishu.AppSecretEnv)
309 if secret == "" {
310 addCheck("bot.feishu.app_secret", "missing", bc.Feishu.AppSecretEnv+" is not set")
311 } else {
312 addCheck("bot.feishu.app_secret", "ok", bc.Feishu.AppSecretEnv+" is set")
313 }
314 if bc.Feishu.AppID == "" {
315 addCheck("bot.feishu.app_id", "missing", "app_id is empty")
316 } else {
317 addCheck("bot.feishu.app_id", "ok", "app_id configured")
318 }
319 mode := bc.Feishu.Mode
320 if mode == "" {
321 mode = "webhook"
322 }
323 addCheck("bot.feishu.mode", "ok", mode)
324 } else {
325 addCheck("bot.feishu", "disabled", "")
326 }
327
328 // 微信检查
329 if bc.Weixin.Enabled {
330 addCheck("bot.weixin.enabled", "ok", "")
331 token := os.Getenv(bc.Weixin.TokenEnv)
332 if token != "" {
333 addCheck("bot.weixin.token", "ok", bc.Weixin.TokenEnv+" is set")
334 } else if weixin.HasSavedAccount(bc.Weixin.AccountID) {
335 addCheck("bot.weixin.token", "ok", "saved iLink account is available")
336 } else {
337 addCheck("bot.weixin.token", "missing", bc.Weixin.TokenEnv+" is not set; run `reasonix bot weixin-login` to save an iLink account")
338 }
339 } else {
340 addCheck("bot.weixin", "disabled", "")
341 }
342
343 enabledConnections := 0
344 for _, conn := range bc.Connections {
345 if conn.Enabled {
346 enabledConnections++
347 }
348 }
349 addCheck("bot.connections", "ok", fmt.Sprintf("enabled=%d total=%d", enabledConnections, len(bc.Connections)))
350 for _, conn := range bc.Connections {
351 id := strings.TrimSpace(conn.ID)
352 if id == "" {
353 id = strings.TrimSpace(conn.Provider)
354 }
355 status := "ok"
356 if !conn.Enabled {
357 status = "disabled"
358 } else if len(conn.SessionMappings) == 0 && (conn.Provider == string(bot.PlatformFeishu) || conn.Provider == string(bot.PlatformWeixin)) {
359 status = "missing"
360 }
361 addCheck("bot.connection."+id+".session_mappings", status,
362 fmt.Sprintf("provider=%s mappings=%d", conn.Provider, len(conn.SessionMappings)))
363 }
364
365 // Allowlist 检查
366 if bc.Allowlist.AllowAll {
367 addCheck("bot.allowlist", "open", "allow_all=true — every reachable user can trigger local tools")
368 } else if bc.Allowlist.Enabled {
369 addCheck("bot.allowlist", "enabled",
370 fmt.Sprintf("qq=%d feishu=%d weixin=%d users approvers=%d admins=%d",
371 len(bc.Allowlist.QQUsers),
372 len(bc.Allowlist.FeishuUsers),
373 len(bc.Allowlist.WeixinUsers),
374 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
375 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
376 } else {
377 addCheck("bot.allowlist", "missing", "bot start will refuse without allowlist or allow_all=true")
378 }
379 if *deep {
380 addCheck("bot.roles", "ok",
381 fmt.Sprintf("approvers=%d admins=%d",
382 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
383 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
384 }
385
386 if *jsonOut {
387 fmt.Println("[")
388 for i, r := range results {
389 comma := ","
390 if i == len(results)-1 {
391 comma = ""
392 }
393 fmt.Printf(" {\"name\":%q,\"status\":%q,\"detail\":%q}%s\n", r.Name, r.Status, r.Detail, comma)
394 }
395 fmt.Println("]")
396 } else {
397 for _, r := range results {
398 marker := "✓"
399 if r.Status == "missing" || r.Status == "disabled" {
400 marker = "✗"
401 }
402 fmt.Printf(" %s %s: %s", marker, r.Name, r.Status)
403 if r.Detail != "" {
404 fmt.Printf(" — %s", r.Detail)
405 }
406 fmt.Println()
407 }
408 }
409
410 return 0
411 }
412
413 func botPairing(args []string) int {
414 if len(args) < 1 {
415 botPairingUsage()
416 return 2
417 }
418 switch args[0] {
419 case "list":
420 reqs, err := bot.ListPairingRequests()
421 if err != nil {
422 fmt.Fprintf(os.Stderr, "error: list pairing requests: %v\n", err)
423 return 1
424 }
425 if len(reqs) == 0 {
426 fmt.Println("No pending bot pairing requests.")
427 return 0
428 }
429 for _, req := range reqs {
430 fmt.Printf("%s\t%s\t%s\tuser=%s\tchat=%s\texpires=%s\n",
431 req.Code,
432 req.Platform,
433 req.ChatType,
434 req.UserID,
435 req.ChatID,
436 req.ExpiresAt.Local().Format("2006-01-02 15:04"),
437 )
438 }
439 return 0
440 case "approve":
441 if len(args) < 2 {
442 fmt.Fprintln(os.Stderr, "error: pairing approve requires a code")
443 return 2
444 }
445 req, err := bot.ApprovePairingCode(args[1])
446 if err != nil {
447 fmt.Fprintf(os.Stderr, "error: approve pairing: %v\n", err)
448 return 1
449 }
450 fmt.Printf("Approved %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
451 return 0
452 case "reject", "deny":
453 if len(args) < 2 {
454 fmt.Fprintln(os.Stderr, "error: pairing reject requires a code")
455 return 2
456 }
457 req, err := bot.RejectPairingCode(args[1])
458 if err != nil {
459 fmt.Fprintf(os.Stderr, "error: reject pairing: %v\n", err)
460 return 1
461 }
462 fmt.Printf("Rejected %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
463 return 0
464 default:
465 fmt.Fprintf(os.Stderr, "unknown bot pairing subcommand %q\n\n", args[0])
466 botPairingUsage()
467 return 2
468 }
469 }
470
471 func botPairingUsage() {
472 fmt.Print(`reasonix bot pairing — approve pending bot DM pairings
473
474 Usage:
475 reasonix bot pairing list
476 reasonix bot pairing approve CODE
477 reasonix bot pairing reject CODE
478 `)
479 }
480
481 func botWeixinLogin(args []string) int {
482 fs := flag.NewFlagSet("bot weixin-login", flag.ContinueOnError)
483 timeoutSeconds := fs.Int("timeout", 480, "登录超时时间(秒)")
484 if code, ok := parseCommandFlags(fs, args); !ok {
485 return code
486 }
487
488 cfg, err := loadBotCommandConfig()
489 if err != nil {
490 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
491 return 1
492 }
493
494 if !cfg.Bot.Weixin.Enabled {
495 fmt.Fprintln(os.Stderr, "error: weixin bot is not enabled in config")
496 return 1
497 }
498
499 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second)
500 defer cancel()
501 result, err := weixin.Login(ctx, os.Stdout, time.Duration(*timeoutSeconds)*time.Second)
502 if err != nil {
503 fmt.Fprintf(os.Stderr, "error: weixin login failed: %v\n", err)
504 return 1
505 }
506 fmt.Printf("\n微信登录成功: account_id=%s user_id=%s base_url=%s\n", result.AccountID, result.UserID, result.BaseURL)
507 fmt.Println("凭据已保存到 Reasonix 用户配置目录;也可以把 [bot.weixin] account_id 设置为该 account_id。")
508
509 return 0
510 }
511
512 func loadBotCommandConfig() (*config.Config, error) {
513 cfg, err := config.Load()
514 if err != nil {
515 return nil, err
516 }
517 userPath := config.UserConfigPath()
518 if strings.TrimSpace(userPath) == "" {
519 return cfg, nil
520 }
521 if _, err := os.Stat(userPath); err != nil {
522 return cfg, nil
523 }
524 userCfg := config.LoadForEdit(userPath)
525 if botConfigIsUserOwned(userCfg.Bot) {
526 cfg.Bot = userCfg.Bot
527 }
528 return cfg, nil
529 }
530
531 func botConfigIsUserOwned(bc config.BotConfig) bool {
532 if bc.Enabled || len(bc.Connections) > 0 || bc.QQ.Enabled || bc.Feishu.Enabled || bc.Weixin.Enabled {
533 return true
534 }
535 if bc.Allowlist.AllowAll || botruntime.AllowlistUserCount(bc.Allowlist) > 0 {
536 return true
537 }
538 if botruntime.BotAccessActive(bc.QQ.Access) {
539 return true
540 }
541 for _, conn := range bc.Connections {
542 if botruntime.BotAccessActive(conn.Access) {
543 return true
544 }
545 }
546 return len(bc.Allowlist.QQGroups)+len(bc.Allowlist.FeishuGroups)+len(bc.Allowlist.WeixinGroups)+
547 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers)+
548 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins) > 0
549 }
550
551 func botUsage() {
552 fmt.Print(`reasonix bot — multi-channel IM bot gateway (QQ / Feishu / WeChat)
553
554 Usage:
555 reasonix bot start [--channels qq,feishu,lark,weixin] [--dir PATH] [--model NAME]
556 reasonix bot doctor [--json] [--deep]
557 reasonix bot pairing list|approve|reject
558 reasonix bot weixin-login [--timeout SECONDS]
559
560 Subcommands:
561 start 启动 bot 网关
562 doctor 诊断 bot 配置和连通性
563 pairing 查看或批准 IM 私聊配对
564 weixin-login 微信 iLink 二维码登录
565
566 Examples:
567 reasonix bot start --channels qq,feishu
568 reasonix bot start --dir /path/to/project --model deepseek-pro
569 reasonix bot doctor --json
570
571 Configuration:
572 Edit reasonix.toml:
573 [bot] enabled / model / max_steps
574 [bot] queue_mode / queue_cap / queue_drop
575 [bot.pairing] enabled / request_ttl_minutes / max_pending_per_platform
576 [bot.allowlist] enabled / users / approvers / admins / groups
577 [bot.qq] enabled / app_id / app_secret_env
578 [bot.feishu] enabled / app_id / app_secret_env / verification_token / mode
579 [bot.weixin] enabled / account_id / token_env / api_base
580
581 All secrets are read from environment variables; never put keys in config files.
582 `)
583 }
584
584 lines GO