package main import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" "reasonix/internal/bot" "reasonix/internal/bot/feishu" "reasonix/internal/bot/weixin" "reasonix/internal/botruntime" "reasonix/internal/config" ) type BotConnectionCredentialView struct { AppID string `json:"appId"` AppSecretEnv string `json:"appSecretEnv"` AccountID string `json:"accountId"` TokenEnv string `json:"tokenEnv"` SecretSet bool `json:"secretSet"` } type BotConnectionSessionMappingView struct { RemoteID string `json:"remoteId"` SessionID string `json:"sessionId"` SessionSource string `json:"sessionSource"` ChatType string `json:"chatType"` UserID string `json:"userId"` ThreadID string `json:"threadId"` Scope string `json:"scope"` WorkspaceRoot string `json:"workspaceRoot"` UpdatedAt string `json:"updatedAt"` } 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"` ToolApprovalMode string `json:"toolApprovalMode"` WorkspaceRoot string `json:"workspaceRoot"` Access BotAccessView `json:"access"` Credential BotConnectionCredentialView `json:"credential"` SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"` LastError string `json:"lastError"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type BotInstallStartResult struct { OK bool `json:"ok"` Provider string `json:"provider"` Domain string `json:"domain"` InstallID string `json:"installId"` URL string `json:"url"` DeviceCode string `json:"deviceCode"` UserCode string `json:"userCode"` Interval int `json:"interval"` ExpireIn int `json:"expireIn"` Message string `json:"message"` } type BotInstallPollResult struct { Done bool `json:"done"` Connection BotConnectionView `json:"connection"` Status string `json:"status"` Message string `json:"message"` Error string `json:"error"` } type BotConnectionDiagnostic struct { ID string `json:"id"` Label string `json:"label"` Status string `json:"status"` Message string `json:"message"` MessageID string `json:"messageId"` Phase string `json:"phase"` Code string `json:"code"` ReportKind string `json:"reportKind"` ReportDetail string `json:"reportDetail"` OccurredAt string `json:"occurredAt"` } type botInstallSession struct { Provider string Domain string PollDomain string DeviceCode string UserCode string StartedAt time.Time ExpireAt time.Time Weixin *weixin.LoginSession } func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) { provider, domain = normalizeBotInstallTarget(provider, domain) if provider == "weixin" { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() session, err := weixin.StartLogin(ctx) if err != nil { return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil } installID := randomInstallID() a.mu.Lock() if a.botInstalls == nil { a.botInstalls = map[string]*botInstallSession{} } a.botInstalls[installID] = &botInstallSession{ Provider: provider, Domain: domain, DeviceCode: session.QRCode, StartedAt: session.StartedAt, ExpireAt: time.Now().Add(2 * time.Minute), Weixin: session, } a.mu.Unlock() return BotInstallStartResult{ OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode), DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。", }, nil } if provider != "feishu" { return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil } return a.startFeishuConnectionInstall(domain) } func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) { installID = strings.TrimSpace(installID) // Copy the session under a.mu: overlapping polls of the same install can // race the locked PollDomain upgrade below with unlocked field reads. a.mu.RLock() sessionPtr := a.botInstalls[installID] var sessionCopy botInstallSession if sessionPtr != nil { sessionCopy = *sessionPtr } a.mu.RUnlock() if sessionPtr == nil { return BotInstallPollResult{Error: "install session not found"}, nil } session := &sessionCopy if time.Now().After(session.ExpireAt) { a.deleteBotInstall(installID) return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil } if session.Provider == "weixin" { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, status, err := weixin.PollLogin(ctx, session.Weixin) if err != nil { return BotInstallPollResult{Status: status, Error: err.Error()}, nil } if result == nil { return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil } a.deleteBotInstall(installID) conn, err := a.upsertBotConnection(config.BotConnectionConfig{ ID: connectionID("weixin", "weixin"), Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected", Access: botInstallAccess(result.UserID), Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"}, }, func(c *config.Config) { c.Bot.Enabled = true c.Bot.Weixin.Enabled = true c.Bot.Weixin.AccountID = result.AccountID c.Bot.Weixin.APIBase = result.BaseURL 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 := a.loadDesktopBotConfig() if err != nil { return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil } for _, conn := range cfg.Bot.Connections { if conn.ID == id { status := "ok" message := "连接配置已保存。" phase := "config" code := "config_ok" reportable := false if !conn.Enabled { status = "disabled" message = "连接已保存但未启用。" code = "connection_disabled" } else if conn.Status != "connected" { status = firstNonEmptyBot(conn.Status, "pending") message = firstNonEmptyBot(conn.LastError, "连接还未完成。") phase = "install" code = "connection_not_connected" reportable = status == "error" || strings.TrimSpace(conn.LastError) != "" } else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) { status = "warning" message = conn.Credential.AppSecretEnv + " 未设置。" phase = "credential" code = "secret_missing" reportable = true } else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) { status = "warning" message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。" phase = "credential" code = "secret_missing" reportable = true } else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) { status = "warning" message = "未找到已保存的微信登录凭据。" phase = "credential" code = "secret_missing" reportable = true } return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil } } return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil } func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) { cfg, err := a.loadDesktopBotConfig() if err != nil { return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil } var conn *config.BotConnectionConfig for i := range cfg.Bot.Connections { if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) { conn = &cfg.Bot.Connections[i] break } } if conn == nil { return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil } target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings)) if conn.Provider != "feishu" && conn.Provider != "weixin" { return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil } if target == "" { return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var result bot.SendResult switch conn.Provider { case "feishu": feishuCfg := cfg.Bot.Feishu feishuCfg.Enabled = true feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain) feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID) feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv) result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。") case "weixin": weixinCfg := cfg.Bot.Weixin weixinCfg.Enabled = true weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID) weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv) result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。") } if err != nil { return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil } _ = a.rememberBotConnectionRemote(conn.ID, target) msg := "测试消息已发送。" if result.MessageID != "" { msg += " Message ID: " + result.MessageID } diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false) diag.MessageID = result.MessageID return diag, nil } func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic { id = strings.TrimSpace(id) label := "" if conn != nil { id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id) label = strings.TrimSpace(conn.Label) } occurredAt := time.Now().UTC().Format(time.RFC3339) diag := BotConnectionDiagnostic{ ID: id, Label: label, Status: strings.TrimSpace(status), Message: strings.TrimSpace(message), Phase: strings.TrimSpace(phase), Code: strings.TrimSpace(code), OccurredAt: occurredAt, } if reportable { diag.ReportKind = "bot" diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt) if diag.ReportDetail == "" { diag.ReportKind = "" } } return diag } func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string { provider := "unknown" domain := "unknown" configuredStatus := "" enabled := false workspaceScope := "global" sessionMappings := 0 appIDSet := false appSecretEnvConfigured := false tokenEnvConfigured := false secretAvailable := false if conn != nil { provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider) domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain) configuredStatus = strings.TrimSpace(conn.Status) enabled = conn.Enabled if strings.TrimSpace(conn.WorkspaceRoot) != "" { workspaceScope = "project" } sessionMappings = len(conn.SessionMappings) appIDSet = strings.TrimSpace(conn.Credential.AppID) != "" appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != "" tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != "" secretAvailable = botCredentialSecretSet(*conn) } summary := botConnectionReportSummary(code, message) lines := []string{ "Bot connection diagnostic", "", "connection_id: " + safeBotReportValue(fallbackID), "provider: " + safeBotReportValue(provider), "domain: " + safeBotReportValue(domain), "status: " + safeBotReportValue(status), "phase: " + safeBotReportValue(phase), "code: " + safeBotReportValue(code), fmt.Sprintf("enabled: %t", enabled), "configured_status: " + safeBotReportValue(configuredStatus), fmt.Sprintf("app_id_set: %t", appIDSet), fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured), fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured), fmt.Sprintf("secret_available: %t", secretAvailable), "workspace_scope: " + workspaceScope, fmt.Sprintf("session_mappings: %d", sessionMappings), "", "summary: " + summary, } payload := frontendCrashPayload{ SchemaVersion: 2, Kind: "bot", Source: "bot.runtime", Label: botConnectionReportLabel(provider, domain, phase), Message: strings.Join(lines, "\n"), ErrorType: "BotConnectionDiagnostic", ErrorMessage: summary, TopFrame: "bot." + safeBotReportSegment(phase), OccurredAt: occurredAt, } detail, err := json.Marshal(payload) if err != nil { return "" } return string(detail) } func botConnectionReportSummary(code, message string) string { switch strings.TrimSpace(code) { case "config_load_failed": return "desktop bot config could not be loaded: " + scrubSensitiveText(message) case "connection_missing": return "bot connection record was not found" case "connection_not_connected": return "bot connection is not connected: " + scrubSensitiveText(message) case "secret_missing": return "required bot credential is not available" case "test_send_failed": return "bot test message failed: " + scrubSensitiveText(message) default: if strings.TrimSpace(message) == "" { return strings.TrimSpace(code) } return scrubSensitiveText(message) } } func botConnectionReportLabel(provider, domain, phase string) string { parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)} return strings.Trim(strings.Join(parts, "."), ".") } func safeBotReportSegment(s string) string { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { return "unknown" } var b strings.Builder for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { b.WriteRune(r) continue } if b.Len() == 0 || strings.HasSuffix(b.String(), ".") { continue } b.WriteByte('.') } out := strings.Trim(b.String(), ".") if out == "" { return "unknown" } return out } func safeBotReportValue(s string) string { s = safeBotReportSegment(s) if len(s) > 80 { return s[:80] } return s } func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) { // 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 { return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil } deviceCode := stringValue(data["device_code"]) verifyURL := stringValue(data["verification_uri_complete"]) userCode := stringValue(data["user_code"]) 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) a.mu.Lock() if a.botInstalls == nil { a.botInstalls = map[string]*botInstallSession{} } a.botInstalls[installID] = &botInstallSession{ 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: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil } func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) { 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 } if errText := stringValue(data["error"]); errText != "" { if errText == "authorization_pending" || errText == "slow_down" { return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil } a.deleteBotInstall(installID) return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil } if statusCode >= 400 { 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(firstNonEmptyBot(pollDomain, session.Domain), data) userID := feishuInstallUserID(data) secretEnv := "FEISHU_BOT_APP_SECRET" if domain == "lark" { secretEnv = "LARK_BOT_APP_SECRET" } if err := upsertDotEnv(secretEnv, appSecret); err != nil { return BotInstallPollResult{Status: "error", Error: err.Error()}, nil } label := "飞书" if domain == "lark" { label = "Lark" } conn, err := a.upsertBotConnection(config.BotConnectionConfig{ ID: connectionID("feishu", domain), Provider: "feishu", Domain: domain, Label: label, Enabled: true, Status: "connected", Access: botInstallAccess(userID), Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv}, }, func(c *config.Config) { c.Bot.Enabled = true c.Bot.Feishu.Enabled = true c.Bot.Feishu.Domain = domain c.Bot.Feishu.AppID = appID 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 } func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) { now := time.Now().UTC().Format(time.RFC3339) if conn.CreatedAt == "" { conn.CreatedAt = now } conn.UpdatedAt = now if conn.Status == "" { conn.Status = "connected" } if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" { conn.ToolApprovalMode = "ask" } if conn.ID == "" { conn.ID = connectionID(conn.Provider, conn.Domain) } err := a.applyConfigOnly(func(c *config.Config) error { if updateLegacy != nil { updateLegacy(c) } replaced := false for i, existing := range c.Bot.Connections { if existing.ID == conn.ID { conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt) if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) { conn.Access = existing.Access } c.Bot.Connections[i] = conn replaced = true break } } if !replaced { c.Bot.Connections = append(c.Bot.Connections, conn) } return nil }) return botConnectionView(conn), err } func (a *App) rememberBotConnectionRemote(id, remoteID string) error { id = strings.TrimSpace(id) remoteID = strings.TrimSpace(remoteID) if id == "" || remoteID == "" { return nil } now := time.Now().UTC().Format(time.RFC3339) return a.applyConfigOnly(func(c *config.Config) error { for i := range c.Bot.Connections { if c.Bot.Connections[i].ID != id { continue } for j := range c.Bot.Connections[i].SessionMappings { if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID { workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot) scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot) c.Bot.Connections[i].SessionMappings[j].Scope = scope c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot) c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now c.Bot.Connections[i].UpdatedAt = now return nil } } scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot) c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{ RemoteID: remoteID, SessionID: "", Scope: scope, WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot), UpdatedAt: now, }) c.Bot.Connections[i].UpdatedAt = now return nil } return nil }) } func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string { for _, mapping := range mappings { if strings.TrimSpace(mapping.RemoteID) != "" { return strings.TrimSpace(mapping.RemoteID) } } return "" } func (a *App) deleteBotInstall(installID string) { a.mu.Lock() delete(a.botInstalls, installID) a.mu.Unlock() } func normalizeBotInstallTarget(provider, domain string) (string, string) { provider = strings.ToLower(strings.TrimSpace(provider)) domain = strings.ToLower(strings.TrimSpace(domain)) if provider == "lark" { provider = "feishu" domain = "lark" } if provider == "weixin" || provider == "wechat" { return "weixin", "weixin" } if domain != "lark" { domain = "feishu" } return "feishu", domain } func feishuAccountsBase(domain string) string { if domain == "lark" { return "https://accounts.larksuite.com" } 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 { return nil, err } if status >= 400 { return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"]))) } return data, nil } func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() reqBody := url.Values{} for k, v := range body { reqBody.Set(k, v) } req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode())) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() var out map[string]any if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, resp.StatusCode, err } return out, resp.StatusCode, nil } 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, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot, Access: botAccessViewFromConfig(conn.Access), Credential: BotConnectionCredentialView{ AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv, SecretSet: botCredentialSecretSet(conn), }, SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot), LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt, } } func botCredentialSecretSet(conn config.BotConnectionConfig) bool { if conn.Credential.AppSecretEnv != "" { return envIsSet(conn.Credential.AppSecretEnv) } if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) { return true } if conn.Provider == "weixin" { return weixin.HasSavedAccount(conn.Credential.AccountID) } return false } func feishuInstallDomain(fallback string, data map[string]any) string { if userInfo, ok := data["user_info"].(map[string]any); ok { if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") { return "lark" } return "feishu" } if strings.EqualFold(fallback, "lark") { return "lark" } 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{} } out := make([]BotConnectionView, 0, len(connections)) for _, conn := range connections { out = append(out, botConnectionView(conn)) } return out } 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), ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"), WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot), Access: botAccessConfigFromView(view.Access), Credential: config.BotConnectionCredential{ AppID: strings.TrimSpace(view.Credential.AppID), AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv), AccountID: strings.TrimSpace(view.Credential.AccountID), TokenEnv: strings.TrimSpace(view.Credential.TokenEnv), }, SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot), LastError: strings.TrimSpace(view.LastError), CreatedAt: strings.TrimSpace(view.CreatedAt), UpdatedAt: strings.TrimSpace(view.UpdatedAt), } } 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 } out := make([]config.BotConnectionConfig, 0, len(views)) for _, view := range views { cfg := botConnectionConfig(view) if cfg.ID == "" || cfg.Provider == "" { continue } out = append(out, cfg) } return out } func botMappingScope(scope, workspaceRoot string) string { if strings.TrimSpace(scope) == "project" { return "project" } if strings.TrimSpace(workspaceRoot) != "" { return "project" } return "global" } func botMappingWorkspaceRoot(scope, workspaceRoot string) string { if botMappingScope(scope, workspaceRoot) != "project" { return "" } return strings.TrimSpace(workspaceRoot) } func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView { if mappings == nil { return []BotConnectionSessionMappingView{} } out := make([]BotConnectionSessionMappingView, 0, len(mappings)) for _, m := range mappings { workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot) scope := botMappingScope(m.Scope, workspaceRoot) out = append(out, BotConnectionSessionMappingView{ RemoteID: m.RemoteID, SessionID: m.SessionID, SessionSource: m.SessionSource, ChatType: m.ChatType, UserID: m.UserID, ThreadID: m.ThreadID, Scope: scope, WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot), UpdatedAt: m.UpdatedAt, }) } return out } func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping { if mappings == nil { return nil } out := make([]config.BotConnectionSessionMapping, 0, len(mappings)) for _, m := range mappings { workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot) scope := botMappingScope(m.Scope, workspaceRoot) out = append(out, config.BotConnectionSessionMapping{ RemoteID: strings.TrimSpace(m.RemoteID), SessionID: strings.TrimSpace(m.SessionID), SessionSource: strings.TrimSpace(m.SessionSource), ChatType: strings.TrimSpace(m.ChatType), UserID: strings.TrimSpace(m.UserID), ThreadID: strings.TrimSpace(m.ThreadID), Scope: scope, WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot), UpdatedAt: strings.TrimSpace(m.UpdatedAt), }) } return out } func connectionID(provider, domain string) string { return strings.Trim(strings.ToLower(provider+"-"+domain), "-") } func botInstallAccess(userID string) config.BotAccessConfig { userID = strings.TrimSpace(userID) access := config.BotAccessConfig{Enabled: true, PairingEnabled: true} if userID != "" { access.Users = []string{userID} } return access } func randomInstallID() string { var b [12]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("install-%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) } func envIsSet(name string) bool { return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != "" } func firstAny(values ...any) any { for _, value := range values { if value != nil { return value } } return nil } func firstNonEmptyBot(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } 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 "" } return strings.TrimSpace(fmt.Sprint(value)) } func intValue(value any, fallback int) int { switch v := value.(type) { case float64: if v > 0 { return int(v) } case int: if v > 0 { return v } case string: var n int if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 { return n } } return fallback } func weixinInstallStatusMessage(status string) string { switch status { case "scaned": return "已扫码,请在微信里确认。" case "scaned_but_redirect": return "已扫码,正在切换微信授权节点。" default: return "等待扫码。" } }