diff --git a/internal/core/config.go b/internal/core/config.go index 9c566ce50..58be84b35 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -58,10 +58,11 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode StrictMode `json:"strictMode,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + ExtraBrands map[string]*Endpoints `json:"extraBrands,omitempty"` + Apps []AppConfig `json:"apps"` } // CurrentAppConfig returns the currently active app config. @@ -199,6 +200,11 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { if len(multi.Apps) == 0 { return nil, fmt.Errorf("invalid config format: no apps") } + for name, ep := range multi.ExtraBrands { + if ep != nil { + RegisterBrand(name, *ep) + } + } return &multi, nil } diff --git a/internal/core/types.go b/internal/core/types.go index cf842e6a4..6a7c83977 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -24,30 +24,70 @@ func ParseBrand(value string) LarkBrand { // Endpoints holds resolved endpoint URLs for different Lark services. type Endpoints struct { - Open string // e.g. "https://open.feishu.cn" - Accounts string // e.g. "https://accounts.feishu.cn" - MCP string // e.g. "https://mcp.feishu.cn" - AppLink string // e.g. "https://applink.feishu.cn" + Open string `json:"open"` // e.g. "https://open.feishu.cn" + Accounts string `json:"accounts"` // e.g. "https://accounts.feishu.cn" + MCP string `json:"mcp"` // e.g. "https://mcp.feishu.cn" + AppLink string `json:"applink"` // e.g. "https://applink.feishu.cn" +} + +// brandRegistry maps brand names to their endpoint defaults. +// Built-in brands (feishu, lark) are pre-registered via init. +// Custom brands can be added via RegisterBrand. +var brandRegistry = map[string]Endpoints{} + +func init() { + brandRegistry[string(BrandFeishu)] = Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + brandRegistry[string(BrandLark)] = Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", + AppLink: "https://applink.larksuite.com", + } +} + +// RegisterBrand adds or updates a custom brand's endpoint defaults. +// Partial overrides are merged on top of the "feishu" defaults. +// Built-in brands (feishu, lark) and empty names are silently ignored. +func RegisterBrand(name string, ep Endpoints) { + if name == "" || name == string(BrandFeishu) || name == string(BrandLark) { + return + } + base := brandRegistry[string(BrandFeishu)] + brandRegistry[name] = MergeEndpointOverrides(base, &ep) +} + +// MergeEndpointOverrides returns a copy of base with non-empty overrides applied. +func MergeEndpointOverrides(base Endpoints, overrides *Endpoints) Endpoints { + if overrides == nil { + return base + } + result := base + if overrides.Open != "" { + result.Open = overrides.Open + } + if overrides.Accounts != "" { + result.Accounts = overrides.Accounts + } + if overrides.MCP != "" { + result.MCP = overrides.MCP + } + if overrides.AppLink != "" { + result.AppLink = overrides.AppLink + } + return result } // ResolveEndpoints resolves endpoint URLs based on brand. func ResolveEndpoints(brand LarkBrand) Endpoints { - switch brand { - case BrandLark: - return Endpoints{ - Open: "https://open.larksuite.com", - Accounts: "https://accounts.larksuite.com", - MCP: "https://mcp.larksuite.com", - AppLink: "https://applink.larksuite.com", - } - default: - return Endpoints{ - Open: "https://open.feishu.cn", - Accounts: "https://accounts.feishu.cn", - MCP: "https://mcp.feishu.cn", - AppLink: "https://applink.feishu.cn", - } + if ep, ok := brandRegistry[string(brand)]; ok { + return ep } + return brandRegistry[string(BrandFeishu)] } // ResolveOpenBaseURL returns the Open API base URL for the given brand. diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 72f331700..633711a81 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -52,3 +52,83 @@ func TestResolveOpenBaseURL(t *testing.T) { t.Errorf("ResolveOpenBaseURL(lark) = %q", got) } } + +func TestMergeEndpointOverrides(t *testing.T) { + base := Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + AppLink: "https://applink.feishu.cn", + } + + t.Run("nil overrides returns base", func(t *testing.T) { + got := MergeEndpointOverrides(base, nil) + if got != base { + t.Errorf("got %v, want %v", got, base) + } + }) + + t.Run("partial override", func(t *testing.T) { + got := MergeEndpointOverrides(base, &Endpoints{Open: "https://proxy.example.com"}) + if got.Open != "https://proxy.example.com" { + t.Errorf("Open = %q", got.Open) + } + if got.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want unchanged", got.Accounts) + } + }) + + t.Run("full override", func(t *testing.T) { + overrides := Endpoints{ + Open: "https://a.example.com", Accounts: "https://b.example.com", + MCP: "https://c.example.com", AppLink: "https://d.example.com", + } + got := MergeEndpointOverrides(base, &overrides) + if got != overrides { + t.Errorf("got %v, want %v", got, overrides) + } + }) +} + +func TestRegisterBrand(t *testing.T) { + RegisterBrand("staging", Endpoints{Open: "https://open-staging.feishu.cn"}) + defer delete(brandRegistry, "staging") + + ep := ResolveEndpoints("staging") + if ep.Open != "https://open-staging.feishu.cn" { + t.Errorf("Open = %q, want staging URL", ep.Open) + } + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want feishu default", ep.Accounts) + } +} + +func TestRegisterBrand_Full(t *testing.T) { + RegisterBrand("proxy", Endpoints{ + Open: "https://api-proxy.example.com", Accounts: "https://acct-proxy.example.com", + MCP: "https://mcp-proxy.example.com", AppLink: "https://applink-proxy.example.com", + }) + defer delete(brandRegistry, "proxy") + + ep := ResolveEndpoints("proxy") + if ep.Open != "https://api-proxy.example.com" { + t.Errorf("Open = %q", ep.Open) + } + if ep.Accounts != "https://acct-proxy.example.com" { + t.Errorf("Accounts = %q", ep.Accounts) + } +} + +func TestRegisterBrand_IgnoresBuiltIn(t *testing.T) { + original := brandRegistry[string(BrandFeishu)] + RegisterBrand("feishu", Endpoints{Open: "https://malicious.example.com"}) + RegisterBrand("lark", Endpoints{Open: "https://malicious.example.com"}) + RegisterBrand("", Endpoints{Open: "https://malicious.example.com"}) + + if brandRegistry[string(BrandFeishu)] != original { + t.Error("RegisterBrand should not overwrite built-in feishu brand") + } + if brandRegistry[string(BrandLark)].Open == "https://malicious.example.com" { + t.Error("RegisterBrand should not overwrite built-in lark brand") + } +}