diff --git a/.gitignore b/.gitignore index dfcfd56f..2c34f568 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,9 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# vscode +.vscode/ + +# Other Tooling # +target diff --git a/get-relay-config-quickstart/README.md b/get-relay-config-quickstart/README.md new file mode 100644 index 00000000..03a631ac --- /dev/null +++ b/get-relay-config-quickstart/README.md @@ -0,0 +1,46 @@ +--- +page_type: sample +languages: +- Java +products: +- azure +- azure-communication-networktraversal +- azure-communication-common +--- + + +# Get a relay Configuration + +## Prerequisites + +- An Azure account with an active subscription. [Create an account for free](https://azure.microsoft.com/free/?WT.mc_id=A261C142F). +- [Java Development Kit (JDK)](https://docs.microsoft.com/azure/developer/java/fundamentals/java-jdk-install) version 8 or above +- [Apache Maven](https://maven.apache.org/download.cgi) +- An deployed Communication Services resource and connection string. For details, see [Create a Communication Services resource](https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource). + +## Code Structure + +- **./get-relay-config-quickstart/src/main/java/com/communication/quickstart/App.java:** contains code for getting a relay configuration. +- **pom.xml:** Project's Project Object Model, or [POM](https://maven.apache.org/guides/introduction/introduction-to-the-pom.html). + +## Before running sample code + +1. Open an instance of PowerShell, Windows Terminal, Command Prompt or equivalent and navigate to the directory that you'd like to clone the sample to. +2. `git clone https://github.com/Azure-Samples/communication-services-java-quickstarts.git` +3. With the Communication Services procured in pre-requisites, add connection string in the code at line no 14 + ```String connectionString = "https://.communication.azure.com/;accesskey=";```. + +## Run the code + +1. Navigate to the directory containing the pom.xml file and compile the project by using command `mvn compile`. +2. Then, build the package using command `mvn package`. +3. Run the command to execute the app `mvn exec:java -Dexec.mainClass="com.communication.quickstart.App" -Dexec.cleanupDaemonThreads=false`. If you are on Windows, run the following command: `mvn exec:java -D"exec.mainClass"="com.communication.quickstart.App" -D"exec.cleanupDaemonThreads"="false"` + +## Next Steps + +Take a look at our [API Documentation][apiref] for more information about the APIs that are available in the clients: + +[getrelayconfiguration]: https://github.com/Azure/azure-sdk-for-java/blob/main/sdk/communication/azure-communication-networktraversal/src/samples/java/com/azure/communication/networktraversal +[freesub]: https://azure.microsoft.com/free/ +[createinstance_azurecommunicationservicesaccount]: https://docs.microsoft.com/azure/communication-services/quickstarts/create-communication-resource +[package]: https://github.com/Azure/azure-sdk-for-java/blob/main/sdk/communication/azure-communication-networktraversal/README.md diff --git a/get-relay-config-quickstart/pom.xml b/get-relay-config-quickstart/pom.xml new file mode 100644 index 00000000..a87092d0 --- /dev/null +++ b/get-relay-config-quickstart/pom.xml @@ -0,0 +1,101 @@ + + + + 4.0.0 + + com.communication.quickstart + communication-quickstart + 1.0-SNAPSHOT + + communication-quickstart + + http://www.example.com + + + UTF-8 + 1.7 + 1.7 + + + + + junit + junit + 4.11 + test + + + com.azure + azure-communication-networktraversal + 1.0.0-beta.2 + + + com.azure + azure-communication-common + 1.0.3 + + + com.azure + azure-communication-identity + 1.1.3 + + + io.netty + netty-all + 4.1.68.Final + + + org.kurento + kurento-client + 6.15.0 + + + + + + + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-jar-plugin + 3.0.2 + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + maven-site-plugin + 3.7.1 + + + maven-project-info-reports-plugin + 3.0.0 + + + + + diff --git a/get-relay-config-quickstart/src/main/java/com/communication/quickstart/App.java b/get-relay-config-quickstart/src/main/java/com/communication/quickstart/App.java new file mode 100644 index 00000000..ca759110 --- /dev/null +++ b/get-relay-config-quickstart/src/main/java/com/communication/quickstart/App.java @@ -0,0 +1,101 @@ +package com.communication.quickstart; + +import com.azure.communication.common.CommunicationUserIdentifier; +import com.azure.communication.identity.CommunicationIdentityClient; +import com.azure.communication.identity.CommunicationIdentityClientBuilder; +import com.azure.communication.networktraversal.*; +import com.azure.communication.networktraversal.models.*; +import org.kurento.client.WebRtcEndpoint; +import org.kurento.client.MediaPipeline; +import org.kurento.client.KurentoClient; +import java.util.List; +import java.lang.reflect.*; + +public class App +{ + private static KurentoClient kurento; + + // You can find your connection string from your resource in the Azure portal + private static String connectionString = "https://.communication.azure.com/;accesskey="; + private static CommunicationIdentityClient communicationIdentityClient = new CommunicationIdentityClientBuilder() + .connectionString(connectionString) + .buildClient(); + + private static CommunicationRelayClient communicationRelayClient = new CommunicationRelayClientBuilder() + .connectionString(connectionString) + .buildClient(); + + public static void main(String[] args) + { + System.out.println("Azure Communication Services - NetworkTraversal Quickstart"); + + System.out.println("Getting a relay configuration"); + getRelayConfiguration(); + + System.out.println("Getting a relay configuration using Identity"); + getRelayConfigurationUsingIdentity(); + + System.out.println("Getting a relay configuration passing a Route Type"); + getRelayConfigurationUsingRouteType(); + } + + public static void getRelayConfiguration() + { + CommunicationRelayConfiguration config = communicationRelayClient.getRelayConfiguration(); + + System.out.println("Expires on:" + config.getExpiresOn()); + List iceServers = config.getIceServers(); + + for (CommunicationIceServer iceS : iceServers) { + System.out.println("URLS: " + iceS.getUrls()); + System.out.println("Username: " + iceS.getUsername()); + System.out.println("credential: " + iceS.getCredential()); + System.out.println("RouteType: " + iceS.getRouteType()); + } + + // Now you can configure your WebRtcEndpoint to use TURN credentials + + // MediaPipeline pipeline = kurento.createMediaPipeline(); + // WebRtcEndpoint webRtcEndpoint = new WebRtcEndpoint.Builder(pipeline).build(); + + // CommunicationIceServer iceServerToUse = iceServers.get(0); + // String urlToUse = iceServerToUse.getUrls().get(0); + + // Format for URL must be user:password@ipaddress:port + // String ipAndPort = urlToUse.substring(5, urlToUse.length()); + // webRtcEndpoint.setTurnUrl(iceServerToUse.getUsername()+ ":" + iceServerToUse.getCredential() + "@" + ipAndPort); + } + + public static void getRelayConfigurationUsingIdentity() + { + CommunicationUserIdentifier user = communicationIdentityClient.createUser(); + System.out.println("User id: " + user.getId()); + + CommunicationRelayConfiguration config = communicationRelayClient.getRelayConfiguration(user); + + System.out.println("Expires on:" + config.getExpiresOn()); + List iceServers = config.getIceServers(); + + for (CommunicationIceServer iceS : iceServers) { + System.out.println("URLS: " + iceS.getUrls()); + System.out.println("Username: " + iceS.getUsername()); + System.out.println("credential: " + iceS.getCredential()); + System.out.println("RouteType: " + iceS.getRouteType()); + } + } + + public static void getRelayConfigurationUsingRouteType() + { + CommunicationRelayConfiguration config = communicationRelayClient.getRelayConfiguration(RouteType.NEAREST); + + System.out.println("Expires on:" + config.getExpiresOn()); + List iceServers = config.getIceServers(); + + for (CommunicationIceServer iceS : iceServers) { + System.out.println("URLS: " + iceS.getUrls()); + System.out.println("Username: " + iceS.getUsername()); + System.out.println("credential: " + iceS.getCredential()); + System.out.println("RouteType: " + iceS.getRouteType()); + } + } +} diff --git a/incomingcallsample/pom.xml b/incomingcallsample/pom.xml new file mode 100644 index 00000000..731bcc42 --- /dev/null +++ b/incomingcallsample/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + + + azure-sdk-for-java + https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-java/maven/v1 + + true + + + true + + + + + + + azure-sdk-for-java + https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-java/maven/v1 + + true + + + true + + + + + + org.springframework.boot + spring-boot-starter-parent + 2.6.1 + + + + com.communication.incomingcallsample + incomingcallsample + 0.0.1-SNAPSHOT + + incomingcallsample + Demo project for IncomingCall + + + UTF-8 + 11 + + + + + com.azure + azure-core + 1.23.0 + + + junit + junit + 4.11 + test + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + com.azure + azure-messaging-eventgrid + 4.6.0 + + + com.azure + azure-cosmos + 4.18.0 + + + com.azure + azure-communication-callingserver + 1.0.0-alpha.20220112.2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/incomingcallsample/readme.md b/incomingcallsample/readme.md new file mode 100644 index 00000000..1027f0cb --- /dev/null +++ b/incomingcallsample/readme.md @@ -0,0 +1,37 @@ +How to run +1. Create ACS resource from Azure Portal + +2. Create 3 ACS User Identities(MRI) +Example +``` +User 1: 8:acs:- +User 2: 8:acs:- +User 3: 8:acs:- +``` + +3. Put User2 MRI into the config AllowedRecipientList + +4. Put User3 MRI into the config TargetParticipant + +5. Start ngrok +```dotnetcli +ngrok http 9008 +``` + +6. Update config with ngrok endpoint. Example +``` +AppCallBackUri=https://4087-75-155-234-140.ngrok.io +``` + +7. Start sample app locally +```dotnetcli +mvn clean package +java -jar .\target\incomingcallsample-0.0.1-SNAPSHOT.jar +``` + +8. Register webhook to your ACS resource +```dotnetcli +armclient put "/subscriptions//resourceGroups//providers/Microsoft.Communication/CommunicationServices//providers/Microsoft.EventGrid/eventSubscriptions/IncomingCallEventSub?api-version=2020-06-01" "{'properties':{'destination':{'properties':{'endpointUrl':'https:///OnIncomingCall'},'endpointType':'WebHook'},'filter':{'includedEventTypes': ['Microsoft.Communication.IncomingCall']}}}" -verbose +``` + +9. Make a call from User1 to User2 \ No newline at end of file diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/Controller/IncomingCallController.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/Controller/IncomingCallController.java new file mode 100644 index 00000000..c2983cbe --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/Controller/IncomingCallController.java @@ -0,0 +1,116 @@ +package com.communication.incomingcallsample.Controller; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestBody; + +import com.azure.communication.callingserver.CallingServerClient; +import com.azure.communication.callingserver.CallingServerClientBuilder; +import com.azure.messaging.eventgrid.EventGridEvent; +import com.azure.messaging.eventgrid.systemevents.SubscriptionValidationEventData; +import com.communication.incomingcallsample.EventHandler.EventAuthHandler; +import com.communication.incomingcallsample.EventHandler.EventDispatcher; +import com.communication.incomingcallsample.logger.Logger; +import com.communication.incomingcallsample.utils.CallConfiguration; +import com.communication.incomingcallsample.utils.ConfigurationManager; +import com.communication.incomingcallsample.utils.IncomingCallHandler; +import com.communication.incomingcallsample.utils.ResponseHandler; + +@RestController +public class IncomingCallController { + private final CallingServerClient callingServerClient; + private CallConfiguration callConfiguration; + private EventAuthHandler eventAuthHandler; + + public IncomingCallController(){ + ConfigurationManager configurationManager = ConfigurationManager.getInstance(); + this.eventAuthHandler = new EventAuthHandler(configurationManager.getAppSettings("SecretValue")); + this.callConfiguration = CallConfiguration.GetCallConfiguration(configurationManager, this.eventAuthHandler.GetSecretQuerystring()); + + this.callingServerClient = new CallingServerClientBuilder() + .connectionString(this.callConfiguration.connectionString) + .buildClient(); + } + + @PostMapping(value = "CallingServerAPICallBacks") + public String callingServerAPICallBacks(@RequestBody(required = false) String data, + @RequestParam(value = "secret", required = false) String secretKey) { + + // Validating the incoming request by using secret set in config.properties + if (this.eventAuthHandler.authorize(secretKey)) { + Logger.logMessage(Logger.MessageType.INFORMATION, "call back event: " + data); + EventDispatcher.getInstance().processNotification(data); + } else { + Logger.logMessage(Logger.MessageType.ERROR, "Unauthorized Request"); + } + + return "OK"; + } + + @PostMapping(value = "/OnIncomingCall") + public ResponseEntity onIncomingRequestAsync(@RequestBody(required = false) String data) { + // parse EventGridEvent + EventGridEvent eventGridEvent = null; + try{ + List eventGridEvents = EventGridEvent.fromString(data); + eventGridEvent = eventGridEvents.get(0); + } + catch(Exception e) { + return new ResponseEntity("Failed to parse EventGridEvent:" + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + if(eventGridEvent == null){ + return ResponseHandler.generateResponse("Could not get EventGridEvent", HttpStatus.INTERNAL_SERVER_ERROR, null); + } + + Logger.logEventGridEvent(Logger.MessageType.INFORMATION, eventGridEvent); + + String type = eventGridEvent.getEventType(); + if(type.equals("Microsoft.EventGrid.SubscriptionValidationEvent")) { + return getRegisterEventGridResponse(eventGridEvent); + } else if(type.equals("Microsoft.Communication.IncomingCall")) { + String to = getRecipientMRI(eventGridEvent); + return handleIncomingCall(data, to); + } else { + return ResponseHandler.generateResponse("unknown EventGridEvent type: " + eventGridEvent.toString() , HttpStatus.BAD_REQUEST, null); + } + } + + private static ResponseEntity getRegisterEventGridResponse(EventGridEvent eventGridEvent){ + SubscriptionValidationEventData subscriptionValidationEventData = eventGridEvent.getData().toObject( + SubscriptionValidationEventData.class); + String validationCode = subscriptionValidationEventData.getValidationCode(); + Logger.logMessage(Logger.MessageType.INFORMATION, "Registered ACS resource Event Grid."); + return ResponseEntity.status(HttpStatus.OK).body(Map.of( + "validationResponse", validationCode)); + } + + private ResponseEntity handleIncomingCall(String data, String to) { + try { + String incomingCallContext = data.split("\"incomingCallContext\":\"")[1].split("\"}")[0]; + if(new ArrayList<>(Arrays.asList(this.callConfiguration.allowedRecipientList)).contains(to)){ + new IncomingCallHandler(this.callingServerClient, this.callConfiguration).report(incomingCallContext); + return ResponseHandler.generateResponse("answer call done", HttpStatus.OK, null); + } else { + Logger.logMessage(Logger.MessageType.INFORMATION, to + " is not in the recipient list that this app supports to answer, skip answering"); + return ResponseHandler.generateResponse("call to " + to + "ignored as it is not in the allow list", HttpStatus.OK, null); + } + } catch(Exception e) { + String message = "Fails in OnIncomingCall ---> " + e.getMessage(); + Logger.logMessage(Logger.MessageType.ERROR, message); + return ResponseHandler.generateResponse(message, HttpStatus.INTERNAL_SERVER_ERROR, null); + } + } + + private String getRecipientMRI(EventGridEvent eventGridEvent) { + return eventGridEvent.getSubject().split("recipient/")[1]; + } +} + diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventAuthHandler.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventAuthHandler.java new file mode 100644 index 00000000..4ae2a930 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventAuthHandler.java @@ -0,0 +1,22 @@ +package com.communication.incomingcallsample.EventHandler; + +public class EventAuthHandler { + private final String secretValue; + private final String secreteKey = "secret"; + + public EventAuthHandler(String secretValue){ + this.secretValue = secretValue; + } + + public String GetSecretQuerystring(){ + return this.secreteKey + "=" + secretValue; + } + + public boolean authorize(String query){ + if(query==null || query.isEmpty()) { + return false; + } + + return query.equals(this.secretValue); + } +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventDispatcher.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventDispatcher.java new file mode 100644 index 00000000..5b01f6bf --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/EventDispatcher.java @@ -0,0 +1,108 @@ +package com.communication.incomingcallsample.EventHandler; + +import java.util.Hashtable; +import java.util.List; + +import com.azure.communication.callingserver.models.events.AddParticipantResultEvent; +import com.azure.communication.callingserver.models.events.CallConnectionStateChangedEvent; +import com.azure.communication.callingserver.models.events.CallingServerEventBase; +import com.azure.communication.callingserver.models.events.CallingServerEventType; +import com.azure.communication.callingserver.models.events.PlayAudioResultEvent; +import com.azure.communication.callingserver.models.events.ToneReceivedEvent; +import com.azure.communication.callingserver.models.events.TransferCallResultEvent; +import com.azure.core.models.CloudEvent; +import com.azure.core.util.BinaryData; + +public class EventDispatcher { + private static EventDispatcher instance = null; + private final Hashtable notificationCallbacks; + + EventDispatcher() { + this.notificationCallbacks = new Hashtable<>(); + } + + /// + /// Get instance of EventDispatcher + /// + public static EventDispatcher getInstance() { + if (instance == null) { + instance = new EventDispatcher(); + } + return instance; + } + + public boolean subscribe(String eventType, String eventKey, NotificationCallback notificationCallback) { + String eventId = buildEventKey(eventType, eventKey); + synchronized (this) { + return (notificationCallbacks.put(eventId, notificationCallback) == null); + } + } + + public void unsubscribe(String eventType, String eventKey) { + String eventId = buildEventKey(eventType, eventKey); + synchronized (this) { + notificationCallbacks.remove(eventId); + } + } + + private String buildEventKey(String eventType, String eventKey) { + return (eventType + "-" + eventKey); + } + + public void processNotification(String request) { + CallingServerEventBase callEvent = this.extractEvent(request); + if (callEvent != null) { + synchronized (this) { + final NotificationCallback notificationCallback = notificationCallbacks.get(getEventKey(callEvent)); + if (notificationCallback != null) { + new Thread(() -> notificationCallback.callback(callEvent)).start(); + } + } + } + } + + private CallingServerEventBase extractEvent(String content) { + try { + List cloudEvents = CloudEvent.fromString(content); + CloudEvent cloudEvent = cloudEvents.get(0); + BinaryData eventData = cloudEvent.getData(); + + if (cloudEvent.getType().equals(CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT.toString())) { + return CallConnectionStateChangedEvent.deserialize(eventData); + } else if (cloudEvent.getType().equals(CallingServerEventType.TONE_RECEIVED_EVENT.toString())) { + return ToneReceivedEvent.deserialize(eventData); + } else if (cloudEvent.getType().equals(CallingServerEventType.PLAY_AUDIO_RESULT_EVENT.toString())) { + return PlayAudioResultEvent.deserialize(eventData); + } else if (cloudEvent.getType().equals(CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT.toString())) { + return AddParticipantResultEvent.deserialize(eventData); + } else if (cloudEvent.getType().equals(CallingServerEventType.TRANSFER_CALL_RESULT_EVENT.toString())) { + return TransferCallResultEvent.deserialize(eventData); + } + } catch (Exception ex) { + System.out.println("Failed to parse request content Exception: " + ex.getMessage()); + } + + return null; + } + + private String getEventKey(CallingServerEventBase callEventBase) { + if (callEventBase.getClass() == CallConnectionStateChangedEvent.class) { + String callLegId = ((CallConnectionStateChangedEvent) callEventBase).getCallConnectionId(); + return buildEventKey(CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT.toString(), callLegId); + } else if (callEventBase.getClass() == ToneReceivedEvent.class) { + String callLegId = ((ToneReceivedEvent) callEventBase).getCallConnectionId(); + return buildEventKey(CallingServerEventType.TONE_RECEIVED_EVENT.toString(), callLegId); + } else if (callEventBase.getClass() == PlayAudioResultEvent.class) { + String operationContext = ((PlayAudioResultEvent) callEventBase).getOperationContext(); + return buildEventKey(CallingServerEventType.PLAY_AUDIO_RESULT_EVENT.toString(), operationContext); + } else if (callEventBase.getClass() == AddParticipantResultEvent.class) { + String operationContext = ((AddParticipantResultEvent) callEventBase).getOperationContext(); + return buildEventKey(CallingServerEventType.ADD_PARTICIPANT_RESULT_EVENT.toString(), operationContext); + } else if (callEventBase.getClass() == TransferCallResultEvent.class) { + String operationContext = ((TransferCallResultEvent) callEventBase).getOperationContext(); + return buildEventKey(CallingServerEventType.TRANSFER_CALL_RESULT_EVENT.toString(), operationContext); + } + + return null; + } +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/NotificationCallback.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/NotificationCallback.java new file mode 100644 index 00000000..f45cc383 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/EventHandler/NotificationCallback.java @@ -0,0 +1,7 @@ +package com.communication.incomingcallsample.EventHandler; + +import com.azure.communication.callingserver.models.events.CallingServerEventBase; + +public interface NotificationCallback { + void callback(CallingServerEventBase callEvent); +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/IncomingcallsampleApplication.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/IncomingcallsampleApplication.java new file mode 100644 index 00000000..2dde202b --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/IncomingcallsampleApplication.java @@ -0,0 +1,24 @@ +package com.communication.incomingcallsample; + +import java.util.*; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +import com.communication.incomingcallsample.Controller.IncomingCallController; + +@SpringBootApplication +@ComponentScan(basePackageClasses= IncomingCallController.class) +public class IncomingcallsampleApplication { + final static String url = "http://localhost:9008"; + final static String serverPort = "9008"; + + public static void main(String[] args) { + SpringApplication app = new SpringApplication(IncomingcallsampleApplication.class); + app.setDefaultProperties(Collections + .singletonMap("server.port", serverPort)); + app.run(args); + } + +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/config.properties b/incomingcallsample/src/main/java/com/communication/incomingcallsample/config.properties new file mode 100644 index 00000000..fca0829a --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/config.properties @@ -0,0 +1,27 @@ +# app settings + +# Connection string of Azure Communication Service Resource. +Connectionstring=%Connectionstring% + +# url of the deployed API +# For e.g. "https://4bf7-75-155-234-140.ngrok.io" +AppCallBackUri=%AppCallBackUri% + +# public url of wav audio +# For e.g. "https://acstestapp1.azurewebsites.net/audio/bot-hold-music-2.wav" +AudiFileUri=%AudiFileUri% + +# Destination identitie to transfer the call to. +# Phone number provisioned for the ACS resource (in E.164 Format, e.g. +1425XXXYYYY) +# or MRI(e.g. 8:acs:ab12b0ea-85ea-4f83-b0b6-84d90209c7c4_00000009-bce0-da09-54b7-xxxxxxxxxxxx) +TargetParticipant=%TargetParticipant% + +# List of MRIs to which that this applicaiton supports answering the call. +# separated by ; +# e.g. 8:acs:d0b8b7b5-66a2-4ede-b66c-c74c675e6ca2_0000000e-e5cf-c451-1252-573a0d001a7c;8:acs:d0b8b7b5-66a2-4ede-b66c-c74c675e6ca2_0000000e-e5cf-c451-1252-573a0d001a7d +AllowedRecipientList=%AllowedRecipientList% + +# Query string for callback URL +SecretValue=%SecretValue% + + diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/logger/Logger.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/logger/Logger.java new file mode 100644 index 00000000..085a04b7 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/logger/Logger.java @@ -0,0 +1,39 @@ +package com.communication.incomingcallsample.logger; + +import com.azure.messaging.eventgrid.EventGridEvent; + +public class Logger { + //Caution: Logging should be removed/disabled if you want to use this sample in production to avoid exposing sensitive information + public enum MessageType + { + INFORMATION, + ERROR + } + + /// + /// Log message to console + /// + /// Type of the message: Information or Error + /// Message string + public static void logMessage(MessageType messageType, String message) + { + String logMessage; + logMessage = messageType + " " + message; + System.out.println(logMessage); + } + + public static void logEventGridEvent(MessageType messageType, EventGridEvent eventGridEvent){ + String log = new StringBuilder() + .append(messageType + " ") + .append("OnIncomingCall API POST request EventGridEvent---->") + .append(" type: " + eventGridEvent.getEventType()) + .append(";") + .append(" topic: " + eventGridEvent.getTopic()) + .append(";") + .append(" subject: " + eventGridEvent.getSubject()) + .append(";") + .append(" data: " + eventGridEvent.getData()) + .toString(); + System.out.println(log); + } +} \ No newline at end of file diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/CallConfiguration.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/CallConfiguration.java new file mode 100644 index 00000000..3aefb51d --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/CallConfiguration.java @@ -0,0 +1,29 @@ +package com.communication.incomingcallsample.utils; + +public class CallConfiguration { + public String connectionString; + public String appBaseUrl; + public String appCallbackUrl; + public String audioFileUrl; + public String targetParticipant; + public String[] allowedRecipientList; + + public CallConfiguration(String connectionString, String appBaseUrl, String audioFileUrl, String targetParticipant, String[] allowedRecipientList, String queryString) { + this.connectionString = connectionString; + this.appBaseUrl = appBaseUrl; + this.appCallbackUrl = this.appBaseUrl + "/CallingServerAPICallBacks?" + queryString; + this.audioFileUrl = audioFileUrl; + this.targetParticipant = targetParticipant; + this.allowedRecipientList = allowedRecipientList; + } + + public static CallConfiguration GetCallConfiguration(ConfigurationManager configurationManager, String queryString) { + return new CallConfiguration( + configurationManager.getAppSettings("Connectionstring"), + configurationManager.getAppSettings("AppCallBackUri"), + configurationManager.getAppSettings("AudiFileUri"), + configurationManager.getAppSettings("TargetParticipant"), + configurationManager.getAppSettings("AllowedRecipientList").split(";"), + queryString); + } +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ConfigurationManager.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ConfigurationManager.java new file mode 100644 index 00000000..ab83a6b0 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ConfigurationManager.java @@ -0,0 +1,46 @@ +package com.communication.incomingcallsample.utils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.util.Properties; + +import com.communication.incomingcallsample.logger.Logger; + +public class ConfigurationManager { + private static ConfigurationManager configurationManager = null; + private final Properties appSettings = new Properties(); + + private ConfigurationManager() { + loadAppSettings(); + } + + // static method to create instance of ConfigurationManager class + public static ConfigurationManager getInstance() { + if (configurationManager == null) { + configurationManager = new ConfigurationManager(); + } + return configurationManager; + } + + public void loadAppSettings() { + try { + File configFile = new File("src/main/java/com/communication/incomingcallsample/config.properties"); + FileReader reader = new FileReader(configFile); + appSettings.load(reader); + reader.close(); + } catch (FileNotFoundException ex) { + Logger.logMessage(Logger.MessageType.ERROR,"Loading app settings failed with error -- > " + ex.getMessage()); + } catch (IOException ex) { + Logger.logMessage(Logger.MessageType.ERROR,"Loading app settings failed with error -- > " + ex.getMessage()); + } + } + + public String getAppSettings(String key) { + if (!key.isEmpty()) { + return appSettings.getProperty(key); + } + return ""; + } +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/IncomingCallHandler.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/IncomingCallHandler.java new file mode 100644 index 00000000..54da6196 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/IncomingCallHandler.java @@ -0,0 +1,343 @@ +package com.communication.incomingcallsample.utils; + +import java.net.URI; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.regex.Pattern; + +import com.azure.communication.callingserver.CallConnection; +import com.azure.communication.callingserver.CallingServerClient; +import com.azure.communication.callingserver.models.AnswerCallOptions; +import com.azure.communication.callingserver.models.CallConnectionState; +import com.azure.communication.callingserver.models.CallMediaType; +import com.azure.communication.callingserver.models.CallingEventSubscriptionType; +import com.azure.communication.callingserver.models.CallingOperationStatus; +import com.azure.communication.callingserver.models.PlayAudioOptions; +import com.azure.communication.callingserver.models.PlayAudioResult; +import com.azure.communication.callingserver.models.ToneInfo; +import com.azure.communication.callingserver.models.ToneValue; +import com.azure.communication.callingserver.models.TransferCallResult; +import com.azure.communication.callingserver.models.events.CallConnectionStateChangedEvent; +import com.azure.communication.callingserver.models.events.CallingServerEventType; +import com.azure.communication.callingserver.models.events.PlayAudioResultEvent; +import com.azure.communication.callingserver.models.events.ToneReceivedEvent; +import com.azure.communication.callingserver.models.events.TransferCallResultEvent; +import com.azure.communication.common.CommunicationIdentifier; +import com.azure.communication.common.CommunicationUserIdentifier; +import com.azure.communication.common.PhoneNumberIdentifier; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.rest.Response; +import com.azure.cosmos.implementation.changefeed.CancellationToken; +import com.azure.cosmos.implementation.changefeed.CancellationTokenSource; +import com.communication.incomingcallsample.EventHandler.EventDispatcher; +import com.communication.incomingcallsample.EventHandler.NotificationCallback; +import com.communication.incomingcallsample.logger.Logger; + +public class IncomingCallHandler { + private final String userIdentityRegex = "8:acs:[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}_[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}"; + private final String phoneIdentityRegex = "^\\+\\d{10,14}$"; + private final int MaxRetryAttemptCount = 3; + + private final CallingServerClient callingServerClient; + private final CallConfiguration callConfiguration; + private CallConnection callConnection; + private CancellationTokenSource reportCancellationTokenSource; + private CancellationToken reportCancellationToken; + private String targetParticipant; + + private CompletableFuture callConnectedTask; + private CompletableFuture playAudioCompletedTask; + private CompletableFuture callTerminatedTask; + private CompletableFuture toneReceivedCompleteTask; + private CompletableFuture transferToParticipantCompleteTask; + + public IncomingCallHandler(CallingServerClient callingServerClient, CallConfiguration callConfiguration) { + this.callingServerClient = callingServerClient; + this.callConfiguration = callConfiguration; + this.callConnection = null; + this.reportCancellationTokenSource = new CancellationTokenSource(); + this.reportCancellationToken = this.reportCancellationTokenSource.getToken(); + this.targetParticipant = this.callConfiguration.targetParticipant; + + this.callConnectedTask = new CompletableFuture<>(); + this.playAudioCompletedTask = new CompletableFuture<>(); + this.callTerminatedTask = new CompletableFuture<>(); + this.toneReceivedCompleteTask = new CompletableFuture<>(); + this.transferToParticipantCompleteTask = new CompletableFuture<>(); + } + + public void report(String incomingCallContext){ + try { + // answer the call + String callConnectionId = answerCall(incomingCallContext); + registerToCallStateChangeEvent(callConnectionId); + // wait for the call to get connected + this.callConnectedTask.get(); + + registerToDtmfResultEvent(callConnectionId); + + // play audio + playAudio(); + // wait for audio play complete + this.playAudioCompletedTask.get(); + + // No ToneReceived event fired, and since audio playing is done, drop the call + if(!this.toneReceivedCompleteTask.isDone()){ + Logger.logMessage(Logger.MessageType.INFORMATION, "dtmf tone not sent from caller, dropped the call"); + hangup(); + } + + // Wait for the call to terminate + this.callTerminatedTask.get(); + + Logger.logMessage(Logger.MessageType.INFORMATION, "call terminated."); + } catch (Exception ex) + { + Logger.logMessage(Logger.MessageType.ERROR, "Call ended unexpectedly, reason: " + ex.getMessage()); + } + } + + private String getResponse(Response response) + { + StringBuilder responseString; + responseString = new StringBuilder("StatusCode: " + response.getStatusCode() + ", Headers: { "); + + for (HttpHeader header : response.getHeaders()) { + responseString.append(header.getName()).append(":").append(header.getValue()).append(", "); + } + responseString.append("} "); + return responseString.toString(); + } + + private String answerCall(String incomingCallContext) throws Exception{ + AnswerCallOptions answerCallOptions = new AnswerCallOptions( + new URI(this.callConfiguration.appCallbackUrl), + new ArrayList() { + { + add(CallMediaType.AUDIO); + } + }, + new ArrayList() { + { + add(CallingEventSubscriptionType.PARTICIPANTS_UPDATED); + add(CallingEventSubscriptionType.TONE_RECEIVED); + } + } + ); + Logger.logMessage(Logger.MessageType.INFORMATION, "Answering call..."); + Response response = this.callingServerClient.answerCallWithResponse(incomingCallContext, answerCallOptions, null); + this.callConnection = response.getValue(); + String callConnectionId = this.callConnection.getCallConnectionId(); + Logger.logMessage(Logger.MessageType.INFORMATION, "AnswerCallAsync Response -----> " + getResponse(response)); + Logger.logMessage(Logger.MessageType.INFORMATION, "AnswerCallAsync call -----> callConnectionId: " + callConnectionId); + + return callConnectionId; + } + + private void registerToCallStateChangeEvent(String callConnectionId) { + // Set the callback method + NotificationCallback callStateChangeNotificaiton = (callEvent) -> { + CallConnectionStateChangedEvent callStateChanged = (CallConnectionStateChangedEvent) callEvent; + + Logger.logMessage( + Logger.MessageType.INFORMATION, + "Call State changed to -- > " + callStateChanged.getCallConnectionState() + " callConnectionId: " + callConnectionId); + + if (callStateChanged.getCallConnectionState().equals(CallConnectionState.CONNECTED)) { + this.callConnectedTask.complete(true); + } else if (callStateChanged.getCallConnectionState().equals(CallConnectionState.DISCONNECTED)) { + EventDispatcher.getInstance() + .unsubscribe(CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT.toString(), callConnectionId); + this.reportCancellationTokenSource.cancel(); + this.callTerminatedTask.complete(true); + } + }; + // Subscribe to the event + EventDispatcher.getInstance().subscribe(CallingServerEventType.CALL_CONNECTION_STATE_CHANGED_EVENT.toString(), + callConnectionId, callStateChangeNotificaiton); + } + + private void playAudio(){ + if (this.reportCancellationToken.isCancellationRequested()) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Cancellation request, PlayAudio will not be performed"); + return; + } + + try{ + PlayAudioOptions playAudioOptions = new PlayAudioOptions(); + playAudioOptions.setLoop(true); + playAudioOptions.setAudioFileId(UUID.randomUUID().toString()); + playAudioOptions.setOperationContext(UUID.randomUUID().toString()); + Logger.logMessage(Logger.MessageType.INFORMATION, "Performing PlayAudio operation"); + Response playAudioResponse = this.callConnection.playAudioWithResponse(new URI(this.callConfiguration.audioFileUrl), playAudioOptions, null); + PlayAudioResult response = playAudioResponse.getValue(); + Logger.logMessage(Logger.MessageType.INFORMATION, "playAudioWithResponse -- > " + getResponse(playAudioResponse) + + ", Id: " + response.getOperationId() + ", OperationContext: " + response.getOperationContext() + ", OperationStatus: " + + response.getStatus().toString()); + + if(response.getStatus().equals(CallingOperationStatus.RUNNING)) { + // listen to play audio events + registerToPlayAudioResultEvent(response.getOperationContext()); + try { + Logger.logMessage(Logger.MessageType.INFORMATION, "Audio is playing for 30 seconds, it can be interrupted by pressing 1 to transfer the call"); + this.playAudioCompletedTask.get(30, TimeUnit.SECONDS); + Logger.logMessage(Logger.MessageType.INFORMATION, "Audio playing done."); + } catch (TimeoutException e) { + Logger.logMessage(Logger.MessageType.INFORMATION, "No response from user in 30 sec."); + cancelMediaProcessing(); + } + } + } catch (CancellationException e) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Play audio operation cancelled"); + this.playAudioCompletedTask.complete(false); + } catch (Exception ex) { + if (playAudioCompletedTask.isCancelled()) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Play audio operation cancelled"); + } else { + Logger.logMessage(Logger.MessageType.INFORMATION, "Failure occurred while playing audio on the call. Exception: " + ex.getMessage()); + } + this.playAudioCompletedTask.complete(false); + } + } + + private void registerToPlayAudioResultEvent(String operationContext) { + // Set the callback method + NotificationCallback playPromptResponseNotification = ((callEvent) -> { + PlayAudioResultEvent playAudioResultEvent = (PlayAudioResultEvent) callEvent; + Logger.logMessage(Logger.MessageType.INFORMATION, "Play audio status -- > " + playAudioResultEvent.getStatus()); + + if (playAudioResultEvent.getStatus().equals(CallingOperationStatus.COMPLETED)) { + EventDispatcher.getInstance().unsubscribe(CallingServerEventType.PLAY_AUDIO_RESULT_EVENT.toString(), + operationContext); + this.playAudioCompletedTask.complete(true); + } else if (playAudioResultEvent.getStatus().equals(CallingOperationStatus.FAILED)) { + this.playAudioCompletedTask.complete(false); + } + }); + + // Subscribe to event + EventDispatcher.getInstance().subscribe(CallingServerEventType.PLAY_AUDIO_RESULT_EVENT.toString(), + operationContext, playPromptResponseNotification); + } + + private void hangup() { + if (reportCancellationToken.isCancellationRequested()) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Cancellation request, Hangup will not be performed"); + return; + } + + Logger.logMessage(Logger.MessageType.INFORMATION, "Performing Hangup operation"); + Response response = this.callConnection.hangupWithResponse(null); + Logger.logMessage(Logger.MessageType.INFORMATION, "hangupWithResponse -- > " + getResponse(response)); + } + + private void cancelMediaProcessing() { + if (reportCancellationToken.isCancellationRequested()) { + Logger.logMessage(Logger.MessageType.INFORMATION,"Cancellation request, CancelMediaProcessing will not be performed"); + return; + } + + Logger.logMessage(Logger.MessageType.INFORMATION, "Performing cancel media processing operation to stop playing audio"); + + Response cancelMediaResponse = this.callConnection.cancelAllMediaOperationsWithResponse(null); + + Logger.logMessage(Logger.MessageType.INFORMATION, "cancelAllMediaOperationsWithResponse -- > " + getResponse(cancelMediaResponse)); + } + + private void registerToDtmfResultEvent(String callLegId) { + NotificationCallback dtmfReceivedEvent = ((callEvent) -> { + ToneReceivedEvent toneReceivedEvent = (ToneReceivedEvent) callEvent; + ToneInfo toneInfo = toneReceivedEvent.getToneInfo(); + Logger.logMessage(Logger.MessageType.INFORMATION, "Tone received -- > : " + toneInfo.getTone()); + + if (toneInfo.getTone().equals(ToneValue.TONE1)) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Transferring call to participant" + this.targetParticipant); + Boolean transferToParticipantCompleted = transferToParticipant(this.targetParticipant); + if (!transferToParticipantCompleted) { + retryTransferToParticipant(this.targetParticipant); + } + this.toneReceivedCompleteTask.complete(true); + + this.playAudioCompletedTask.complete(true); + + } else { + this.toneReceivedCompleteTask.complete(false); + } + + EventDispatcher.getInstance().unsubscribe(CallingServerEventType.TONE_RECEIVED_EVENT.toString(), callLegId); + }); + // Subscribe to event + EventDispatcher.getInstance().subscribe(CallingServerEventType.TONE_RECEIVED_EVENT.toString(), callLegId, + dtmfReceivedEvent); + } + + private boolean transferToParticipant(String targetParticipant) { + CommunicationIdentifier identifier = getCommunicationIdentifier(targetParticipant); + + if(identifier == null) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Unknown identity provided. Enter valid phone number or communication user id"); + return true; + } + + String operationContext = UUID.randomUUID().toString(); + RegisterToTransferParticipantsResultEvent(operationContext); + Response response = this.callConnection.transferToParticipantWithResponse(identifier, null, null, operationContext, null); + Logger.logMessage(Logger.MessageType.INFORMATION, "Transfer to participant response -- > " + getResponse(response)); + + Boolean transferToParticipantCompleted = false; + try { + transferToParticipantCompleted = this.transferToParticipantCompleteTask.get(); + } catch (Exception ex) { + Logger.logMessage(Logger.MessageType.ERROR, "Failed to add participant InterruptedException -- > " + ex.getMessage()); + } + return transferToParticipantCompleted; + } + + private void RegisterToTransferParticipantsResultEvent(String operationContext) { + NotificationCallback transferParticipantsResultEvent = ((callEvent) -> { + TransferCallResultEvent transferCallResultEvent = (TransferCallResultEvent) callEvent; + CallingOperationStatus operationStatus = transferCallResultEvent.getStatus(); + Logger.logMessage(Logger.MessageType.INFORMATION, "transfer to participant status -- > " + operationStatus); + if (operationStatus.equals(CallingOperationStatus.COMPLETED)) { + this.transferToParticipantCompleteTask.complete(true); + } else if (operationStatus.equals(CallingOperationStatus.FAILED)) { + this.transferToParticipantCompleteTask.complete(false); + } + EventDispatcher.getInstance().unsubscribe(CallingServerEventType.TRANSFER_CALL_RESULT_EVENT.toString(), + operationContext); + }); + + // Subscribe to event + EventDispatcher.getInstance().subscribe(CallingServerEventType.TRANSFER_CALL_RESULT_EVENT.toString(), + operationContext, transferParticipantsResultEvent); + } + + private void retryTransferToParticipant(String targetParticipant) { + int retryAttemptCount = 1; + while (retryAttemptCount <= this.MaxRetryAttemptCount) { + Logger.logMessage(Logger.MessageType.INFORMATION, "Retrying transfer participant attempt -- > " + retryAttemptCount + " is in progress"); + Boolean transferToParticipantCompleted = transferToParticipant(targetParticipant); + + if (transferToParticipantCompleted) { + return; + } else { + Logger.logMessage(Logger.MessageType.INFORMATION, "Retry transfer participant attempt -- > " + retryAttemptCount + " has failed"); + retryAttemptCount++; + } + } + } + + private CommunicationIdentifier getCommunicationIdentifier(String targetParticipant) { + if(Pattern.matches(userIdentityRegex, targetParticipant)) { + return new CommunicationUserIdentifier(targetParticipant); + } else if (Pattern.matches(phoneIdentityRegex, targetParticipant)) { + return new PhoneNumberIdentifier(targetParticipant); + } else { + return null; + } + } +} diff --git a/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ResponseHandler.java b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ResponseHandler.java new file mode 100644 index 00000000..23428863 --- /dev/null +++ b/incomingcallsample/src/main/java/com/communication/incomingcallsample/utils/ResponseHandler.java @@ -0,0 +1,18 @@ +package com.communication.incomingcallsample.utils; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.HashMap; +import java.util.Map; + +public class ResponseHandler { + public static ResponseEntity generateResponse(String message, HttpStatus status, Object responseObj) { + Map map = new HashMap(); + map.put("message", message); + map.put("status", status.value()); + map.put("data", responseObj); + + return new ResponseEntity(map,status); + } +}