返回 DeepSeek-Reasonix
runtime.go
根目录 / internal / botruntime / runtime.go
1 package botruntime
2
3 import (
4 "fmt"
5 "log/slog"
6 "path/filepath"
7 "strings"
8 "sync"
9 "time"
10
11 "reasonix/internal/bot"
12 "reasonix/internal/bot/dingtalk"
13 "reasonix/internal/bot/feishu"
14 "reasonix/internal/bot/qq"
15 "reasonix/internal/bot/weixin"
16 "reasonix/internal/config"
17 "reasonix/internal/permissionpreset"
18 )
19
20 // EnabledPlatforms resolves the requested channel list against the saved config.
21 // "lark" is a domain alias for the Feishu adapter platform.
22 func EnabledPlatforms(cfg *config.Config, channels []string) (map[bot.Platform]bool, []string) {
23 enabled := make(map[bot.Platform]bool)
24 var warnings []string
25 if len(channels) > 0 {
26 for _, ch := range channels {
27 ch = strings.TrimSpace(ch)
28 switch bot.Platform(ch) {
29 case bot.PlatformQQ:
30 enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
31 case bot.PlatformFeishu:
32 enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
33 case bot.PlatformWeixin:
34 enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
35 case bot.PlatformDingtalk:
36 enabled[bot.PlatformDingtalk] = PlatformConfigured(cfg, bot.PlatformDingtalk)
37 default:
38 if strings.EqualFold(ch, "lark") {
39 enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
40 } else if ch != "" {
41 warnings = append(warnings, ch)
42 }
43 }
44 }
45 return enabled, warnings
46 }
47 enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
48 enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
49 enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
50 enabled[bot.PlatformDingtalk] = PlatformConfigured(cfg, bot.PlatformDingtalk)
51 return enabled, warnings
52 }
53
54 // RequestedFeishuDomains returns the Feishu-family domains the caller explicitly
55 // named ("feishu"/"lark"), or nil when neither was requested (no restriction).
56 func RequestedFeishuDomains(channels []string) map[string]bool {
57 domains := make(map[string]bool)
58 for _, ch := range channels {
59 switch {
60 case strings.EqualFold(strings.TrimSpace(ch), string(bot.PlatformFeishu)):
61 domains["feishu"] = true
62 case strings.EqualFold(strings.TrimSpace(ch), "lark"):
63 domains["lark"] = true
64 }
65 }
66 if len(domains) == 0 {
67 return nil
68 }
69 return domains
70 }
71
72 func feishuDomainKey(domain string) string {
73 if strings.EqualFold(strings.TrimSpace(domain), "lark") {
74 return "lark"
75 }
76 return "feishu"
77 }
78
79 func HasEnabledPlatform(enabled map[bot.Platform]bool) bool {
80 for _, value := range enabled {
81 if value {
82 return true
83 }
84 }
85 return false
86 }
87
88 func PlatformConfigured(cfg *config.Config, platform bot.Platform) bool {
89 if cfg == nil {
90 return false
91 }
92 switch platform {
93 case bot.PlatformQQ:
94 if cfg.Bot.QQ.Enabled {
95 return true
96 }
97 case bot.PlatformFeishu:
98 if cfg.Bot.Feishu.Enabled {
99 return true
100 }
101 case bot.PlatformWeixin:
102 if cfg.Bot.Weixin.Enabled {
103 return true
104 }
105 case bot.PlatformDingtalk:
106 if cfg.Bot.Dingtalk.Enabled {
107 return true
108 }
109 }
110 for _, conn := range cfg.Bot.Connections {
111 if conn.Enabled && bot.Platform(strings.TrimSpace(conn.Provider)) == platform {
112 return true
113 }
114 }
115 return false
116 }
117
118 func ChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[bot.Platform]bot.ChannelConfig {
119 if len(connections) == 0 {
120 return nil
121 }
122 out := make(map[bot.Platform]bot.ChannelConfig)
123 for _, conn := range connections {
124 if !conn.Enabled {
125 continue
126 }
127 plat := bot.Platform(strings.TrimSpace(conn.Provider))
128 switch plat {
129 case bot.PlatformQQ, bot.PlatformFeishu, bot.PlatformWeixin, bot.PlatformDingtalk:
130 default:
131 continue
132 }
133 channel := out[plat]
134 if includeModel {
135 channel.Model = strings.TrimSpace(conn.Model)
136 }
137 if includeWorkspaceRoot {
138 channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
139 }
140 if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
141 channel.ToolApprovalMode = value
142 }
143 if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" {
144 out[plat] = channel
145 }
146 }
147 if len(out) == 0 {
148 return nil
149 }
150 return out
151 }
152
153 func ConnectionChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[string]bot.ChannelConfig {
154 if len(connections) == 0 {
155 return nil
156 }
157 out := make(map[string]bot.ChannelConfig)
158 for _, conn := range connections {
159 if !conn.Enabled {
160 continue
161 }
162 id := ConnectionRuntimeID(conn)
163 if id == "" {
164 continue
165 }
166 var channel bot.ChannelConfig
167 if includeModel {
168 channel.Model = strings.TrimSpace(conn.Model)
169 }
170 if includeWorkspaceRoot {
171 channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
172 channel.SessionMappings = SessionMappings(conn.SessionMappings)
173 }
174 if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
175 channel.ToolApprovalMode = value
176 }
177 if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" || len(channel.SessionMappings) > 0 {
178 out[id] = channel
179 }
180 }
181 if len(out) == 0 {
182 return nil
183 }
184 return out
185 }
186
187 func ConnectionAccessConfigs(cfg *config.Config) map[string]bot.AccessConfig {
188 if cfg == nil {
189 return nil
190 }
191 out := make(map[string]bot.AccessConfig)
192 if BotAccessActive(cfg.Bot.QQ.Access) {
193 out[string(bot.PlatformQQ)] = botAccessConfig(cfg.Bot.QQ.Access)
194 }
195 if BotAccessActive(cfg.Bot.Dingtalk.Access) {
196 out[string(bot.PlatformDingtalk)] = botAccessConfig(cfg.Bot.Dingtalk.Access)
197 }
198 for _, conn := range cfg.Bot.Connections {
199 if !conn.Enabled {
200 continue
201 }
202 id := ConnectionRuntimeID(conn)
203 if id == "" || !BotAccessActive(conn.Access) {
204 continue
205 }
206 out[id] = botAccessConfig(conn.Access)
207 }
208 if len(out) == 0 {
209 return nil
210 }
211 return out
212 }
213
214 func BotAccessActive(access config.BotAccessConfig) bool {
215 return access.Enabled ||
216 access.AllowAll ||
217 access.PairingEnabled ||
218 len(access.Users) > 0 ||
219 len(access.Groups) > 0 ||
220 len(access.Approvers) > 0 ||
221 len(access.Admins) > 0
222 }
223
224 func botAccessConfig(access config.BotAccessConfig) bot.AccessConfig {
225 return bot.AccessConfig{
226 Enabled: access.Enabled,
227 AllowAll: access.AllowAll,
228 PairingEnabled: access.PairingEnabled,
229 Users: trimStringSlice(access.Users),
230 Groups: trimStringSlice(access.Groups),
231 Approvers: trimStringSlice(access.Approvers),
232 Admins: trimStringSlice(access.Admins),
233 }
234 }
235
236 func trimStringSlice(values []string) []string {
237 if len(values) == 0 {
238 return nil
239 }
240 out := make([]string, 0, len(values))
241 for _, value := range values {
242 value = strings.TrimSpace(value)
243 if value != "" {
244 out = append(out, value)
245 }
246 }
247 return out
248 }
249
250 // SessionMappings 把配置层会话绑定转换为 gateway 运行时映射(connection 与
251 // legacy 直配渠道共用)。
252 func SessionMappings(mappings []config.BotConnectionSessionMapping) []bot.SessionMapping {
253 if len(mappings) == 0 {
254 return nil
255 }
256 out := make([]bot.SessionMapping, 0, len(mappings))
257 for _, mapping := range mappings {
258 out = append(out, bot.SessionMapping{
259 RemoteID: strings.TrimSpace(mapping.RemoteID),
260 SessionID: strings.TrimSpace(mapping.SessionID),
261 SessionSource: strings.TrimSpace(mapping.SessionSource),
262 ChatType: strings.TrimSpace(mapping.ChatType),
263 UserID: strings.TrimSpace(mapping.UserID),
264 ThreadID: strings.TrimSpace(mapping.ThreadID),
265 Scope: strings.TrimSpace(mapping.Scope),
266 WorkspaceRoot: strings.TrimSpace(mapping.WorkspaceRoot),
267 UpdatedAt: strings.TrimSpace(mapping.UpdatedAt),
268 })
269 }
270 return out
271 }
272
273 func RouteConfigs(routes []config.BotRouteConfig, includeModel bool, includeWorkspaceRoot bool) []bot.RouteConfig {
274 if len(routes) == 0 {
275 return nil
276 }
277 out := make([]bot.RouteConfig, 0, len(routes))
278 for _, route := range routes {
279 var channel bot.ChannelConfig
280 if includeModel {
281 channel.Model = strings.TrimSpace(route.Model)
282 }
283 if includeWorkspaceRoot {
284 channel.WorkspaceRoot = strings.TrimSpace(route.WorkspaceRoot)
285 }
286 if value := normalizeToolApprovalMode(route.ToolApprovalMode); value != "" {
287 channel.ToolApprovalMode = value
288 }
289 if channel.Model == "" && channel.WorkspaceRoot == "" && channel.ToolApprovalMode == "" {
290 continue
291 }
292 out = append(out, bot.RouteConfig{
293 ConnectionID: strings.TrimSpace(route.ConnectionID),
294 Platform: bot.Platform(strings.TrimSpace(route.Platform)),
295 ChatType: bot.ChatType(strings.TrimSpace(route.ChatType)),
296 ChatID: strings.TrimSpace(route.ChatID),
297 UserID: strings.TrimSpace(route.UserID),
298 ThreadID: strings.TrimSpace(route.ThreadID),
299 Channel: channel,
300 })
301 }
302 if len(out) == 0 {
303 return nil
304 }
305 return out
306 }
307
308 func normalizeToolApprovalMode(mode string) string {
309 if strings.TrimSpace(mode) == "" {
310 return ""
311 }
312 return string(permissionpreset.Normalize(mode))
313 }
314
315 // MergeLegacyDingtalkChannel merges the legacy [bot.dingtalk] runtime options
316 // (model / tool_approval_mode / workspace_root) into the per-platform and
317 // per-connection channel maps so a directly-configured DingTalk bot (no
318 // [[bot.connections]] record) honors them. The desktop does the same via
319 // desktopBotChannelsWithLegacyDingtalk; the CLI bot mode needs the equivalent.
320 func MergeLegacyDingtalkChannel(dt config.DingtalkBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
321 channel := bot.ChannelConfig{
322 Model: strings.TrimSpace(dt.Model),
323 ToolApprovalMode: normalizeToolApprovalMode(dt.ToolApprovalMode),
324 WorkspaceRoot: strings.TrimSpace(dt.WorkspaceRoot),
325 SessionMappings: SessionMappings(dt.SessionMappings),
326 }
327 if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" && len(channel.SessionMappings) == 0 {
328 return channels, connectionChannels
329 }
330 if channels == nil {
331 channels = make(map[bot.Platform]bot.ChannelConfig)
332 }
333 if _, ok := channels[bot.PlatformDingtalk]; !ok {
334 channels[bot.PlatformDingtalk] = channel
335 }
336 if connectionChannels == nil {
337 connectionChannels = make(map[string]bot.ChannelConfig)
338 }
339 if _, ok := connectionChannels[string(bot.PlatformDingtalk)]; !ok {
340 connectionChannels[string(bot.PlatformDingtalk)] = channel
341 }
342 return channels, connectionChannels
343 }
344
345 func AdapterBindings(cfg *config.Config, enabled map[bot.Platform]bool, feishuDomains map[string]bool, logger *slog.Logger) []bot.AdapterBinding {
346 if cfg == nil {
347 return nil
348 }
349 var bindings []bot.AdapterBinding
350 hasConnection := make(map[bot.Platform]bool)
351 for _, conn := range cfg.Bot.Connections {
352 if !conn.Enabled {
353 continue
354 }
355 platform := bot.Platform(strings.TrimSpace(conn.Provider))
356 if !enabled[platform] {
357 continue
358 }
359 id := ConnectionRuntimeID(conn)
360 switch platform {
361 case bot.PlatformQQ:
362 qqCfg := cfg.Bot.QQ
363 qqCfg.Enabled = true
364 qqCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), qqCfg.AppID)
365 qqCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), qqCfg.AppSecretEnv)
366 bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: qq.New(qqCfg, logger)})
367 hasConnection[platform] = true
368 case bot.PlatformFeishu:
369 feishuCfg := cfg.Bot.Feishu
370 feishuCfg.Enabled = true
371 feishuCfg.Domain = firstNonEmptyString(strings.TrimSpace(conn.Domain), feishuCfg.Domain)
372 if feishuDomains != nil && !feishuDomains[feishuDomainKey(feishuCfg.Domain)] {
373 continue
374 }
375 feishuCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), feishuCfg.AppID)
376 feishuCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), feishuCfg.AppSecretEnv)
377 bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: feishuCfg.Domain, Platform: platform, Adapter: feishu.New(feishuCfg, logger)})
378 hasConnection[platform] = true
379 case bot.PlatformWeixin:
380 weixinCfg := cfg.Bot.Weixin
381 weixinCfg.Enabled = true
382 weixinCfg.AccountID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AccountID), weixinCfg.AccountID)
383 weixinCfg.TokenEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.TokenEnv), weixinCfg.TokenEnv)
384 bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: weixin.New(weixinCfg, logger)})
385 hasConnection[platform] = true
386 case bot.PlatformDingtalk:
387 dingtalkCfg := cfg.Bot.Dingtalk
388 dingtalkCfg.Enabled = true
389 dingtalkCfg.ClientID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), dingtalkCfg.ClientID)
390 dingtalkCfg.SecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), dingtalkCfg.SecretEnv)
391 bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: dingtalk.New(dingtalkCfg, logger)})
392 hasConnection[platform] = true
393 }
394 }
395 if enabled[bot.PlatformQQ] && !hasConnection[bot.PlatformQQ] {
396 bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformQQ), Platform: bot.PlatformQQ, Adapter: qq.New(cfg.Bot.QQ, logger)})
397 }
398 if enabled[bot.PlatformFeishu] && !hasConnection[bot.PlatformFeishu] {
399 if feishuDomains == nil || feishuDomains[feishuDomainKey(cfg.Bot.Feishu.Domain)] {
400 bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformFeishu), Domain: cfg.Bot.Feishu.Domain, Platform: bot.PlatformFeishu, Adapter: feishu.New(cfg.Bot.Feishu, logger)})
401 }
402 }
403 if enabled[bot.PlatformWeixin] && !hasConnection[bot.PlatformWeixin] {
404 bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformWeixin), Domain: "weixin", Platform: bot.PlatformWeixin, Adapter: weixin.New(cfg.Bot.Weixin, logger)})
405 }
406 if enabled[bot.PlatformDingtalk] && !hasConnection[bot.PlatformDingtalk] {
407 bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformDingtalk), Domain: "dingtalk", Platform: bot.PlatformDingtalk, Adapter: dingtalk.New(cfg.Bot.Dingtalk, logger)})
408 }
409 return bindings
410 }
411
412 func ConnectionRuntimeID(conn config.BotConnectionConfig) string {
413 if id := strings.TrimSpace(conn.ID); id != "" {
414 return id
415 }
416 provider := strings.TrimSpace(conn.Provider)
417 domain := strings.TrimSpace(conn.Domain)
418 if provider == "" {
419 return ""
420 }
421 if domain == "" {
422 return provider
423 }
424 return provider + "-" + domain
425 }
426
427 func ModelName(cfg *config.Config, override string) string {
428 if strings.TrimSpace(override) != "" {
429 return strings.TrimSpace(override)
430 }
431 if cfg == nil {
432 return ""
433 }
434 if strings.TrimSpace(cfg.Bot.Model) != "" {
435 return strings.TrimSpace(cfg.Bot.Model)
436 }
437 return strings.TrimSpace(cfg.DefaultModel)
438 }
439
440 // ModelResolver 构造 /model 切换前的模型预校验器:模型必须可解析
441 // (provider/model 存在)且该 provider 已配置 API key,否则拒绝切换并
442 // 保留当前会话 controller(失败原子性,见 bot.GatewayConfig.ModelResolver)。
443 func ModelResolver(cfg *config.Config) func(string) error {
444 return func(ref string) error {
445 entry, ok := cfg.ResolveModel(strings.TrimSpace(ref))
446 if !ok {
447 return fmt.Errorf("未配置该模型(provider/model 不存在)")
448 }
449 if !entry.Configured() {
450 return fmt.Errorf("%s 未配置 API key,无法使用", entry.Name)
451 }
452 return nil
453 }
454 }
455
456 func AllowlistUserCount(a config.BotAllowlist) int {
457 return len(a.QQUsers) + len(a.FeishuUsers) + len(a.WeixinUsers) + len(a.DingtalkUsers) +
458 len(a.QQApprovers) + len(a.FeishuApprovers) + len(a.WeixinApprovers) + len(a.DingtalkApprovers) +
459 len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins) + len(a.DingtalkAdmins)
460 }
461
462 func BotAccessUserCount(access config.BotAccessConfig) int {
463 return len(access.Users) + len(access.Groups) + len(access.Approvers) + len(access.Admins)
464 }
465
466 func BotConfigHasAccessControl(bc config.BotConfig) bool {
467 if bc.Allowlist.AllowAll || bc.Pairing.Enabled || (bc.Allowlist.Enabled && AllowlistUserCount(bc.Allowlist) > 0) {
468 return true
469 }
470 if BotAccessActive(bc.QQ.Access) || BotAccessActive(bc.Dingtalk.Access) {
471 return true
472 }
473 for _, conn := range bc.Connections {
474 if conn.Enabled && BotAccessActive(conn.Access) {
475 return true
476 }
477 }
478 return false
479 }
480
481 func NewRemoteRememberer(logger *slog.Logger) func(bot.InboundMessage) {
482 var mu sync.Mutex
483 seen := make(map[string]bool)
484 return func(msg bot.InboundMessage) {
485 remoteID := strings.TrimSpace(msg.ChatID)
486 if remoteID == "" {
487 return
488 }
489 key := strings.Join([]string{
490 string(msg.Platform),
491 strings.TrimSpace(msg.ConnectionID),
492 strings.TrimSpace(msg.Domain),
493 string(msg.ChatType),
494 remoteID,
495 strings.TrimSpace(msg.UserID),
496 }, "\x00")
497 mu.Lock()
498 if seen[key] {
499 mu.Unlock()
500 return
501 }
502 seen[key] = true
503 mu.Unlock()
504
505 if err := RememberInbound(msg); err != nil && logger != nil {
506 logger.Warn("remember bot remote failed", "platform", msg.Platform, "err", err)
507 }
508 }
509 }
510
511 func NewSessionRememberer(logger *slog.Logger) func(bot.InboundMessage, string) error {
512 return NewSessionRemembererWithWorkspace(logger, "")
513 }
514
515 func NewSessionRemembererWithWorkspace(logger *slog.Logger, workspaceRoot string) func(bot.InboundMessage, string) error {
516 return func(msg bot.InboundMessage, sessionID string) error {
517 if err := RememberInboundSessionWorkspace(msg, sessionID, workspaceRoot); err != nil {
518 if logger != nil {
519 logger.Warn("remember bot session failed", "platform", msg.Platform, "err", err)
520 }
521 return err
522 }
523 return nil
524 }
525 }
526
527 func RememberInbound(msg bot.InboundMessage) error {
528 return rememberInbound(msg, "", "")
529 }
530
531 func RememberInboundSession(msg bot.InboundMessage, sessionID string) error {
532 return RememberInboundSessionWorkspace(msg, sessionID, "")
533 }
534
535 func RememberInboundSessionWorkspace(msg bot.InboundMessage, sessionID string, workspaceRoot string) error {
536 return rememberInbound(msg, strings.TrimSpace(sessionID), strings.TrimSpace(workspaceRoot))
537 }
538
539 func ForgetAutoSessionMappingsForPath(sessionPath string) error {
540 target := normalizedBotSessionPath(sessionPath)
541 if target == "" {
542 return nil
543 }
544 userPath := config.UserConfigPath()
545 if strings.TrimSpace(userPath) == "" {
546 return nil
547 }
548 unlock := config.LockUserConfigEdits()
549 defer unlock()
550
551 cfg := config.LoadForEdit(userPath)
552 now := time.Now().UTC().Format(time.RFC3339)
553 changed := false
554 for i := range cfg.Bot.Connections {
555 conn := &cfg.Bot.Connections[i]
556 next := conn.SessionMappings[:0]
557 removed := false
558 for _, mapping := range conn.SessionMappings {
559 if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target {
560 removed = true
561 continue
562 }
563 next = append(next, mapping)
564 }
565 if !removed {
566 continue
567 }
568 conn.SessionMappings = next
569 conn.UpdatedAt = now
570 changed = true
571 }
572 // legacy 直配渠道的 auto mapping 同样清理,避免残留旧会话绑定。
573 ding := &cfg.Bot.Dingtalk
574 dingNext := ding.SessionMappings[:0]
575 dingRemoved := false
576 for _, mapping := range ding.SessionMappings {
577 if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target {
578 dingRemoved = true
579 continue
580 }
581 dingNext = append(dingNext, mapping)
582 }
583 if dingRemoved {
584 ding.SessionMappings = dingNext
585 changed = true
586 }
587 if !changed {
588 return nil
589 }
590 return cfg.SaveTo(userPath)
591 }
592
593 func rememberInbound(msg bot.InboundMessage, sessionID string, actualWorkspaceRoot string) error {
594 userPath := config.UserConfigPath()
595 platform := msg.Platform
596 remoteID := strings.TrimSpace(msg.ChatID)
597 if userPath == "" || remoteID == "" {
598 return nil
599 }
600 unlock := config.LockUserConfigEdits()
601 defer unlock()
602
603 cfg := config.LoadForEdit(userPath)
604 now := time.Now().UTC().Format(time.RFC3339)
605 changed := false
606 for i := range cfg.Bot.Connections {
607 conn := &cfg.Bot.Connections[i]
608 if strings.TrimSpace(conn.Provider) != string(platform) || !conn.Enabled || !connectionMatchesInbound(*conn, msg) {
609 continue
610 }
611 if rememberConnMappings(&conn.SessionMappings, conn.WorkspaceRoot, msg, remoteID, sessionID, actualWorkspaceRoot, now) {
612 conn.UpdatedAt = now
613 changed = true
614 }
615 }
616 // legacy 直配 [bot.dingtalk] 没有 connection 记录:/new 旋转后的会话
617 // 路径持久化到 Dingtalk.SessionMappings,重启后仍能恢复(#9116 review)。
618 if platform == bot.PlatformDingtalk && cfg.Bot.Dingtalk.Enabled && !botHasDingtalkConnection(cfg.Bot.Connections) {
619 if rememberConnMappings(&cfg.Bot.Dingtalk.SessionMappings, cfg.Bot.Dingtalk.WorkspaceRoot, msg, remoteID, sessionID, actualWorkspaceRoot, now) {
620 changed = true
621 }
622 }
623 if rememberAllowlist(&cfg.Bot.Allowlist, platform, msg.UserID, remoteID, msg.ChatType) {
624 changed = true
625 }
626 if !changed {
627 return nil
628 }
629 return cfg.SaveTo(userPath)
630 }
631
632 // botHasDingtalkConnection 判断是否存在匹配的钉钉 connection 记录。
633 func botHasDingtalkConnection(conns []config.BotConnectionConfig) bool {
634 for _, conn := range conns {
635 if conn.Enabled && strings.TrimSpace(conn.Provider) == string(bot.PlatformDingtalk) {
636 return true
637 }
638 }
639 return false
640 }
641
642 // rememberConnMappings 把会话绑定写入 mappings 切片(connection 与 legacy
643 // 直配渠道共用)。返回是否有变更。
644 func rememberConnMappings(mappings *[]config.BotConnectionSessionMapping, channelWorkspaceRoot string, msg bot.InboundMessage, remoteID, sessionID, actualWorkspaceRoot, now string) bool {
645 mappingIndex := -1
646 for j := range *mappings {
647 if botSessionMappingMatches((*mappings)[j], msg) {
648 mappingIndex = j
649 break
650 }
651 }
652 if mappingIndex >= 0 {
653 if sessionID == "" {
654 return false
655 }
656 mapping := &(*mappings)[mappingIndex]
657 current := strings.TrimSpace(mapping.SessionID)
658 if current == sessionID || botSessionMappingHasExplicitTarget(*mapping) {
659 return false
660 }
661 mapping.SessionID = sessionID
662 mapping.SessionSource = "auto"
663 mapping.UpdatedAt = now
664 return true
665 }
666 scope := "global"
667 workspaceRoot := ""
668 if strings.TrimSpace(channelWorkspaceRoot) != "" {
669 scope = "project"
670 workspaceRoot = strings.TrimSpace(channelWorkspaceRoot)
671 } else if actualWorkspaceRoot != "" {
672 scope = "project"
673 workspaceRoot = actualWorkspaceRoot
674 }
675 chatType, userID, threadID := botSessionMappingIdentity(msg)
676 *mappings = append(*mappings, config.BotConnectionSessionMapping{
677 RemoteID: remoteID,
678 SessionID: sessionID,
679 SessionSource: botSessionSource(sessionID),
680 ChatType: chatType,
681 UserID: userID,
682 ThreadID: threadID,
683 Scope: scope,
684 WorkspaceRoot: workspaceRoot,
685 UpdatedAt: now,
686 })
687 return true
688 }
689
690 func botSessionMappingMatches(mapping config.BotConnectionSessionMapping, msg bot.InboundMessage) bool {
691 if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
692 return false
693 }
694 chatType, userID, threadID := botSessionMappingIdentity(msg)
695 mappingChatType := strings.TrimSpace(mapping.ChatType)
696 if mappingChatType == "" {
697 return chatType == ""
698 }
699 if mappingChatType != chatType {
700 return false
701 }
702 if strings.TrimSpace(mapping.UserID) != userID {
703 return false
704 }
705 return strings.TrimSpace(mapping.ThreadID) == threadID
706 }
707
708 func botSessionMappingIdentity(msg bot.InboundMessage) (chatType string, userID string, threadID string) {
709 switch msg.ChatType {
710 case bot.ChatGroup, bot.ChatGuild:
711 chatType = string(msg.ChatType)
712 userID = strings.TrimSpace(msg.UserID)
713 case bot.ChatThread:
714 chatType = string(msg.ChatType)
715 threadID = strings.TrimSpace(msg.ThreadID)
716 if threadID == "" {
717 threadID = strings.TrimSpace(msg.ChatID)
718 }
719 }
720 return chatType, userID, threadID
721 }
722
723 func botSessionMappingHasExplicitTarget(mapping config.BotConnectionSessionMapping) bool {
724 sessionID := strings.TrimSpace(mapping.SessionID)
725 if sessionID == "" || strings.TrimSpace(mapping.SessionSource) == "auto" {
726 return false
727 }
728 return true
729 }
730
731 func botSessionSource(sessionID string) string {
732 if strings.TrimSpace(sessionID) == "" {
733 return ""
734 }
735 return "auto"
736 }
737
738 func normalizedBotSessionPath(sessionID string) string {
739 sessionID = strings.TrimSpace(sessionID)
740 if sessionID == "" {
741 return ""
742 }
743 if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
744 sessionID = strings.TrimSpace(sessionID[5:])
745 }
746 if sessionID == "" {
747 return ""
748 }
749 if !(strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~")) {
750 return ""
751 }
752 return filepath.Clean(sessionID)
753 }
754
755 func connectionMatchesInbound(conn config.BotConnectionConfig, msg bot.InboundMessage) bool {
756 if msg.ConnectionID != "" {
757 return ConnectionRuntimeID(conn) == strings.TrimSpace(msg.ConnectionID)
758 }
759 if msg.Domain != "" && strings.TrimSpace(conn.Domain) != "" {
760 return strings.EqualFold(strings.TrimSpace(conn.Domain), strings.TrimSpace(msg.Domain))
761 }
762 return true
763 }
764
765 func rememberAllowlist(allowlist *config.BotAllowlist, platform bot.Platform, userID string, chatID string, chatType bot.ChatType) bool {
766 if allowlist == nil {
767 return false
768 }
769 changed := false
770 userID = strings.TrimSpace(userID)
771 if userID != "" {
772 switch platform {
773 case bot.PlatformQQ:
774 allowlist.QQUsers, changed = appendUniqueString(allowlist.QQUsers, userID)
775 case bot.PlatformFeishu:
776 allowlist.FeishuUsers, changed = appendUniqueString(allowlist.FeishuUsers, userID)
777 case bot.PlatformWeixin:
778 allowlist.WeixinUsers, changed = appendUniqueString(allowlist.WeixinUsers, userID)
779 case bot.PlatformDingtalk:
780 allowlist.DingtalkUsers, changed = appendUniqueString(allowlist.DingtalkUsers, userID)
781 }
782 }
783 if !chatUsesGroupAllowlist(chatType) {
784 return changed
785 }
786 groupID := strings.TrimSpace(chatID)
787 if groupID == "" {
788 return changed
789 }
790 groupChanged := false
791 switch platform {
792 case bot.PlatformQQ:
793 allowlist.QQGroups, groupChanged = appendUniqueString(allowlist.QQGroups, groupID)
794 case bot.PlatformFeishu:
795 allowlist.FeishuGroups, groupChanged = appendUniqueString(allowlist.FeishuGroups, groupID)
796 case bot.PlatformWeixin:
797 allowlist.WeixinGroups, groupChanged = appendUniqueString(allowlist.WeixinGroups, groupID)
798 case bot.PlatformDingtalk:
799 allowlist.DingtalkGroups, groupChanged = appendUniqueString(allowlist.DingtalkGroups, groupID)
800 }
801 return changed || groupChanged
802 }
803
804 func appendUniqueString(values []string, next string) ([]string, bool) {
805 next = strings.TrimSpace(next)
806 if next == "" {
807 return values, false
808 }
809 for _, value := range values {
810 if strings.TrimSpace(value) == next {
811 return values, false
812 }
813 }
814 return append(values, next), true
815 }
816
817 func chatUsesGroupAllowlist(chatType bot.ChatType) bool {
818 switch chatType {
819 case bot.ChatGroup, bot.ChatGuild, bot.ChatThread:
820 return true
821 default:
822 return false
823 }
824 }
825
826 func firstNonEmptyString(vals ...string) string {
827 for _, val := range vals {
828 if strings.TrimSpace(val) != "" {
829 return val
830 }
831 }
832 return ""
833 }
834
834 lines GO