Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6814433
feat(bot): improve IM bot setup and diagnostics
SivanCola Jun 11, 2026
2abde43
fix(bot): add Lark registration QR metadata
SivanCola Jun 11, 2026
27913fe
fix(bot): run saved IM connections independently
SivanCola Jun 11, 2026
1c3b325
fix(bot): auto-start desktop IM runtime
SivanCola Jun 11, 2026
e9f6bf4
fix(bot): remember distinct IM group users
SivanCola Jun 11, 2026
05ce378
fix(ci): sync bot runtime frontend binding
SivanCola Jun 11, 2026
065923f
fix(bot): load desktop bot settings consistently
SivanCola Jun 11, 2026
d102985
fix(bot): fail fast on missing IM credentials
SivanCola Jun 11, 2026
107d03e
fix(bot): keep healthy IM adapters running
SivanCola Jun 11, 2026
96cc509
fix(bot): prefer user IM settings in CLI
SivanCola Jun 11, 2026
923afac
fix(bot): migrate legacy desktop bot bindings
SivanCola Jun 11, 2026
8821e44
fix(bot): persist legacy bindings without user config
SivanCola Jun 11, 2026
add0135
fix(bot): align Lark registration with SDK flow
SivanCola Jun 11, 2026
32a4ea8
test(bot): cover Feishu binding persistence
SivanCola Jun 11, 2026
b8d7ddc
test(bot): pin bot-only legacy migration
SivanCola Jun 12, 2026
d7bc951
fix(bot): start Lark registration on Lark domain
SivanCola Jun 12, 2026
15cdd36
test(bot): cover Weixin binding persistence
SivanCola Jun 12, 2026
b2e34d1
test(bot): cover desktop credential reload
SivanCola Jun 12, 2026
4f53957
fix(bot): align lark registration with sdk domain flow
SivanCola Jun 12, 2026
f661459
fix(bot): handle feishu card approvals over websocket
SivanCola Jun 12, 2026
93df448
fix(bot): accept numeric approval replies
SivanCola Jun 12, 2026
5725c2f
fix(bot): guide stale approval shortcuts
SivanCola Jun 12, 2026
29d32f7
fix(bot): wire ask shortcuts and yolo mode
SivanCola Jun 12, 2026
ade3270
test(bot): cover ask and approval card actions
SivanCola Jun 12, 2026
6951bca
feat(bot): document IM usage and remote yolo commands
SivanCola Jun 12, 2026
5f987c3
Merge remote-tracking branch 'origin/main-v2' into feature/bot-im-set…
SivanCola Jun 12, 2026
5099405
fix(bot): satisfy feishu lint
SivanCola Jun 12, 2026
fa01784
fix(bot): authenticate feishu card clicks against the operator
esengine Jun 12, 2026
713ee05
fix(botruntime): isolate the requested feishu/lark domain
esengine Jun 12, 2026
4bf387a
Merge branch 'main-v2' into feature/bot-im-settings
esengine Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)** —— 基于快照的编辑安全网
Expand Down
5 changes: 4 additions & 1 deletion desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
137 changes: 104 additions & 33 deletions desktop/bot_connection_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -82,6 +83,7 @@ type BotConnectionDiagnostic struct {
type botInstallSession struct {
Provider string
Domain string
PollDomain string
DeviceCode string
UserCode string
StartedAt time.Time
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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{}
Expand All @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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 ""
Expand Down
Loading
Loading