diff --git a/AGENTS.md b/AGENTS.md index 0890691..97f5b7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 --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 --plain` — look at the `state` field. | | `transcription create --wait` | Transcription may be processing | Check `band transcription get --plain`. | @@ -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):** @@ -323,6 +326,7 @@ band number list --plain band number search --area-code 919 --quantity 1 --plain band number order --wait # 5. order number band vcp assign # 6. assign number to VCP +band number activate --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. diff --git a/README.md b/README.md index 066f76a..a4e53e2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ` | Order numbers | | `band number get ` | Get voice config details (including VCP assignment) | +| `band number activate ` | Activate voice/messaging services (e.g. enable inbound) | +| `band number deactivate ` | Deactivate voice/messaging services | | `band number list` | List your in-service numbers | | `band number release ` | Release a number | diff --git a/cmd/number/activate.go b/cmd/number/activate.go new file mode 100644 index 0000000..2ea7b48 --- /dev/null +++ b/cmd/number/activate.go @@ -0,0 +1,39 @@ +package number + +import ( + "github.com/spf13/cobra" +) + +func init() { + Cmd.AddCommand(activateCmd) + registerServiceActivationFlags(activateCmd) +} + +var activateCmd = &cobra.Command{ + Use: "activate ", + 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) + }, +} diff --git a/cmd/number/deactivate.go b/cmd/number/deactivate.go new file mode 100644 index 0000000..83a0b99 --- /dev/null +++ b/cmd/number/deactivate.go @@ -0,0 +1,33 @@ +package number + +import ( + "github.com/spf13/cobra" +) + +func init() { + Cmd.AddCommand(deactivateCmd) + registerServiceActivationFlags(deactivateCmd) +} + +var deactivateCmd = &cobra.Command{ + Use: "deactivate ", + 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) + }, +} diff --git a/cmd/number/number_test.go b/cmd/number/number_test.go index bce2e40..dc21ce5 100644 --- a/cmd/number/number_test.go +++ b/cmd/number/number_test.go @@ -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)) + } +} diff --git a/cmd/number/service_activation.go b/cmd/number/service_activation.go new file mode 100644 index 0000000..d61f3d0 --- /dev/null +++ b/cmd/number/service_activation.go @@ -0,0 +1,191 @@ +package number + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +// Flags shared by `number activate` and `number deactivate`. +var ( + saVoiceInbound bool + saVoiceOutNational bool + saVoiceOutInternat bool + saMessaging bool + saDryRun bool + saWait bool + saTimeout time.Duration + saCustomerOrderID string +) + +// registerServiceActivationFlags wires the shared flag set onto a command. +// Keeps activate and deactivate in lockstep so we can never forget a flag +// on one and not the other. +func registerServiceActivationFlags(cmd *cobra.Command) { + cmd.Flags().BoolVar(&saVoiceInbound, "voice-inbound", false, "Target the voice INBOUND service") + cmd.Flags().BoolVar(&saVoiceOutNational, "voice-outbound-national", false, "Target the voice OUTBOUND_NATIONAL service") + cmd.Flags().BoolVar(&saVoiceOutInternat, "voice-outbound-international", false, "Target the voice OUTBOUND_INTERNATIONAL service") + cmd.Flags().BoolVar(&saMessaging, "messaging", false, "Target the messaging (ALL) service") + cmd.Flags().BoolVar(&saDryRun, "dry-run", false, "Run the eligibility checker instead of creating an order") + cmd.Flags().BoolVar(&saWait, "wait", false, "Block until the order reaches a terminal status") + cmd.Flags().DurationVar(&saTimeout, "timeout", 60*time.Second, "Maximum time to wait when --wait is set (default 60s)") + cmd.Flags().StringVar(&saCustomerOrderID, "customer-order-id", "", "Optional customer-supplied order ID for tracking") +} + +// ServiceActivationOpts holds the parsed flag state for one invocation. +type ServiceActivationOpts struct { + Action string // "ACTIVATE" or "DEACTIVATE" + PhoneNumbers []string + VoiceInbound bool + VoiceOutNat bool + VoiceOutInt bool + Messaging bool + CustomerOrderID string +} + +// BuildServiceActivationBody constructs the JSON body for +// POST /api/v2/accounts/{accountId}/serviceActivation. +// +// The API requires at least one service to be specified; we surface that +// requirement as a CLI-level validation error rather than letting the API +// reject the request. +func BuildServiceActivationBody(opts ServiceActivationOpts) (map[string]interface{}, error) { + voice := make([]string, 0, 3) + if opts.VoiceInbound { + voice = append(voice, "INBOUND") + } + if opts.VoiceOutNat { + voice = append(voice, "OUTBOUND_NATIONAL") + } + if opts.VoiceOutInt { + voice = append(voice, "OUTBOUND_INTERNATIONAL") + } + + services := map[string]interface{}{} + if len(voice) > 0 { + services["voice"] = voice + } + if opts.Messaging { + services["messaging"] = []string{"ALL"} + } + + if len(services) == 0 { + return nil, fmt.Errorf("at least one service flag must be provided: --voice-inbound, --voice-outbound-national, --voice-outbound-international, or --messaging") + } + + body := map[string]interface{}{ + "action": opts.Action, + "phoneNumbers": opts.PhoneNumbers, + "services": services, + } + if opts.CustomerOrderID != "" { + body["customerOrderId"] = opts.CustomerOrderID + } + return body, nil +} + +// BuildCheckerBody constructs the body for the dry-run +// POST /api/v2/accounts/{accountId}/serviceActivationChecker. +func BuildCheckerBody(phoneNumbers []string) map[string]interface{} { + return map[string]interface{}{"phoneNumbers": phoneNumbers} +} + +// runServiceActivation is the shared RunE for activate/deactivate. +func runServiceActivation(cmd *cobra.Command, action string, args []string) error { + client, acctID, err := cmdutil.PlatformClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + + // Dry-run path: hit the checker, return the eligibility matrix, done. + // Service flags are ignored in dry-run mode — the checker reports state + // for every service regardless of what we asked for. + if saDryRun { + body := BuildCheckerBody(args) + var result interface{} + path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivationChecker", acctID) + if err := client.Post(path, body, &result); err != nil { + return fmt.Errorf("checking service activation: %w", err) + } + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, result) + } + + opts := ServiceActivationOpts{ + Action: action, + PhoneNumbers: args, + VoiceInbound: saVoiceInbound, + VoiceOutNat: saVoiceOutNational, + VoiceOutInt: saVoiceOutInternat, + Messaging: saMessaging, + CustomerOrderID: saCustomerOrderID, + } + body, err := BuildServiceActivationBody(opts) + if err != nil { + return err + } + + var orderResult map[string]interface{} + path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivation", acctID) + if err := client.Post(path, body, &orderResult); err != nil { + return fmt.Errorf("creating service activation order: %w", err) + } + + if !saWait { + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, orderResult) + } + + orderID, ok := extractOrderID(orderResult) + if !ok { + return fmt.Errorf("service activation order created but no orderId in response") + } + + final, err := pollServiceActivationOrder(client, acctID, orderID, saTimeout) + if err != nil { + return err + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, final) +} + +func extractOrderID(orderResult map[string]interface{}) (string, bool) { + data, ok := orderResult["data"].(map[string]interface{}) + if !ok { + return "", false + } + orderID, ok := data["orderId"].(string) + return orderID, ok && orderID != "" +} + +// pollServiceActivationOrder polls until the order leaves the in-flight +// states (RECEIVED / PROCESSING) or the timeout fires. We don't enumerate +// terminal states explicitly — anything that's not in-flight is treated +// as terminal so the caller can inspect the final response. +func pollServiceActivationOrder(client *api.Client, acctID, orderID string, timeout time.Duration) (interface{}, error) { + return cmdutil.Poll(cmdutil.PollConfig{ + Interval: 2 * time.Second, + Timeout: timeout, + Check: func() (bool, interface{}, error) { + var result map[string]interface{} + path := fmt.Sprintf("/api/v2/accounts/%s/serviceActivation/%s", acctID, orderID) + if err := client.Get(path, &result); err != nil { + return false, nil, fmt.Errorf("polling order %s: %w", orderID, err) + } + data, _ := result["data"].(map[string]interface{}) + status, _ := data["orderStatus"].(string) + switch status { + case "RECEIVED", "PROCESSING": + return false, nil, nil + default: + return true, result, nil + } + }, + }) +} diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..c315229 --- /dev/null +++ b/context7.json @@ -0,0 +1,4 @@ +{ + "url": "https://context7.com/bandwidth/cli", + "public_key": "pk_5g8PETO0EdX92ouz6drrx" +} \ No newline at end of file