From 108f5cdf0c50bd760c85ed97d7550e527fc60def Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 24 Aug 2026 16:33:55 -0400 Subject: [PATCH 1/3] feat(insights): voice usage monitors (minutes, calls, connection rates, durations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add band insights — read-only usage/quality aggregates from the Insights Monitoring API, answering 'how much traffic does this number carry?': - insights minutes-of-use | completed-calls | failed-calls | connection-rates | average-durations, all sharing the same filters: --to/--from (comma-separated E.164), --direction, --call-type (dash or underscore forms accepted, e.g. TOLLFREE-IN), --subaccount, and --since/--until (RFC3339 or relative shorthand: 30d, 24h, 90m) - new InsightsClient against insights.bandwidth.com (single prod host, BW_INSIGHTS_URL override); the standard OAuth Bearer token is accepted (verified live) - deepObject query encoding per the API spec (accountId[eq], timestamp[gte/lte], toPhoneNumber[eq], ...) - the Monitoring-feature 403 maps to an actionable message (exit 2), same pattern as the toll-free template gate Live-probed against production: token acceptance, query encoding, the feature-gate 403 mapping, and flag validation (exit 6). Happy path needs a Monitoring-enabled account; envelope unwrapping is unit-tested against spec examples and passes unexpected shapes through raw. --- AGENTS.md | 7 ++ README.md | 12 +++ cmd/insights/insights.go | 23 ++++ cmd/insights/insights_test.go | 117 +++++++++++++++++++++ cmd/insights/monitors.go | 193 ++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + internal/cmdutil/helpers.go | 21 ++++ 7 files changed, 375 insertions(+) create mode 100644 cmd/insights/insights.go create mode 100644 cmd/insights/insights_test.go create mode 100644 cmd/insights/monitors.go diff --git a/AGENTS.md b/AGENTS.md index aa84aa9..f3e40f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,6 +280,13 @@ For full flag/argument reference, use `band --help`. This section cove - **`tollfree template` is account-gated.** The underlying endpoint requires the `TollFreeTemplateAssignmentSearch` account setting (off by default; Bandwidth enables it on request). Expect exit 2 with a "not enabled on account" message until then — that is the correct behavior, not a bug. Numbers must be in-service on the account, toll-free (800/888/877/866/855/844/833), and at most 5000 per invocation. - **The template name is the answer, not a carrier name.** The CLI returns `templateName` exactly as the registry stores it; mapping template names to ingress carriers is operator knowledge the API does not expose. +### Insights + +- **`insights` commands are usage aggregates, not call logs.** Each returns time slices whose granularity the API picks from the window size (hourly for days, monthly for months) — it is not configurable. History caps at one year. With no `--since`/`--until`, the window is the last 7 days. +- **Feature-gated:** requires the Monitoring API feature on the account; expect exit 2 with a "not enabled" message otherwise — correct behavior, not a bug. +- **A number's traffic profile in one pass:** run `insights minutes-of-use`, `insights completed-calls`, and `insights average-durations` with the same `--to +1800... --since 30d` filters. Add `--call-type TOLLFREE-IN` to isolate toll-free ingress. Phone-number filters are slow on large accounts per the API docs — narrow with `--direction`/`--subaccount` when possible. +- **`--call-type` accepts dash or underscore forms** (`TOLLFREE-IN` and `TOLLFREE_IN` both work; the CLI normalizes). + ### VCPs - **`vcp delete` fails if numbers are assigned.** Move them first with `vcp assign `. diff --git a/README.md b/README.md index 6752b5a..f8ada9f 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,18 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f |---------|-------------| | `band tollfree template ` | Look up the routing template assigned to toll-free numbers (account-gated; 403 until enabled) | +### Insights (voice usage) + +| Command | What it does | +|---------|-------------| +| `band insights minutes-of-use` | Aggregated minutes of use per time slice | +| `band insights completed-calls` | Completed call counts per time slice | +| `band insights failed-calls` | Failed call counts per time slice | +| `band insights connection-rates` | Call connection rates per time slice | +| `band insights average-durations` | Average call durations per time slice | + +All five share the same filters: `--to`/`--from` (comma-separated E.164), `--direction`, `--call-type` (e.g. `TOLLFREE-IN`), `--subaccount`, and `--since`/`--until` (RFC3339 or relative like `30d`). Requires the Monitoring API feature on the account. + ### Messaging | Command | What it does | diff --git a/cmd/insights/insights.go b/cmd/insights/insights.go new file mode 100644 index 0000000..94a9104 --- /dev/null +++ b/cmd/insights/insights.go @@ -0,0 +1,23 @@ +// Package insights implements `band insights`, read-only voice usage and +// quality aggregates from the Bandwidth Insights Monitoring API. +package insights + +import "github.com/spf13/cobra" + +// Cmd is the `band insights` parent command. +var Cmd = &cobra.Command{ + Use: "insights", + Short: "Voice usage and quality aggregates (minutes of use, call counts, connection rates)", + Long: `Read aggregated voice traffic data from the Bandwidth Insights API: +minutes of use, completed and failed calls, connection rates, and average +call durations — filterable by phone number, direction, call type, and +sub-account. + +Results are broken into time slices whose granularity scales with the +requested window (hours for a few days, months for long ranges). History +goes back at most one year. Defaults to the last 7 days when no time range +is given. + +Requires the Monitoring API feature on your account. If you get a 403 +error, ask your Bandwidth account manager to enable it.`, +} diff --git a/cmd/insights/insights_test.go b/cmd/insights/insights_test.go new file mode 100644 index 0000000..f4f74f8 --- /dev/null +++ b/cmd/insights/insights_test.go @@ -0,0 +1,117 @@ +package insights + +import ( + "testing" + "time" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "insights" { + t.Errorf("Use = %q, want %q", Cmd.Use, "insights") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"minutes-of-use", "completed-calls", "failed-calls", "connection-rates", "average-durations"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestParseTimeFlag(t *testing.T) { + now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + tests := []struct { + input string + want string + wantErr bool + }{ + {input: "7d", want: "2026-08-17T12:00:00Z"}, + {input: "24h", want: "2026-08-23T12:00:00Z"}, + {input: "90m", want: "2026-08-24T10:30:00Z"}, + {input: "2026-07-01T00:00:00Z", want: "2026-07-01T00:00:00Z"}, + {input: "2026-07-01T00:00:00-05:00", want: "2026-07-01T00:00:00-05:00"}, + {input: "yesterday", wantErr: true}, + {input: "2026-07-01", wantErr: true}, + {input: "7w", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseTimeFlag(tt.input, now) + if tt.wantErr { + if err == nil { + t.Fatalf("parseTimeFlag(%q) = %q, want error", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("parseTimeFlag(%q) error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("parseTimeFlag(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBuildMonitorQuery(t *testing.T) { + now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + q, err := buildMonitorQuery("9901303", monitorFlags{ + To: "+18005551234,+18885551234", + Direction: "inbound", + CallType: "tollfree-in", + Since: "30d", + }, now) + if err != nil { + t.Fatalf("buildMonitorQuery error: %v", err) + } + want := map[string]string{ + "accountId[eq]": "9901303", + "toPhoneNumber[eq]": "+18005551234,+18885551234", + "direction[eq]": "INBOUND", + "callType[eq]": "TOLLFREE_IN", + "timestamp[gte]": "2026-07-25T12:00:00Z", + } + for k, v := range want { + if q.Get(k) != v { + t.Errorf("q[%s] = %q, want %q", k, q.Get(k), v) + } + } + if q.Get("timestamp[lte]") != "" { + t.Error("timestamp[lte] should be unset when --until absent") + } + + if _, err := buildMonitorQuery("1", monitorFlags{Direction: "SIDEWAYS"}, now); err == nil { + t.Error("invalid direction should be a flag error") + } +} + +func TestNormalizeCallType(t *testing.T) { + for input, want := range map[string]string{ + "TOLLFREE-IN": "TOLLFREE_IN", + "tollfree_in": "TOLLFREE_IN", + "local": "LOCAL", + } { + if got := normalizeCallType(input); got != want { + t.Errorf("normalizeCallType(%q) = %q, want %q", input, got, want) + } + } +} + +func TestUnwrapMonitorData(t *testing.T) { + env := map[string]interface{}{ + "links": []interface{}{}, + "data": map[string]interface{}{"aggregation": "hourly", "slices": []interface{}{}}, + "errors": []interface{}{}, + } + got, ok := unwrapMonitorData(env).(map[string]interface{}) + if !ok || got["aggregation"] != "hourly" { + t.Errorf("unwrap = %#v, want data object", unwrapMonitorData(env)) + } + odd := map[string]interface{}{"surprise": true} + if unwrapMonitorData(odd) == nil { + t.Error("unexpected shape should pass through") + } +} diff --git a/cmd/insights/monitors.go b/cmd/insights/monitors.go new file mode 100644 index 0000000..daae468 --- /dev/null +++ b/cmd/insights/monitors.go @@ -0,0 +1,193 @@ +package insights + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +// monitorFlags are the filters shared by every monitor subcommand. The +// Insights API encodes them deepObject-style (e.g. timestamp[gte]=...). +type monitorFlags struct { + To string + From string + Direction string + CallType string + Subaccount string + Since string + Until string +} + +// monitor describes one /v1/monitors/voice endpoint exposed as a subcommand. +type monitor struct { + use string // subcommand name; matches the API path segment + short string +} + +// monitors are the v1 set: the aggregates that together describe a number's +// or account's traffic profile. The API's other monitors (calls-per-second, +// concurrent-calls, error-percentages, network-efficiency-ratios, +// short-calls, call-data) can be added to this table as needed. +var monitors = []monitor{ + {use: "minutes-of-use", short: "Aggregated minutes of use per time slice"}, + {use: "completed-calls", short: "Completed call counts per time slice"}, + {use: "failed-calls", short: "Failed call counts per time slice"}, + {use: "connection-rates", short: "Call connection rates per time slice"}, + {use: "average-durations", short: "Average call durations per time slice"}, +} + +func init() { + for _, m := range monitors { + Cmd.AddCommand(newMonitorCmd(m)) + } +} + +func newMonitorCmd(m monitor) *cobra.Command { + flags := &monitorFlags{} + cmd := &cobra.Command{ + Use: m.use, + Short: m.short, + Long: m.short + `. + +All filters are optional; unfiltered requests cover the whole account over +the last 7 days. Phone-number filters accept comma-separated E.164 values +and can be slow on large accounts — add other filters to narrow the scope.`, + Example: fmt.Sprintf(` band insights %[1]s + band insights %[1]s --to +18005551234 --since 30d + band insights %[1]s --call-type TOLLFREE-IN --direction INBOUND --since 2026-07-01T00:00:00Z`, m.use), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitor(cmd, m.use, flags) + }, + } + cmd.Flags().StringVar(&flags.To, "to", "", "Filter by destination number(s), comma-separated E.164") + cmd.Flags().StringVar(&flags.From, "from", "", "Filter by originating number(s), comma-separated E.164") + cmd.Flags().StringVar(&flags.Direction, "direction", "", "Filter by call direction: INBOUND or OUTBOUND") + cmd.Flags().StringVar(&flags.CallType, "call-type", "", "Filter by call type (e.g. TOLLFREE-IN, TOLLFREE-OUT, LOCAL, INTERSTATE)") + cmd.Flags().StringVar(&flags.Subaccount, "subaccount", "", "Filter by sub-account ID") + cmd.Flags().StringVar(&flags.Since, "since", "", "Start of the time range: RFC3339 or relative (e.g. 30d, 24h, 90m); default 7 days ago") + cmd.Flags().StringVar(&flags.Until, "until", "", "End of the time range: RFC3339 or relative; default now") + return cmd +} + +// relativeTimeRe matches the relative time shorthand: d, h, or m. +var relativeTimeRe = regexp.MustCompile(`^(\d+)([dhm])$`) + +// parseTimeFlag converts a --since/--until value to RFC3339. Relative values +// are anchored at now; RFC3339 values pass through verbatim (the API handles +// timezone interpretation). +func parseTimeFlag(value string, now time.Time) (string, error) { + if m := relativeTimeRe.FindStringSubmatch(value); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid relative time %q", value)) + } + var unit time.Duration + switch m[2] { + case "d": + unit = 24 * time.Hour + case "h": + unit = time.Hour + case "m": + unit = time.Minute + } + return now.Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil + } + if _, err := time.Parse(time.RFC3339, value); err != nil { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid time %q: use RFC3339 (2026-07-01T00:00:00Z) or relative shorthand (30d, 24h, 90m)", value)) + } + return value, nil +} + +// normalizeCallType uppercases and converts dashes to underscores: the query +// filter enum uses TOLLFREE_IN while responses (and Bandwidth docs) render +// TOLLFREE-IN, so accept either form. +func normalizeCallType(v string) string { + return strings.ReplaceAll(strings.ToUpper(v), "-", "_") +} + +// buildMonitorQuery renders the deepObject query parameters for a monitor +// request. accountId[eq] is required by the API and always present. +func buildMonitorQuery(acctID string, f monitorFlags, now time.Time) (url.Values, error) { + q := url.Values{} + q.Set("accountId[eq]", acctID) + if f.To != "" { + q.Set("toPhoneNumber[eq]", f.To) + } + if f.From != "" { + q.Set("fromPhoneNumber[eq]", f.From) + } + if f.Direction != "" { + d := strings.ToUpper(f.Direction) + if d != "INBOUND" && d != "OUTBOUND" { + return nil, cmdutil.NewFlagError(fmt.Sprintf("invalid --direction %q: use INBOUND or OUTBOUND", f.Direction)) + } + q.Set("direction[eq]", d) + } + if f.CallType != "" { + q.Set("callType[eq]", normalizeCallType(f.CallType)) + } + if f.Subaccount != "" { + q.Set("subAccount[eq]", f.Subaccount) + } + if f.Since != "" { + ts, err := parseTimeFlag(f.Since, now) + if err != nil { + return nil, err + } + q.Set("timestamp[gte]", ts) + } + if f.Until != "" { + ts, err := parseTimeFlag(f.Until, now) + if err != nil { + return nil, err + } + q.Set("timestamp[lte]", ts) + } + return q, nil +} + +// unwrapMonitorData extracts the data object from the {links, data, errors} +// envelope. Unexpected shapes pass through raw. +func unwrapMonitorData(result interface{}) interface{} { + if m, ok := result.(map[string]interface{}); ok { + if data, ok := m["data"]; ok && data != nil { + return data + } + } + return result +} + +func runMonitor(cmd *cobra.Command, endpoint string, flags *monitorFlags) error { + client, acctID, err := cmdutil.InsightsClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + q, err := buildMonitorQuery(acctID, *flags, time.Now()) + if err != nil { + return err + } + + var result interface{} + if err := client.Get("/v1/monitors/voice/"+endpoint+"?"+q.Encode(), &result); err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { + return fmt.Errorf("the Monitoring API feature is not enabled on account %s — ask your Bandwidth account manager to enable it: %w", acctID, err) + } + return fmt.Errorf("getting %s: %w", endpoint, err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, unwrapMonitorData(result)) +} diff --git a/cmd/root.go b/cmd/root.go index 0d508fd..afccdb0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( bxmlcmd "github.com/Bandwidth/cli/cmd/bxml" callcmd "github.com/Bandwidth/cli/cmd/call" customerprofilecmd "github.com/Bandwidth/cli/cmd/customerprofile" + insightscmd "github.com/Bandwidth/cli/cmd/insights" locationcmd "github.com/Bandwidth/cli/cmd/location" messagecmd "github.com/Bandwidth/cli/cmd/message" numbercmd "github.com/Bandwidth/cli/cmd/number" @@ -105,6 +106,7 @@ func init() { rootCmd.AddCommand(numbercmd.Cmd) rootCmd.AddCommand(callcmd.Cmd) rootCmd.AddCommand(customerprofilecmd.Cmd) + rootCmd.AddCommand(insightscmd.Cmd) rootCmd.AddCommand(messagecmd.Cmd) rootCmd.AddCommand(recordingcmd.Cmd) rootCmd.AddCommand(transcriptioncmd.Cmd) diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index 13096de..4d9db83 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -90,6 +90,16 @@ func voiceHostForEnvironment(env string) string { } } +// insightsHost returns the Insights API base host. The Insights API publishes +// a single production host (no test environment). BW_INSIGHTS_URL overrides +// the base URL for local proxies. +func insightsHost() string { + if v := os.Getenv("BW_INSIGHTS_URL"); v != "" { + return strings.TrimRight(v, "/") + } + return "https://insights.bandwidth.com" +} + // messagingHost returns the Messaging API base host. The Bandwidth Messaging // API is PRODUCTION-ONLY — there is no public test/sandbox host, so unlike the // api/voice clients it does NOT vary by --environment. (Confirmed against all @@ -261,6 +271,17 @@ func PlatformClient(accountIDOverride string) (*api.Client, string, error) { return api.NewClient(apiHostForEnvironment(env), tm), acctID, nil } +// InsightsClient returns a JSON client for the Bandwidth Insights API. +// Insights is production-only (single published host); the OAuth token from +// the standard auth flow is sent as a Bearer token, which the API accepts. +func InsightsClient(accountIDOverride string) (*api.Client, string, error) { + tm, acctID, _, err := authenticate(accountIDOverride) + if err != nil { + return nil, "", err + } + return api.NewClient(insightsHost()+"/api", tm), acctID, nil +} + // messagingProdOnlyWarning returns a user warning when env is a non-production // environment, because the Bandwidth Messaging API is production-only (there is // no test host). Worded to be accurate for any messaging command (not just From 0bb6f81807466dc3a3322c53ab047d523142fa32 Mon Sep 17 00:00:00 2001 From: Kush Date: Mon, 24 Aug 2026 18:09:43 -0400 Subject: [PATCH 2/3] fix(insights): prod-only token minting, call-type enum validation, validate-before-auth, relative-time bounds Review findings from an independent pass: - Insights is production-only, but the token was minted per --environment: an active test profile produced a test-realm token that the prod Insights host rejects with 401. Mint against prod and warn on non-prod environments, mirroring MessagingClient. - --call-type now validates against the API enum after normalization (--call-type banana was reaching the server as BANANA and returning a 400 instead of a local flag error). - Flag validation moved before authentication so misuse exits 6 deterministically regardless of login state (repo pattern). - Relative time shorthand is bounded at 400d: the API keeps one year of history, and unbounded values overflowed time.Duration into future timestamps (e.g. 106752d). --- cmd/insights/insights_test.go | 27 +++++++++++++++--- cmd/insights/monitors.go | 53 +++++++++++++++++++++++++++-------- internal/cmdutil/helpers.go | 20 +++++++++++-- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/cmd/insights/insights_test.go b/cmd/insights/insights_test.go index f4f74f8..b2904e1 100644 --- a/cmd/insights/insights_test.go +++ b/cmd/insights/insights_test.go @@ -36,6 +36,11 @@ func TestParseTimeFlag(t *testing.T) { {input: "yesterday", wantErr: true}, {input: "2026-07-01", wantErr: true}, {input: "7w", wantErr: true}, + // Beyond the one-year history cap; large enough values would + // otherwise overflow time.Duration and land in the future. + {input: "401d", wantErr: true}, + {input: "106752d", wantErr: true}, + {input: "9999999999999m", wantErr: true}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { @@ -58,7 +63,7 @@ func TestParseTimeFlag(t *testing.T) { func TestBuildMonitorQuery(t *testing.T) { now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) - q, err := buildMonitorQuery("9901303", monitorFlags{ + q, err := buildMonitorQuery(monitorFlags{ To: "+18005551234,+18885551234", Direction: "inbound", CallType: "tollfree-in", @@ -68,7 +73,6 @@ func TestBuildMonitorQuery(t *testing.T) { t.Fatalf("buildMonitorQuery error: %v", err) } want := map[string]string{ - "accountId[eq]": "9901303", "toPhoneNumber[eq]": "+18005551234,+18885551234", "direction[eq]": "INBOUND", "callType[eq]": "TOLLFREE_IN", @@ -82,10 +86,17 @@ func TestBuildMonitorQuery(t *testing.T) { if q.Get("timestamp[lte]") != "" { t.Error("timestamp[lte] should be unset when --until absent") } + // accountId[eq] is added by the caller after auth resolves the account. + if q.Get("accountId[eq]") != "" { + t.Error("accountId[eq] should not be set by buildMonitorQuery") + } - if _, err := buildMonitorQuery("1", monitorFlags{Direction: "SIDEWAYS"}, now); err == nil { + if _, err := buildMonitorQuery(monitorFlags{Direction: "SIDEWAYS"}, now); err == nil { t.Error("invalid direction should be a flag error") } + if _, err := buildMonitorQuery(monitorFlags{CallType: "banana"}, now); err == nil { + t.Error("invalid call type should be a flag error") + } } func TestNormalizeCallType(t *testing.T) { @@ -94,10 +105,18 @@ func TestNormalizeCallType(t *testing.T) { "tollfree_in": "TOLLFREE_IN", "local": "LOCAL", } { - if got := normalizeCallType(input); got != want { + got, err := normalizeCallType(input) + if err != nil { + t.Errorf("normalizeCallType(%q) error: %v", input, err) + continue + } + if got != want { t.Errorf("normalizeCallType(%q) = %q, want %q", input, got, want) } } + if _, err := normalizeCallType("banana"); err == nil { + t.Error("normalizeCallType should reject values outside the enum") + } } func TestUnwrapMonitorData(t *testing.T) { diff --git a/cmd/insights/monitors.go b/cmd/insights/monitors.go index daae468..86cfe61 100644 --- a/cmd/insights/monitors.go +++ b/cmd/insights/monitors.go @@ -83,6 +83,11 @@ and can be slow on large accounts — add other filters to narrow the scope.`, // relativeTimeRe matches the relative time shorthand: d, h, or m. var relativeTimeRe = regexp.MustCompile(`^(\d+)([dhm])$`) +// maxRelativeTime bounds the relative shorthand. The API returns at most one +// year of history, so anything past ~400 days is either a typo or an integer +// large enough to overflow time.Duration arithmetic — reject both. +const maxRelativeTime = 400 * 24 * time.Hour + // parseTimeFlag converts a --since/--until value to RFC3339. Relative values // are anchored at now; RFC3339 values pass through verbatim (the API handles // timezone interpretation). @@ -101,6 +106,11 @@ func parseTimeFlag(value string, now time.Time) (string, error) { case "m": unit = time.Minute } + // Bound before multiplying: a large enough n wraps time.Duration + // negative and would silently produce a timestamp in the future. + if int64(n) > int64(maxRelativeTime/unit) { + return "", cmdutil.NewFlagError(fmt.Sprintf("relative time %q is out of range: the API keeps at most one year of history", value)) + } return now.Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil } if _, err := time.Parse(time.RFC3339, value); err != nil { @@ -109,18 +119,32 @@ func parseTimeFlag(value string, now time.Time) (string, error) { return value, nil } -// normalizeCallType uppercases and converts dashes to underscores: the query -// filter enum uses TOLLFREE_IN while responses (and Bandwidth docs) render -// TOLLFREE-IN, so accept either form. -func normalizeCallType(v string) string { - return strings.ReplaceAll(strings.ToUpper(v), "-", "_") +// callTypes is the API's callType filter enum (underscore form). +var callTypes = map[string]bool{ + "EMERGENCY": true, "INBOUND_TFOOS": true, "INFORMATION": true, + "INTERNATIONAL": true, "INTERNATIONAL_INTERNAL": true, "INTERSTATE": true, + "INTRASTATE": true, "INTL_BLOCK": true, "LOCAL": true, "OPERATOR": true, + "OTHER_N11": true, "SIPURI_EXT": true, "TOLLFREE_IN": true, + "TOLLFREE_OUT": true, "UNDETERMINED": true, +} + +// normalizeCallType uppercases and converts dashes to underscores, then +// validates against the API enum: the query filter uses TOLLFREE_IN while +// responses (and Bandwidth docs) render TOLLFREE-IN, so accept either form. +func normalizeCallType(v string) (string, error) { + n := strings.ReplaceAll(strings.ToUpper(v), "-", "_") + if !callTypes[n] { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid --call-type %q: use one of LOCAL, INTERSTATE, INTRASTATE, INTERNATIONAL, TOLLFREE-IN, TOLLFREE-OUT, EMERGENCY, ... (see the Insights API docs for the full list)", v)) + } + return n, nil } // buildMonitorQuery renders the deepObject query parameters for a monitor -// request. accountId[eq] is required by the API and always present. -func buildMonitorQuery(acctID string, f monitorFlags, now time.Time) (url.Values, error) { +// request and validates every flag. It runs before authentication so flag +// misuse fails deterministically (exit 6) regardless of login state; the +// caller adds the required accountId[eq] once auth has resolved the account. +func buildMonitorQuery(f monitorFlags, now time.Time) (url.Values, error) { q := url.Values{} - q.Set("accountId[eq]", acctID) if f.To != "" { q.Set("toPhoneNumber[eq]", f.To) } @@ -135,7 +159,11 @@ func buildMonitorQuery(acctID string, f monitorFlags, now time.Time) (url.Values q.Set("direction[eq]", d) } if f.CallType != "" { - q.Set("callType[eq]", normalizeCallType(f.CallType)) + ct, err := normalizeCallType(f.CallType) + if err != nil { + return nil, err + } + q.Set("callType[eq]", ct) } if f.Subaccount != "" { q.Set("subAccount[eq]", f.Subaccount) @@ -169,15 +197,18 @@ func unwrapMonitorData(result interface{}) interface{} { } func runMonitor(cmd *cobra.Command, endpoint string, flags *monitorFlags) error { - client, acctID, err := cmdutil.InsightsClient(cmdutil.AccountIDFlag(cmd)) + // Validate flags before authenticating so misuse fails fast (exit 6) + // even when logged out. + q, err := buildMonitorQuery(*flags, time.Now()) if err != nil { return err } - q, err := buildMonitorQuery(acctID, *flags, time.Now()) + client, acctID, err := cmdutil.InsightsClient(cmdutil.AccountIDFlag(cmd)) if err != nil { return err } + q.Set("accountId[eq]", acctID) var result interface{} if err := client.Get("/v1/monitors/voice/"+endpoint+"?"+q.Encode(), &result); err != nil { diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index 4d9db83..5af54ea 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -272,13 +272,27 @@ func PlatformClient(accountIDOverride string) (*api.Client, string, error) { } // InsightsClient returns a JSON client for the Bandwidth Insights API. -// Insights is production-only (single published host); the OAuth token from -// the standard auth flow is sent as a Bearer token, which the API accepts. +// Insights is production-only (single published host), so — like +// MessagingClient — the OAuth token must be minted against the PROD API host: +// authenticate() would mint against the test realm under --environment test, +// and a test-realm token is rejected by the prod Insights endpoint. func InsightsClient(accountIDOverride string) (*api.Client, string, error) { - tm, acctID, _, err := authenticate(accountIDOverride) + cfg, p, clientSecret, err := loadConfigAndAuth() if err != nil { return nil, "", err } + acctID, err := resolveAccountID(cfg, p, accountIDOverride) + if err != nil { + return nil, "", err + } + env, err := resolveEnvironment(p.Environment) + if err != nil { + return nil, "", err + } + if env != "" && env != "prod" { + ui.Warnf("Bandwidth Insights has no test environment — this request hits PRODUCTION data regardless of --environment.") + } + tm := auth.NewTokenManager(p.ClientID, clientSecret, apiHostForEnvironment("prod")) return api.NewClient(insightsHost()+"/api", tm), acctID, nil } From e7baea52a56c6b9cd59161a193fd322ff0f68f10 Mon Sep 17 00:00:00 2001 From: Kush Date: Tue, 25 Aug 2026 13:45:04 -0400 Subject: [PATCH 3/3] fix(insights): adopt context-threaded client API Merging main brought in the context.Context client migration; pass cmd.Context() at the insights call site like the rest of the tree. --- cmd/insights/monitors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/insights/monitors.go b/cmd/insights/monitors.go index 86cfe61..931a41e 100644 --- a/cmd/insights/monitors.go +++ b/cmd/insights/monitors.go @@ -211,7 +211,7 @@ func runMonitor(cmd *cobra.Command, endpoint string, flags *monitorFlags) error q.Set("accountId[eq]", acctID) var result interface{} - if err := client.Get("/v1/monitors/voice/"+endpoint+"?"+q.Encode(), &result); err != nil { + if err := client.Get(cmd.Context(), "/v1/monitors/voice/"+endpoint+"?"+q.Encode(), &result); err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { return fmt.Errorf("the Monitoring API feature is not enabled on account %s — ask your Bandwidth account manager to enable it: %w", acctID, err)