package botruntime import ( "log/slog" "path/filepath" "strings" "sync" "time" "reasonix/internal/bot" "reasonix/internal/bot/feishu" "reasonix/internal/bot/qq" "reasonix/internal/bot/weixin" "reasonix/internal/config" ) // EnabledPlatforms resolves the requested channel list against the saved config. // "lark" is a domain alias for the Feishu adapter platform. func EnabledPlatforms(cfg *config.Config, channels []string) (map[bot.Platform]bool, []string) { enabled := make(map[bot.Platform]bool) var warnings []string if len(channels) > 0 { for _, ch := range channels { ch = strings.TrimSpace(ch) switch bot.Platform(ch) { case bot.PlatformQQ: enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ) case bot.PlatformFeishu: enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu) case bot.PlatformWeixin: enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin) default: if strings.EqualFold(ch, "lark") { enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu) } else if ch != "" { warnings = append(warnings, ch) } } } return enabled, warnings } enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ) enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu) enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin) return enabled, warnings } // RequestedFeishuDomains returns the Feishu-family domains the caller explicitly // named ("feishu"/"lark"), or nil when neither was requested (no restriction). func RequestedFeishuDomains(channels []string) map[string]bool { domains := make(map[string]bool) for _, ch := range channels { switch { case strings.EqualFold(strings.TrimSpace(ch), string(bot.PlatformFeishu)): domains["feishu"] = true case strings.EqualFold(strings.TrimSpace(ch), "lark"): domains["lark"] = true } } if len(domains) == 0 { return nil } return domains } func feishuDomainKey(domain string) string { if strings.EqualFold(strings.TrimSpace(domain), "lark") { return "lark" } return "feishu" } func HasEnabledPlatform(enabled map[bot.Platform]bool) bool { for _, value := range enabled { if value { return true } } return false } func PlatformConfigured(cfg *config.Config, platform bot.Platform) bool { if cfg == nil { return false } switch platform { case bot.PlatformQQ: if cfg.Bot.QQ.Enabled { return true } case bot.PlatformFeishu: if cfg.Bot.Feishu.Enabled { return true } case bot.PlatformWeixin: if cfg.Bot.Weixin.Enabled { return true } } for _, conn := range cfg.Bot.Connections { if conn.Enabled && bot.Platform(strings.TrimSpace(conn.Provider)) == platform { return true } } return false } func ChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[bot.Platform]bot.ChannelConfig { if len(connections) == 0 { return nil } out := make(map[bot.Platform]bot.ChannelConfig) for _, conn := range connections { if !conn.Enabled { continue } plat := bot.Platform(strings.TrimSpace(conn.Provider)) switch plat { case bot.PlatformQQ, bot.PlatformFeishu, bot.PlatformWeixin: default: continue } channel := out[plat] if includeModel { channel.Model = strings.TrimSpace(conn.Model) } if includeWorkspaceRoot { channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot) } if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" { channel.ToolApprovalMode = value } if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" { out[plat] = channel } } if len(out) == 0 { return nil } return out } func ConnectionChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[string]bot.ChannelConfig { if len(connections) == 0 { return nil } out := make(map[string]bot.ChannelConfig) for _, conn := range connections { if !conn.Enabled { continue } id := ConnectionRuntimeID(conn) if id == "" { continue } var channel bot.ChannelConfig if includeModel { channel.Model = strings.TrimSpace(conn.Model) } if includeWorkspaceRoot { channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot) channel.SessionMappings = botSessionMappings(conn.SessionMappings) } if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" { channel.ToolApprovalMode = value } if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" || len(channel.SessionMappings) > 0 { out[id] = channel } } if len(out) == 0 { return nil } return out } func ConnectionAccessConfigs(cfg *config.Config) map[string]bot.AccessConfig { if cfg == nil { return nil } out := make(map[string]bot.AccessConfig) if BotAccessActive(cfg.Bot.QQ.Access) { out[string(bot.PlatformQQ)] = botAccessConfig(cfg.Bot.QQ.Access) } for _, conn := range cfg.Bot.Connections { if !conn.Enabled { continue } id := ConnectionRuntimeID(conn) if id == "" || !BotAccessActive(conn.Access) { continue } out[id] = botAccessConfig(conn.Access) } if len(out) == 0 { return nil } return out } func BotAccessActive(access config.BotAccessConfig) bool { return access.Enabled || access.AllowAll || access.PairingEnabled || len(access.Users) > 0 || len(access.Groups) > 0 || len(access.Approvers) > 0 || len(access.Admins) > 0 } func botAccessConfig(access config.BotAccessConfig) bot.AccessConfig { return bot.AccessConfig{ Enabled: access.Enabled, AllowAll: access.AllowAll, PairingEnabled: access.PairingEnabled, Users: trimStringSlice(access.Users), Groups: trimStringSlice(access.Groups), Approvers: trimStringSlice(access.Approvers), Admins: trimStringSlice(access.Admins), } } func trimStringSlice(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { out = append(out, value) } } return out } func botSessionMappings(mappings []config.BotConnectionSessionMapping) []bot.SessionMapping { if len(mappings) == 0 { return nil } out := make([]bot.SessionMapping, 0, len(mappings)) for _, mapping := range mappings { out = append(out, bot.SessionMapping{ RemoteID: strings.TrimSpace(mapping.RemoteID), SessionID: strings.TrimSpace(mapping.SessionID), SessionSource: strings.TrimSpace(mapping.SessionSource), ChatType: strings.TrimSpace(mapping.ChatType), UserID: strings.TrimSpace(mapping.UserID), ThreadID: strings.TrimSpace(mapping.ThreadID), Scope: strings.TrimSpace(mapping.Scope), WorkspaceRoot: strings.TrimSpace(mapping.WorkspaceRoot), UpdatedAt: strings.TrimSpace(mapping.UpdatedAt), }) } return out } func RouteConfigs(routes []config.BotRouteConfig, includeModel bool, includeWorkspaceRoot bool) []bot.RouteConfig { if len(routes) == 0 { return nil } out := make([]bot.RouteConfig, 0, len(routes)) for _, route := range routes { var channel bot.ChannelConfig if includeModel { channel.Model = strings.TrimSpace(route.Model) } if includeWorkspaceRoot { channel.WorkspaceRoot = strings.TrimSpace(route.WorkspaceRoot) } if value := normalizeToolApprovalMode(route.ToolApprovalMode); value != "" { channel.ToolApprovalMode = value } if channel.Model == "" && channel.WorkspaceRoot == "" && channel.ToolApprovalMode == "" { continue } out = append(out, bot.RouteConfig{ ConnectionID: strings.TrimSpace(route.ConnectionID), Platform: bot.Platform(strings.TrimSpace(route.Platform)), ChatType: bot.ChatType(strings.TrimSpace(route.ChatType)), ChatID: strings.TrimSpace(route.ChatID), UserID: strings.TrimSpace(route.UserID), ThreadID: strings.TrimSpace(route.ThreadID), Channel: channel, }) } if len(out) == 0 { return nil } return out } func normalizeToolApprovalMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "ask": return "ask" case "auto": return "auto" case "yolo", "full", "full-access", "bypass": return "yolo" default: return "" } } func AdapterBindings(cfg *config.Config, enabled map[bot.Platform]bool, feishuDomains map[string]bool, logger *slog.Logger) []bot.AdapterBinding { if cfg == nil { return nil } var bindings []bot.AdapterBinding hasConnection := make(map[bot.Platform]bool) for _, conn := range cfg.Bot.Connections { if !conn.Enabled { continue } platform := bot.Platform(strings.TrimSpace(conn.Provider)) if !enabled[platform] { continue } id := ConnectionRuntimeID(conn) switch platform { case bot.PlatformQQ: qqCfg := cfg.Bot.QQ qqCfg.Enabled = true qqCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), qqCfg.AppID) qqCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), qqCfg.AppSecretEnv) bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: qq.New(qqCfg, logger)}) hasConnection[platform] = true case bot.PlatformFeishu: feishuCfg := cfg.Bot.Feishu feishuCfg.Enabled = true feishuCfg.Domain = firstNonEmptyString(strings.TrimSpace(conn.Domain), feishuCfg.Domain) if feishuDomains != nil && !feishuDomains[feishuDomainKey(feishuCfg.Domain)] { continue } feishuCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), feishuCfg.AppID) feishuCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), feishuCfg.AppSecretEnv) bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: feishuCfg.Domain, Platform: platform, Adapter: feishu.New(feishuCfg, logger)}) hasConnection[platform] = true case bot.PlatformWeixin: weixinCfg := cfg.Bot.Weixin weixinCfg.Enabled = true weixinCfg.AccountID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AccountID), weixinCfg.AccountID) weixinCfg.TokenEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.TokenEnv), weixinCfg.TokenEnv) bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: weixin.New(weixinCfg, logger)}) hasConnection[platform] = true } } if enabled[bot.PlatformQQ] && !hasConnection[bot.PlatformQQ] { bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformQQ), Platform: bot.PlatformQQ, Adapter: qq.New(cfg.Bot.QQ, logger)}) } if enabled[bot.PlatformFeishu] && !hasConnection[bot.PlatformFeishu] { if feishuDomains == nil || feishuDomains[feishuDomainKey(cfg.Bot.Feishu.Domain)] { bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformFeishu), Domain: cfg.Bot.Feishu.Domain, Platform: bot.PlatformFeishu, Adapter: feishu.New(cfg.Bot.Feishu, logger)}) } } if enabled[bot.PlatformWeixin] && !hasConnection[bot.PlatformWeixin] { bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformWeixin), Domain: "weixin", Platform: bot.PlatformWeixin, Adapter: weixin.New(cfg.Bot.Weixin, logger)}) } return bindings } func ConnectionRuntimeID(conn config.BotConnectionConfig) string { if id := strings.TrimSpace(conn.ID); id != "" { return id } provider := strings.TrimSpace(conn.Provider) domain := strings.TrimSpace(conn.Domain) if provider == "" { return "" } if domain == "" { return provider } return provider + "-" + domain } func ModelName(cfg *config.Config, override string) string { if strings.TrimSpace(override) != "" { return strings.TrimSpace(override) } if cfg == nil { return "" } if strings.TrimSpace(cfg.Bot.Model) != "" { return strings.TrimSpace(cfg.Bot.Model) } return strings.TrimSpace(cfg.DefaultModel) } func AllowlistUserCount(a config.BotAllowlist) int { return len(a.QQUsers) + len(a.FeishuUsers) + len(a.WeixinUsers) + len(a.QQApprovers) + len(a.FeishuApprovers) + len(a.WeixinApprovers) + len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins) } func BotAccessUserCount(access config.BotAccessConfig) int { return len(access.Users) + len(access.Groups) + len(access.Approvers) + len(access.Admins) } func BotConfigHasAccessControl(bc config.BotConfig) bool { if bc.Allowlist.AllowAll || bc.Pairing.Enabled || (bc.Allowlist.Enabled && AllowlistUserCount(bc.Allowlist) > 0) { return true } if BotAccessActive(bc.QQ.Access) { return true } for _, conn := range bc.Connections { if conn.Enabled && BotAccessActive(conn.Access) { return true } } return false } func NewRemoteRememberer(logger *slog.Logger) func(bot.InboundMessage) { var mu sync.Mutex seen := make(map[string]bool) return func(msg bot.InboundMessage) { remoteID := strings.TrimSpace(msg.ChatID) if remoteID == "" { return } key := strings.Join([]string{ string(msg.Platform), strings.TrimSpace(msg.ConnectionID), strings.TrimSpace(msg.Domain), string(msg.ChatType), remoteID, strings.TrimSpace(msg.UserID), }, "\x00") mu.Lock() if seen[key] { mu.Unlock() return } seen[key] = true mu.Unlock() if err := RememberInbound(msg); err != nil && logger != nil { logger.Warn("remember bot remote failed", "platform", msg.Platform, "err", err) } } } func NewSessionRememberer(logger *slog.Logger) func(bot.InboundMessage, string) error { return NewSessionRemembererWithWorkspace(logger, "") } func NewSessionRemembererWithWorkspace(logger *slog.Logger, workspaceRoot string) func(bot.InboundMessage, string) error { return func(msg bot.InboundMessage, sessionID string) error { if err := RememberInboundSessionWorkspace(msg, sessionID, workspaceRoot); err != nil { if logger != nil { logger.Warn("remember bot session failed", "platform", msg.Platform, "err", err) } return err } return nil } } func RememberInbound(msg bot.InboundMessage) error { return rememberInbound(msg, "", "") } func RememberInboundSession(msg bot.InboundMessage, sessionID string) error { return RememberInboundSessionWorkspace(msg, sessionID, "") } func RememberInboundSessionWorkspace(msg bot.InboundMessage, sessionID string, workspaceRoot string) error { return rememberInbound(msg, strings.TrimSpace(sessionID), strings.TrimSpace(workspaceRoot)) } func ForgetAutoSessionMappingsForPath(sessionPath string) error { target := normalizedBotSessionPath(sessionPath) if target == "" { return nil } userPath := config.UserConfigPath() if strings.TrimSpace(userPath) == "" { return nil } unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(userPath) now := time.Now().UTC().Format(time.RFC3339) changed := false for i := range cfg.Bot.Connections { conn := &cfg.Bot.Connections[i] next := conn.SessionMappings[:0] removed := false for _, mapping := range conn.SessionMappings { if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target { removed = true continue } next = append(next, mapping) } if !removed { continue } conn.SessionMappings = next conn.UpdatedAt = now changed = true } if !changed { return nil } return cfg.SaveTo(userPath) } func rememberInbound(msg bot.InboundMessage, sessionID string, actualWorkspaceRoot string) error { userPath := config.UserConfigPath() platform := msg.Platform remoteID := strings.TrimSpace(msg.ChatID) if userPath == "" || remoteID == "" { return nil } unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(userPath) now := time.Now().UTC().Format(time.RFC3339) changed := false for i := range cfg.Bot.Connections { conn := &cfg.Bot.Connections[i] if strings.TrimSpace(conn.Provider) != string(platform) || !conn.Enabled || !connectionMatchesInbound(*conn, msg) { continue } mappingIndex := -1 for j := range conn.SessionMappings { if botSessionMappingMatches(conn.SessionMappings[j], msg) { mappingIndex = j break } } if mappingIndex >= 0 { if sessionID == "" { continue } mapping := &conn.SessionMappings[mappingIndex] current := strings.TrimSpace(mapping.SessionID) if current == sessionID || botSessionMappingHasExplicitTarget(*mapping) { continue } mapping.SessionID = sessionID mapping.SessionSource = "auto" mapping.UpdatedAt = now conn.UpdatedAt = now changed = true continue } scope := "global" workspaceRoot := "" if strings.TrimSpace(conn.WorkspaceRoot) != "" { scope = "project" workspaceRoot = strings.TrimSpace(conn.WorkspaceRoot) } else if actualWorkspaceRoot != "" { scope = "project" workspaceRoot = actualWorkspaceRoot } chatType, userID, threadID := botSessionMappingIdentity(msg) conn.SessionMappings = append(conn.SessionMappings, config.BotConnectionSessionMapping{ RemoteID: remoteID, SessionID: sessionID, SessionSource: botSessionSource(sessionID), ChatType: chatType, UserID: userID, ThreadID: threadID, Scope: scope, WorkspaceRoot: workspaceRoot, UpdatedAt: now, }) conn.UpdatedAt = now changed = true } if rememberAllowlist(&cfg.Bot.Allowlist, platform, msg.UserID, remoteID, msg.ChatType) { changed = true } if !changed { return nil } return cfg.SaveTo(userPath) } func botSessionMappingMatches(mapping config.BotConnectionSessionMapping, msg bot.InboundMessage) bool { if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) { return false } chatType, userID, threadID := botSessionMappingIdentity(msg) mappingChatType := strings.TrimSpace(mapping.ChatType) if mappingChatType == "" { return chatType == "" } if mappingChatType != chatType { return false } if strings.TrimSpace(mapping.UserID) != userID { return false } return strings.TrimSpace(mapping.ThreadID) == threadID } func botSessionMappingIdentity(msg bot.InboundMessage) (chatType string, userID string, threadID string) { switch msg.ChatType { case bot.ChatGroup, bot.ChatGuild: chatType = string(msg.ChatType) userID = strings.TrimSpace(msg.UserID) case bot.ChatThread: chatType = string(msg.ChatType) threadID = strings.TrimSpace(msg.ThreadID) if threadID == "" { threadID = strings.TrimSpace(msg.ChatID) } } return chatType, userID, threadID } func botSessionMappingHasExplicitTarget(mapping config.BotConnectionSessionMapping) bool { sessionID := strings.TrimSpace(mapping.SessionID) if sessionID == "" || strings.TrimSpace(mapping.SessionSource) == "auto" { return false } return true } func botSessionSource(sessionID string) string { if strings.TrimSpace(sessionID) == "" { return "" } return "auto" } func normalizedBotSessionPath(sessionID string) string { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return "" } if strings.HasPrefix(strings.ToLower(sessionID), "path:") { sessionID = strings.TrimSpace(sessionID[5:]) } if sessionID == "" { return "" } if !(strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~")) { return "" } return filepath.Clean(sessionID) } func connectionMatchesInbound(conn config.BotConnectionConfig, msg bot.InboundMessage) bool { if msg.ConnectionID != "" { return ConnectionRuntimeID(conn) == strings.TrimSpace(msg.ConnectionID) } if msg.Domain != "" && strings.TrimSpace(conn.Domain) != "" { return strings.EqualFold(strings.TrimSpace(conn.Domain), strings.TrimSpace(msg.Domain)) } return true } func rememberAllowlist(allowlist *config.BotAllowlist, platform bot.Platform, userID string, chatID string, chatType bot.ChatType) bool { if allowlist == nil { return false } changed := false userID = strings.TrimSpace(userID) if userID != "" { switch platform { case bot.PlatformQQ: allowlist.QQUsers, changed = appendUniqueString(allowlist.QQUsers, userID) case bot.PlatformFeishu: allowlist.FeishuUsers, changed = appendUniqueString(allowlist.FeishuUsers, userID) case bot.PlatformWeixin: allowlist.WeixinUsers, changed = appendUniqueString(allowlist.WeixinUsers, userID) } } if !chatUsesGroupAllowlist(chatType) { return changed } groupID := strings.TrimSpace(chatID) if groupID == "" { return changed } groupChanged := false switch platform { case bot.PlatformQQ: allowlist.QQGroups, groupChanged = appendUniqueString(allowlist.QQGroups, groupID) case bot.PlatformFeishu: allowlist.FeishuGroups, groupChanged = appendUniqueString(allowlist.FeishuGroups, groupID) case bot.PlatformWeixin: allowlist.WeixinGroups, groupChanged = appendUniqueString(allowlist.WeixinGroups, groupID) } return changed || groupChanged } func appendUniqueString(values []string, next string) ([]string, bool) { next = strings.TrimSpace(next) if next == "" { return values, false } for _, value := range values { if strings.TrimSpace(value) == next { return values, false } } return append(values, next), true } func chatUsesGroupAllowlist(chatType bot.ChatType) bool { switch chatType { case bot.ChatGroup, bot.ChatGuild, bot.ChatThread: return true default: return false } } func firstNonEmptyString(vals ...string) string { for _, val := range vals { if strings.TrimSpace(val) != "" { return val } } return "" }