Skip to content
Open
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
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