| 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 |