diff --git a/README.md b/README.md index 4042159f4..ab02001d0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ commands, `@` references, and two-model setup are all in the - **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins (MCP), slash commands, `@` references, two-model collaboration. +- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots + from the desktop app, then use approvals, YOLO, and commands from IM. - **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries, data types, and roadmap. - **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy diff --git a/README.zh-CN.md b/README.zh-CN.md index 9577b28ac..f6dfc8858 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,6 +117,8 @@ Linux 为 `~/.config/reasonix/`,macOS 为 `~/Library/Application Support/reasoni - **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、 `@` 引用、双模型协同。 +- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信 + Bot,以及 IM 里的审批、YOLO 和命令交互。 - **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。 - **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。 - **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网 diff --git a/desktop/app.go b/desktop/app.go index 0a05526b3..1999c1014 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -78,6 +78,7 @@ type App struct { mediaTokens *mediaTokenStore botInstalls map[string]*botInstallSession + botRuntime *desktopBotRuntime metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics } @@ -248,7 +249,7 @@ func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { // NewApp constructs the bound object. Tabs are restored in startup from the // last session's desktop-tabs.json. func NewApp() *App { - return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}} + return &App{tabs: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}, botRuntime: newDesktopBotRuntime()} } func (a *App) bootContext() context.Context { @@ -277,6 +278,7 @@ func (a *App) startup(ctx context.Context) { } go a.restoreOrBuildTabs() + go a.refreshBotRuntime() go a.sendStartupPing() go a.flushMetrics() go a.flushPendingCrash() @@ -471,6 +473,7 @@ func (a *App) snapshotAllTabs() { // shutdown snapshots all tabs, saves the final window geometry, and closes tabs. func (a *App) shutdown(context.Context) { + a.stopBotRuntime() a.stopTray() // Save window geometry synchronously from Go so it's persisted even if the // frontend's beforeunload promise hasn't resolved yet. diff --git a/desktop/bot_connection_app.go b/desktop/bot_connection_app.go index 9b682a3c7..86ef7d0dd 100644 --- a/desktop/bot_connection_app.go +++ b/desktop/bot_connection_app.go @@ -35,19 +35,20 @@ type BotConnectionSessionMappingView struct { } type BotConnectionView struct { - ID string `json:"id"` - Provider string `json:"provider"` - Domain string `json:"domain"` - Label string `json:"label"` - Enabled bool `json:"enabled"` - Status string `json:"status"` - Model string `json:"model"` - WorkspaceRoot string `json:"workspaceRoot"` - Credential BotConnectionCredentialView `json:"credential"` - SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"` - LastError string `json:"lastError"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Provider string `json:"provider"` + Domain string `json:"domain"` + Label string `json:"label"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Model string `json:"model"` + ToolApprovalMode string `json:"toolApprovalMode"` + WorkspaceRoot string `json:"workspaceRoot"` + Credential BotConnectionCredentialView `json:"credential"` + SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"` + LastError string `json:"lastError"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } type BotInstallStartResult struct { @@ -82,6 +83,7 @@ type BotConnectionDiagnostic struct { type botInstallSession struct { Provider string Domain string + PollDomain string DeviceCode string UserCode string StartedAt time.Time @@ -162,17 +164,19 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, if c.Bot.Weixin.TokenEnv == "" { c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN" } + c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID) }) if err != nil { return BotInstallPollResult{Status: "error", Error: err.Error()}, nil } + a.refreshBotRuntimeAsync() return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil } return a.pollFeishuConnectionInstall(installID, session) } func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) { - cfg, err := config.Load() + cfg, err := a.loadDesktopBotConfig() if err != nil { return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, nil } @@ -197,7 +201,7 @@ func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) } func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) { - cfg, err := config.Load() + cfg, err := a.loadDesktopBotConfig() if err != nil { return BotConnectionDiagnostic{ID: id, Status: "error", Message: err.Error()}, nil } @@ -248,11 +252,11 @@ func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, err } func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) { - base := feishuAccountsBase(domain) - if _, err := postFeishuInstallForm(base, map[string]string{"action": "init"}); err != nil { - return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil - } - data, err := postFeishuInstallForm(base, map[string]string{ + // The official registration SDK always begins on the Feishu accounts domain. + // Lark tenants are detected from the first poll response, then polling moves + // to the Lark accounts domain for the final credential exchange. + beginDomain := "feishu" + data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{ "action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id", }) if err != nil { @@ -264,6 +268,10 @@ func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult if deviceCode == "" || verifyURL == "" { return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil } + qrURL, err := feishuRegistrationQRCodeURL(verifyURL) + if err != nil { + return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil + } installID := randomInstallID() interval := intValue(data["interval"], 5) expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300) @@ -272,15 +280,16 @@ func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult a.botInstalls = map[string]*botInstallSession{} } a.botInstalls[installID] = &botInstallSession{ - Provider: "feishu", Domain: domain, DeviceCode: deviceCode, UserCode: userCode, + Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode, StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second), } a.mu.Unlock() - return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: verifyURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil + return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil } func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) { - data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(session.Domain), map[string]string{"action": "poll", "device_code": session.DeviceCode}) + pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu") + data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode}) if err != nil { return BotInstallPollResult{Status: "error", Error: err.Error()}, nil } @@ -295,13 +304,22 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS a.deleteBotInstall(installID) return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil } + if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" { + a.mu.Lock() + if current := a.botInstalls[installID]; current != nil { + current.PollDomain = "lark" + } + a.mu.Unlock() + return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil + } appID := stringValue(data["client_id"]) appSecret := stringValue(data["client_secret"]) if appID == "" || appSecret == "" { return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil } a.deleteBotInstall(installID) - domain := feishuInstallDomain(session.Domain, data) + domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data) + userID := feishuInstallUserID(data) secretEnv := "FEISHU_BOT_APP_SECRET" if domain == "lark" { secretEnv = "LARK_BOT_APP_SECRET" @@ -329,10 +347,12 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS c.Bot.Feishu.AppSecretEnv = secretEnv c.Bot.Feishu.Mode = "websocket" c.Bot.Feishu.RequireMention = true + c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID) }) if err != nil { return BotInstallPollResult{Status: "error", Error: err.Error()}, nil } + a.refreshBotRuntimeAsync() return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil } @@ -445,6 +465,19 @@ func feishuAccountsBase(domain string) string { return "https://accounts.feishu.cn" } +func feishuRegistrationQRCodeURL(rawURL string) (string, error) { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", err + } + query := parsedURL.Query() + query.Set("from", "sdk") + query.Set("tp", "sdk") + query.Set("source", "go-sdk") + parsedURL.RawQuery = query.Encode() + return parsedURL.String(), nil +} + func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) { data, status, err := postFeishuInstallFormResult(base, body) if err != nil { @@ -483,7 +516,7 @@ func postFeishuInstallFormResult(base string, body map[string]string) (map[strin func botConnectionView(conn config.BotConnectionConfig) BotConnectionView { return BotConnectionView{ ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status, - Model: conn.Model, WorkspaceRoot: conn.WorkspaceRoot, + Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot, Credential: BotConnectionCredentialView{ AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv, SecretSet: botCredentialSecretSet(conn), @@ -519,6 +552,17 @@ func feishuInstallDomain(fallback string, data map[string]any) string { return "feishu" } +func feishuInstallUserID(data map[string]any) string { + if userInfo, ok := data["user_info"].(map[string]any); ok { + return firstNonEmptyBot( + stringValue(userInfo["open_id"]), + stringValue(userInfo["union_id"]), + stringValue(userInfo["user_id"]), + ) + } + return "" +} + func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView { if connections == nil { return []BotConnectionView{} @@ -532,14 +576,15 @@ func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectio func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig { return config.BotConnectionConfig{ - ID: strings.TrimSpace(view.ID), - Provider: strings.TrimSpace(view.Provider), - Domain: strings.TrimSpace(view.Domain), - Label: strings.TrimSpace(view.Label), - Enabled: view.Enabled, - Status: strings.TrimSpace(view.Status), - Model: strings.TrimSpace(view.Model), - WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot), + ID: strings.TrimSpace(view.ID), + Provider: strings.TrimSpace(view.Provider), + Domain: strings.TrimSpace(view.Domain), + Label: strings.TrimSpace(view.Label), + Enabled: view.Enabled, + Status: strings.TrimSpace(view.Status), + Model: strings.TrimSpace(view.Model), + ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), + WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot), Credential: config.BotConnectionCredential{ AppID: strings.TrimSpace(view.Credential.AppID), AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv), @@ -553,6 +598,19 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig { } } +func normalizeBotConnectionToolApprovalMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "ask": + return "ask" + case "auto": + return "auto" + case "yolo", "full", "full-access", "bypass": + return "yolo" + default: + return "" + } +} + func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig { if views == nil { return nil @@ -657,6 +715,19 @@ func firstNonEmptyBot(values ...string) string { return "" } +func appendUniqueBotString(values []string, next string) []string { + next = strings.TrimSpace(next) + if next == "" { + return values + } + for _, value := range values { + if strings.TrimSpace(value) == next { + return values + } + } + return append(values, next) +} + func stringValue(value any) string { if value == nil { return "" diff --git a/desktop/bot_connection_app_test.go b/desktop/bot_connection_app_test.go index 189460786..a2d9a7e5e 100644 --- a/desktop/bot_connection_app_test.go +++ b/desktop/bot_connection_app_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -32,8 +33,13 @@ func TestNormalizeBotInstallTarget(t *testing.T) { } } -func TestFeishuInstallUsesReturnedTenantBrandAndStoresSecret(t *testing.T) { +func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) { isolateDesktopUserDirs(t) + t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") }) + pollCount := 0 + var beginHost string + var pollHosts []string + var actions []string withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth/v1/app/registration" { http.NotFound(w, r) @@ -44,29 +50,36 @@ func TestFeishuInstallUsesReturnedTenantBrandAndStoresSecret(t *testing.T) { return } switch r.Form.Get("action") { - case "init": - writeJSON(t, w, map[string]any{"ok": true}) case "begin": + beginHost = r.Header.Get("X-Test-Original-Host") + actions = append(actions, "begin") if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" { http.Error(w, "wrong begin form", http.StatusBadRequest) return } writeJSON(t, w, map[string]any{ - "device_code": "dev-feishu", - "verification_uri_complete": "https://accounts.example/verify", + "device_code": "dev-lark", + "verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE", "user_code": "CODE", "interval": 3, "expire_in": 300, }) case "poll": - if r.Form.Get("device_code") != "dev-feishu" { + pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host")) + actions = append(actions, "poll") + if r.Form.Get("device_code") != "dev-lark" { http.Error(w, "wrong device code", http.StatusBadRequest) return } + pollCount++ + if pollCount == 1 { + writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}}) + return + } writeJSON(t, w, map[string]any{ "client_id": "cli-1", "client_secret": "secret-1", - "user_info": map[string]any{"tenant_brand": "lark"}, + "user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"}, }) default: http.Error(w, "unknown action", http.StatusBadRequest) @@ -74,14 +87,32 @@ func TestFeishuInstallUsesReturnedTenantBrandAndStoresSecret(t *testing.T) { })) app := NewApp() - start, err := app.StartBotConnectionInstall("feishu", "feishu") + start, err := app.StartBotConnectionInstall("lark", "") if err != nil { t.Fatalf("StartBotConnectionInstall: %v", err) } - if !start.OK || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-feishu" { + if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" { t.Fatalf("start result = %+v, want ok lark-capable QR result", start) } + qrURL, err := url.Parse(start.URL) + if err != nil { + t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err) + } + query := qrURL.Query() + if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" { + t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query) + } + if qrURL.Host != "open.feishu.cn" { + t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host) + } + pending, err := app.PollBotConnectionInstall(start.InstallID) + if err != nil { + t.Fatalf("PollBotConnectionInstall pending: %v", err) + } + if pending.Done || pending.Status != "pending" { + t.Fatalf("pending poll result = %+v, want pending domain switch", pending) + } poll, err := app.PollBotConnectionInstall(start.InstallID) if err != nil { t.Fatalf("PollBotConnectionInstall: %v", err) @@ -92,6 +123,15 @@ func TestFeishuInstallUsesReturnedTenantBrandAndStoresSecret(t *testing.T) { if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" { t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection) } + if beginHost != "accounts.feishu.cn" { + t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost) + } + if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" { + t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got) + } + if got := strings.Join(actions, ","); got != "begin,poll,poll" { + t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got) + } if poll.Connection.WorkspaceRoot != "" { t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot) } @@ -102,6 +142,178 @@ func TestFeishuInstallUsesReturnedTenantBrandAndStoresSecret(t *testing.T) { if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention { t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu) } + if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" { + t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers) + } + if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil { + t.Fatalf("unset lark secret env: %v", err) + } + reloaded, err := config.Load() + if err != nil { + t.Fatalf("reload config: %v", err) + } + if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" { + t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got) + } + if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet { + t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections) + } +} + +func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) { + isolateDesktopUserDirs(t) + t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") }) + pollCount := 0 + var beginHost string + var pollHosts []string + withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/v1/app/registration" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch r.Form.Get("action") { + case "begin": + beginHost = r.Header.Get("X-Test-Original-Host") + writeJSON(t, w, map[string]any{ + "device_code": "dev-feishu", + "verification_uri_complete": "https://accounts.example/verify?user_code=CODE", + "user_code": "CODE", + "interval": 3, + "expire_in": 300, + }) + case "poll": + pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host")) + if r.Form.Get("device_code") != "dev-feishu" { + http.Error(w, "wrong device code", http.StatusBadRequest) + return + } + pollCount++ + if pollCount == 1 { + writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}}) + return + } + writeJSON(t, w, map[string]any{ + "client_id": "cli-lark", + "client_secret": "secret-lark", + "user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"}, + }) + default: + http.Error(w, "unknown action", http.StatusBadRequest) + } + })) + + app := NewApp() + start, err := app.StartBotConnectionInstall("feishu", "") + if err != nil { + t.Fatalf("StartBotConnectionInstall: %v", err) + } + if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" { + t.Fatalf("start result = %+v, want Feishu QR result", start) + } + pending, err := app.PollBotConnectionInstall(start.InstallID) + if err != nil { + t.Fatalf("PollBotConnectionInstall pending: %v", err) + } + if pending.Done || pending.Status != "pending" { + t.Fatalf("pending poll result = %+v, want pending domain switch", pending) + } + poll, err := app.PollBotConnectionInstall(start.InstallID) + if err != nil { + t.Fatalf("PollBotConnectionInstall: %v", err) + } + if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" { + t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll) + } + if beginHost != "accounts.feishu.cn" { + t.Fatalf("begin host = %q, want Feishu accounts host", beginHost) + } + if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" { + t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got) + } +} + +func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) { + isolateDesktopUserDirs(t) + t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") }) + var hosts []string + withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/v1/app/registration" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host")) + switch r.Form.Get("action") { + case "begin": + writeJSON(t, w, map[string]any{ + "device_code": "dev-feishu", + "verification_uri_complete": "https://accounts.example/verify?user_code=CODE", + "user_code": "CODE", + "interval": 3, + "expire_in": 300, + }) + case "poll": + writeJSON(t, w, map[string]any{ + "client_id": "cli-feishu", + "client_secret": "secret-feishu", + "user_info": map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"}, + }) + default: + http.Error(w, "unknown action", http.StatusBadRequest) + } + })) + + app := NewApp() + start, err := app.StartBotConnectionInstall("feishu", "") + if err != nil { + t.Fatalf("StartBotConnectionInstall: %v", err) + } + if !start.OK || start.Domain != "feishu" || start.InstallID == "" { + t.Fatalf("start result = %+v, want ok Feishu QR result", start) + } + poll, err := app.PollBotConnectionInstall(start.InstallID) + if err != nil { + t.Fatalf("PollBotConnectionInstall: %v", err) + } + if !poll.Done { + t.Fatalf("poll result = %+v, want done", poll) + } + if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" { + t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection) + } + if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet { + t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential) + } + if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" { + t.Fatalf("registration hosts = %q, want Feishu begin and poll", got) + } + cfg := config.LoadForEdit(config.UserConfigPath()) + if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" { + t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu) + } + if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" { + t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers) + } + if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil { + t.Fatalf("unset feishu secret env: %v", err) + } + reloaded, err := config.Load() + if err != nil { + t.Fatalf("reload config: %v", err) + } + if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" { + t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got) + } + if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet { + t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections) + } } func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) { @@ -163,6 +375,38 @@ func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) { if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" { t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin) } + if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" { + t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers) + } + reloaded, err := config.Load() + if err != nil { + t.Fatalf("reload config: %v", err) + } + if len(reloaded.Bot.Connections) != 1 { + t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections) + } + reloadedConnection := botConnectionView(reloaded.Bot.Connections[0]) + if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet { + t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential) + } +} + +func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) { + qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old") + if err != nil { + t.Fatalf("feishuRegistrationQRCodeURL: %v", err) + } + parsed, err := url.Parse(qrURL) + if err != nil { + t.Fatalf("parse QR URL: %v", err) + } + query := parsed.Query() + if query.Get("user_code") != "ABCD-1234" { + t.Fatalf("user_code = %q, want preserved code", query.Get("user_code")) + } + if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" { + t.Fatalf("query = %v, want SDK registration metadata", query) + } } func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) { @@ -242,6 +486,7 @@ type rewriteHTTPTransport struct { func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { clone := req.Clone(req.Context()) + clone.Header.Set("X-Test-Original-Host", req.URL.Host) clone.URL.Scheme = r.target.Scheme clone.URL.Host = r.target.Host clone.Host = r.target.Host diff --git a/desktop/bot_runtime_app.go b/desktop/bot_runtime_app.go new file mode 100644 index 000000000..46c668e7f --- /dev/null +++ b/desktop/bot_runtime_app.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "time" + + "reasonix/internal/bot" + "reasonix/internal/botruntime" + "reasonix/internal/config" +) + +type BotRuntimeStatusView struct { + Running bool `json:"running"` + Status string `json:"status"` + Message string `json:"message"` + Connections int `json:"connections"` + StartedAt string `json:"startedAt"` +} + +type desktopBotRuntime struct { + mu sync.Mutex + cancel context.CancelFunc + gw *bot.BotGateway + status BotRuntimeStatusView +} + +func newDesktopBotRuntime() *desktopBotRuntime { + return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}} +} + +func (a *App) refreshBotRuntimeAsync() { + if a.ctx == nil { + return + } + go a.refreshBotRuntime() +} + +func (a *App) refreshBotRuntime() { + if a.botRuntime == nil { + a.botRuntime = newDesktopBotRuntime() + } + cfg, err := a.loadDesktopBotConfig() + if err != nil { + a.botRuntime.stop("error", err.Error()) + return + } + _ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode) +} + +func (a *App) loadDesktopBotConfig() (*config.Config, error) { + cfg, _, err := a.loadDesktopUserConfigForEdit() + if err != nil { + return nil, err + } + return cfg, nil +} + +func (a *App) stopBotRuntime() { + if a.botRuntime != nil { + a.botRuntime.stop("stopped", "bot runtime stopped") + } +} + +func (a *App) BotRuntimeStatus() BotRuntimeStatusView { + if a.botRuntime == nil { + return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"} + } + return a.botRuntime.snapshot() +} + +func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error) error { + if r == nil { + return nil + } + if parent == nil { + parent = context.Background() + } + plan := desktopBotRuntimePlan(cfg) + r.mu.Lock() + defer r.mu.Unlock() + r.stopLocked() + if !plan.Start { + r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message} + return nil + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + ctx, cancel := context.WithCancel(parent) + modelName := botruntime.ModelName(cfg, "") + gwCfg := bot.GatewayConfig{ + Model: modelName, + ToolApprovalMode: cfg.Bot.ToolApprovalMode, + MaxSteps: cfg.Bot.MaxSteps, + WorkspaceRoot: workspaceRoot, + Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true), + ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true), + Enabled: plan.Enabled, + 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, + }, + 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: botruntime.NewRemoteRememberer(logger), + OnToolApprovalModeChange: onToolApprovalModeChange, + } + bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger) + if len(bindings) == 0 { + cancel() + r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"} + return nil + } + gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger) + if err := gw.Start(ctx); err != nil { + cancel() + gw.Stop() + r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()} + return err + } + runningConnections := gw.AdapterCount() + startErrors := gw.StartErrors() + status := "running" + message := fmt.Sprintf("%d bot connection(s) running", runningConnections) + if len(startErrors) > 0 { + status = "degraded" + message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors)) + } + r.cancel = cancel + r.gw = gw + r.status = BotRuntimeStatusView{ + Running: true, + Status: status, + Message: message, + Connections: runningConnections, + StartedAt: time.Now().UTC().Format(time.RFC3339), + } + return nil +} + +func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error { + mode = normalizeBotConnectionToolApprovalMode(mode) + if mode == "" { + return nil + } + return a.applyConfigOnly(func(c *config.Config) error { + id := strings.TrimSpace(msg.ConnectionID) + now := time.Now().UTC().Format(time.RFC3339) + if id != "" { + for i := range c.Bot.Connections { + if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id { + c.Bot.Connections[i].ToolApprovalMode = mode + c.Bot.Connections[i].UpdatedAt = now + return nil + } + } + } + c.Bot.ToolApprovalMode = mode + return nil + }) +} + +func summarizeBotRuntimeErrors(errs []error) string { + parts := make([]string, 0, len(errs)) + for _, err := range errs { + if err == nil { + continue + } + parts = append(parts, err.Error()) + } + if len(parts) == 0 { + return "" + } + if len(parts) > 3 { + hidden := len(parts) - 3 + parts = append(parts[:3], fmt.Sprintf("%d more", hidden)) + } + return strings.Join(parts, "; ") +} + +type botRuntimePlan struct { + Start bool + Status string + Message string + Enabled map[bot.Platform]bool +} + +func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan { + if cfg == nil { + return botRuntimePlan{Status: "error", Message: "config is unavailable"} + } + if !cfg.Bot.Enabled { + return botRuntimePlan{Status: "stopped", Message: "bot is disabled"} + } + if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) { + return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"} + } + enabled, unknown := botruntime.EnabledPlatforms(cfg, nil) + if len(unknown) > 0 { + return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")} + } + if !botruntime.HasEnabledPlatform(enabled) { + return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"} + } + return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled} +} + +func (r *desktopBotRuntime) stop(status, message string) { + r.mu.Lock() + defer r.mu.Unlock() + r.stopLocked() + r.status = BotRuntimeStatusView{Status: status, Message: message} +} + +func (r *desktopBotRuntime) stopLocked() { + if r.cancel != nil { + r.cancel() + r.cancel = nil + } + if r.gw != nil { + r.gw.Stop() + r.gw = nil + } +} + +func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView { + r.mu.Lock() + defer r.mu.Unlock() + return r.status +} diff --git a/desktop/bot_runtime_app_test.go b/desktop/bot_runtime_app_test.go new file mode 100644 index 000000000..8b6025dca --- /dev/null +++ b/desktop/bot_runtime_app_test.go @@ -0,0 +1,413 @@ +package main + +import ( + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/bot" + "reasonix/internal/botruntime" + "reasonix/internal/config" +) + +func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) { + cfg := config.Default() + cfg.Bot.Enabled = true + cfg.Bot.Allowlist.Enabled = true + cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"} + cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"} + cfg.Bot.Connections = []config.BotConnectionConfig{ + {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true}, + {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true}, + {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true}, + } + + plan := desktopBotRuntimePlan(cfg) + if !plan.Start { + t.Fatalf("plan = %+v, want start", plan) + } + if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] { + t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled) + } +} + +func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) { + cfg := config.Default() + cfg.Bot.Enabled = true + cfg.Bot.Allowlist.Enabled = true + cfg.Bot.Connections = []config.BotConnectionConfig{ + {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true}, + } + + plan := desktopBotRuntimePlan(cfg) + if plan.Start || plan.Status != "blocked" { + t.Fatalf("plan = %+v, want blocked without allowlist", plan) + } +} + +func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) { + cfg := config.Default() + cfg.Bot.Enabled = false + cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"} + cfg.Bot.Connections = []config.BotConnectionConfig{ + {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true}, + } + + plan := desktopBotRuntimePlan(cfg) + if plan.Start || plan.Status != "stopped" { + t.Fatalf("plan = %+v, want stopped when disabled", plan) + } +} + +func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) { + isolateDesktopUserDirs(t) + + userCfg := config.LoadForEdit(config.UserConfigPath()) + userCfg.Bot.Enabled = true + userCfg.Bot.Allowlist.Enabled = true + userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"} + userCfg.Bot.Connections = []config.BotConnectionConfig{ + {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"}, + } + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + + project := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` +[bot] +enabled = false +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(project); err != nil { + t.Fatalf("chdir project: %v", err) + } + + got, err := NewApp().loadDesktopBotConfig() + if err != nil { + t.Fatalf("load desktop bot config: %v", err) + } + plan := desktopBotRuntimePlan(got) + if !plan.Start || !plan.Enabled[bot.PlatformFeishu] { + t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan) + } +} + +func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) { + isolateDesktopUserDirs(t) + t.Cleanup(func() { + _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") + _ = os.Unsetenv("LARK_BOT_APP_SECRET") + }) + + userCfg := config.Default() + userCfg.Bot.Enabled = true + userCfg.Bot.Allowlist.Enabled = true + userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"} + userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"} + userCfg.Bot.Feishu.Enabled = true + userCfg.Bot.Weixin.Enabled = true + userCfg.Bot.Weixin.AccountID = "weixin-account" + userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN" + userCfg.Bot.Connections = []config.BotConnectionConfig{ + { + ID: "feishu-feishu", + Provider: "feishu", + Domain: "feishu", + Enabled: true, + Status: "connected", + Credential: config.BotConnectionCredential{ + AppID: "cli-feishu", + AppSecretEnv: "FEISHU_BOT_APP_SECRET", + }, + }, + { + ID: "feishu-lark", + Provider: "feishu", + Domain: "lark", + Enabled: true, + Status: "connected", + Credential: config.BotConnectionCredential{ + AppID: "cli-lark", + AppSecretEnv: "LARK_BOT_APP_SECRET", + }, + }, + { + ID: "weixin-weixin", + Provider: "weixin", + Domain: "weixin", + Enabled: true, + Status: "connected", + Credential: config.BotConnectionCredential{ + AccountID: "weixin-account", + TokenEnv: "WEIXIN_BOT_TOKEN", + }, + }, + } + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil { + t.Fatalf("create credentials dir: %v", err) + } + if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil { + t.Fatalf("write credentials: %v", err) + } + weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json") + if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil { + t.Fatalf("create weixin account dir: %v", err) + } + if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil { + t.Fatalf("write weixin account: %v", err) + } + _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") + _ = os.Unsetenv("LARK_BOT_APP_SECRET") + + got, err := NewApp().loadDesktopBotConfig() + if err != nil { + t.Fatalf("load desktop bot config: %v", err) + } + views := botConnectionViews(got.Bot.Connections) + if len(views) != 3 { + t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views) + } + for _, view := range views { + if !view.Credential.SecretSet { + t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential) + } + } + plan := desktopBotRuntimePlan(got) + if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] { + t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger) + if len(bindings) != 3 { + t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings) + } +} + +func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) { + isolateDesktopUserDirs(t) + + userCfg := config.Default() + if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil { + t.Fatalf("set desktop appearance: %v", err) + } + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + + project := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` +[bot] +enabled = true + +[bot.allowlist] +enabled = true +feishu_users = ["ou-legacy"] + +[[bot.connections]] +id = "feishu-lark" +provider = "feishu" +domain = "lark" +label = "Lark" +enabled = true +status = "connected" +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(project); err != nil { + t.Fatalf("chdir project: %v", err) + } + + got, err := NewApp().loadDesktopBotConfig() + if err != nil { + t.Fatalf("load desktop bot config: %v", err) + } + if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" { + t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot) + } + + persisted := config.LoadForEdit(config.UserConfigPath()) + if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" { + t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot) + } + if persisted.DesktopTheme() != "dark" { + t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme()) + } +} + +func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) { + isolateDesktopUserDirs(t) + + project := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` +[desktop] +theme = "dark" + +[bot] +enabled = true + +[bot.allowlist] +enabled = true +feishu_users = ["ou-legacy"] + +[[bot.connections]] +id = "feishu-lark" +provider = "feishu" +domain = "lark" +label = "Lark" +enabled = true +status = "connected" +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(project); err != nil { + t.Fatalf("chdir project: %v", err) + } + + got, err := NewApp().loadDesktopBotConfig() + if err != nil { + t.Fatalf("load desktop bot config: %v", err) + } + if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" { + t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot) + } + + persisted := config.LoadForEdit(config.UserConfigPath()) + if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" { + t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot) + } + if persisted.DesktopTheme() == "dark" { + t.Fatal("legacy project desktop theme should not be persisted during bot-only migration") + } +} + +func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) { + isolateDesktopUserDirs(t) + + project := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` +[desktop] +theme = "dark" +close_behavior = "quit" + +[bot] +enabled = true + +[bot.allowlist] +enabled = true +feishu_users = ["ou-legacy"] + +[[bot.connections]] +id = "feishu-lark" +provider = "feishu" +domain = "lark" +label = "Lark" +enabled = true +status = "connected" +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(project); err != nil { + t.Fatalf("chdir project: %v", err) + } + + settings := NewApp().Settings() + if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" { + t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot) + } + if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" { + t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior) + } + + persisted := config.LoadForEdit(config.UserConfigPath()) + if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" { + t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior()) + } +} + +func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) { + isolateDesktopUserDirs(t) + + userCfg := config.Default() + userCfg.Bot.Enabled = true + userCfg.Bot.Allowlist.Enabled = true + userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"} + userCfg.Bot.Connections = []config.BotConnectionConfig{ + {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"}, + } + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("save user config: %v", err) + } + + project := robustTempDir(t) + if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` +[bot] +enabled = true + +[bot.allowlist] +enabled = true +feishu_users = ["ou-legacy"] + +[[bot.connections]] +id = "feishu-lark" +provider = "feishu" +domain = "lark" +enabled = true +status = "connected" +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + + orig, _ := os.Getwd() + defer func() { _ = os.Chdir(orig) }() + if err := os.Chdir(project); err != nil { + t.Fatalf("chdir project: %v", err) + } + + got, err := NewApp().loadDesktopBotConfig() + if err != nil { + t.Fatalf("load desktop bot config: %v", err) + } + if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" { + t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot) + } +} + +func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) { + got := summarizeBotRuntimeErrors([]error{ + errors.New("first"), + nil, + errors.New("second"), + errors.New("third"), + errors.New("fourth"), + }) + + for _, want := range []string{"first", "second", "third", "1 more"} { + if !strings.Contains(got, want) { + t.Fatalf("summary = %q, want %q", got, want) + } + } + if strings.Contains(got, "fourth") { + t.Fatalf("summary = %q, should cap extra errors", got) + } +} diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 53d66c197..6c1739acd 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -37,7 +37,7 @@ import { ClearContextCard } from "./components/ClearContextCard"; import { StatusBar } from "./components/StatusBar"; import { HistoryPanel } from "./components/HistoryPanel"; import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; -import { SettingsPanel } from "./components/SettingsPanel"; +import { SettingsPanel, type SettingsInitialFocus } from "./components/SettingsPanel"; import { UpdateBanner } from "./components/UpdateBanner"; import { ContextPanel } from "./components/ContextPanel"; import { WorkspacePanel } from "./components/WorkspacePanel"; @@ -149,6 +149,10 @@ type SidebarImConnection = { sessionId: string; scope: "global" | "project"; workspaceRoot: string; + allowAll: boolean; + allowlistEnabled: boolean; + allowlistUsers: string[]; + allowlistMatched: boolean; }; type SidebarImTopicSource = { platform: SidebarImPlatform; @@ -162,6 +166,7 @@ type SidebarImConnectionDetailProps = { onClose: () => void; onOpenSession: () => void; onOpenSettings: () => void; + onManageAllowlist: () => void; }; function isSidebarImConnection(connection: BotConnectionView): boolean { @@ -230,6 +235,15 @@ function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): s } } +function uniqueTrimmedValues(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { + if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); + return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); +} + function sidebarImConnectionsFromBot(bot: BotSettingsView | null | undefined, translate: Translator): SidebarImConnection[] { if (!bot?.connections?.length) return []; return bot.connections @@ -244,6 +258,7 @@ function sidebarImConnectionsFromBot(bot: BotSettingsView | null | undefined, tr const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot); const status = sidebarImStatus(connection, bot.enabled); const title = connection.label.trim() || platformLabel; + const allowlistUsers = sidebarImAllowlistUsers(bot, platform); const subtitleParts = [ remoteId ? compactRemoteId(remoteId) : platformLabel, connection.model.trim() || "", @@ -261,6 +276,10 @@ function sidebarImConnectionsFromBot(bot: BotSettingsView | null | undefined, tr sessionId, scope, workspaceRoot, + allowAll: bot.allowlist.allowAll, + allowlistEnabled: bot.allowlist.enabled, + allowlistUsers, + allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, }; }); } @@ -321,9 +340,28 @@ function sidebarImSessionLabel(connection: SidebarImConnection, translate: Trans return target.value; } -function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings }: SidebarImConnectionDetailProps) { +function sidebarImAccessModeLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessAllowAll"); + if (connection.allowlistEnabled) return translate("botDetail.accessWhitelist"); + return translate("botDetail.accessDisabled"); +} + +function sidebarImAccessStatusLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessOpen"); + if (!connection.remoteId) return translate("botDetail.accessUnknown"); + return connection.allowlistMatched ? translate("botDetail.accessMatched") : translate("botDetail.accessMissing"); +} + +function sidebarImAccessStatusClass(connection: SidebarImConnection): string { + if (connection.allowAll || connection.allowlistMatched) return "ok"; + if (!connection.remoteId) return "muted"; + return "warn"; +} + +function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings, onManageAllowlist }: SidebarImConnectionDetailProps) { const translate = useT(); const target = mappedSessionTarget(connection.sessionId); + const accessStatusClass = sidebarImAccessStatusClass(connection); return (
@@ -354,6 +392,54 @@ function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenS
+
+
+ {translate("botDetail.access")} +
+ {connection.remoteId ? ( + + ) : null} + +
+
+
+
+ {translate("botDetail.accessMode")} + {sidebarImAccessModeLabel(connection, translate)} +
+
+ {translate("botDetail.accessCurrentUser")} + {connection.remoteId || "—"} +
+
+ {translate("botDetail.accessStatus")} + + {sidebarImAccessStatusLabel(connection, translate)} + +
+
+
+ {translate("botDetail.channelAllowlistUsers")} +
+ {connection.allowlistUsers.length > 0 ? ( + connection.allowlistUsers.map((id) => ( + + {id} + + )) + ) : ( + {translate("botDetail.emptyAllowlistUsers")} + )} +
+
+
+
{translate("botDetail.summary")} @@ -686,6 +772,7 @@ export default function App() { // clearing the key mid-session is the Settings panel's job, not the gate's. const [needsOnboarding, setNeedsOnboarding] = useState(null); const [settingsTarget, setSettingsTarget] = useState(null); + const [settingsFocus, setSettingsFocus] = useState(null); const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState(null); const [histView, setHistView] = useState(null); const [paletteOpen, setPaletteOpen] = useState(false); @@ -696,7 +783,6 @@ export default function App() { const [activeSidebarImConnectionId, setActiveSidebarImConnectionId] = useState(""); const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); const [sidebarImExpanded, setSidebarImExpanded] = useState(false); - const [isDevBuild, setIsDevBuild] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); const [sidebarResizing, setSidebarResizing] = useState(false); @@ -749,10 +835,6 @@ export default function App() { // Persist window geometry across launches. useWindowStatePersistence(); - useEffect(() => { - void app.Version().then((v) => setIsDevBuild(v === "dev")); - }, []); - const closeTransientOverlays = useCallback(() => { setTransientOverlayDismissSignal((signal) => signal + 1); }, []); @@ -779,6 +861,15 @@ export default function App() { closeTransientOverlays(); setSidebarImExpanded(false); setSidebarImDetailConnectionId(""); + setSettingsFocus(null); + setSettingsTarget("bots"); + }, [closeTransientOverlays]); + + const openBotAllowlistSettings = useCallback((connectionId: string) => { + closeTransientOverlays(); + setSidebarImExpanded(false); + setSidebarImDetailConnectionId(""); + setSettingsFocus({ target: "bot-allowlist", connectionId }); setSettingsTarget("bots"); }, [closeTransientOverlays]); @@ -2119,14 +2210,15 @@ export default function App() { ? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ") : [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · "); const sidebarImConnectedCount = sidebarImConnections.filter((connection) => connection.status === "connected").length; - const sidebarImSummaryText = sidebarImConnections.length === 0 - ? t("sidebar.imEmpty") - : sidebarImConnectedCount > 0 + const sidebarImHasConnections = sidebarImConnections.length > 0; + const sidebarImSummaryText = sidebarImHasConnections + ? sidebarImConnectedCount > 0 ? t("sidebar.imOnlineCount", { n: sidebarImConnectedCount }) - : t("sidebar.imConnectionCount", { n: sidebarImConnections.length }); - const sidebarImToggleLabel = sidebarImConnections.length === 0 - ? t("sidebar.imEmpty") - : t(sidebarImExpanded ? "common.collapse" : "common.expand"); + : t("sidebar.imConnectionCount", { n: sidebarImConnections.length }) + : ""; + const sidebarImToggleLabel = !sidebarImHasConnections + ? t("sidebar.im") + : t(sidebarImExpanded ? "sidebar.imCollapse" : "sidebar.imExpand"); return ( @@ -2205,7 +2297,6 @@ export default function App() {