Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ For full flag/argument reference, use `band <command> --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 <other-vcp-id> <numbers...>`.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,18 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
|---------|-------------|
| `band tollfree template <number...>` | 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 |
Expand Down
23 changes: 23 additions & 0 deletions cmd/insights/insights.go
Original file line number Diff line number Diff line change
@@ -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.`,
}
136 changes: 136 additions & 0 deletions cmd/insights/insights_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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},
// 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) {
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(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{
"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")
}
// 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(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) {
for input, want := range map[string]string{
"TOLLFREE-IN": "TOLLFREE_IN",
"tollfree_in": "TOLLFREE_IN",
"local": "LOCAL",
} {
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) {
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")
}
}
Loading
Loading