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
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ auth login
└─→ vcp create (links to app via --app-id)
└─→ number search → number order
└─→ vcp assign (attach numbers to VCP)
└─→ call create (requires --from, --app-id, --answer-url)
└─→ number activate --voice-inbound (required for inbound)
└─→ call create (requires --from, --app-id, --answer-url)
```

### Legacy
Expand Down Expand Up @@ -212,6 +213,7 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the
| Command | On timeout | Recovery |
|---------|-----------|----------|
| `number order --wait` | Number may be activating | Check `band number list --plain` — if the number appears, it completed. If not, retry the order. |
| `number activate --wait` / `number deactivate --wait` | Service activation order may still be RECEIVED/PROCESSING | Check `band number get <number> --plain` — the `inboundActivated` / `outbound*Activated` flags reflect the terminal state. Re-running the same activate is idempotent. |
| `call create --wait` | Call may still be active | Check `band call get <call-id> --plain` — look at the `state` field. |
| `transcription create --wait` | Transcription may be processing | Check `band transcription get <call-id> <rec-id> --plain`. |

Expand Down Expand Up @@ -260,7 +262,8 @@ account + auth
└─→ vcp create (links to app)
└─→ number search → number order
└─→ vcp assign
└─→ call create
└─→ number activate --voice-inbound
└─→ call create
```

**Voice (Legacy):**
Expand Down Expand Up @@ -323,6 +326,7 @@ band number list --plain
band number search --area-code 919 --quantity 1 --plain
band number order <number> --wait # 5. order number
band vcp assign <vcp-id> <number> # 6. assign number to VCP
band number activate <number> --voice-inbound --wait # 7. enable inbound voice
```

If step 2 fails with 409 "HTTP voice feature is required," or step 3 fails with 403, fall back to legacy.
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,11 @@ A fresh UP account typically has one sub-account and one location already create
### Numbers

```sh
band number list # list your numbers
band number search --area-code 919 --quantity 5 # search available numbers
band number order +19195551234 --wait # order (blocks until active)
band number release +19195551234 # release a number
band number list # list your numbers
band number search --area-code 919 --quantity 5 # search available numbers
band number order +19195551234 --wait # order (blocks until active)
band number activate +19195551234 --voice-inbound --wait # turn on inbound voice
band number release +19195551234 # release a number
```

### Messaging
Expand Down Expand Up @@ -405,6 +406,8 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `band number search` | Search available numbers by area code |
| `band number order <number...>` | Order numbers |
| `band number get <number>` | Get voice config details (including VCP assignment) |
| `band number activate <number...>` | Activate voice/messaging services (e.g. enable inbound) |
| `band number deactivate <number...>` | Deactivate voice/messaging services |
| `band number list` | List your in-service numbers |
| `band number release <number>` | Release a number |

Expand Down
39 changes: 39 additions & 0 deletions cmd/number/activate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package number

import (
"github.com/spf13/cobra"
)

func init() {
Cmd.AddCommand(activateCmd)
registerServiceActivationFlags(activateCmd)
}

var activateCmd = &cobra.Command{
Use: "activate <number...>",
Short: "Activate voice or messaging services on phone numbers",
Long: `Creates a service activation order to enable voice and/or messaging
services on one or more phone numbers via the Universal Platform.

At least one service flag must be provided. Use --dry-run to check
eligibility (status per service) without creating an order. Use --wait
to block until the order reaches a terminal status.

Underlying API: POST /api/v2/accounts/{accountId}/serviceActivation`,
Example: ` # Enable inbound voice on a single number
band number activate +19195551234 --voice-inbound

# Enable all voice services on multiple numbers and wait
band number activate +19195551234 +19195551235 --voice-inbound \
--voice-outbound-national --voice-outbound-international --wait

# Eligibility check only — no order created
band number activate +19195551234 --voice-inbound --dry-run

# With a customer-supplied order ID for tracking
band number activate +19195551234 --voice-inbound --customer-order-id my-order-123`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runServiceActivation(cmd, "ACTIVATE", args)
},
}
33 changes: 33 additions & 0 deletions cmd/number/deactivate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package number

import (
"github.com/spf13/cobra"
)

func init() {
Cmd.AddCommand(deactivateCmd)
registerServiceActivationFlags(deactivateCmd)
}

var deactivateCmd = &cobra.Command{
Use: "deactivate <number...>",
Short: "Deactivate voice or messaging services on phone numbers",
Long: `Creates a service deactivation order to disable voice and/or messaging
services on one or more phone numbers via the Universal Platform.

At least one service flag must be provided. Use --dry-run to inspect
the eligibility matrix (which mirrors activate's). Use --wait to block
until the order reaches a terminal status.

Underlying API: POST /api/v2/accounts/{accountId}/serviceActivation
with action=DEACTIVATE`,
Example: ` # Disable inbound voice on a number
band number deactivate +19195551234 --voice-inbound

# Disable inbound voice and wait for the order to settle
band number deactivate +19195551234 --voice-inbound --wait`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runServiceActivation(cmd, "DEACTIVATE", args)
},
}
128 changes: 128 additions & 0 deletions cmd/number/number_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,131 @@ func TestWrapTNsError_500(t *testing.T) {
t.Errorf("500 should not get the 403 message, got %q", err.Error())
}
}

// --- Service Activation ---

func TestBuildServiceActivationBody_VoiceInboundOnly(t *testing.T) {
body, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "ACTIVATE",
PhoneNumbers: []string{"+19195551234"},
VoiceInbound: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body["action"] != "ACTIVATE" {
t.Errorf("action = %v, want ACTIVATE", body["action"])
}
nums, _ := body["phoneNumbers"].([]string)
if len(nums) != 1 || nums[0] != "+19195551234" {
t.Errorf("phoneNumbers = %v, want [+19195551234]", nums)
}
services, _ := body["services"].(map[string]interface{})
voice, _ := services["voice"].([]string)
if len(voice) != 1 || voice[0] != "INBOUND" {
t.Errorf("services.voice = %v, want [INBOUND]", voice)
}
if _, has := services["messaging"]; has {
t.Errorf("messaging should not be set when --messaging is false")
}
if _, has := body["customerOrderId"]; has {
t.Errorf("customerOrderId should be omitted when not provided")
}
}

func TestBuildServiceActivationBody_AllVoiceServices(t *testing.T) {
body, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "ACTIVATE",
PhoneNumbers: []string{"+19195551234", "+19195551235"},
VoiceInbound: true,
VoiceOutNat: true,
VoiceOutInt: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
services := body["services"].(map[string]interface{})
voice := services["voice"].([]string)
want := []string{"INBOUND", "OUTBOUND_NATIONAL", "OUTBOUND_INTERNATIONAL"}
if len(voice) != len(want) {
t.Fatalf("expected %d voice services, got %d: %v", len(want), len(voice), voice)
}
for i, w := range want {
if voice[i] != w {
t.Errorf("voice[%d] = %q, want %q", i, voice[i], w)
}
}
}

func TestBuildServiceActivationBody_VoiceAndMessaging(t *testing.T) {
body, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "ACTIVATE",
PhoneNumbers: []string{"+19195551234"},
VoiceInbound: true,
Messaging: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
services := body["services"].(map[string]interface{})
if _, has := services["voice"]; !has {
t.Error("voice should be present")
}
msg, _ := services["messaging"].([]string)
if len(msg) != 1 || msg[0] != "ALL" {
t.Errorf("messaging = %v, want [ALL]", msg)
}
}

func TestBuildServiceActivationBody_DeactivateAction(t *testing.T) {
body, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "DEACTIVATE",
PhoneNumbers: []string{"+19195551234"},
VoiceInbound: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body["action"] != "DEACTIVATE" {
t.Errorf("action = %v, want DEACTIVATE", body["action"])
}
}

func TestBuildServiceActivationBody_NoServicesIsError(t *testing.T) {
_, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "ACTIVATE",
PhoneNumbers: []string{"+19195551234"},
})
if err == nil {
t.Fatal("expected error when no services flagged, got nil")
}
if !strings.Contains(err.Error(), "--voice-inbound") {
t.Errorf("error should hint at the available flags, got %q", err.Error())
}
}

func TestBuildServiceActivationBody_CustomerOrderIDIncluded(t *testing.T) {
body, err := BuildServiceActivationBody(ServiceActivationOpts{
Action: "ACTIVATE",
PhoneNumbers: []string{"+19195551234"},
VoiceInbound: true,
CustomerOrderID: "my-order-123",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body["customerOrderId"] != "my-order-123" {
t.Errorf("customerOrderId = %v, want my-order-123", body["customerOrderId"])
}
}

func TestBuildCheckerBody(t *testing.T) {
body := BuildCheckerBody([]string{"+19195551234", "+19195551235"})
nums, ok := body["phoneNumbers"].([]string)
if !ok {
t.Fatalf("phoneNumbers wrong type: %T", body["phoneNumbers"])
}
if len(nums) != 2 {
t.Errorf("expected 2 numbers, got %d", len(nums))
}
}
Loading
Loading