package cli import ( "context" "flag" "fmt" "log/slog" "os" "os/signal" "strings" "syscall" "time" "reasonix/internal/bot" "reasonix/internal/bot/weixin" "reasonix/internal/botruntime" "reasonix/internal/config" ) func botCommand(args []string, version string) int { if len(args) < 1 { botUsage() return 2 } sub := args[0] rest := args[1:] switch sub { case "start": return botStart(rest, version) case "doctor": return botDoctor(rest) case "pairing": return botPairing(rest) case "weixin-login": return botWeixinLogin(rest) case "help", "--help", "-h": botUsage() return 0 default: fmt.Fprintf(os.Stderr, "unknown bot subcommand %q\n\n", sub) botUsage() return 2 } } func botStart(args []string, version string) int { fs := flag.NewFlagSet("bot start", flag.ContinueOnError) channels := fs.String("channels", "", "启用的平台,逗号分隔:qq,feishu,lark,weixin") dir := fs.String("dir", "", "工作目录") model := fs.String("model", "", "模型名(空则用 default_model)") if err := fs.Parse(args); err != nil { return 2 } ctx, cancel := context.WithCancel(context.Background()) defer cancel() cfg, err := loadBotCommandConfig() if err != nil { fmt.Fprintf(os.Stderr, "error: load config: %v\n", err) return 1 } if !cfg.Bot.Enabled { fmt.Fprintln(os.Stderr, "error: bot is not enabled in config — set [bot] enabled = true") return 1 } if !botruntime.BotConfigHasAccessControl(cfg.Bot) { 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") return 1 } workspaceRoot := *dir if workspaceRoot == "" { if wd, err := os.Getwd(); err == nil { workspaceRoot = wd } } requestedChannels := splitBotChannels(*channels) enabledPlatforms, unknownChannels := botruntime.EnabledPlatforms(cfg, requestedChannels) for _, ch := range unknownChannels { fmt.Fprintf(os.Stderr, "warning: unknown channel %q\n", ch) } if !botruntime.HasEnabledPlatform(enabledPlatforms) { fmt.Fprintln(os.Stderr, "error: no bot channels enabled — enable at least one in config") return 1 } modelName := botruntime.ModelName(cfg, *model) logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) rememberInboundRemote := botruntime.NewRemoteRememberer(logger) // 构建网关配置 gwCfg := bot.GatewayConfig{ Model: modelName, ToolApprovalMode: cfg.Bot.ToolApprovalMode, MaxSteps: cfg.Bot.MaxSteps, QueueMode: cfg.Bot.QueueMode, QueueCap: cfg.Bot.QueueCap, QueueDrop: cfg.Bot.QueueDrop, PairingEnabled: cfg.Bot.Pairing.Enabled, PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute, PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform, IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages, SelfUserIDs: map[bot.Platform][]string{ bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ, bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu, bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin, }, ControlEnabled: cfg.Bot.Control.Enabled, ControlAddr: cfg.Bot.Control.Addr, ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)), WorkspaceRoot: workspaceRoot, Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""), ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, *model == "", *dir == ""), Routes: botruntime.RouteConfigs(cfg.Bot.Routes, *model == "", *dir == ""), ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg), Enabled: enabledPlatforms, Allowlist: bot.AllowlistConfig{ Enabled: cfg.Bot.Allowlist.Enabled, AllowAll: cfg.Bot.Allowlist.AllowAll, Users: map[bot.Platform][]string{ bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers, bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers, bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers, }, Approvers: map[bot.Platform][]string{ bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers, bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers, bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers, }, Admins: map[bot.Platform][]string{ bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins, bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins, bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins, }, Groups: map[bot.Platform][]string{ bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups, bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups, bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups, }, }, Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond, OnInbound: rememberInboundRemote, OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot), } feishuDomains := botruntime.RequestedFeishuDomains(requestedChannels) gw := bot.NewGatewayWithAdapterBindings(gwCfg, botruntime.AdapterBindings(cfg, enabledPlatforms, feishuDomains, logger), logger) // 信号处理 sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigCh fmt.Fprintln(os.Stderr, "\nshutting down...") cancel() gw.Stop() }() fmt.Fprintf(os.Stderr, "reasonix bot starting (model: %s, channels: %s)...\n", modelName, *channels) fmt.Fprintf(os.Stderr, "version: %s\n", version) if err := gw.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "error: start gateway: %v\n", err) return 1 } // 等待信号或 context 取消 <-ctx.Done() return 0 } func splitBotChannels(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } return strings.Split(raw, ",") } func botDoctor(args []string) int { fs := flag.NewFlagSet("bot doctor", flag.ContinueOnError) jsonOut := fs.Bool("json", false, "JSON 格式输出") deep := fs.Bool("deep", false, "执行更详细的本机诊断") if err := fs.Parse(args); err != nil { return 2 } cfg, err := loadBotCommandConfig() if err != nil { fmt.Fprintf(os.Stderr, "error: load config: %v\n", err) return 1 } bc := cfg.Bot type checkResult struct { Name string `json:"name"` Status string `json:"status"` Detail string `json:"detail,omitempty"` } var results []checkResult addCheck := func(name, status, detail string) { results = append(results, checkResult{Name: name, Status: status, Detail: detail}) } // 基础检查 if bc.Enabled { addCheck("bot.enabled", "ok", "") } else { addCheck("bot.enabled", "disabled", "bot is not enabled in config") } if *deep { if path := config.UserConfigPath(); path != "" { if _, err := os.Stat(path); err == nil { addCheck("bot.config.user", "ok", path) } else { addCheck("bot.config.user", "missing", path) } } if dir := config.SessionDir(); dir != "" { addCheck("bot.sessions.dir", "ok", dir) } } queueMode := bot.NormalizeQueueMode(bc.QueueMode) queueCap := bc.QueueCap if queueCap <= 0 { queueCap = bot.DefaultQueueCap } addCheck("bot.queue", "ok", fmt.Sprintf("mode=%s cap=%d drop=%s", queueMode, queueCap, bot.NormalizeQueueDrop(bc.QueueDrop))) if bc.Pairing.Enabled { addCheck("bot.pairing", "enabled", fmt.Sprintf("ttl=%dm max_pending=%d", bc.Pairing.RequestTTLMinutes, bc.Pairing.MaxPendingPerPlatform)) } else { addCheck("bot.pairing", "disabled", "") } if *deep { reqs, err := bot.ListPairingRequests() if err != nil { addCheck("bot.pairing.pending", "error", err.Error()) } else { addCheck("bot.pairing.pending", "ok", fmt.Sprintf("%d pending", len(reqs))) } if path := bot.PairingStorePath(); path != "" { if info, err := os.Stat(path); err == nil { addCheck("bot.pairing.store", "ok", fmt.Sprintf("%s mode=%s", path, info.Mode().Perm())) } else { addCheck("bot.pairing.store", "missing", path) } } } if *deep { selfStatus := "disabled" if bc.IgnoreSelfMessages { selfStatus = "enabled" } addCheck("bot.self_protection", selfStatus, fmt.Sprintf("self_ids=%d", len(bc.SelfUserIDs.QQ)+len(bc.SelfUserIDs.Feishu)+len(bc.SelfUserIDs.Weixin))) controlStatus := "disabled" controlDetail := "" if bc.Control.Enabled { controlStatus = "enabled" tokenStatus := "missing_token" if strings.TrimSpace(bc.Control.TokenEnv) != "" && os.Getenv(strings.TrimSpace(bc.Control.TokenEnv)) != "" { tokenStatus = "token_set" } addr := strings.TrimSpace(bc.Control.Addr) if addr == "" { addr = "127.0.0.1:37913" } controlDetail = fmt.Sprintf("addr=%s token_env=%s %s", addr, bc.Control.TokenEnv, tokenStatus) } addCheck("bot.control", controlStatus, controlDetail) addCheck("bot.routes", "ok", fmt.Sprintf("%d routes", len(bc.Routes))) } // QQ 检查 if bc.QQ.Enabled { addCheck("bot.qq.enabled", "ok", "") secret := os.Getenv(bc.QQ.AppSecretEnv) if secret == "" { addCheck("bot.qq.app_secret", "missing", bc.QQ.AppSecretEnv+" is not set") } else { addCheck("bot.qq.app_secret", "ok", bc.QQ.AppSecretEnv+" is set") } if bc.QQ.AppID == "" { addCheck("bot.qq.app_id", "missing", "app_id is empty") } else { addCheck("bot.qq.app_id", "ok", "app_id configured") } } else { addCheck("bot.qq", "disabled", "") } // 飞书检查 if bc.Feishu.Enabled { addCheck("bot.feishu.enabled", "ok", "") secret := os.Getenv(bc.Feishu.AppSecretEnv) if secret == "" { addCheck("bot.feishu.app_secret", "missing", bc.Feishu.AppSecretEnv+" is not set") } else { addCheck("bot.feishu.app_secret", "ok", bc.Feishu.AppSecretEnv+" is set") } if bc.Feishu.AppID == "" { addCheck("bot.feishu.app_id", "missing", "app_id is empty") } else { addCheck("bot.feishu.app_id", "ok", "app_id configured") } mode := bc.Feishu.Mode if mode == "" { mode = "webhook" } addCheck("bot.feishu.mode", "ok", mode) } else { addCheck("bot.feishu", "disabled", "") } // 微信检查 if bc.Weixin.Enabled { addCheck("bot.weixin.enabled", "ok", "") token := os.Getenv(bc.Weixin.TokenEnv) if token != "" { addCheck("bot.weixin.token", "ok", bc.Weixin.TokenEnv+" is set") } else if weixin.HasSavedAccount(bc.Weixin.AccountID) { addCheck("bot.weixin.token", "ok", "saved iLink account is available") } else { addCheck("bot.weixin.token", "missing", bc.Weixin.TokenEnv+" is not set; run `reasonix bot weixin-login` to save an iLink account") } } else { addCheck("bot.weixin", "disabled", "") } enabledConnections := 0 for _, conn := range bc.Connections { if conn.Enabled { enabledConnections++ } } addCheck("bot.connections", "ok", fmt.Sprintf("enabled=%d total=%d", enabledConnections, len(bc.Connections))) for _, conn := range bc.Connections { id := strings.TrimSpace(conn.ID) if id == "" { id = strings.TrimSpace(conn.Provider) } status := "ok" if !conn.Enabled { status = "disabled" } else if len(conn.SessionMappings) == 0 && (conn.Provider == string(bot.PlatformFeishu) || conn.Provider == string(bot.PlatformWeixin)) { status = "missing" } addCheck("bot.connection."+id+".session_mappings", status, fmt.Sprintf("provider=%s mappings=%d", conn.Provider, len(conn.SessionMappings))) } // Allowlist 检查 if bc.Allowlist.AllowAll { addCheck("bot.allowlist", "open", "allow_all=true — every reachable user can trigger local tools") } else if bc.Allowlist.Enabled { addCheck("bot.allowlist", "enabled", fmt.Sprintf("qq=%d feishu=%d weixin=%d users approvers=%d admins=%d", len(bc.Allowlist.QQUsers), len(bc.Allowlist.FeishuUsers), len(bc.Allowlist.WeixinUsers), len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers), len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins))) } else { addCheck("bot.allowlist", "missing", "bot start will refuse without allowlist or allow_all=true") } if *deep { addCheck("bot.roles", "ok", fmt.Sprintf("approvers=%d admins=%d", len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers), len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins))) } if *jsonOut { fmt.Println("[") for i, r := range results { comma := "," if i == len(results)-1 { comma = "" } fmt.Printf(" {\"name\":%q,\"status\":%q,\"detail\":%q}%s\n", r.Name, r.Status, r.Detail, comma) } fmt.Println("]") } else { for _, r := range results { marker := "✓" if r.Status == "missing" || r.Status == "disabled" { marker = "✗" } fmt.Printf(" %s %s: %s", marker, r.Name, r.Status) if r.Detail != "" { fmt.Printf(" — %s", r.Detail) } fmt.Println() } } return 0 } func botPairing(args []string) int { if len(args) < 1 { botPairingUsage() return 2 } switch args[0] { case "list": reqs, err := bot.ListPairingRequests() if err != nil { fmt.Fprintf(os.Stderr, "error: list pairing requests: %v\n", err) return 1 } if len(reqs) == 0 { fmt.Println("No pending bot pairing requests.") return 0 } for _, req := range reqs { fmt.Printf("%s\t%s\t%s\tuser=%s\tchat=%s\texpires=%s\n", req.Code, req.Platform, req.ChatType, req.UserID, req.ChatID, req.ExpiresAt.Local().Format("2006-01-02 15:04"), ) } return 0 case "approve": if len(args) < 2 { fmt.Fprintln(os.Stderr, "error: pairing approve requires a code") return 2 } req, err := bot.ApprovePairingCode(args[1]) if err != nil { fmt.Fprintf(os.Stderr, "error: approve pairing: %v\n", err) return 1 } fmt.Printf("Approved %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID) return 0 case "reject", "deny": if len(args) < 2 { fmt.Fprintln(os.Stderr, "error: pairing reject requires a code") return 2 } req, err := bot.RejectPairingCode(args[1]) if err != nil { fmt.Fprintf(os.Stderr, "error: reject pairing: %v\n", err) return 1 } fmt.Printf("Rejected %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID) return 0 default: fmt.Fprintf(os.Stderr, "unknown bot pairing subcommand %q\n\n", args[0]) botPairingUsage() return 2 } } func botPairingUsage() { fmt.Print(`reasonix bot pairing — approve pending bot DM pairings Usage: reasonix bot pairing list reasonix bot pairing approve CODE reasonix bot pairing reject CODE `) } func botWeixinLogin(args []string) int { fs := flag.NewFlagSet("bot weixin-login", flag.ContinueOnError) timeoutSeconds := fs.Int("timeout", 480, "登录超时时间(秒)") if err := fs.Parse(args); err != nil { return 2 } cfg, err := loadBotCommandConfig() if err != nil { fmt.Fprintf(os.Stderr, "error: load config: %v\n", err) return 1 } if !cfg.Bot.Weixin.Enabled { fmt.Fprintln(os.Stderr, "error: weixin bot is not enabled in config") return 1 } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second) defer cancel() result, err := weixin.Login(ctx, os.Stdout, time.Duration(*timeoutSeconds)*time.Second) if err != nil { fmt.Fprintf(os.Stderr, "error: weixin login failed: %v\n", err) return 1 } fmt.Printf("\n微信登录成功: account_id=%s user_id=%s base_url=%s\n", result.AccountID, result.UserID, result.BaseURL) fmt.Println("凭据已保存到 Reasonix 用户配置目录;也可以把 [bot.weixin] account_id 设置为该 account_id。") return 0 } func loadBotCommandConfig() (*config.Config, error) { cfg, err := config.Load() if err != nil { return nil, err } userPath := config.UserConfigPath() if strings.TrimSpace(userPath) == "" { return cfg, nil } if _, err := os.Stat(userPath); err != nil { return cfg, nil } userCfg := config.LoadForEdit(userPath) if botConfigIsUserOwned(userCfg.Bot) { cfg.Bot = userCfg.Bot } return cfg, nil } func botConfigIsUserOwned(bc config.BotConfig) bool { if bc.Enabled || len(bc.Connections) > 0 || bc.QQ.Enabled || bc.Feishu.Enabled || bc.Weixin.Enabled { return true } if bc.Allowlist.AllowAll || botruntime.AllowlistUserCount(bc.Allowlist) > 0 { return true } if botruntime.BotAccessActive(bc.QQ.Access) { return true } for _, conn := range bc.Connections { if botruntime.BotAccessActive(conn.Access) { return true } } return len(bc.Allowlist.QQGroups)+len(bc.Allowlist.FeishuGroups)+len(bc.Allowlist.WeixinGroups)+ len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers)+ len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins) > 0 } func botUsage() { fmt.Print(`reasonix bot — multi-channel IM bot gateway (QQ / Feishu / WeChat) Usage: reasonix bot start [--channels qq,feishu,lark,weixin] [--dir PATH] [--model NAME] reasonix bot doctor [--json] [--deep] reasonix bot pairing list|approve|reject reasonix bot weixin-login [--timeout SECONDS] Subcommands: start 启动 bot 网关 doctor 诊断 bot 配置和连通性 pairing 查看或批准 IM 私聊配对 weixin-login 微信 iLink 二维码登录 Examples: reasonix bot start --channels qq,feishu reasonix bot start --dir /path/to/project --model deepseek-pro reasonix bot doctor --json Configuration: Edit reasonix.toml: [bot] enabled / model / max_steps [bot] queue_mode / queue_cap / queue_drop [bot.pairing] enabled / request_ttl_minutes / max_pending_per_platform [bot.allowlist] enabled / users / approvers / admins / groups [bot.qq] enabled / app_id / app_secret_env [bot.feishu] enabled / app_id / app_secret_env / verification_token / mode [bot.weixin] enabled / account_id / token_env / api_base All secrets are read from environment variables; never put keys in config files. `) }