Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/admin/analytics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions cmd/calling/reports_detailed_call_history.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions cmd/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

"github.com/Cloverhound/webex-cli/internal/appconfig"
"github.com/Cloverhound/webex-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -26,7 +27,7 @@ To use your own Webex Integration instead of the built-in default:
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Long: "Set a configuration value. Valid keys: client-id, client-secret, scopes",
Long: "Set a configuration value. Valid keys: client-id, client-secret, scopes, region",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
Expand All @@ -44,8 +45,13 @@ var configSetCmd = &cobra.Command{
cfg.ClientSecret = value
case "scopes":
cfg.Scopes = value
case "region":
if err := setRegion(value); err != nil {
return err
}
cfg.Region = config.Region()
default:
return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes)", key)
return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key)
}

if err := cfg.Save(); err != nil {
Expand All @@ -60,7 +66,7 @@ var configSetCmd = &cobra.Command{
var configGetCmd = &cobra.Command{
Use: "get <key>",
Short: "Get a configuration value",
Long: "Get a configuration value. Valid keys: client-id, client-secret, scopes",
Long: "Get a configuration value. Valid keys: client-id, client-secret, scopes, region",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
Expand Down Expand Up @@ -102,8 +108,14 @@ var configGetCmd = &cobra.Command{
} else {
fmt.Printf("%s (default)\n", value)
}
case "region":
if cfg.Region != "" {
fmt.Printf("%s (custom)\n", cfg.Region)
} else {
fmt.Println("us (default)")
}
default:
return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes)", key)
return fmt.Errorf("unknown config key: %s (valid: client-id, client-secret, scopes, region)", key)
}

return nil
Expand Down
30 changes: 30 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"os"
"slices"
"strings"

"github.com/Cloverhound/webex-cli/internal/appconfig"
"github.com/Cloverhound/webex-cli/internal/auth"
Expand Down Expand Up @@ -48,6 +50,19 @@ var rootCmd = &cobra.Command{
return fmt.Errorf("loading config: %w", err)
}

// Data region for region-specific hosts (Detailed Call History):
// --region flag > WEBEX_REGION env > config file.
regionFlag, _ := cmd.Flags().GetString("region")
if regionFlag == "" {
regionFlag = os.Getenv("WEBEX_REGION")
}
if regionFlag == "" {
regionFlag = cfg.Region
}
if err := setRegion(regionFlag); err != nil {
return err
}

// Skip auth for certain commands
if skipAuth(cmd) {
return nil
Expand Down Expand Up @@ -126,6 +141,20 @@ var rootCmd = &cobra.Command{
},
}

// setRegion validates a region value and stores it for region-specific hosts.
func setRegion(region string) error {
if region == "" {
config.SetRegion("")
return nil
}
normalized := strings.ToLower(strings.TrimSpace(region))
if !slices.Contains(config.Regions, normalized) {
return fmt.Errorf("unknown region %q (valid: %s)", region, strings.Join(config.Regions, ", "))
}
config.SetRegion(normalized)
return nil
}

// RootCommand returns the root cobra command for external introspection.
func RootCommand() *cobra.Command { return rootCmd }

Expand Down Expand Up @@ -178,6 +207,7 @@ func init() {
rootCmd.PersistentFlags().Bool("dry-run", false, "Print write requests without executing them")
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().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)")

Expand Down
35 changes: 32 additions & 3 deletions codegen/generate_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,34 @@
}


# Endpoints served from a different host than the rest of their collection.
# The Postman collections use "{{baseUrl}}" for every request, so the real host
# is not in the source data and has to be recorded here.
# Collection name → URL path prefix → Go expression for the base URL.
BASE_URL_OVERRIDES = {
"Webex Cloud Calling": {
# Detailed Call History (CDR) — region-specific analytics FQDN.
"/cdr_feed": "config.AnalyticsCallingBaseURL()",
"/cdr_stream": "config.AnalyticsCallingBaseURL()",
},
"Webex Admin": {
# Analytics reports. These paths already include /v1, so the base URL
# deliberately has no version segment.
"/v1/analytics/": "config.AnalyticsBaseURL",
},
}


def resolve_base_url(collection_name, path, default):
"""Return the base URL expression for an endpoint (longest prefix wins)."""
overrides = BASE_URL_OVERRIDES.get(collection_name, {})
match = ""
for prefix in overrides:
if path.startswith(prefix) and len(prefix) > len(match):
match = prefix
return overrides[match] if match else default


# Path params that are always the same value — hardcode instead of generating a flag.
HARDCODED_PATH_PARAMS = {
"projectId": "5e5c9ad6d61f870d6d778c1b",
Expand Down Expand Up @@ -196,15 +224,16 @@ def generate_group_file(group, endpoints, pkg, parent_var, base_url_const, is_ca
if ep['command'] in skip_cmds:
print(f" Skipping command '{ep['command']}' (custom override)")
continue
lines.extend(generate_command(ep, group_var, base_url_const, is_calling))
ep_base_url = resolve_base_url(collection_name, ep['path'], base_url_const)
lines.extend(generate_command(ep, group_var, ep_base_url, is_calling))
lines.append('')

lines.append('}')

return '\n'.join(lines) + '\n'


def generate_command(ep, group_var, base_url_const, is_calling):
def generate_command(ep, group_var, base_url_expr, is_calling):
"""Generate Go source for one command within a group's init()."""
lines = []
indent = '\t'
Expand Down Expand Up @@ -329,7 +358,7 @@ def generate_command(ep, group_var, base_url_const, is_calling):
lines.append(f'{indent2}\tRunE: func(cmd *cobra.Command, args []string) error {{')
indent3 = indent2 + '\t\t'

lines.append(f'{indent3}req := client.NewRequest({base_url_const}, "{method}", {escape_go_double_quoted(path)})')
lines.append(f'{indent3}req := client.NewRequest({base_url_expr}, "{method}", {escape_go_double_quoted(path)})')

# --last → from/to conversion
if has_from:
Expand Down
1 change: 1 addition & 0 deletions internal/appconfig/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Config struct {
Scopes string `json:"scopes,omitempty"`
DefaultOrgID string `json:"default_org_id,omitempty"`
DefaultOrgName string `json:"default_org_name,omitempty"`
Region string `json:"region,omitempty"`

path string // file path, not serialized
}
Expand Down
26 changes: 26 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
urlpkg "net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -51,9 +52,34 @@ func Do(req *Request) ([]byte, int, error) {
body, status, headers, err = doOnce(req)
}

if status == 451 {
return body, status, wrongRegionError(body)
}

return body, status, err
}

// endpointURLPattern finds the regional endpoint Webex returns in a 451 body.
var endpointURLPattern = regexp.MustCompile(`https?://[a-zA-Z0-9.\-]+`)

// wrongRegionError turns an HTTP 451 (org data lives in another region) into an
// actionable message naming the endpoint the API pointed us at.
func wrongRegionError(body []byte) error {
msg := fmt.Sprintf("wrong data region (451): this organization's data is not hosted in region %q", regionLabel())
if m := endpointURLPattern.Find(body); m != nil {
msg += fmt.Sprintf("; Webex says to use %s", m)
}
return fmt.Errorf("%s — rerun with --region <%s> (or: webex config set region <region>)",
msg, strings.Join(config.Regions, "|"))
}

func regionLabel() string {
if r := config.Region(); r != "" {
return r
}
return "us (default)"
}

// retryAfterDuration parses the Retry-After header value (seconds integer) and
// returns the duration to wait. Defaults to 5 seconds if the header is absent
// or unparseable.
Expand Down
36 changes: 36 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,44 @@ 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"
)

// 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.
var analyticsCallingHosts = map[string]string{
"us": "https://analytics-calling.webexapis.com",
"ca": "https://analytics-calling.webexapis.com",
"eu": "https://analytics-calling-eu.webexapis.com",
"eun": "https://analytics-calling-eu.webexapis.com",
"in": "https://analytics-calling-in.webexapis.com",
"au": "https://analytics-calling-au.webexapis.com",
}

// Regions lists the supported --region values.
var Regions = []string{"us", "ca", "eu", "eun", "in", "au"}

var region string

// SetRegion stores the org's data region (us, ca, eu, eun, in, au).
// Empty means "use the default host and let Webex route by geography".
func SetRegion(r string) { region = strings.ToLower(strings.TrimSpace(r)) }
func Region() string { return region }

// AnalyticsCallingBaseURL returns the Detailed Call History base URL for the
// configured region, falling back to the US/Canada host.
func AnalyticsCallingBaseURL() string {
host, ok := analyticsCallingHosts[region]
if !ok {
host = analyticsCallingHosts["us"]
}
return host + "/v1"
}

// DecodeOrgID converts a base64-encoded Webex org ID (ciscospark://us/ORGANIZATION/<uuid>)
// to the raw UUID. If the input is already a UUID or unrecognized, it is returned as-is.
func DecodeOrgID(id string) string {
Expand Down
1 change: 1 addition & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Aliases: `devices` for `device`, `meeting` for `meetings`, `msg` for `messaging`
- `--debug` — Show HTTP request/response details
- `--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 <region>` or `WEBEX_REGION`. A `451` error means the org's data lives in another region — the error names the correct one.

## Contact Center `--orgid` Handling

Expand Down
Loading