Files
2026-07-13 13:00:08 +08:00

983 lines
33 KiB
Go

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 "等待扫码。"
}
}