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