diff --git a/AGENTS.md b/AGENTS.md index 1da738a..6e123c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ gh release edit v0.X.0 --title "v0.X.0" --notes "## Improvements ## Key Files -- `cmd/root.go` — Root cobra command, global flags (token, org, output, debug, dry-run, paginate, 429 retries, region) +- `cmd/root.go` — Root cobra command, global flags (token, org, output, debug, dry-run, paginate, 429 retries, region, cc-region) - `cmd/auth.go` — Auth subcommand (status, list, switch, set-org, clear-org) - `cmd/login.go` / `cmd/logout.go` — OAuth PKCE and device login - `cmd/mcp.go` / `cmd/mcp/` — MCP serve/stop, tools, resources, usage log diff --git a/README.md b/README.md index 97e3192..51cef3e 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ All upload commands support `--dry-run` to preview the request without sending i ### Contact Center (`webex cc`) -64 resource groups including sites, queues, entry points, teams, flows, skills, desktop layouts, global variables, business hours, auxiliary codes, campaigns, callbacks, realtime stats, AI assistant, journey analytics, subscriptions, and more. +64 resource groups including sites, queues, entry points, teams, flows, skills, desktop layouts, global variables, business hours, auxiliary codes, campaigns, callbacks, realtime stats, AI assistant, journey analytics, subscriptions, and more. For an organization outside the US data center, see [Contact Center data center](#contact-center-data-center). ### Admin (`webex admin`) @@ -248,6 +248,7 @@ webex cc users list --output csv > users.csv | `--debug` | Show HTTP request/response details | | `--paginate` | Auto-paginate list results (ignored by commands that return one object) | | `--dry-run` | Print write requests without executing them | +| `--cc-region us1\|eu1\|eu2\|anz1\|ca1\|jp1\|sg1\|in1` | Contact Center data center for `cc` commands (default: the organization's saved region, else `us1`) | ## Configuration @@ -256,11 +257,16 @@ webex config set client-id # Use custom OAuth client ID webex config set client-secret # Use custom OAuth client secret webex config set scopes # Override OAuth scopes webex config set read-only-scopes # Override scopes for login --read-only +webex config set cc-region # Save the current organization's Contact Center data center webex config get client-id # View current value ``` Config is stored in `~/.webex-cli/config.json`. +### Contact Center data center + +Each organization's Contact Center, and its AI Agent Studio, is in one data center: `us1`, `eu1`, `eu2`, `anz1`, `ca1`, `jp1`, `sg1` or `in1`. `cc` commands use, in order: `--cc-region`, `WEBEX_CC_REGION`, the region saved for the organization, then `us1`. `webex config set cc-region eu1` saves the region for the current organization (or the one named with `--organization`), so organizations in different data centers each keep their own. `--cc-region` alone saves nothing. + ## MCP Server > **Claude Desktop users:** see [CLAUDE_DESKTOP.md](CLAUDE_DESKTOP.md) for installation, DXT extension setup, and skill configuration specific to Claude Desktop. diff --git a/cmd/cc.go b/cmd/cc.go index 4e0b40e..6f76613 100644 --- a/cmd/cc.go +++ b/cmd/cc.go @@ -8,7 +8,11 @@ var CcCmd = &cobra.Command{ Use: "cc", Aliases: []string{"contact-center"}, Short: "Webex Contact Center APIs", - Long: `Manage Webex Contact Center resources — agents, queues, tasks, routing, and more.`, + Long: `Manage Webex Contact Center resources — agents, queues, tasks, routing, and more. + +Requests go to the organization's Contact Center data center: --cc-region, else +$WEBEX_CC_REGION, else the region saved with 'webex config set cc-region ', +else us1. Regions: us1, eu1, eu2, anz1, ca1, jp1, sg1, in1.`, } func init() { diff --git a/cmd/ccregion_test.go b/cmd/ccregion_test.go new file mode 100644 index 0000000..39b054b --- /dev/null +++ b/cmd/ccregion_test.go @@ -0,0 +1,272 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/Cloverhound/webex-cli/internal/appconfig" + "github.com/Cloverhound/webex-cli/internal/config" + "github.com/spf13/cobra" +) + +const ( + euOrg = "11111111-1111-1111-1111-111111111111" + usOrg = "22222222-2222-2222-2222-222222222222" +) + +// isolate gives a test its own config file and resets what it changes. +func isolate(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + for _, env := range []string{"WEBEX_CC_REGION", "WEBEX_USER", "WEBEX_TOKEN", "WEBEX_REFRESH_TOKEN", "WEBEX_REGION", readOnlyEnv} { + t.Setenv(env, "") + } + t.Cleanup(func() { + for _, flag := range []string{"organization", "cc-region", "token"} { + rootCmd.PersistentFlags().Set(flag, "") + } + rootCmd.SetArgs(nil) + config.SetToken("") + config.SetOrgID("") + if err := setCCRegion(""); err != nil { + t.Error(err) + } + }) +} + +func run(t *testing.T, args ...string) (string, error) { + t.Helper() + rootCmd.SetArgs(args) + var err error + out := captureStdout(t, func() { err = rootCmd.Execute() }) + return out, err +} + +func TestConfigSetCCRegionSavesItForTheOrganization(t *testing.T) { + isolate(t) + if _, err := run(t, "config", "set", "cc-region", "eu1", "--organization", config.EncodeOrgID(euOrg)); err != nil { + t.Fatal(err) + } + cfg, err := appconfig.Load() + if err != nil { + t.Fatal(err) + } + if got := cfg.CCRegions[euOrg]; got != "eu1" { + t.Errorf("saved region for %s = %q, want eu1 (saved: %v)", euOrg, got, cfg.CCRegions) + } + if got, ok := cfg.CCRegions[usOrg]; ok { + t.Errorf("another organization got region %q", got) + } +} + +func TestConfigSetCCRegionUsesTheDefaultUsersOrganization(t *testing.T) { + isolate(t) + cfg, _ := appconfig.Load() + cfg.DefaultUser = "me@example.com" + cfg.Users["me@example.com"] = appconfig.UserInfo{OrgID: euOrg} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + if _, err := run(t, "config", "set", "cc-region", "anz1"); err != nil { + t.Fatal(err) + } + cfg, _ = appconfig.Load() + if got := cfg.CCRegions[euOrg]; got != "anz1" { + t.Errorf("saved region = %q, want anz1", got) + } +} + +func TestConfigSetCCRegionNeedsAnOrganization(t *testing.T) { + isolate(t) + if _, err := run(t, "config", "set", "cc-region", "eu1"); err == nil || !strings.Contains(err.Error(), "organization") { + t.Errorf("err = %v, want one asking for an organization", err) + } +} + +func TestConfigSetCCRegionRefusesAnUnknownRegion(t *testing.T) { + isolate(t) + if _, err := run(t, "config", "set", "cc-region", "eu9", "--organization", euOrg); err == nil || !strings.Contains(err.Error(), "eu9") { + t.Errorf("err = %v, want one naming eu9", err) + } +} + +func TestConfigGetCCRegionShowsTheOrganizationsRegion(t *testing.T) { + isolate(t) + cfg, _ := appconfig.Load() + cfg.CCRegions = map[string]string{euOrg: "eu2"} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + out, err := run(t, "config", "get", "cc-region", "--organization", euOrg) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "eu2") || !strings.Contains(out, euOrg) { + t.Errorf("output = %q, want eu2 for %s", out, euOrg) + } + out, err = run(t, "config", "get", "cc-region", "--organization", usOrg) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "us1 (default)") { + t.Errorf("output = %q, want us1 (default)", out) + } +} + +func TestTheSavedRegionSelectsTheOrganizationsHosts(t *testing.T) { + isolate(t) + cfg := &appconfig.Config{CCRegions: map[string]string{euOrg: "eu1"}} + + if err := resolveCCRegion("", euOrg, cfg); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-eu1.cisco.com" || config.AiAgentBaseURL != "https://studio.aiagent-eu1.cisco.com" { + t.Errorf("hosts = %s, %s; want eu1's", config.CcBaseURL, config.AiAgentBaseURL) + } + + if err := resolveCCRegion("", usOrg, cfg); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-us1.cisco.com" || config.AiAgentBaseURL != "https://studio.aiagent-us1.cisco.com" { + t.Errorf("hosts = %s, %s; want us1's for an organization with no saved region", config.CcBaseURL, config.AiAgentBaseURL) + } +} + +func TestAnExplicitOrgidSelectsThatOrganizationsRegion(t *testing.T) { + isolate(t) + t.Setenv("WEBEX_TOKEN", "") + cfg, _ := appconfig.Load() + cfg.CCRegions = map[string]string{euOrg: "eu1"} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + var host string + probe := &cobra.Command{Use: "region-probe", RunE: func(*cobra.Command, []string) error { + host = config.CcBaseURL + return nil + }} + probe.Flags().String("orgid", "", "") + CcCmd.AddCommand(probe) + t.Cleanup(func() { CcCmd.RemoveCommand(probe) }) + + if _, err := run(t, "cc", "region-probe", "--token", "dummy", "--orgid="+config.EncodeOrgID(euOrg)); err != nil { + t.Fatal(err) + } + if host != "https://api.wxcc-eu1.cisco.com" { + t.Errorf("CC host for --orgid=%s = %s, want eu1's", euOrg, host) + } +} + +// regionFor runs a cc command that names its org with orgFlag and returns the CC +// host it would send to. +func regionFor(t *testing.T, orgFlag, org string) string { + t.Helper() + var host string + probe := &cobra.Command{Use: "region-probe-" + orgFlag, RunE: func(*cobra.Command, []string) error { + host = config.CcBaseURL + return nil + }} + probe.Flags().String(orgFlag, "", "") + CcCmd.AddCommand(probe) + t.Cleanup(func() { CcCmd.RemoveCommand(probe) }) + if _, err := run(t, "cc", probe.Use, "--token", "dummy", "--"+orgFlag+"="+org); err != nil { + t.Fatal(err) + } + return host +} + +func TestEveryWayACommandNamesItsOrgSelectsThatOrgsRegion(t *testing.T) { + for _, flag := range []string{"orgid", "org-id", "x-organization-id", "organization-id"} { + t.Run(flag, func(t *testing.T) { + isolate(t) + cfg, _ := appconfig.Load() + cfg.CCRegions = map[string]string{euOrg: "eu1"} + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + if got := regionFor(t, flag, config.EncodeOrgID(euOrg)); got != "https://api.wxcc-eu1.cisco.com" { + t.Errorf("CC host for --%s=%s = %s, want eu1's", flag, euOrg, got) + } + }) + } +} + +func TestIndiaIsADataCenter(t *testing.T) { + isolate(t) + if err := resolveCCRegion("in1", euOrg, &appconfig.Config{}); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-in1.cisco.com" || config.AiAgentBaseURL != "https://studio.aiagent-in1.cisco.com" { + t.Errorf("hosts = %s, %s; want in1's", config.CcBaseURL, config.AiAgentBaseURL) + } +} + +func TestAnOrgIsSavedAndFoundWhateverTheCaseOfItsID(t *testing.T) { + isolate(t) + const org = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + if _, err := run(t, "config", "set", "cc-region", "eu2", "--organization", strings.ToUpper(org)); err != nil { + t.Fatal(err) + } + cfg, _ := appconfig.Load() + if err := resolveCCRegion("", org, cfg); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-eu2.cisco.com" { + t.Errorf("region saved for the upper-case org ID was not found for the lower-case one: %v", cfg.CCRegions) + } +} + +func TestConfigSetCCRegionRefusesSomethingThatIsNotAnOrgID(t *testing.T) { + isolate(t) + if _, err := run(t, "config", "set", "cc-region", "eu1", "--organization", "Acme"); err == nil { + t.Error("an organization that is not an org ID was accepted") + } +} + +func TestABlankRegionDoesNotHideTheSavedOne(t *testing.T) { + isolate(t) + t.Setenv("WEBEX_CC_REGION", " ") + if err := resolveCCRegion(" ", euOrg, &appconfig.Config{CCRegions: map[string]string{euOrg: "eu1"}}); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-eu1.cisco.com" { + t.Errorf("CC host = %s, want the saved eu1", config.CcBaseURL) + } +} + +func TestConfigGetCCRegionSaysWhenThereIsNoOrganization(t *testing.T) { + isolate(t) + out, err := run(t, "config", "get", "cc-region") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "no organization") { + t.Errorf("output = %q, want it to say no organization was found", out) + } +} + +func TestTheFlagAndEnvironmentOverrideTheSavedRegion(t *testing.T) { + isolate(t) + cfg := &appconfig.Config{CCRegions: map[string]string{euOrg: "eu1"}} + + t.Setenv("WEBEX_CC_REGION", "ca1") + if err := resolveCCRegion("", euOrg, cfg); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-ca1.cisco.com" { + t.Errorf("with WEBEX_CC_REGION=ca1, CC host = %s", config.CcBaseURL) + } + + if err := resolveCCRegion("JP1", euOrg, cfg); err != nil { + t.Fatal(err) + } + if config.CcBaseURL != "https://api.wxcc-jp1.cisco.com" { + t.Errorf("with --cc-region JP1, CC host = %s", config.CcBaseURL) + } + + if err := resolveCCRegion("mars", euOrg, cfg); err == nil { + t.Error("an unknown region was accepted") + } +} diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index ea42d7e..cd1a5cc 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const configKeys = "client-id, client-secret, scopes, read-only-scopes, region" +const configKeys = "client-id, client-secret, scopes, read-only-scopes, region, cc-region" var configCmd = &cobra.Command{ Use: "config", @@ -24,14 +24,18 @@ To use your own Webex Integration instead of the built-in default: 3. Add the OAuth scopes required for the APIs you plan to use 4. Run: webex config set client-id webex config set client-secret - webex config set scopes "spark:all spark-admin:all "`, + webex config set scopes "spark:all spark-admin:all " + +For an organization whose Contact Center is outside the US data center, save its +region once: webex config set cc-region eu1 (add --organization for another org)`, } var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a configuration value", - Long: "Set a configuration value. Valid keys: " + configKeys + ". read-only-scopes accepts only read scopes.", - Args: cobra.ExactArgs(2), + Long: "Set a configuration value. Valid keys: " + configKeys + ". read-only-scopes accepts only read scopes. " + + "cc-region is saved for the current organization (or --organization), since each org's Contact Center is in one data center.", + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] value := args[1] @@ -41,6 +45,7 @@ var configSetCmd = &cobra.Command{ return fmt.Errorf("loading config: %w", err) } + done := "Set " + key switch key { case "client-id": cfg.ClientID = value @@ -58,6 +63,26 @@ var configSetCmd = &cobra.Command{ return err } cfg.Region = config.Region() + case "cc-region": + region, err := normalizeCCRegion(value) + if err != nil { + return err + } + if region == "" { + return fmt.Errorf("cc-region needs a value (valid: %s)", strings.Join(config.CCRegions, ", ")) + } + org := configuredOrgID(cmd, cfg) + if org == "" { + return fmt.Errorf("no organization to save cc-region for: log in, or pass --organization") + } + if !orgUUID.MatchString(org) { + return fmt.Errorf("%q is not an organization ID", org) + } + if cfg.CCRegions == nil { + cfg.CCRegions = map[string]string{} + } + cfg.CCRegions[org] = region + done = fmt.Sprintf("Set cc-region to %s for organization %s", region, org) default: return fmt.Errorf("unknown config key: %s (valid: %s)", key, configKeys) } @@ -66,7 +91,7 @@ var configSetCmd = &cobra.Command{ return fmt.Errorf("saving config: %w", err) } - fmt.Printf("Set %s\n", key) + fmt.Println(done) return nil }, } @@ -129,6 +154,16 @@ var configGetCmd = &cobra.Command{ } else { fmt.Println("us (default)") } + case "cc-region": + org := configuredOrgID(cmd, cfg) + switch region := cfg.CCRegions[org]; { + case org == "": + fmt.Printf("%s (default; no organization found: log in, or pass --organization)\n", config.DefaultCCRegion) + case region != "": + fmt.Printf("%s (saved for organization %s)\n", region, org) + default: + fmt.Printf("%s (default)\n", config.DefaultCCRegion) + } default: return fmt.Errorf("unknown config key: %s (valid: %s)", key, configKeys) } diff --git a/cmd/root.go b/cmd/root.go index 68f3782..8880eda 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "regexp" "slices" "strings" @@ -119,6 +120,15 @@ var rootCmd = &cobra.Command{ } } + ccOrg := requestOrgID(cmd) + if ccOrg == "" { + ccOrg = orgKey(config.OrgID()) + } + ccRegionFlag, _ := cmd.Flags().GetString("cc-region") + if err := resolveCCRegion(ccRegionFlag, ccOrg, cfg); err != nil { + return err + } + // Auto-populate --org-id on non-CC commands from base64 org ID. // These APIs require base64 format, so convert if needed. if f := cmd.Flags().Lookup("org-id"); f != nil { @@ -166,6 +176,76 @@ func setRegion(region string) error { return nil } +// resolveCCRegion selects the Contact Center data center for orgID: +// --cc-region flag > WEBEX_CC_REGION env > the region saved for the org > us1. +func resolveCCRegion(flag, orgID string, cfg *appconfig.Config) error { + region := strings.TrimSpace(flag) + if region == "" { + region = strings.TrimSpace(os.Getenv("WEBEX_CC_REGION")) + } + if region == "" { + region = cfg.CCRegions[orgKey(orgID)] + } + return setCCRegion(region) +} + +// requestOrgID returns the org a command names with a flag of its own, which can +// differ from the resolved org. Contact Center commands name it in the path +// (--orgid), the body (--org-id, --organization-id) or a header +// (--x-organization-id). +func requestOrgID(cmd *cobra.Command) string { + for _, name := range []string{"orgid", "org-id", "x-organization-id", "organization-id"} { + if f := cmd.Flags().Lookup(name); f != nil && f.Changed && f.Value.String() != "" { + return orgKey(f.Value.String()) + } + } + return "" +} + +// orgKey is the form saved regions are keyed by: the lower-case UUID, whichever +// form the org ID was given in. +func orgKey(id string) string { return strings.ToLower(config.DecodeOrgID(strings.TrimSpace(id))) } + +var orgUUID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// setCCRegion validates a Contact Center data center and points requests at it. +func setCCRegion(region string) error { + normalized, err := normalizeCCRegion(region) + if err != nil { + return err + } + config.SetCCRegion(normalized) + return nil +} + +func normalizeCCRegion(region string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(region)) + if normalized != "" && !slices.Contains(config.CCRegions, normalized) { + return "", fmt.Errorf("unknown Contact Center region %q (valid: %s)", region, strings.Join(config.CCRegions, ", ")) + } + return normalized, nil +} + +// configuredOrgID returns the org a command would use, from the flag and the config +// file alone, for commands that run without a login. +func configuredOrgID(cmd *cobra.Command, cfg *appconfig.Config) string { + if org, _ := cmd.Flags().GetString("organization"); org != "" { + return orgKey(org) + } + if cfg.DefaultOrgID != "" { + return orgKey(cfg.DefaultOrgID) + } + flagUser, envUser := requestedUser(cmd) + user := flagUser + if user == "" { + user = envUser + } + if user == "" { + user = cfg.DefaultUser + } + return orgKey(cfg.Users[user].OrgID) +} + // RootCommand returns the root cobra command for external introspection. func RootCommand() *cobra.Command { return rootCmd } @@ -219,6 +299,7 @@ func init() { rootCmd.PersistentFlags().String("user", "", "Use a specific authenticated user (email)") rootCmd.PersistentFlags().String("organization", "", "Override organization ID for this command") rootCmd.PersistentFlags().String("region", "", "Data region for region-specific APIs: "+strings.Join(config.Regions, ", ")+" (default us)") + rootCmd.PersistentFlags().String("cc-region", "", "Contact Center data center: "+strings.Join(config.CCRegions, ", ")+" (default: the organization's saved region, else us1)") rootCmd.PersistentFlags().Int("max-retry", 3, "Max number of 429 retries before giving up (0 = no retries)") rootCmd.PersistentFlags().Int("max-retry-timer", 60, "Max total seconds to wait across all 429 retries (0 = unlimited)") diff --git a/internal/aiagent/aiagent.go b/internal/aiagent/aiagent.go index e1bdb02..38e7f99 100644 --- a/internal/aiagent/aiagent.go +++ b/internal/aiagent/aiagent.go @@ -11,8 +11,9 @@ import ( "github.com/Cloverhound/webex-cli/internal/config" ) -// baseURL is a variable so tests can point it at a fake Studio. -var baseURL = config.AiAgentBaseURL +// studioURL is read at call time, after the command has chosen the org's data +// center, and is a variable so tests can point it at a fake Studio. +var studioURL = func() string { return config.AiAgentBaseURL } // BotToken returns the runtime token of an AI agent in the current // organization. Studio's v1 API selects the agent of a call by this token, and @@ -22,7 +23,7 @@ func BotToken(agentID string) (string, error) { if orgID == "" { return "", fmt.Errorf("no organization: log in or pass --organization") } - req := client.NewRequest(baseURL, "GET", "/api/v2/organizations/{orgid}/agents/") + req := client.NewRequest(studioURL(), "GET", "/api/v2/organizations/{orgid}/agents/") req.PathParam("orgid", orgID) req.OffsetPaging("offset", "limit", 0) body, _, err := req.DoPaginated(false) diff --git a/internal/aiagent/aiagent_test.go b/internal/aiagent/aiagent_test.go index 09dbccb..f520926 100644 --- a/internal/aiagent/aiagent_test.go +++ b/internal/aiagent/aiagent_test.go @@ -26,8 +26,9 @@ func studio(t *testing.T) *httptest.Server { }) })) t.Cleanup(srv.Close) - baseURL = srv.URL - t.Cleanup(func() { baseURL = config.AiAgentBaseURL }) + old := studioURL + studioURL = func() string { return srv.URL } + t.Cleanup(func() { studioURL = old }) config.SetOrgID("org-1") t.Cleanup(func() { config.SetOrgID("") }) return srv @@ -41,6 +42,21 @@ func TestBotTokenFindsTheAgent(t *testing.T) { } } +// The region is chosen after startup, so BotToken must reach the Studio host +// selected then, which a fake stands in for here. +func TestBotTokenUsesTheSelectedRegionsStudio(t *testing.T) { + srv := studio(t) + old := studioURL + t.Cleanup(func() { studioURL = old }) + studioURL = func() string { return config.AiAgentBaseURL } + config.SetCCRegion("eu1") + config.AiAgentBaseURL = srv.URL + t.Cleanup(func() { config.SetCCRegion("") }) + if got, err := BotToken("a1"); err != nil || got != "token-1" { + t.Fatalf("BotToken = %q, %v; it did not reach the selected Studio", got, err) + } +} + func TestBotTokenNamesAMissingAgent(t *testing.T) { studio(t) if _, err := BotToken("zz"); err == nil || !strings.Contains(err.Error(), "zz") { diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go index 541658d..7b81846 100644 --- a/internal/appconfig/appconfig.go +++ b/internal/appconfig/appconfig.go @@ -45,6 +45,10 @@ type Config struct { ReadOnly bool `json:"read_only,omitempty"` ReadOnlyScopes string `json:"read_only_scopes,omitempty"` + // CCRegions holds each org's Contact Center data center, keyed by org UUID: + // orgs one login manages can live in different data centers. + CCRegions map[string]string `json:"cc_regions,omitempty"` + path string // file path, not serialized } diff --git a/internal/config/config.go b/internal/config/config.go index 619d840..f9ebee6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,17 +60,39 @@ func OrgIDBase64() string { return orgIDBase64 } const ( CallingBaseURL = "https://webexapis.com/v1" - CcBaseURL = "https://api.wxcc-us1.cisco.com" // AnalyticsBaseURL serves the Admin analytics reports. Paths for those // endpoints already carry their own /v1 segment. AnalyticsBaseURL = "https://analytics.webexapis.com" +) + +// CCRegions lists the Contact Center data centers. An org's Contact Center lives +// in one of them, and each has its own API and AI Agent Studio host. +var CCRegions = []string{"us1", "eu1", "eu2", "anz1", "ca1", "jp1", "sg1", "in1"} + +// DefaultCCRegion is used when no region is given or saved for the org. +const DefaultCCRegion = "us1" + +var ( + CcBaseURL = ccHost(DefaultCCRegion) - // AiAgentBaseURL is AI Agent Studio in the same data center as CcBaseURL; - // each Contact Center data center has its own Studio host. - AiAgentBaseURL = "https://studio.aiagent-us1.cisco.com" + // AiAgentBaseURL is AI Agent Studio in the same data center as CcBaseURL. + AiAgentBaseURL = aiAgentHost(DefaultCCRegion) ) +func ccHost(region string) string { return "https://api.wxcc-" + region + ".cisco.com" } +func aiAgentHost(region string) string { return "https://studio.aiagent-" + region + ".cisco.com" } + +// SetCCRegion points Contact Center and AI Agent Studio requests at a data center +// from CCRegions. Empty means DefaultCCRegion. +func SetCCRegion(region string) { + if region == "" { + region = DefaultCCRegion + } + CcBaseURL = ccHost(region) + AiAgentBaseURL = aiAgentHost(region) +} + // analyticsCallingHosts maps a data region to the Detailed Call History (CDR) FQDN. // Calls to the default host are routed to the nearest region; if that region does // not hold the org's data the API returns HTTP 451 with the correct endpoint. diff --git a/site/src/content/docs/cc.mdx b/site/src/content/docs/cc.mdx index c2933c1..f637a39 100644 --- a/site/src/content/docs/cc.mdx +++ b/site/src/content/docs/cc.mdx @@ -5,6 +5,8 @@ description: Webex Contact Center API commands reference. The `webex cc` subcommand provides access to the Webex Contact Center API. The `--orgid` flag is auto-populated from your login, so you typically don't need to pass it. +Requests go to the organization's Contact Center data center: the one saved with `webex config set cc-region `, else `us1`. `--cc-region` overrides it for one command. See [Contact Center data center](../getting-started/#contact-center-data-center). + ## Resource Groups | Resource | Description | @@ -208,7 +210,7 @@ webex cc ai-agent-session list --agent-id --paginate - A tool needs `input_entities` in JSON Schema form even when it takes no input. Start from a tool in `get-configuration`. - In an organization with Advanced Data Protection, the messages of live sessions are encrypted. `ai-agent-transcript create-audit-record` adds a permanent compliance entry under your name, and with a regular Webex login the messages stay encrypted afterwards. - A voice session's `interaction_id` is the Contact Center task ID that `captures list` accepts. -- The Studio host is the US data center's, as for the rest of `webex cc`. +- Studio is in the organization's Contact Center data center, which `--cc-region` or `webex config set cc-region` selects, as for the rest of `webex cc`. ## Other refreshed Contact Center APIs diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 174fb91..57163d8 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -87,6 +87,19 @@ webex messaging [flags] # Webex Messaging APIs | `--output json\|table\|csv\|raw` | Output format (default: json) | | `--debug` | Show HTTP request/response details | | `--paginate` | Auto-paginate list results (ignored by commands that return one object) | +| `--cc-region us1\|eu1\|eu2\|anz1\|ca1\|jp1\|sg1\|in1` | Contact Center data center for `cc` commands (default: the organization's saved region, else `us1`) | + +## Contact Center data center + +Each organization's Contact Center, and its AI Agent Studio, is in one data center. `cc` commands use, in order: `--cc-region`, `WEBEX_CC_REGION`, the region saved for the organization, then `us1`. Save an organization's region once: + +```bash +webex config set cc-region eu1 # the current organization +webex config set cc-region anz1 --organization # another organization +webex config get cc-region +``` + +`--cc-region` on its own applies to one command and saves nothing. ## Admin Examples diff --git a/skill/SKILL.md b/skill/SKILL.md index 0291969..05fee38 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -68,6 +68,7 @@ Aliases: `devices` for `device`, `meeting` for `meetings`, `msg` for `messaging` - `--paginate` — Auto-paginate list results - `--dry-run` — Print write requests (POST/PUT/DELETE/PATCH) without executing them; read operations still run normally - `--region us|ca|eu|eun|in|au` — Data region for region-specific hosts, used by `calling reports-detailed-call-history` (default: `us`). Also settable with `webex config set region ` or `WEBEX_REGION`. A `451` error means the org's data lives in another region — the error names the correct one. +- `--cc-region us1|eu1|eu2|anz1|ca1|jp1|sg1|in1` — Contact Center data center for `cc` commands, including AI Agent Studio. Order: `--cc-region`, `WEBEX_CC_REGION`, the region saved for the organization, then `us1`. A person saves it once per organization in a terminal with `webex config set cc-region ` (add `--organization ` for another org); the flag alone saves nothing. The MCP server does not run `config set`: pass `cc-region` in `flags` instead. ## Contact Center `--orgid` Handling diff --git a/skill/cc/SKILL.md b/skill/cc/SKILL.md index fa860ca..c29cab4 100644 --- a/skill/cc/SKILL.md +++ b/skill/cc/SKILL.md @@ -10,6 +10,8 @@ Alias: `webex contact-center ` **`--orgid` is auto-populated from the logged-in user's org.** Use `--orgid=` (with `=`) only when overriding. Both UUID and base64 org ID formats are accepted. +**Data center:** `cc` commands, including `ai-agent*`, go to the org's saved Contact Center data center, else `us1`. For an org outside the US, `webex config get cc-region` shows the saved one. Pass `--cc-region ` for one command (through MCP, `"cc-region"` in `flags`); a person saves it with `webex config set cc-region ` in a terminal, since MCP does not run `config set`. + ## Resources | Resource | Common operations |