diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index beb9074..12776e1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # Global rule: -* @Bandwidth-Samples/dx +* @Bandwidth-Samples/band-swi @Bandwidth-Samples/band-devx diff --git a/.github/actions/maven-test/Dockerfile b/.github/actions/maven-test/Dockerfile deleted file mode 100644 index 8f75cd0..0000000 --- a/.github/actions/maven-test/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -# Container image that runs your code -FROM maven:3.6.2-jdk-14 - -# Copies your code file from your action repository to the filesystem path `/` of the container -COPY entrypoint.sh /entrypoint.sh - -#Make entrypoint.sh exacutable -RUN chmod +x /entrypoint.sh - -# Code file to execute when the docker container starts up (`entrypoint.sh`) -ENTRYPOINT ["/entrypoint.sh"] diff --git a/.github/actions/maven-test/action.yml b/.github/actions/maven-test/action.yml deleted file mode 100644 index 598f220..0000000 --- a/.github/actions/maven-test/action.yml +++ /dev/null @@ -1,6 +0,0 @@ -# action.yml -name: 'Test' -description: 'Runs Maven test command' -runs: - using: 'docker' - image: 'Dockerfile' diff --git a/.github/actions/maven-test/entrypoint.sh b/.github/actions/maven-test/entrypoint.sh deleted file mode 100644 index 91f2160..0000000 --- a/.github/actions/maven-test/entrypoint.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -mvn test diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..78cbe5d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 + +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + ignore: + - dependency-name: "com.bandwidth.sdk:bandwidth-sdk" + update-types: + - version-update:semver-minor + - version-update:semver-patch + groups: + dependencies: + patterns: + - "*" + exclude-patterns: + - "com.bandwidth.sdk:bandwidth-sdk" diff --git a/.github/workflows/on-pull-request.yaml b/.github/workflows/on-pull-request.yaml deleted file mode 100644 index 17573fb..0000000 --- a/.github/workflows/on-pull-request.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Test - -on: - pull_request: - branches: - - main - -jobs: - maven-test: - name: Test - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v1 - - name: Test - uses: ./.github/actions/maven-test \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e1ed587 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: Test + +on: + pull_request: + branches: + - main + schedule: + - cron: "0 9 * * 1" + +jobs: + contract-test: + name: Contract Test + uses: Bandwidth-Samples/sample-app-tooling/.github/workflows/java.yml@main + with: + sample: voice-gather diff --git a/LICENSE b/LICENSE index df9461d..ddc77f4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Bandwidth Samples +Copyright (c) 2026 Bandwidth Samples Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 005edbc..f76df09 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,15 @@ - [Description](#description) - [Bandwidth](#bandwidth) - [Environmental Variables](#environmental-variables) +- [Running the Application](#running-the-application) - [Callback URLs](#callback-urls) - [Ngrok](#ngrok) # Description -A short description of your sample app and its capabilities. + +This sample app creates an outbound call to the supplied phone number, which if answered will prompt the user using [Gather BXML](https://dev.bandwidth.com/docs/voice/bxml/gather) to select between a list of options to hear different messages played back. # Bandwidth @@ -29,20 +31,36 @@ For more information about API credentials see [here](https://dev.bandwidth.com/ The sample app uses the below environmental variables. ```sh BW_ACCOUNT_ID # Your Bandwidth Account Id -BW_USERNAME # Your Bandwidth API Token -BW_PASSWORD # Your Bandwidth API Secret +BW_CLIENT_ID # Your Bandwidth API OAuth Client Id +BW_CLIENT_SECRET # Your Bandwidth API OAuth Client Secret +BW_NUMBER # The Bandwidth phone number involved with this application BW_VOICE_APPLICATION_ID # Your Voice Application Id created in the dashboard -BASE_CALLBACK_URL # Your public base url +BASE_CALLBACK_URL # Your public base url to receive Bandwidth Webhooks. No trailing '/' LOCAL_PORT # The port number you wish to run the sample on ``` +# Running the Application + +To install the required packages and build the app, run the command: + +```sh +mvn package +``` + +Use the following command to run the application: + +```sh +mvn spring-boot:run +``` + # Callback URLs For a detailed introduction to Bandwidth Callbacks see https://dev.bandwidth.com/guides/callbacks/callbacks.html Below are the callback paths: +* `/calls` * `/callbacks/outbound/voice` -* `` +* `/callbacks/outbound/voice/gather` ## Ngrok @@ -51,4 +69,4 @@ After you have downloaded and installed `ngrok` run the following command to ope ```cmd ngrok http $LOCAL_PORT ``` -You can view your public URL at `http://127.0.0.1:{LOCAL_PORT}` after ngrok is running. You can also view the status of the tunnel and requests/responses here. +You can view your public URL at `http://127.0.0.1:4040` after ngrok is running. You can also view the status of the tunnel and requests/responses here. diff --git a/pom.xml b/pom.xml index da3f541..3df93bc 100644 --- a/pom.xml +++ b/pom.xml @@ -11,24 +11,36 @@ UTF-8 UTF-8 - 11 - 2.5.12 + 21 + 3.5.16 + + + + org.springframework.boot + spring-boot-dependencies + ${spring.version} + pom + import + + + + com.bandwidth.sdk bandwidth-sdk - 9.1.2 + 18.3.1 commons-io commons-io - 2.14.0 + 2.15.1 diff --git a/src/main/java/com/bandwidth/Main.java b/src/main/java/com/bandwidth/Main.java index cc544e0..34e8cde 100644 --- a/src/main/java/com/bandwidth/Main.java +++ b/src/main/java/com/bandwidth/Main.java @@ -1,26 +1,33 @@ package com.bandwidth; +import java.util.Collections; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; + import org.springframework.web.bind.annotation.RestController; @SpringBootApplication @RestController - public class Main { static Logger logger = LoggerFactory.getLogger(Main.class); public static void main(String[] args) { - SpringApplication.run(Main.class, args); + String port = System.getenv("LOCAL_PORT"); + if (System.getenv("BW_ACCOUNT_ID") == null || System.getenv("BW_CLIENT_ID") == null + || System.getenv("BW_CLIENT_SECRET") == null || System.getenv("BW_VOICE_APPLICATION_ID") == null + || System.getenv("BW_NUMBER") == null || System.getenv("BASE_CALLBACK_URL") == null + || port == null || !port.matches("\\d+")) { + System.err.println("Please set the environmental variables defined in the README"); + System.exit(1); + } + + SpringApplication application = new SpringApplication(Main.class); + application.setDefaultProperties(Collections.singletonMap("server.port", port)); + application.run(args); logger.info("Server has started..."); } - - - - - } diff --git a/src/main/java/com/bandwidth/Model/CallRequest.java b/src/main/java/com/bandwidth/Model/CallRequest.java index 3c5dc0d..3ad0d69 100644 --- a/src/main/java/com/bandwidth/Model/CallRequest.java +++ b/src/main/java/com/bandwidth/Model/CallRequest.java @@ -3,12 +3,9 @@ import lombok.Getter; import lombok.Setter; -import java.util.List; - @Getter @Setter public class CallRequest { private String to; - private String from; } diff --git a/src/main/java/com/bandwidth/Model/VoiceReply.java b/src/main/java/com/bandwidth/Model/VoiceReply.java deleted file mode 100644 index 3782129..0000000 --- a/src/main/java/com/bandwidth/Model/VoiceReply.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.bandwidth.model; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class VoiceReply { - - private boolean success; - private String error; -} diff --git a/src/main/java/com/bandwidth/controller/CallController.java b/src/main/java/com/bandwidth/controller/CallController.java index 2da42a6..511da26 100644 --- a/src/main/java/com/bandwidth/controller/CallController.java +++ b/src/main/java/com/bandwidth/controller/CallController.java @@ -1,37 +1,20 @@ package com.bandwidth.controller; -import com.bandwidth.Main; import com.bandwidth.model.CallRequest; -import com.bandwidth.model.VoiceReply; import com.bandwidth.sdk.ApiClient; -import com.bandwidth.sdk.ApiResponse; -import com.bandwidth.sdk.ApiException; -import com.bandwidth.sdk.auth.HttpBasicAuth; -import com.bandwidth.sdk.Configuration; -import com.bandwidth.sdk.model.*; import com.bandwidth.sdk.api.CallsApi; import com.bandwidth.sdk.model.CreateCall; -import com.bandwidth.sdk.model.CreateCallResponse; - -import jakarta.xml.bind.JAXBContext; -import jakarta.xml.bind.JAXBException; import java.net.URI; -import java.net.URISyntaxException; -import java.io.IOException; -import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.server.ResponseStatusException; - +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("calls") @@ -39,38 +22,34 @@ public class CallController { Logger logger = LoggerFactory.getLogger(CallController.class); - private final String username = System.getenv("BW_USERNAME"); - private final String password = System.getenv("BW_PASSWORD"); private final String accountId = System.getenv("BW_ACCOUNT_ID"); + private final String clientId = System.getenv("BW_CLIENT_ID"); + private final String clientSecret = System.getenv("BW_CLIENT_SECRET"); private final String applicationId = System.getenv("BW_VOICE_APPLICATION_ID"); - private final String answerUrl = System.getenv("BASE_CALLBACK_URL"); - - public ApiClient defaultClient = Configuration.getDefaultApiClient(); - public HttpBasicAuth Basic = (HttpBasicAuth) defaultClient.getAuthentication("Basic"); - public final CallsApi api = new CallsApi(defaultClient); - private static CreateCall createCallBody = new CreateCall(); + private final String number = System.getenv("BW_NUMBER"); + private final String baseCallbackUrl = System.getenv("BASE_CALLBACK_URL"); + private final CallsApi callsApi = new CallsApi(new ApiClient(clientId, clientSecret, null)); @PostMapping() - public VoiceReply createCall(@RequestBody CallRequest callRequest) throws ApiException, URISyntaxException { - - Basic.setUsername(username); - Basic.setPassword(password); - createCallBody.setTo(callRequest.getTo()); - createCallBody.setFrom(callRequest.getFrom()); - createCallBody.setApplicationId(applicationId); - createCallBody.setAnswerUrl(new URI(answerUrl + "/callbacks/outbound/voice")); + public ResponseEntity createCall(@RequestBody CallRequest callRequest) { + if (callRequest.getTo() == null) { + return ResponseEntity.badRequest().build(); + } + CreateCall createCall = new CreateCall() + .applicationId(applicationId) + .to(callRequest.getTo()) + .from(number) + .answerUrl(URI.create(baseCallbackUrl + "/callbacks/outbound/voice")); - VoiceReply voiceReply = new VoiceReply(); try { - ApiResponse response = api.createCallWithHttpInfo(accountId, createCallBody); - voiceReply.setSuccess(true); - } catch (ApiException e) { // Bandwidth API response status not 2XX - voiceReply.setSuccess(false); - voiceReply.setError(e.getMessage()); + callsApi.createCall(accountId, createCall); + } catch (Exception e) { + logger.error("Failed to create call: " + e.getMessage()); + return ResponseEntity.internalServerError().build(); } - return voiceReply; + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/bandwidth/controller/CallbacksController.java b/src/main/java/com/bandwidth/controller/CallbacksController.java index 3abca87..c4441dc 100644 --- a/src/main/java/com/bandwidth/controller/CallbacksController.java +++ b/src/main/java/com/bandwidth/controller/CallbacksController.java @@ -1,100 +1,81 @@ package com.bandwidth.controller; -import com.bandwidth.sdk.model.bxml.*; -import com.bandwidth.sdk.model.bxml.Response; import com.bandwidth.sdk.model.AnswerCallback; import com.bandwidth.sdk.model.GatherCallback; - -import java.util.List; +import com.bandwidth.sdk.model.bxml.*; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import java.util.List; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - @RestController @RequestMapping("callbacks") public class CallbacksController { Logger logger = LoggerFactory.getLogger(CallbacksController.class); - @RequestMapping("/outbound/voice") - public String voiceCallback(@RequestBody AnswerCallback callback) throws JAXBException { - + @PostMapping(value = "/outbound/voice", produces = "application/xml") + public ResponseEntity voiceCallback(@RequestBody AnswerCallback callback) throws JAXBException { Response response = new Response(); - JAXBContext jaxbContext = JAXBContext.newInstance(Response.class); + JAXBContext jaxbContext = JAXBContext.newInstance(Response.class); - logger.info(callback.getEventType()); - logger.info(callback.getCallId()); - switch( callback.getEventType()) { + String eventType = String.valueOf(callback.getEventType()); + switch (eventType) { case "answer": - - - SpeakSentence speakSentence = new SpeakSentence("Please hit the number 1, 2, or 3 followed by pound when finished"); + SpeakSentence speakSentence = new SpeakSentence("Press 1 to choose option 1. Press 2 to choose option 2. Press pound when you are finished."); Gather gather = new Gather().builder() - .repeatCount(3) - .gatherUrl("/callbacks/outbound/gather") + .gatherUrl("/callbacks/outbound/voice/gather") .terminatingDigits("#") - .children(List.of(speakSentence)) + .children(List.of(speakSentence)) .build(); response.with(gather); - - return response.toBxml(jaxbContext); + break; case "initiate": - logger.info("The Initiate event is fired when an inbound call is received for an application. Sent to the URL specified in the application."); - - return response.with( - new SpeakSentence("Initiate event recieved but not inteded. Ending call") - ).toBxml(jaxbContext); - - + response.withVerbs( + new SpeakSentence("Initiate event received but not intended. Ending call."), + new Hangup() + ); + break; case "disconnect": - logger.info("The Disconnect event is fired when a call ends, for any reason. The cause for a disconnect event on a call can be:"); logger.info("Call " + callback.getCallId() + " has disconnected"); - - - return response.with( - new SpeakSentence("Disconnect event recieved but not inteded. Ending call") - ).toBxml(jaxbContext); - + break; default: - return response.with( - new SpeakSentence( callback.getEventType() + " event recieved but not inteded. Ending call") - ).toBxml(jaxbContext); + logger.info("Unexpected event type " + eventType + " received"); + break; } + return ResponseEntity.ok(response.toBxml(jaxbContext)); } - @RequestMapping("/outbound/gather") - public String gatherCallback(@RequestBody GatherCallback callback) throws JAXBException { - - Response response = new Response(); - JAXBContext jaxbContext = JAXBContext.newInstance(Response.class); - - logger.info(callback.getEventType()); - logger.info(callback.getCallId()); - if("gather".equalsIgnoreCase(callback.getEventType())) { + @PostMapping(value = "/outbound/voice/gather", produces = "application/xml") + public ResponseEntity gatherCallback(@RequestBody GatherCallback callback) throws JAXBException { + if (!"gather".equals(callback.getEventType())) { + return ResponseEntity.badRequest().build(); + } - String digit = callback.getDigits(); + SpeakSentence speakSentence; + if ("1".equals(callback.getDigits())) { + speakSentence = new SpeakSentence("You chose option 1. Goodbye."); + } else if ("2".equals(callback.getDigits())) { + speakSentence = new SpeakSentence("You chose option 2. Goodbye."); + } else { + speakSentence = new SpeakSentence("You did not choose a valid option. Goodbye."); + } - SpeakSentence speakSentence; - if(digit.equalsIgnoreCase("1")) { - speakSentence = new SpeakSentence("You have chosen choice 1"); - } else if(digit.equalsIgnoreCase("2")) { - speakSentence = new SpeakSentence("You have chosen choice 2"); - } else { - speakSentence = new SpeakSentence("Invalid choice"); - } + Response response = new Response(); + response.with(speakSentence); - response.with(speakSentence); - } - return response.toBxml(jaxbContext); + return ResponseEntity.ok(response.toBxml(JAXBContext.newInstance(Response.class))); } } diff --git a/application.properties b/src/main/resources/application.properties similarity index 100% rename from application.properties rename to src/main/resources/application.properties