mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
* refactor(go): adopt modern stdlib idioms across kernel and desktop Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/ usestdlibvars fixers plus hand-fixes for what they could not rewrite: slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor, maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int. Net -549 lines with no behavior change. Three loops in session.go stay index-based: slices.Backward yields element copies, so the fixer's rewrite silently dropped writes to DecisionReceipts and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49 slices.Backward sites were audited for that shape; only these three write through the index, and they carry a nolint with the reason. CaptureAfter's scan loop had an empty body and never ran any logic, so the fixer could not express it. Drop it and the accessor it was the only caller of. omitzero is deliberately untouched: turning omitempty into omitzero changes the JSON written for nested structs, which is a wire-format change to session files and the extension protocol. * build(lint): gate the modern-idiom linters Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both modules are now clean for modernize, intrange, copyloopvar, and usestdlibvars, so enable them rather than leaving the sweep as a one-off. omitzero stays disabled with the reason inline; usetesting, perfsprint, and errorlint still have residue and land separately.
983 lines
33 KiB
Go
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, http.MethodPost, 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 "等待扫码。"
|
|
}
|
|
}
|