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() {