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