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