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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

---

Expand Down Expand Up @@ -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.

Expand Down
145 changes: 143 additions & 2 deletions cmd/sample/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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,
},
Expand Down
70 changes: 53 additions & 17 deletions cmd/sample/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
}
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -438,15 +474,15 @@ 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 {
return fmt.Errorf("writing .env: %w", err)
}
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
}
Expand Down
16 changes: 16 additions & 0 deletions cmd/sample/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 3 additions & 1 deletion cmd/sample/sample.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading