diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6bb951..f1ad825 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ make test # run tests make lint # run golangci-lint ``` -Requires Go 1.27.3+ and [golangci-lint](https://golangci-lint.run/welcome/install/). +Requires Go 1.27.4+ and [golangci-lint](https://golangci-lint.run/welcome/install/). Go 1.27.3 includes the `crypto/x509` fix rejecting the compromised eMudhra intermediate certificates. CI and release builds select the toolchain from diff --git a/Dockerfile b/Dockerfile index 4f9cf99..2d355b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.27.3-alpine AS builder +FROM golang:1.27.4-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index 3fcee1f..e29930d 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,7 @@ All five share the same filters: `--to`/`--from` (comma-separated E.164), `--dir | `BW_MESSAGING_URL` | Override the Messaging API base URL. Messaging is production-only (no test host), so `--environment`/`BW_ENVIRONMENT` does not change it; use this for local proxies or the internal lab. | | `OPENAI_API_KEY` | OpenAI API key for the `live-assistant` sample (`band sample run`) | | `TRANSFER_TO` | Transfer destination in E.164 for the `live-assistant` sample (`band sample run`) | +| `USER_NUMBER` | Destination number for the `voice-bridge` sample (`band sample run`) | --- @@ -708,9 +709,11 @@ Want to see Bandwidth in action without writing any boilerplate? The CLI ships w ```sh band sample list # see what's available OPENAI_API_KEY=sk-... TRANSFER_TO=+19195550100 band sample run live-assistant --language python +band sample run voice-record --language python --call-to +19195550101 +USER_NUMBER=+19195550100 band sample run voice-bridge --language python --call-to +19195550101 ``` -`band sample run` handles everything: clones the GitHub repo, creates a virtual environment, installs dependencies, starts an ngrok tunnel, wires your Bandwidth credentials into a `.env` file, creates or reuses a voice application, and launches the app. Pass `--call-to` and it dials a number automatically once the app is healthy. +`band sample run` handles everything: clones the GitHub repo, installs dependencies, starts an ngrok tunnel, wires your Bandwidth credentials into a `.env` file, creates or reuses a voice application, and launches the app. Pass `--call-to` and it dials a number automatically once the app is healthy. Samples that need their own configuration read it from the environment, the same way `BW_CLIENT_ID` and `BW_CLIENT_SECRET` do. Run a sample without them and the CLI tells you which variables it expects. diff --git a/cmd/sample/catalog.go b/cmd/sample/catalog.go index 173a2f0..977fe86 100644 --- a/cmd/sample/catalog.go +++ b/cmd/sample/catalog.go @@ -19,6 +19,19 @@ type langSetup struct { HealthPath string } +// voiceConfig describes how to wire a sample app up for Programmable Voice callbacks. +type voiceConfig struct { + // InitiateCallbackPath is appended to the ngrok base URL and used as the voice + // application's CallInitiatedCallbackUrl. + InitiateCallbackPath string + // StatusCallbackPath is appended to the ngrok base URL and used as the voice + // application's CallStatusCallbackUrl. When empty, InitiateCallbackPath is used. + StatusCallbackPath string + // DialAnswerPath is appended to the ngrok base URL and used as the answer URL + // when `band sample run --call-to` auto-initiates an outbound call. + DialAnswerPath string +} + // SampleEntry describes one sample app and its variants. type SampleEntry struct { Description string @@ -28,6 +41,8 @@ type SampleEntry struct { ExtraEnv []envPrompt // Setup maps language name → build/run instructions. Setup map[string]langSetup + // Voice is non-nil when the sample expects Programmable Voice callbacks. + Voice *voiceConfig // Port is the default local port the app listens on. Port int } @@ -51,20 +66,146 @@ var catalog = map[string]*SampleEntry{ HealthPath: "/health", }, }, + Voice: &voiceConfig{ + InitiateCallbackPath: "/webhooks/bandwidth/voice/initiate", + StatusCallbackPath: "/webhooks/bandwidth/voice/status", + DialAnswerPath: "/webhooks/bandwidth/voice/initiate", + }, Port: 3000, }, "voice-gather": { Description: "In-call DTMF gather example", Repos: map[string]string{ - "python": "https://github.com/Bandwidth-Samples/in-call-gather-python", + "python": "https://github.com/Bandwidth-Samples/voice-gather-python", + "node": "https://github.com/Bandwidth-Samples/voice-gather-js", + "java": "https://github.com/Bandwidth-Samples/voice-gather-java", + }, + Setup: map[string]langSetup{ + "python": { + AppDir: ".", + InstallCmd: []string{".venv/bin/pip", "install", "-q", "-r", "requirements.txt"}, + RunCmd: []string{".venv/bin/python3", "main.py"}, + HealthPath: "/", + }, + "node": { + AppDir: ".", + InstallCmd: []string{"npm", "i"}, + RunCmd: []string{"npm", "start"}, + HealthPath: "/", + }, + "java": { + AppDir: ".", + InstallCmd: []string{"mvn", "package", "-q"}, + RunCmd: []string{"mvn", "spring-boot:run"}, + HealthPath: "/", + }, + }, + Voice: &voiceConfig{ + InitiateCallbackPath: "/callbacks/outbound/voice", + DialAnswerPath: "/callbacks/outbound/voice", + }, + Port: 3000, + }, + "voice-record": { + Description: "Record a message from a call (voicemail-style)", + Repos: map[string]string{ + "python": "https://github.com/Bandwidth-Samples/voice-record-python", + "node": "https://github.com/Bandwidth-Samples/voice-record-js", + "java": "https://github.com/Bandwidth-Samples/voice-record-java", + }, + Setup: map[string]langSetup{ + "python": { + AppDir: ".", + InstallCmd: []string{".venv/bin/pip", "install", "-q", "-r", "requirements.txt"}, + RunCmd: []string{".venv/bin/python3", "main.py"}, + HealthPath: "/", + }, + "node": { + AppDir: ".", + InstallCmd: []string{"npm", "i"}, + RunCmd: []string{"npm", "start"}, + HealthPath: "/", + }, + "java": { + AppDir: ".", + InstallCmd: []string{"mvn", "package", "-q"}, + RunCmd: []string{"mvn", "spring-boot:run"}, + HealthPath: "/", + }, + }, + Voice: &voiceConfig{ + InitiateCallbackPath: "/callbacks/inbound/voice", + DialAnswerPath: "/callbacks/inbound/voice", + }, + Port: 3000, + }, + "voice-bridge": { + Description: "Mask a call by bridging the caller to a configured destination number", + Repos: map[string]string{ + "python": "https://github.com/Bandwidth-Samples/voice-bridge-python", + "node": "https://github.com/Bandwidth-Samples/voice-bridge-js", + "java": "https://github.com/Bandwidth-Samples/voice-bridge-java", + }, + ExtraEnv: []envPrompt{ + {Key: "USER_NUMBER", Description: "Phone number to bridge calls to (E.164, e.g. +19195550100)"}, }, Setup: map[string]langSetup{ "python": { AppDir: ".", InstallCmd: []string{".venv/bin/pip", "install", "-q", "-r", "requirements.txt"}, - RunCmd: []string{".venv/bin/python3", "app.py"}, + RunCmd: []string{".venv/bin/python3", "main.py"}, HealthPath: "/", }, + "node": { + AppDir: ".", + InstallCmd: []string{"npm", "i"}, + RunCmd: []string{"npm", "start"}, + HealthPath: "/", + }, + "java": { + AppDir: ".", + InstallCmd: []string{"mvn", "package", "-q"}, + RunCmd: []string{"mvn", "spring-boot:run"}, + HealthPath: "/", + }, + }, + Voice: &voiceConfig{ + InitiateCallbackPath: "/callbacks/inbound/voice", + DialAnswerPath: "/callbacks/inbound/voice", + }, + Port: 3000, + }, + "voice-redirect-loop": { + Description: "Indefinitely redirect a call until interrupted (demo of Redirect and UpdateCall)", + Repos: map[string]string{ + "python": "https://github.com/Bandwidth-Samples/voice-redirect-loop-python", + "node": "https://github.com/Bandwidth-Samples/voice-redirect-loop-js", + "java": "https://github.com/Bandwidth-Samples/voice-redirect-loop-java", + }, + Setup: map[string]langSetup{ + "python": { + AppDir: ".", + InstallCmd: []string{".venv/bin/pip", "install", "-q", "-r", "requirements.txt"}, + RunCmd: []string{".venv/bin/python3", "main.py"}, + HealthPath: "/", + }, + "node": { + AppDir: ".", + InstallCmd: []string{"npm", "i"}, + RunCmd: []string{"npm", "start"}, + HealthPath: "/", + }, + "java": { + AppDir: ".", + InstallCmd: []string{"mvn", "package", "-q"}, + RunCmd: []string{"mvn", "spring-boot:run"}, + HealthPath: "/", + }, + }, + Voice: &voiceConfig{ + InitiateCallbackPath: "/callbacks/inbound/voice", + StatusCallbackPath: "/callbacks/inbound/voice/disconnect", + DialAnswerPath: "/callbacks/inbound/voice", }, Port: 3000, }, diff --git a/cmd/sample/run.go b/cmd/sample/run.go index fa86b48..80eead6 100644 --- a/cmd/sample/run.go +++ b/cmd/sample/run.go @@ -60,6 +60,9 @@ func runSample(cmd *cobra.Command, args []string) error { if !ok { return fmt.Errorf("unknown sample %q — run `band sample list` to see available samples", name) } + if entry.Voice == nil { + return fmt.Errorf("sample %q is not runnable yet — open a PR to add its provisioning and runtime wiring", name) + } repoURL, ok := entry.Repos[runLanguage] if !ok { supported := make([]string, 0, len(entry.Repos)) @@ -130,9 +133,13 @@ func runSample(cmd *cobra.Command, args []string) error { return err } + statusPath := entry.Voice.StatusCallbackPath + if statusPath == "" { + statusPath = entry.Voice.InitiateCallbackPath + } appID, err := ensureVoiceApp(ctx, dashClient, acctID, "band-sample-"+name, - publicURL+"/webhooks/bandwidth/voice/initiate", - publicURL+"/webhooks/bandwidth/voice/status", + publicURL+entry.Voice.InitiateCallbackPath, + publicURL+statusPath, ) if err != nil { return err @@ -174,9 +181,10 @@ func runSample(cmd *cobra.Command, args []string) error { "BW_ACCOUNT_ID": acctID, "BW_CLIENT_ID": clientID, "BW_CLIENT_SECRET": clientSecret, + "BASE_URL": publicURL, + "BASE_CALLBACK_URL": publicURL, "BW_VOICE_APPLICATION_ID": appID, "BW_NUMBER": phoneNumber, - "BASE_URL": publicURL, "LOCAL_PORT": strconv.Itoa(port), "LOG_LEVEL": "INFO", } @@ -203,6 +211,10 @@ func runSample(cmd *cobra.Command, args []string) error { } appCmd := exec.CommandContext(ctx, runExe, setup.RunCmd[1:]...) appCmd.Dir = filepath.Join(cloneDir, setup.AppDir) + appCmd.Env = os.Environ() + for k, v := range envVars { + appCmd.Env = append(appCmd.Env, k+"="+v) + } appCmd.Stdout = os.Stdout appCmd.Stderr = os.Stderr if err := appCmd.Start(); err != nil { @@ -220,11 +232,16 @@ func runSample(cmd *cobra.Command, args []string) error { time.Sleep(500 * time.Millisecond) // brief pause after health before dialing fmt.Fprintf(os.Stderr, "\n[sample] Dialing %s...\n", runCallTo) var resp interface{} + answerPath := entry.Voice.DialAnswerPath + if answerPath == "" { + answerPath = entry.Voice.InitiateCallbackPath + } + answerURL := publicURL + answerPath body := callcmd.BuildCreateBody(callcmd.CreateOpts{ From: phoneNumber, To: runCallTo, AppID: appID, - AnswerURL: publicURL + "/webhooks/bandwidth/voice/initiate", + AnswerURL: answerURL, }) if err := voiceClient.Post(ctx, fmt.Sprintf("/accounts/%s/calls", acctID), body, &resp); err != nil { fmt.Fprintf(os.Stderr, "[sample] call failed: %v\n", err) @@ -303,18 +320,35 @@ func ensureCloned(ctx context.Context, repoURL, name, lang string) (string, erro cloneDir := filepath.Join(home, ".band", "samples", name+"-"+lang) if _, err := os.Stat(filepath.Join(cloneDir, ".git")); err == nil { - spin := ui.NewSpinner("Updating sample repo...") - spin.Start() - pullCmd := exec.CommandContext(ctx, "git", "pull", "--ff-only") - pullCmd.Dir = cloneDir - out, err := pullCmd.CombinedOutput() - spin.Stop() - if err != nil { - fmt.Fprintf(os.Stderr, "git pull failed (%v), proceeding with existing clone\n%s\n", err, out) + remoteCmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") + remoteCmd.Dir = cloneDir + remoteOut, remoteErr := remoteCmd.CombinedOutput() + if remoteErr == nil { + remote := strings.TrimSpace(string(remoteOut)) + if remote != "" && remote != repoURL { + backupDir := cloneDir + ".bak-" + strconv.FormatInt(time.Now().Unix(), 10) + if err := os.Rename(cloneDir, backupDir); err != nil { + return "", fmt.Errorf("existing sample clone has origin %q (expected %q), and moving it aside failed: %w", remote, repoURL, err) + } + ui.Successf("Existing clone origin mismatch; moved aside: %s", backupDir) + } + } + if _, err := os.Stat(filepath.Join(cloneDir, ".git")); err != nil { + // cloneDir was moved aside above; fall through to clone below } else { - ui.Successf("Repo up to date: %s", cloneDir) + spin := ui.NewSpinner("Updating sample repo...") + spin.Start() + pullCmd := exec.CommandContext(ctx, "git", "pull", "--ff-only") + pullCmd.Dir = cloneDir + out, err := pullCmd.CombinedOutput() + spin.Stop() + if err != nil { + fmt.Fprintf(os.Stderr, "git pull failed (%v), proceeding with existing clone\n%s\n", err, out) + } else { + ui.Successf("Repo up to date: %s", cloneDir) + } + return cloneDir, nil } - return cloneDir, nil } if err := os.MkdirAll(filepath.Dir(cloneDir), 0o755); err != nil { @@ -373,7 +407,9 @@ func waitForHealth(ctx context.Context, url string, timeout time.Duration) error resp, err := http.Get(url) if err == nil { resp.Body.Close() - if resp.StatusCode < 300 { + // Many samples don't have a dedicated health endpoint; treat any + // non-5xx response as "the server is up". + if resp.StatusCode < 500 { return nil } } @@ -438,7 +474,7 @@ func firstPhoneNumber(ctx context.Context, client *api.Client, acctID string) (s return tn, nil } -// writeEnvFile writes env vars in `export KEY="VALUE"` format. +// writeEnvFile writes env vars in .env format. func writeEnvFile(path string, vars map[string]string) error { f, err := os.Create(path) if err != nil { @@ -446,7 +482,7 @@ func writeEnvFile(path string, vars map[string]string) error { } defer f.Close() for k, v := range vars { - fmt.Fprintf(f, "export %s=%q\n", k, v) + fmt.Fprintf(f, "%s=%s\n", k, strconv.Quote(v)) } return nil } diff --git a/cmd/sample/run_test.go b/cmd/sample/run_test.go index e76df94..e7a8740 100644 --- a/cmd/sample/run_test.go +++ b/cmd/sample/run_test.go @@ -61,3 +61,19 @@ func TestCollectExtraEnvNoRequirements(t *testing.T) { t.Errorf("got %v, want empty map", got) } } + +func TestCollectExtraEnvReportsMissingForVoiceBridge(t *testing.T) { + t.Setenv("USER_NUMBER", "") + + _, err := collectExtraEnv("voice-bridge", "python", catalog["voice-bridge"]) + if err == nil { + t.Fatal("expected an error when USER_NUMBER is unset, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "USER_NUMBER") { + t.Errorf("error should name the missing variable, got: %s", msg) + } + if !strings.Contains(msg, "USER_NUMBER=...") { + t.Errorf("hint should show the missing variable, got: %s", msg) + } +} diff --git a/cmd/sample/sample.go b/cmd/sample/sample.go index 0e483cb..3d36618 100644 --- a/cmd/sample/sample.go +++ b/cmd/sample/sample.go @@ -14,7 +14,9 @@ wired from your active band profile. Examples: band sample list band sample run live-assistant --language python - band sample run live-assistant --language python --call-to +19195550101`, + band sample run live-assistant --language python --call-to +19195550101 + band sample run voice-record --language python --call-to +19195550101 + USER_NUMBER=+19195550100 band sample run voice-bridge --language python --call-to +19195550101`, } func init() { diff --git a/go.mod b/go.mod index 755af6b..d12dcea 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Bandwidth/cli -go 1.27.3 +go 1.27.4 require ( github.com/briandowns/spinner v1.23.2