diff --git a/CallAutomation_AppointmentBooking/pom.xml b/CallAutomation_AppointmentBooking/pom.xml new file mode 100644 index 0000000..fe5038f --- /dev/null +++ b/CallAutomation_AppointmentBooking/pom.xml @@ -0,0 +1,180 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.0.6 + + + + com.communication.callautomation + AppointmentBooking + 1.0-SNAPSHOT + + AppointmentBooking + CallAutomation Sample application for instructional usage + + + 18 + 18 + UTF-8 + 1.18.26 + + + + + test-feed + https://pkgs.dev.azure.com/msazuredev/6bd2c509-7068-4110-a280-4902014c36cb/_packaging/test-feed/maven/v1 + + true + + + true + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + junit + junit + 4.13.2 + test + + + com.azure + azure-core + 1.39.0 + + + com.azure + azure-identity + 1.9.0 + + + com.azure + azure-communication-identity + 1.4.5 + + + com.azure + azure-communication-common + + + + + com.azure + azure-communication-callautomation + 1.0.0-alpha.20230517.2 + + + com.azure + azure-messaging-eventgrid + 4.15.1 + + + com.azure + azure-communication-common + 2.0.0-beta.1 + + + com.google.code.gson + gson + 2.10 + + + org.projectlombok + lombok + provided + ${lombok.version} + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.json + json + 20230227 + + + + + + + + maven-clean-plugin + 3.2.0 + + + maven-resources-plugin + 3.3.1 + + + maven-compiler-plugin + 3.11.0 + + + maven-surefire-plugin + 3.1.0 + + + maven-jar-plugin + 3.3.0 + + + maven-deploy-plugin + 3.1.1 + + + maven-site-plugin + 3.12.1 + + + maven-project-info-reports-plugin + 3.4.3 + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + + java + + + + + com.communication.callautomation.Main + + + + + + \ No newline at end of file diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/Main.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/Main.java new file mode 100644 index 0000000..615a8e1 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/Main.java @@ -0,0 +1,21 @@ +package com.communication.callautomation; + +import com.azure.core.http.HttpClient; +import com.communication.callautomation.config.AcsConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +@EnableConfigurationProperties(value = AcsConfig.class) +public class Main { + + @Bean + HttpClient azureHttpClient() { + return HttpClient.createDefault(); + } + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} \ No newline at end of file diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/AcsClient.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/AcsClient.java new file mode 100644 index 0000000..616715e --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/AcsClient.java @@ -0,0 +1,128 @@ +package com.communication.callautomation.acs.client; + +import com.azure.communication.callautomation.models.*; +import com.azure.communication.common.CommunicationIdentifier; +import com.azure.core.http.rest.Response; +import com.azure.core.util.Context; +import com.communication.callautomation.config.AcsConfig; +import com.communication.callautomation.core.CallAutomationService; +import com.communication.callautomation.core.model.EventInfo; +import com.communication.callautomation.exceptions.AzureCallAutomationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +@Component +@Slf4j +public class AcsClient implements CallAutomationService { + private final AcsConfig acsConfig; + private final CallAutomationClientFactory callAutomationClientFactory; + + @Autowired + public AcsClient(final AcsConfig acsConfig, + final CallAutomationClientFactory callAutomationClientFactory) { + this.acsConfig = acsConfig; + this.callAutomationClientFactory = callAutomationClientFactory; + } + + @Override + public String answerCall(final EventInfo eventInfo) { + String incomingCallContext = eventInfo.getIncomingCallContext(); + String callbackUri = acsConfig.getCallbackUri(eventInfo.getFromId()); + String correlationId = eventInfo.getCorrelationId(); + + log.info("Answering media call with following callbackuri: {}", callbackUri); + + try{ + AnswerCallResult answerCallResponse = callAutomationClientFactory.getCallAutomationClient(correlationId) + .answerCall(incomingCallContext, callbackUri); + return answerCallResponse.getCallConnectionProperties().getCallConnectionId(); + } catch(Exception e) { + log.error("Error occurred when Answering the call"); + throw new AzureCallAutomationException(e.getMessage(), e); + } + } + + @Override + public String startRecording(final EventInfo eventInfo) { + try { + ServerCallLocator serverCallLocator = new ServerCallLocator(callAutomationClientFactory + .getCallAutomationClient(eventInfo.getCorrelationId()) + .getCallConnection(eventInfo.getCallConnectionId()) + .getCallProperties() + .getServerCallId()); + + StartRecordingOptions recordingOptions = new StartRecordingOptions(serverCallLocator); + + Response response = callAutomationClientFactory.getCallAutomationClient(eventInfo.getCorrelationId()) + .getCallRecording() + .startWithResponse(recordingOptions, Context.NONE); + String recordingId = response.getValue().getRecordingId(); + log.info("Start Recording with recording ID: {}", recordingId); + return "Start Recording operation finished"; + } catch(Exception e) { + log.error("Recording operation failed {} {}", e.getMessage(), e.getCause()); + throw new AzureCallAutomationException(e.getMessage(), e); + } + } + + @Override + public String playAudio(final EventInfo eventInfo, final String prompt) { + List listTargets = Arrays.asList(CommunicationIdentifier.fromRawId(eventInfo.getFromId())); + PlaySource playSource = new FileSource().setUrl(acsConfig.getMediaUri(prompt)); + PlayOptions playOptions = new PlayOptions(playSource, listTargets); + log.info("Play audio operation started"); + try { + Response response = callAutomationClientFactory.getCallAutomationClient(eventInfo.getCorrelationId()) + .getCallConnection(eventInfo.getCallConnectionId()) + .getCallMedia() + .playWithResponse(playOptions, Context.NONE); + return "Play audio operation finished"; + } catch(Exception e) { + log.error("Error when Playing audio to participant {} {}", e.getMessage(), e.getCause()); + throw new AzureCallAutomationException(e.getMessage(), e); + } + } + + @Override + public String singleDigitDtmfRecognitionWithPrompt(final EventInfo eventInfo, final String prompt) { + CommunicationIdentifier rectarget = CommunicationIdentifier.fromRawId(eventInfo.getFromId()); + PlaySource playSource = new FileSource().setUrl(acsConfig.getMediaUri(prompt)); + CallMediaRecognizeDtmfOptions recognizeDtmfOptions = new CallMediaRecognizeDtmfOptions(rectarget, 1); + recognizeDtmfOptions.setInterToneTimeout(Duration.ofSeconds(10)) + .setInitialSilenceTimeout(Duration.ofSeconds(15)) + .setInterruptPrompt(true) + .setPlayPrompt(playSource); + log.info("DTMF Recognition operation started"); + try { + Response response = callAutomationClientFactory.getCallAutomationClient(eventInfo.getCorrelationId()) + .getCallConnection(eventInfo.getCallConnectionId()) + .getCallMedia() + .startRecognizingWithResponse(recognizeDtmfOptions, Context.NONE); + return "DTMF Recognition operation ended"; + } catch(Exception e) { + log.error("DTMF Recognition operation failed {} {}", e.getMessage(), e.getCause()); + throw new AzureCallAutomationException(e.getMessage(), e); + } + } + + @Override + public String terminateCall(final EventInfo eventInfo) { + log.info("Terminating the call"); + try { + callAutomationClientFactory.getCallAutomationClient(eventInfo.getCorrelationId()) + .getCallConnection(eventInfo.getCallConnectionId()) + .hangUp(true); + return "HangUp call for all participants operation ended"; + } catch(Exception e) { + log.error("HangUp call for all participants operation failed {} {}", e.getMessage(), e.getCause()); + throw new AzureCallAutomationException(e.getMessage(), e); + } + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientFactory.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientFactory.java new file mode 100644 index 0000000..d322d04 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientFactory.java @@ -0,0 +1,7 @@ +package com.communication.callautomation.acs.client; + +import com.azure.communication.callautomation.CallAutomationClient; + +public interface CallAutomationClientFactory { + CallAutomationClient getCallAutomationClient(final String correlationId); +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientImpl.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientImpl.java new file mode 100644 index 0000000..cc2e37a --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/acs/client/CallAutomationClientImpl.java @@ -0,0 +1,51 @@ +package com.communication.callautomation.acs.client; + +import com.azure.communication.callautomation.CallAutomationAsyncClient; +import com.azure.communication.callautomation.CallAutomationClient; +import com.azure.communication.callautomation.CallAutomationClientBuilder; +import com.azure.core.http.HttpClient; +import com.communication.callautomation.config.AcsConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class CallAutomationClientImpl implements CallAutomationClientFactory { + private final AcsConfig acsConfig; + private final HttpClient azureHttpClient; + private final Map clientMap = new ConcurrentHashMap(); + + @Autowired + public CallAutomationClientImpl(final AcsConfig acsConfig, + final HttpClient azureHttpClient) { + this.acsConfig = acsConfig; + this.azureHttpClient = azureHttpClient; + } + @Override + public CallAutomationClient getCallAutomationClient(final String correlationId) { + log.debug("Start: getCallAutomationClient"); + String connectionString = acsConfig.getConnectionString(); + CallAutomationClient callAutomationClient; + callAutomationClient = clientMap.get(correlationId); + if (callAutomationClient == null) { + callAutomationClient = createCallAutomationClient(connectionString, correlationId); + } + log.debug("End: getCallAutomationClient"); + return callAutomationClient; + } + + private synchronized CallAutomationClient createCallAutomationClient(final String connectionString, final String correlationId) { + log.debug("Start: createCallAutomationClient"); + CallAutomationClientBuilder callAutomationClientBuilder = new CallAutomationClientBuilder() + .httpClient(azureHttpClient) + .connectionString(connectionString); + CallAutomationClient callAutomationClient = callAutomationClientBuilder.buildClient(); + clientMap.put(correlationId, callAutomationClient); + log.debug("End: createCallAutomationClient"); + return callAutomationClient; + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/config/AcsConfig.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/config/AcsConfig.java new file mode 100644 index 0000000..2af5826 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/config/AcsConfig.java @@ -0,0 +1,57 @@ +package com.communication.callautomation.config; + +import com.azure.core.implementation.util.EnvironmentConfiguration; +import com.azure.core.util.Configuration; +import com.communication.callautomation.controller.ApiVersion; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +import java.util.UUID; + +@ConfigurationProperties(prefix = "acs") +@Getter +public class AcsConfig { + private final String connectionString; + private final String basecallbackuri; + private final String timeoutMs; + private final String mediarecordingstarted; + private final String mediamainmenu; + private final String mediaretry; + private final String mediagoodbye; + private final String mediachoice1; + private final String mediachoice2; + private final String mediachoice3; + + @ConstructorBinding + AcsConfig(final String connectionString, + final String basecallbackuri, + final String timeoutMs, + final String mediarecordingstarted, + final String mediamainmenu, + final String mediaretry, + final String mediagoodbye, + final String mediachoice1, + final String mediachoice2, + final String mediachoice3) { + this.connectionString = connectionString; + this.basecallbackuri = basecallbackuri; + this.timeoutMs = timeoutMs; + this.mediarecordingstarted = mediarecordingstarted; + this.mediamainmenu = mediamainmenu; + this.mediaretry = mediaretry; + this.mediagoodbye = mediagoodbye; + this.mediachoice1 = mediachoice1; + this.mediachoice2 = mediachoice2; + this.mediachoice3 = mediachoice3; + EnvironmentConfiguration.getGlobalConfiguration().put(Configuration.PROPERTY_AZURE_REQUEST_RESPONSE_TIMEOUT, timeoutMs); + } + + public String getCallbackUri(String callerId){ + return basecallbackuri + String.format("%s/calls/ongoing/%s?callerId=%s", ApiVersion.CURRENT, UUID.randomUUID(), callerId); + } + + public String getMediaUri(String filename){ + return basecallbackuri + String.format("%s/calls/media/%s", ApiVersion.CURRENT, filename); + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ApiVersion.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ApiVersion.java new file mode 100644 index 0000000..048f37f --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ApiVersion.java @@ -0,0 +1,7 @@ +package com.communication.callautomation.controller; + +public class ApiVersion { + private ApiVersion() {} + + public static final String CURRENT = "/v1"; +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/CallController.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/CallController.java new file mode 100644 index 0000000..6ede769 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/CallController.java @@ -0,0 +1,59 @@ +package com.communication.callautomation.controller; + +import com.azure.messaging.eventgrid.EventGridEvent; +import com.communication.callautomation.exceptions.InvalidEventPayloadException; +import com.communication.callautomation.exceptions.MediaLoadingException; +import com.communication.callautomation.handler.EventsHandler; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONException; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.util.List; + +@RestController +@RequestMapping(ApiVersion.CURRENT + "/calls") +@Slf4j +public class CallController { + private EventsHandler eventsHandler; + + public CallController(final EventsHandler eventsHandler) { this.eventsHandler = eventsHandler; } + + @PostMapping(path = "/incoming", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity incomingCallEndpoint(@RequestBody final String reqBody) { + List events; + try{ + events = EventGridEvent.fromString(reqBody); + } catch (IllegalArgumentException ex) { + throw new InvalidEventPayloadException(ex.getMessage(), ex); + } + log.trace("Request received at CallController"); + return eventsHandler.handleIncomingEvents(events); + } + + @PostMapping(path = "/ongoing/{contextId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity ongoingCallEndpoint(@RequestBody final String reqBody, + @PathVariable final String contextId, + @RequestParam(name = "callerId") String callerId) { + JSONArray events; + try{ + events = new JSONArray(reqBody); + } catch (JSONException ex) { + throw new InvalidEventPayloadException(ex.getMessage(), ex); + } + log.trace("Ongoing events received at CallController"); + return eventsHandler.handleOngoingEvents(reqBody); + } + + @GetMapping(path = "/media/{filename}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity mediaLoadingEndpoint(@PathVariable final String filename) { + log.trace("Media loading events received at CallController"); + return eventsHandler.handleMediaLoadingEvents(filename); + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ControllerExceptionHandler.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ControllerExceptionHandler.java new file mode 100644 index 0000000..bd0e1ac --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/controller/ControllerExceptionHandler.java @@ -0,0 +1,48 @@ +package com.communication.callautomation.controller; + +import com.communication.callautomation.exceptions.AzureCallAutomationException; +import com.communication.callautomation.exceptions.InvalidEventPayloadException; +import com.communication.callautomation.exceptions.MediaLoadingException; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + + +/** + * Top level exception handler for rest controllers. This class makes sure that we don't leak any exception to the client and is also responsible for + * mapping various exceptions to HTTP error codes. + */ +@RestControllerAdvice +@Slf4j +public class ControllerExceptionHandler { + @ExceptionHandler(value = InvalidEventPayloadException.class) + public ResponseEntity handleInvalidEventPayloadException(final Exception exception) { + log.warn("Invalid payload : {}", exception.getMessage()); + return buildExceptionResponse("Missing information in payload !", HttpStatus.OK); + } + + @ExceptionHandler(value = AzureCallAutomationException.class) + public ResponseEntity handleAzureCallAutomationException(final Exception exception) { + log.warn("Remote server error : {}", exception.getMessage()); + return buildExceptionResponse("An error occurred while talking to a remote server !", HttpStatus.OK); + } + + @ExceptionHandler(value = MediaLoadingException.class) + public ResponseEntity handleMediaLoadingException(final Exception exception) { + log.warn("Server error : {}", exception.getMessage()); + return buildExceptionResponse("An error occurred in server IO operation !", HttpStatus.OK); + } + + @ExceptionHandler(value = Exception.class) + public ResponseEntity handleGenericException(final Exception exception) { + log.error("Unhandled exception occurred : {}", exception); + return buildExceptionResponse("An unexpected error occurred !", HttpStatus.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity buildExceptionResponse(final String message, final HttpStatus status) { + return ResponseEntity.status(status).body(message); + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/CallAutomationService.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/CallAutomationService.java new file mode 100644 index 0000000..7051247 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/CallAutomationService.java @@ -0,0 +1,20 @@ +package com.communication.callautomation.core; + +import com.communication.callautomation.core.model.EventInfo; +import com.communication.callautomation.exceptions.AzureCallAutomationException; + +/** + * Interface for the CallAutomation SDK services. + */ +public interface CallAutomationService { + + String answerCall(final EventInfo eventInfo); + + String startRecording(final EventInfo eventInfo); + + String playAudio(final EventInfo eventInfo, final String prompt); + + String singleDigitDtmfRecognitionWithPrompt(final EventInfo eventInfo, final String prompt); + + String terminateCall(final EventInfo eventInfo); +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/CallState.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/CallState.java new file mode 100644 index 0000000..e124d68 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/CallState.java @@ -0,0 +1,32 @@ +package com.communication.callautomation.core.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@AllArgsConstructor +public class CallState { + public static enum CallStateEnum { + STARTED, + ONRETRY, + FINISHED; + } + private static CallStateEnum currentState; + private static int retryCount; + + public static void incrementRetryCount() { + retryCount++; + } + public static void resetCount() { + retryCount = 0; + } + public static int getIncrementRetryCount() { + return retryCount; + } + public static void setCallState(final CallStateEnum state){ + currentState = state; + } + public static CallStateEnum getCurrentState() { + return currentState; + } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/EventInfo.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/EventInfo.java new file mode 100644 index 0000000..01e2127 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/EventInfo.java @@ -0,0 +1,17 @@ +package com.communication.callautomation.core.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@AllArgsConstructor +@Builder(toBuilder = true) +public class EventInfo { + private String incomingCallContext; + private String validationCode; + private String topic; + private String fromId; + private String callConnectionId; + private String correlationId; +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/Prompts.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/Prompts.java new file mode 100644 index 0000000..2dac1a4 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/core/model/Prompts.java @@ -0,0 +1,20 @@ +package com.communication.callautomation.core.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@AllArgsConstructor +@ToString +public enum Prompts { + CHOICE1("choice1.wav"), + CHOICE2("choice2.wav"), + CHOICE3("choice3.wav"), + GOODBYE("goodbye.wav"), + MAINMENU("mainmenu.wav"), + RECORDINGSTARTED("recordingstarted.wav"), + RETRY("retry.wav"); + + private final String mediafile; +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/AzureCallAutomationException.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/AzureCallAutomationException.java new file mode 100644 index 0000000..13dd561 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/AzureCallAutomationException.java @@ -0,0 +1,17 @@ +package com.communication.callautomation.exceptions; + +/** + * Thrown when an error happens while interacting with ACS through the CallAutomation SDK. + */ +public class AzureCallAutomationException extends BaseException { + /** + * @param message + */ + public AzureCallAutomationException(final String message) { super(message); } + + /** + * @param message + * @param cause + */ + public AzureCallAutomationException(final String message, final Throwable cause) { super(message, cause); } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/BaseException.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/BaseException.java new file mode 100644 index 0000000..c9a1735 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/BaseException.java @@ -0,0 +1,7 @@ +package com.communication.callautomation.exceptions; + +public class BaseException extends RuntimeException { + public BaseException(final String message) { super(message); } + + public BaseException(final String message, Throwable cause) { super(message, cause); } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/InvalidEventPayloadException.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/InvalidEventPayloadException.java new file mode 100644 index 0000000..d3e46b8 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/InvalidEventPayloadException.java @@ -0,0 +1,11 @@ +package com.communication.callautomation.exceptions; + +/** + * Thrown when an error happens at the controller from incoming payload. + */ +public class InvalidEventPayloadException extends BaseException { + + public InvalidEventPayloadException(String message) { super(message); } + + public InvalidEventPayloadException(String message, Throwable cause) { super(message, cause); } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/MediaLoadingException.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/MediaLoadingException.java new file mode 100644 index 0000000..3a2ac00 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/exceptions/MediaLoadingException.java @@ -0,0 +1,8 @@ +package com.communication.callautomation.exceptions; + +public class MediaLoadingException extends BaseException { + + public MediaLoadingException(String message) { super(message); } + + public MediaLoadingException(String message, Throwable cause) { super(message, cause); } +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/handler/EventsHandler.java b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/handler/EventsHandler.java new file mode 100644 index 0000000..ed5df8b --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/handler/EventsHandler.java @@ -0,0 +1,255 @@ +package com.communication.callautomation.handler; + +import com.azure.communication.callautomation.models.DtmfResult; +import com.azure.communication.callautomation.models.events.*; +import com.azure.messaging.eventgrid.EventGridEvent; +import com.communication.callautomation.core.CallAutomationService; +import com.communication.callautomation.core.model.CallState; +import com.communication.callautomation.core.model.EventInfo; +import com.communication.callautomation.core.model.Prompts; +import com.communication.callautomation.exceptions.AzureCallAutomationException; +import com.communication.callautomation.exceptions.InvalidEventPayloadException; +import com.azure.communication.callautomation.models.DtmfTone; +import com.azure.communication.callautomation.CallAutomationEventParser; +import com.communication.callautomation.exceptions.MediaLoadingException; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONException; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Slf4j +public class EventsHandler { + private final CallAutomationService callAutomationService; + private final Map targetByCorrelationId = new HashMap<>(); + + //constants + public static final String VALIDATION_CODE = "validationCode"; + public static final String INCOMING_CALL_CONTEXT = "incomingCallContext"; + public static final String CORRELATION_ID = "correlationId"; + + @Autowired + public EventsHandler(final CallAutomationService callAutomationService) { + this.callAutomationService = callAutomationService; + } + + public ResponseEntity handleIncomingEvents(final List events) { + JSONObject response = new JSONObject(); + for(EventGridEvent eventGridEvent : events) { + JSONObject eventData = null; + eventData = new JSONObject(eventGridEvent.getData().toString()); + String eventType = eventGridEvent.getEventType(); + + try{ + log.info("Received: {}", eventType); + switch (eventType){ + case "Microsoft.EventGrid.SubscriptionValidationEvent": + response = handleSubscriptionValidationEvent(EventInfo.builder() + .validationCode(getFromEventData(eventData, VALIDATION_CODE, eventType)) + .build()); + break; + + case "Microsoft.Communication.IncomingCall": + String callerId = ""; + String incomingCallContext = ""; + String correlationId = ""; + try { + callerId = eventData.getJSONObject("from").getString("rawId"); + incomingCallContext = eventData.getString(INCOMING_CALL_CONTEXT); + correlationId = eventData.getString(CORRELATION_ID); + + targetByCorrelationId.put(correlationId, callerId); + } catch (JSONException e) { + throw new InvalidEventPayloadException(String.format(Locale.ROOT, "%s, %s",eventType ,e.getMessage())); + } + response = handleIncomingCallEvent(EventInfo.builder() + .incomingCallContext(incomingCallContext) + .fromId(callerId) + .correlationId(correlationId) + .build()); + break; + + default: + log.debug("Unknown event type: {}", eventType); + return ResponseEntity.status(HttpStatus.OK).body(response.put("message", "Not Implemented").toString()); + } + } catch (AzureCallAutomationException e) { + throw new AzureCallAutomationException(String.format(Locale.ROOT, + "%s", "%s", + eventType, e.getMessage(), e.getCause())); + } + } + return ResponseEntity.status(HttpStatus.OK).body(response.toString()); + } + + public ResponseEntity handleMediaLoadingEvents(final String filename) { + String filePath = "src/main/java/com/communication/callautomation/mediafiles/" + filename; + File file = new File(filePath); + InputStreamResource resource = null; + + try{ + resource = new InputStreamResource(new FileInputStream(file)); + } catch (FileNotFoundException ex) { + throw new MediaLoadingException(ex.getMessage(), ex); + } + + HttpHeaders headers = new HttpHeaders(); + headers.add("Cache-Control", "no-cache, no-store, must-revalidate"); + headers.add("Pragma", "no-cache"); + + return ResponseEntity.status(HttpStatus.OK) + .headers(headers) + .contentLength(file.length()) + .contentType(MediaType.parseMediaType("audio/x-wav")) + .body(resource); + } + + public ResponseEntity handleOngoingEvents(final String reqBody) { + JSONObject response = new JSONObject(); + List events = CallAutomationEventParser.parseEvents(reqBody); + response = handleAppointmentBookingScript(events); + + return ResponseEntity.status(HttpStatus.OK).body(response.toString()); + } + + private JSONObject handleSubscriptionValidationEvent(final EventInfo eventInfo) { + JSONObject response = new JSONObject(); + log.info("Subscription Validation Event received"); + response.put("ValidationResponse", eventInfo.getValidationCode()); + log.info("Subscription Validation Event successful"); + + return response; + } + + private JSONObject handleIncomingCallEvent(final EventInfo eventInfo) { + JSONObject response = new JSONObject(); + log.info("Received Incoming Call event"); + callAutomationService.answerCall(eventInfo); + + return response; + } + + private String getFromEventData(final JSONObject data, final String field, final String eventType) throws InvalidEventPayloadException { + try { + return data.getString(field); + + } catch (JSONException e) { + throw new InvalidEventPayloadException(String.format(Locale.ROOT, "%s, %s", eventType, e.getMessage()), e.getCause()); + } + } + + private JSONObject handleAppointmentBookingScript(final List events) { + JSONObject response = new JSONObject(); + int retryCounter = 0; + for(CallAutomationEventBase acsEvent : events) { + try{ + String callConnectionId = acsEvent.getCallConnectionId(); + String correlationId = acsEvent.getCorrelationId(); + String target = targetByCorrelationId.get(correlationId); + if (correlationId == null || callConnectionId == null) { + throw new InvalidEventPayloadException("Missing correlationId in CallConnected event !"); + } + if (target == null) { + throw new RuntimeException("Unknown error when retrieving target from memory cache"); + } + EventInfo eventInfo = EventInfo.builder() + .correlationId(correlationId) + .fromId(target) + .callConnectionId(callConnectionId) + .build(); + + if (acsEvent instanceof CallConnected) { + //Start Recording + CallState.setCallState(CallState.CallStateEnum.STARTED); + CallState.resetCount(); + String recordingId = callAutomationService.startRecording(eventInfo); + log.info("Started to record the call, Record ID: {}", recordingId); + + //PlayAudio + String responsePlay = callAutomationService.playAudio(eventInfo, Prompts.RECORDINGSTARTED.getMediafile()); + log.info("Played Recording Started to caller: {}", responsePlay); + } + else if(acsEvent instanceof PlayCompleted) { + //Single-digit DTMF recognition + if (CallState.getCurrentState() == CallState.CallStateEnum.FINISHED) { + callAutomationService.terminateCall(eventInfo); + log.info("Call hangup executed"); + } else { + String responseDtmfRec = callAutomationService.singleDigitDtmfRecognitionWithPrompt(eventInfo, Prompts.MAINMENU.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.STARTED); + log.info("Started single digit DTMF Recognition: {}", responseDtmfRec); + } + } + else if(acsEvent instanceof RecognizeCompleted) { + RecognizeCompleted event = (RecognizeCompleted) acsEvent; + DtmfResult result = (DtmfResult) event.getRecognizeResult().get(); + DtmfTone tone = result.getTones().get(0); + log.info("Tone received {}", tone.convertToString()); + switch(tone.convertToString()){ + case "1": + log.info("Playing option 1 based on DMTF received from caller"); + callAutomationService.playAudio(eventInfo, Prompts.CHOICE1.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.FINISHED); + break; + case "2": + log.info("Playing option 2 based on DMTF received from caller"); + callAutomationService.playAudio(eventInfo, Prompts.CHOICE2.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.FINISHED); + break; + case "3": + log.info("Playing option 3 based on DMTF received from caller"); + callAutomationService.playAudio(eventInfo, Prompts.CHOICE3.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.FINISHED); + break; + default: + log.info("Choosen DMTF triggered a retry"); + if (CallState.getIncrementRetryCount() > 2) { + log.info("Retry exceed maximum amount set, ending the call"); + callAutomationService.playAudio(eventInfo, Prompts.GOODBYE.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.FINISHED); + } else { + callAutomationService.singleDigitDtmfRecognitionWithPrompt(eventInfo, Prompts.RETRY.getMediafile()); + CallState.setCallState(CallState.CallStateEnum.ONRETRY); + CallState.incrementRetryCount(); + } + break; + } + } + else if (acsEvent instanceof RecognizeFailed) { + RecognizeFailed event = (RecognizeFailed) acsEvent; + String reasonFailed = event.getResultInformation().getMessage(); + log.error("Recognize failed with following error message: {}", reasonFailed); + callAutomationService.terminateCall(eventInfo); + } + else if (acsEvent instanceof PlayFailed) { + PlayFailed event = (PlayFailed) acsEvent; + String reasonFailed = event.getResultInformation().getMessage(); + log.error("Play audio to participant failed with following error message: {}", reasonFailed); + callAutomationService.terminateCall(eventInfo); + } + else { + log.debug("Received unhandled event"); + } + } catch (AzureCallAutomationException e) { + throw new AzureCallAutomationException(e.getMessage(), e.getCause()); + } catch (InvalidEventPayloadException e) { + throw new InvalidEventPayloadException(e.getMessage(), e.getCause()); + } + } + return response; + } + + +} diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice1.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice1.wav new file mode 100644 index 0000000..3f10514 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice1.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice2.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice2.wav new file mode 100644 index 0000000..2f83bb6 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice2.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice3.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice3.wav new file mode 100644 index 0000000..943cd8a Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/choice3.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/goodbye.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/goodbye.wav new file mode 100644 index 0000000..f7642f7 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/goodbye.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/mainmenu.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/mainmenu.wav new file mode 100644 index 0000000..ca5e798 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/mainmenu.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/recordingstarted.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/recordingstarted.wav new file mode 100644 index 0000000..0f1d6c8 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/recordingstarted.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/retry.wav b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/retry.wav new file mode 100644 index 0000000..0c7b706 Binary files /dev/null and b/CallAutomation_AppointmentBooking/src/main/java/com/communication/callautomation/mediafiles/retry.wav differ diff --git a/CallAutomation_AppointmentBooking/src/main/resources/application.yml b/CallAutomation_AppointmentBooking/src/main/resources/application.yml new file mode 100644 index 0000000..2ecb6d0 --- /dev/null +++ b/CallAutomation_AppointmentBooking/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + application: + name: AppointmentBooking + +server: + port: 9099 + +acs: + connectionstring: + basecallbackuri: ex. https://b9de-174-176-165-160.ngrok.io + timeoutms: 30000 + mediarecordingstarted: "/mediafiles/recordingstarted.wav" + mediamainmenu: "/mediafiles/mainmenu.wav" + mediaretry: "/mediafiles/retry.wav" + mediagoodbye: "/mediafiles/goodbye.wav" + mediachoice1: "/mediafiles/choice1.wav" + mediachoice2: "/mediafiles/choice2.wav" + mediachoice3: "/mediafiles/choice3.wav"