返回 DeepSeek-Reasonix
bot_connection_app.go
根目录 / desktop / bot_connection_app.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "net/http"
10 "net/url"
11 "os"
12 "strings"
13 "time"
14
15 "reasonix/internal/bot"
16 "reasonix/internal/bot/feishu"
17 "reasonix/internal/bot/weixin"
18 "reasonix/internal/botruntime"
19 "reasonix/internal/config"
20 )
21
22 type BotConnectionCredentialView struct {
23 AppID string `json:"appId"`
24 AppSecretEnv string `json:"appSecretEnv"`
25 AccountID string `json:"accountId"`
26 TokenEnv string `json:"tokenEnv"`
27 SecretSet bool `json:"secretSet"`
28 }
29
30 type BotConnectionSessionMappingView struct {
31 RemoteID string `json:"remoteId"`
32 SessionID string `json:"sessionId"`
33 SessionSource string `json:"sessionSource"`
34 ChatType string `json:"chatType"`
35 UserID string `json:"userId"`
36 ThreadID string `json:"threadId"`
37 Scope string `json:"scope"`
38 WorkspaceRoot string `json:"workspaceRoot"`
39 UpdatedAt string `json:"updatedAt"`
40 }
41
42 type BotConnectionView struct {
43 ID string `json:"id"`
44 Provider string `json:"provider"`
45 Domain string `json:"domain"`
46 Label string `json:"label"`
47 Enabled bool `json:"enabled"`
48 Status string `json:"status"`
49 Model string `json:"model"`
50 ToolApprovalMode string `json:"toolApprovalMode"`
51 WorkspaceRoot string `json:"workspaceRoot"`
52 Access BotAccessView `json:"access"`
53 Credential BotConnectionCredentialView `json:"credential"`
54 SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
55 LastError string `json:"lastError"`
56 CreatedAt string `json:"createdAt"`
57 UpdatedAt string `json:"updatedAt"`
58 }
59
60 type BotInstallStartResult struct {
61 OK bool `json:"ok"`
62 Provider string `json:"provider"`
63 Domain string `json:"domain"`
64 InstallID string `json:"installId"`
65 URL string `json:"url"`
66 DeviceCode string `json:"deviceCode"`
67 UserCode string `json:"userCode"`
68 Interval int `json:"interval"`
69 ExpireIn int `json:"expireIn"`
70 Message string `json:"message"`
71 }
72
73 type BotInstallPollResult struct {
74 Done bool `json:"done"`
75 Connection BotConnectionView `json:"connection"`
76 Status string `json:"status"`
77 Message string `json:"message"`
78 Error string `json:"error"`
79 }
80
81 type BotConnectionDiagnostic struct {
82 ID string `json:"id"`
83 Label string `json:"label"`
84 Status string `json:"status"`
85 Message string `json:"message"`
86 MessageID string `json:"messageId"`
87 Phase string `json:"phase"`
88 Code string `json:"code"`
89 ReportKind string `json:"reportKind"`
90 ReportDetail string `json:"reportDetail"`
91 OccurredAt string `json:"occurredAt"`
92 }
93
94 type botInstallSession struct {
95 Provider string
96 Domain string
97 PollDomain string
98 DeviceCode string
99 UserCode string
100 StartedAt time.Time
101 ExpireAt time.Time
102 Weixin *weixin.LoginSession
103 }
104
105 func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
106 provider, domain = normalizeBotInstallTarget(provider, domain)
107 if provider == "weixin" {
108 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
109 defer cancel()
110 session, err := weixin.StartLogin(ctx)
111 if err != nil {
112 return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
113 }
114 installID := randomInstallID()
115 a.mu.Lock()
116 if a.botInstalls == nil {
117 a.botInstalls = map[string]*botInstallSession{}
118 }
119 a.botInstalls[installID] = &botInstallSession{
120 Provider: provider,
121 Domain: domain,
122 DeviceCode: session.QRCode,
123 StartedAt: session.StartedAt,
124 ExpireAt: time.Now().Add(2 * time.Minute),
125 Weixin: session,
126 }
127 a.mu.Unlock()
128 return BotInstallStartResult{
129 OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
130 DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
131 }, nil
132 }
133 if provider != "feishu" {
134 return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
135 }
136 return a.startFeishuConnectionInstall(domain)
137 }
138
139 func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
140 installID = strings.TrimSpace(installID)
141 // Copy the session under a.mu: overlapping polls of the same install can
142 // race the locked PollDomain upgrade below with unlocked field reads.
143 a.mu.RLock()
144 sessionPtr := a.botInstalls[installID]
145 var sessionCopy botInstallSession
146 if sessionPtr != nil {
147 sessionCopy = *sessionPtr
148 }
149 a.mu.RUnlock()
150 if sessionPtr == nil {
151 return BotInstallPollResult{Error: "install session not found"}, nil
152 }
153 session := &sessionCopy
154 if time.Now().After(session.ExpireAt) {
155 a.deleteBotInstall(installID)
156 return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
157 }
158 if session.Provider == "weixin" {
159 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
160 defer cancel()
161 result, status, err := weixin.PollLogin(ctx, session.Weixin)
162 if err != nil {
163 return BotInstallPollResult{Status: status, Error: err.Error()}, nil
164 }
165 if result == nil {
166 return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
167 }
168 a.deleteBotInstall(installID)
169 conn, err := a.upsertBotConnection(config.BotConnectionConfig{
170 ID: connectionID("weixin", "weixin"),
171 Provider: "weixin",
172 Domain: "weixin",
173 Label: "微信",
174 Enabled: true,
175 Status: "connected",
176 Access: botInstallAccess(result.UserID),
177 Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
178 }, func(c *config.Config) {
179 c.Bot.Enabled = true
180 c.Bot.Weixin.Enabled = true
181 c.Bot.Weixin.AccountID = result.AccountID
182 c.Bot.Weixin.APIBase = result.BaseURL
183 if c.Bot.Weixin.TokenEnv == "" {
184 c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
185 }
186 c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
187 })
188 if err != nil {
189 return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
190 }
191 a.refreshBotRuntimeAsync()
192 return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
193 }
194 return a.pollFeishuConnectionInstall(installID, session)
195 }
196
197 func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
198 cfg, err := a.loadDesktopBotConfig()
199 if err != nil {
200 return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
201 }
202 for _, conn := range cfg.Bot.Connections {
203 if conn.ID == id {
204 status := "ok"
205 message := "连接配置已保存。"
206 phase := "config"
207 code := "config_ok"
208 reportable := false
209 if !conn.Enabled {
210 status = "disabled"
211 message = "连接已保存但未启用。"
212 code = "connection_disabled"
213 } else if conn.Status != "connected" {
214 status = firstNonEmptyBot(conn.Status, "pending")
215 message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
216 phase = "install"
217 code = "connection_not_connected"
218 reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
219 } else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
220 status = "warning"
221 message = conn.Credential.AppSecretEnv + " 未设置。"
222 phase = "credential"
223 code = "secret_missing"
224 reportable = true
225 } else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
226 status = "warning"
227 message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
228 phase = "credential"
229 code = "secret_missing"
230 reportable = true
231 } else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
232 status = "warning"
233 message = "未找到已保存的微信登录凭据。"
234 phase = "credential"
235 code = "secret_missing"
236 reportable = true
237 }
238 return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
239 }
240 }
241 return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
242 }
243
244 func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
245 cfg, err := a.loadDesktopBotConfig()
246 if err != nil {
247 return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
248 }
249 var conn *config.BotConnectionConfig
250 for i := range cfg.Bot.Connections {
251 if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
252 conn = &cfg.Bot.Connections[i]
253 break
254 }
255 }
256 if conn == nil {
257 return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
258 }
259 target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
260 if conn.Provider != "feishu" && conn.Provider != "weixin" {
261 return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
262 }
263 if target == "" {
264 return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
265 }
266 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
267 defer cancel()
268 var result bot.SendResult
269 switch conn.Provider {
270 case "feishu":
271 feishuCfg := cfg.Bot.Feishu
272 feishuCfg.Enabled = true
273 feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
274 feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
275 feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
276 result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
277 case "weixin":
278 weixinCfg := cfg.Bot.Weixin
279 weixinCfg.Enabled = true
280 weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
281 weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
282 result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
283 }
284 if err != nil {
285 return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
286 }
287 _ = a.rememberBotConnectionRemote(conn.ID, target)
288 msg := "测试消息已发送。"
289 if result.MessageID != "" {
290 msg += " Message ID: " + result.MessageID
291 }
292 diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
293 diag.MessageID = result.MessageID
294 return diag, nil
295 }
296
297 func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
298 id = strings.TrimSpace(id)
299 label := ""
300 if conn != nil {
301 id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
302 label = strings.TrimSpace(conn.Label)
303 }
304 occurredAt := time.Now().UTC().Format(time.RFC3339)
305 diag := BotConnectionDiagnostic{
306 ID: id,
307 Label: label,
308 Status: strings.TrimSpace(status),
309 Message: strings.TrimSpace(message),
310 Phase: strings.TrimSpace(phase),
311 Code: strings.TrimSpace(code),
312 OccurredAt: occurredAt,
313 }
314 if reportable {
315 diag.ReportKind = "bot"
316 diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
317 if diag.ReportDetail == "" {
318 diag.ReportKind = ""
319 }
320 }
321 return diag
322 }
323
324 func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
325 provider := "unknown"
326 domain := "unknown"
327 configuredStatus := ""
328 enabled := false
329 workspaceScope := "global"
330 sessionMappings := 0
331 appIDSet := false
332 appSecretEnvConfigured := false
333 tokenEnvConfigured := false
334 secretAvailable := false
335 if conn != nil {
336 provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
337 domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
338 configuredStatus = strings.TrimSpace(conn.Status)
339 enabled = conn.Enabled
340 if strings.TrimSpace(conn.WorkspaceRoot) != "" {
341 workspaceScope = "project"
342 }
343 sessionMappings = len(conn.SessionMappings)
344 appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
345 appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
346 tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
347 secretAvailable = botCredentialSecretSet(*conn)
348 }
349 summary := botConnectionReportSummary(code, message)
350 lines := []string{
351 "Bot connection diagnostic",
352 "",
353 "connection_id: " + safeBotReportValue(fallbackID),
354 "provider: " + safeBotReportValue(provider),
355 "domain: " + safeBotReportValue(domain),
356 "status: " + safeBotReportValue(status),
357 "phase: " + safeBotReportValue(phase),
358 "code: " + safeBotReportValue(code),
359 fmt.Sprintf("enabled: %t", enabled),
360 "configured_status: " + safeBotReportValue(configuredStatus),
361 fmt.Sprintf("app_id_set: %t", appIDSet),
362 fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
363 fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
364 fmt.Sprintf("secret_available: %t", secretAvailable),
365 "workspace_scope: " + workspaceScope,
366 fmt.Sprintf("session_mappings: %d", sessionMappings),
367 "",
368 "summary: " + summary,
369 }
370 payload := frontendCrashPayload{
371 SchemaVersion: 2,
372 Kind: "bot",
373 Source: "bot.runtime",
374 Label: botConnectionReportLabel(provider, domain, phase),
375 Message: strings.Join(lines, "\n"),
376 ErrorType: "BotConnectionDiagnostic",
377 ErrorMessage: summary,
378 TopFrame: "bot." + safeBotReportSegment(phase),
379 OccurredAt: occurredAt,
380 }
381 detail, err := json.Marshal(payload)
382 if err != nil {
383 return ""
384 }
385 return string(detail)
386 }
387
388 func botConnectionReportSummary(code, message string) string {
389 switch strings.TrimSpace(code) {
390 case "config_load_failed":
391 return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
392 case "connection_missing":
393 return "bot connection record was not found"
394 case "connection_not_connected":
395 return "bot connection is not connected: " + scrubSensitiveText(message)
396 case "secret_missing":
397 return "required bot credential is not available"
398 case "test_send_failed":
399 return "bot test message failed: " + scrubSensitiveText(message)
400 default:
401 if strings.TrimSpace(message) == "" {
402 return strings.TrimSpace(code)
403 }
404 return scrubSensitiveText(message)
405 }
406 }
407
408 func botConnectionReportLabel(provider, domain, phase string) string {
409 parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
410 return strings.Trim(strings.Join(parts, "."), ".")
411 }
412
413 func safeBotReportSegment(s string) string {
414 s = strings.ToLower(strings.TrimSpace(s))
415 if s == "" {
416 return "unknown"
417 }
418 var b strings.Builder
419 for _, r := range s {
420 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
421 b.WriteRune(r)
422 continue
423 }
424 if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
425 continue
426 }
427 b.WriteByte('.')
428 }
429 out := strings.Trim(b.String(), ".")
430 if out == "" {
431 return "unknown"
432 }
433 return out
434 }
435
436 func safeBotReportValue(s string) string {
437 s = safeBotReportSegment(s)
438 if len(s) > 80 {
439 return s[:80]
440 }
441 return s
442 }
443
444 func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
445 // The official registration SDK always begins on the Feishu accounts domain.
446 // Lark tenants are detected from the first poll response, then polling moves
447 // to the Lark accounts domain for the final credential exchange.
448 beginDomain := "feishu"
449 data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
450 "action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
451 })
452 if err != nil {
453 return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
454 }
455 deviceCode := stringValue(data["device_code"])
456 verifyURL := stringValue(data["verification_uri_complete"])
457 userCode := stringValue(data["user_code"])
458 if deviceCode == "" || verifyURL == "" {
459 return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
460 }
461 qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
462 if err != nil {
463 return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
464 }
465 installID := randomInstallID()
466 interval := intValue(data["interval"], 5)
467 expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
468 a.mu.Lock()
469 if a.botInstalls == nil {
470 a.botInstalls = map[string]*botInstallSession{}
471 }
472 a.botInstalls[installID] = &botInstallSession{
473 Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
474 StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
475 }
476 a.mu.Unlock()
477 return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
478 }
479
480 func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
481 pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
482 data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
483 if err != nil {
484 return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
485 }
486 if errText := stringValue(data["error"]); errText != "" {
487 if errText == "authorization_pending" || errText == "slow_down" {
488 return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
489 }
490 a.deleteBotInstall(installID)
491 return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
492 }
493 if statusCode >= 400 {
494 a.deleteBotInstall(installID)
495 return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
496 }
497 if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
498 a.mu.Lock()
499 if current := a.botInstalls[installID]; current != nil {
500 current.PollDomain = "lark"
501 }
502 a.mu.Unlock()
503 return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
504 }
505 appID := stringValue(data["client_id"])
506 appSecret := stringValue(data["client_secret"])
507 if appID == "" || appSecret == "" {
508 return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
509 }
510 a.deleteBotInstall(installID)
511 domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
512 userID := feishuInstallUserID(data)
513 secretEnv := "FEISHU_BOT_APP_SECRET"
514 if domain == "lark" {
515 secretEnv = "LARK_BOT_APP_SECRET"
516 }
517 if err := upsertDotEnv(secretEnv, appSecret); err != nil {
518 return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
519 }
520 label := "飞书"
521 if domain == "lark" {
522 label = "Lark"
523 }
524 conn, err := a.upsertBotConnection(config.BotConnectionConfig{
525 ID: connectionID("feishu", domain),
526 Provider: "feishu",
527 Domain: domain,
528 Label: label,
529 Enabled: true,
530 Status: "connected",
531 Access: botInstallAccess(userID),
532 Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
533 }, func(c *config.Config) {
534 c.Bot.Enabled = true
535 c.Bot.Feishu.Enabled = true
536 c.Bot.Feishu.Domain = domain
537 c.Bot.Feishu.AppID = appID
538 c.Bot.Feishu.AppSecretEnv = secretEnv
539 c.Bot.Feishu.Mode = "websocket"
540 c.Bot.Feishu.RequireMention = true
541 c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
542 })
543 if err != nil {
544 return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
545 }
546 a.refreshBotRuntimeAsync()
547 return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
548 }
549
550 func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
551 now := time.Now().UTC().Format(time.RFC3339)
552 if conn.CreatedAt == "" {
553 conn.CreatedAt = now
554 }
555 conn.UpdatedAt = now
556 if conn.Status == "" {
557 conn.Status = "connected"
558 }
559 if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
560 conn.ToolApprovalMode = "ask"
561 }
562 if conn.ID == "" {
563 conn.ID = connectionID(conn.Provider, conn.Domain)
564 }
565 err := a.applyConfigOnly(func(c *config.Config) error {
566 if updateLegacy != nil {
567 updateLegacy(c)
568 }
569 replaced := false
570 for i, existing := range c.Bot.Connections {
571 if existing.ID == conn.ID {
572 conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
573 if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
574 conn.Access = existing.Access
575 }
576 c.Bot.Connections[i] = conn
577 replaced = true
578 break
579 }
580 }
581 if !replaced {
582 c.Bot.Connections = append(c.Bot.Connections, conn)
583 }
584 return nil
585 })
586 return botConnectionView(conn), err
587 }
588
589 func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
590 id = strings.TrimSpace(id)
591 remoteID = strings.TrimSpace(remoteID)
592 if id == "" || remoteID == "" {
593 return nil
594 }
595 now := time.Now().UTC().Format(time.RFC3339)
596 return a.applyConfigOnly(func(c *config.Config) error {
597 for i := range c.Bot.Connections {
598 if c.Bot.Connections[i].ID != id {
599 continue
600 }
601 for j := range c.Bot.Connections[i].SessionMappings {
602 if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
603 workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
604 scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
605 c.Bot.Connections[i].SessionMappings[j].Scope = scope
606 c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
607 c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
608 c.Bot.Connections[i].UpdatedAt = now
609 return nil
610 }
611 }
612 scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
613 c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
614 RemoteID: remoteID,
615 SessionID: "",
616 Scope: scope,
617 WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
618 UpdatedAt: now,
619 })
620 c.Bot.Connections[i].UpdatedAt = now
621 return nil
622 }
623 return nil
624 })
625 }
626
627 func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
628 for _, mapping := range mappings {
629 if strings.TrimSpace(mapping.RemoteID) != "" {
630 return strings.TrimSpace(mapping.RemoteID)
631 }
632 }
633 return ""
634 }
635
636 func (a *App) deleteBotInstall(installID string) {
637 a.mu.Lock()
638 delete(a.botInstalls, installID)
639 a.mu.Unlock()
640 }
641
642 func normalizeBotInstallTarget(provider, domain string) (string, string) {
643 provider = strings.ToLower(strings.TrimSpace(provider))
644 domain = strings.ToLower(strings.TrimSpace(domain))
645 if provider == "lark" {
646 provider = "feishu"
647 domain = "lark"
648 }
649 if provider == "weixin" || provider == "wechat" {
650 return "weixin", "weixin"
651 }
652 if domain != "lark" {
653 domain = "feishu"
654 }
655 return "feishu", domain
656 }
657
658 func feishuAccountsBase(domain string) string {
659 if domain == "lark" {
660 return "https://accounts.larksuite.com"
661 }
662 return "https://accounts.feishu.cn"
663 }
664
665 func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
666 parsedURL, err := url.Parse(rawURL)
667 if err != nil {
668 return "", err
669 }
670 query := parsedURL.Query()
671 query.Set("from", "sdk")
672 query.Set("tp", "sdk")
673 query.Set("source", "go-sdk")
674 parsedURL.RawQuery = query.Encode()
675 return parsedURL.String(), nil
676 }
677
678 func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
679 data, status, err := postFeishuInstallFormResult(base, body)
680 if err != nil {
681 return nil, err
682 }
683 if status >= 400 {
684 return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
685 }
686 return data, nil
687 }
688
689 func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
690 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
691 defer cancel()
692 reqBody := url.Values{}
693 for k, v := range body {
694 reqBody.Set(k, v)
695 }
696 req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
697 if err != nil {
698 return nil, 0, err
699 }
700 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
701 resp, err := http.DefaultClient.Do(req)
702 if err != nil {
703 return nil, 0, err
704 }
705 defer resp.Body.Close()
706 var out map[string]any
707 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
708 return nil, resp.StatusCode, err
709 }
710 return out, resp.StatusCode, nil
711 }
712
713 func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
714 return BotConnectionView{
715 ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
716 Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
717 Access: botAccessViewFromConfig(conn.Access),
718 Credential: BotConnectionCredentialView{
719 AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
720 SecretSet: botCredentialSecretSet(conn),
721 },
722 SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
723 LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
724 }
725 }
726
727 func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
728 if conn.Credential.AppSecretEnv != "" {
729 return envIsSet(conn.Credential.AppSecretEnv)
730 }
731 if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
732 return true
733 }
734 if conn.Provider == "weixin" {
735 return weixin.HasSavedAccount(conn.Credential.AccountID)
736 }
737 return false
738 }
739
740 func feishuInstallDomain(fallback string, data map[string]any) string {
741 if userInfo, ok := data["user_info"].(map[string]any); ok {
742 if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
743 return "lark"
744 }
745 return "feishu"
746 }
747 if strings.EqualFold(fallback, "lark") {
748 return "lark"
749 }
750 return "feishu"
751 }
752
753 func feishuInstallUserID(data map[string]any) string {
754 if userInfo, ok := data["user_info"].(map[string]any); ok {
755 return firstNonEmptyBot(
756 stringValue(userInfo["open_id"]),
757 stringValue(userInfo["union_id"]),
758 stringValue(userInfo["user_id"]),
759 )
760 }
761 return ""
762 }
763
764 func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
765 if connections == nil {
766 return []BotConnectionView{}
767 }
768 out := make([]BotConnectionView, 0, len(connections))
769 for _, conn := range connections {
770 out = append(out, botConnectionView(conn))
771 }
772 return out
773 }
774
775 func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
776 return config.BotConnectionConfig{
777 ID: strings.TrimSpace(view.ID),
778 Provider: strings.TrimSpace(view.Provider),
779 Domain: strings.TrimSpace(view.Domain),
780 Label: strings.TrimSpace(view.Label),
781 Enabled: view.Enabled,
782 Status: strings.TrimSpace(view.Status),
783 Model: strings.TrimSpace(view.Model),
784 ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
785 WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
786 Access: botAccessConfigFromView(view.Access),
787 Credential: config.BotConnectionCredential{
788 AppID: strings.TrimSpace(view.Credential.AppID),
789 AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
790 AccountID: strings.TrimSpace(view.Credential.AccountID),
791 TokenEnv: strings.TrimSpace(view.Credential.TokenEnv),
792 },
793 SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
794 LastError: strings.TrimSpace(view.LastError),
795 CreatedAt: strings.TrimSpace(view.CreatedAt),
796 UpdatedAt: strings.TrimSpace(view.UpdatedAt),
797 }
798 }
799
800 func normalizeBotConnectionToolApprovalMode(mode string) string {
801 switch strings.ToLower(strings.TrimSpace(mode)) {
802 case "ask":
803 return "ask"
804 case "auto":
805 return "auto"
806 case "yolo", "full", "full-access", "bypass":
807 return "yolo"
808 default:
809 return ""
810 }
811 }
812
813 func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
814 if views == nil {
815 return nil
816 }
817 out := make([]config.BotConnectionConfig, 0, len(views))
818 for _, view := range views {
819 cfg := botConnectionConfig(view)
820 if cfg.ID == "" || cfg.Provider == "" {
821 continue
822 }
823 out = append(out, cfg)
824 }
825 return out
826 }
827
828 func botMappingScope(scope, workspaceRoot string) string {
829 if strings.TrimSpace(scope) == "project" {
830 return "project"
831 }
832 if strings.TrimSpace(workspaceRoot) != "" {
833 return "project"
834 }
835 return "global"
836 }
837
838 func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
839 if botMappingScope(scope, workspaceRoot) != "project" {
840 return ""
841 }
842 return strings.TrimSpace(workspaceRoot)
843 }
844
845 func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
846 if mappings == nil {
847 return []BotConnectionSessionMappingView{}
848 }
849 out := make([]BotConnectionSessionMappingView, 0, len(mappings))
850 for _, m := range mappings {
851 workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
852 scope := botMappingScope(m.Scope, workspaceRoot)
853 out = append(out, BotConnectionSessionMappingView{
854 RemoteID: m.RemoteID,
855 SessionID: m.SessionID,
856 SessionSource: m.SessionSource,
857 ChatType: m.ChatType,
858 UserID: m.UserID,
859 ThreadID: m.ThreadID,
860 Scope: scope,
861 WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
862 UpdatedAt: m.UpdatedAt,
863 })
864 }
865 return out
866 }
867
868 func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
869 if mappings == nil {
870 return nil
871 }
872 out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
873 for _, m := range mappings {
874 workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
875 scope := botMappingScope(m.Scope, workspaceRoot)
876 out = append(out, config.BotConnectionSessionMapping{
877 RemoteID: strings.TrimSpace(m.RemoteID),
878 SessionID: strings.TrimSpace(m.SessionID),
879 SessionSource: strings.TrimSpace(m.SessionSource),
880 ChatType: strings.TrimSpace(m.ChatType),
881 UserID: strings.TrimSpace(m.UserID),
882 ThreadID: strings.TrimSpace(m.ThreadID),
883 Scope: scope,
884 WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
885 UpdatedAt: strings.TrimSpace(m.UpdatedAt),
886 })
887 }
888 return out
889 }
890
891 func connectionID(provider, domain string) string {
892 return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
893 }
894
895 func botInstallAccess(userID string) config.BotAccessConfig {
896 userID = strings.TrimSpace(userID)
897 access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
898 if userID != "" {
899 access.Users = []string{userID}
900 }
901 return access
902 }
903
904 func randomInstallID() string {
905 var b [12]byte
906 if _, err := rand.Read(b[:]); err != nil {
907 return fmt.Sprintf("install-%d", time.Now().UnixNano())
908 }
909 return hex.EncodeToString(b[:])
910 }
911
912 func envIsSet(name string) bool {
913 return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
914 }
915
916 func firstAny(values ...any) any {
917 for _, value := range values {
918 if value != nil {
919 return value
920 }
921 }
922 return nil
923 }
924
925 func firstNonEmptyBot(values ...string) string {
926 for _, value := range values {
927 if strings.TrimSpace(value) != "" {
928 return value
929 }
930 }
931 return ""
932 }
933
934 func appendUniqueBotString(values []string, next string) []string {
935 next = strings.TrimSpace(next)
936 if next == "" {
937 return values
938 }
939 for _, value := range values {
940 if strings.TrimSpace(value) == next {
941 return values
942 }
943 }
944 return append(values, next)
945 }
946
947 func stringValue(value any) string {
948 if value == nil {
949 return ""
950 }
951 return strings.TrimSpace(fmt.Sprint(value))
952 }
953
954 func intValue(value any, fallback int) int {
955 switch v := value.(type) {
956 case float64:
957 if v > 0 {
958 return int(v)
959 }
960 case int:
961 if v > 0 {
962 return v
963 }
964 case string:
965 var n int
966 if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
967 return n
968 }
969 }
970 return fallback
971 }
972
973 func weixinInstallStatusMessage(status string) string {
974 switch status {
975 case "scaned":
976 return "已扫码,请在微信里确认。"
977 case "scaned_but_redirect":
978 return "已扫码,正在切换微信授权节点。"
979 default:
980 return "等待扫码。"
981 }
982 }
983
983 lines GO