diff --git a/.gitignore b/.gitignore index c9f8a31..8cc84f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea +.vscode **/target \ No newline at end of file diff --git a/tutorials/compile.sh b/tutorials/compile.sh index c1e6228..cdeab37 100755 --- a/tutorials/compile.sh +++ b/tutorials/compile.sh @@ -1,3 +1,4 @@ #!/bin/sh (cd sms/auto-subscribe-app && mvn clean package) +(cd voice/capture-leads-app && mvn clean package) diff --git a/tutorials/voice/capture-leads-app/README.md b/tutorials/voice/capture-leads-app/README.md new file mode 100644 index 0000000..fbac3b6 --- /dev/null +++ b/tutorials/voice/capture-leads-app/README.md @@ -0,0 +1,80 @@ +# Qualify leads application sample + +This directory contains samples related to Java SDK tutorials: [qualify leads](https://developers.sinch.com/docs/voice/tutorials/qualify-leads/java) + +## DISCLAIMER + +This tutorial is based on mixing a command-line function with a server-side backend service. + +It is not a correct use of the CLI outside of an educational purpose. + +## Requirements + +- JDK 21 or later +- [Maven](https://maven.apache.org/) +- [ngrok](https://ngrok.com/docs) +- [Sinch account](https://dashboard.sinch.com) + +## Usage + +### Configure application settings + +Application settings are using the SpringBoot configuration file: [`application.yaml`](src/main/resources/application.yaml) file and enable to configure: + +#### Required Sinch credentials + +Located in `credentials` section (*you can find all of the credentials you need on your [Sinch dashboard](https://dashboard.sinch.com)*): + +- `application-api-key`: YOUR_application_key +- `application-api-secret`: YOUR_application_secret + +#### Other required values + +This tutorial uses other values that you should also assign: + +- `sinch-number`: This is the Sinch number assigned to your [Voice app](https://dashboard.sinch.com/voice/apps). +- `sip-address`: If you are performing this tutorial with a SIP infrastructure, this is where you would enter your SIP address. + +#### Server port + +Located in `server` section: + +- port: The port to be used to listen to incoming requests. Default: 8090 + +### Starting server locally + +Compile and run the application as server locally. + +```bash +mvn spring-boot:run +``` + +### Use ngrok to forward request to local server + +Forwarding request to same `8090` port used above: + +*Note: The `8090` value is coming from default config and can be changed (see [Server port](#Server-port) configuration section)* + +```bash +ngrok http 8090 +``` + +ngrok output will contains output like: + +```shell +ngrok (Ctrl+C to quit) + +... +Forwarding https://0e64-78-117-86-140.ngrok-free.app -> http://localhost:8090 + +``` + +The line + +```shell +Forwarding https://0e64-78-117-86-140.ngrok-free.app -> http://localhost:8090 +``` + +Contains `https://0e64-78-117-86-140.ngrok-free.app` value. + +This value must be used to configure the callback URL on your [Sinch dashboard](https://dashboard.sinch.com/voice/apps) \ No newline at end of file diff --git a/tutorials/voice/capture-leads-app/pom.xml b/tutorials/voice/capture-leads-app/pom.xml new file mode 100644 index 0000000..0abdbbb --- /dev/null +++ b/tutorials/voice/capture-leads-app/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + my.company.com + sinch-sdk-java-tuturial-auto-subscribe + 0.0.1-SNAPSHOT + Sinch Java SDK Capture Leads Sample Application + Demo Project for Capturing Leads + + + [1.2.0,) + 21 + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-web + + + + com.sinch.sdk + sinch-sdk-java + ${sinch.sdk.java.version} + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java new file mode 100644 index 0000000..03b399f --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Application.java @@ -0,0 +1,12 @@ +package com.mycompany.app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java new file mode 100644 index 0000000..0401753 --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/Config.java @@ -0,0 +1,29 @@ +package com.mycompany.app; + +import com.sinch.sdk.SinchClient; +import com.sinch.sdk.domains.voice.VoiceService; +import com.sinch.sdk.models.Configuration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; + +@org.springframework.context.annotation.Configuration +public class Config { + + @Value("${credentials.application-api-key}") + String applicationKey; + + @Value("${credentials.application-api-secret}") + String applicationSecret; + + @Bean + public VoiceService voiceService() { + + var configuration = + Configuration.builder() + .setApplicationKey(applicationKey) + .setApplicationSecret(applicationSecret) + .build(); + + return new SinchClient(configuration).voice(); + } +} diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java new file mode 100644 index 0000000..cab8bbd --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/CLIHelper.java @@ -0,0 +1,94 @@ +package com.mycompany.app.voice; + +import com.sinch.sdk.core.utils.StringUtil; +import com.sinch.sdk.domains.voice.CalloutsService; +import com.sinch.sdk.domains.voice.VoiceService; +import com.sinch.sdk.domains.voice.models.requests.CalloutRequestParametersCustom; +import com.sinch.sdk.domains.voice.models.svaml.ActionConnectPstn; +import com.sinch.sdk.domains.voice.models.svaml.AnsweringMachineDetection; +import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; +import com.sinch.sdk.models.E164PhoneNumber; +import java.util.Scanner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +public class CLIHelper implements CommandLineRunner { + + @Value("${sinch-number}") + String sinchNumber; + + private final CalloutsService calloutsService; + + @Autowired + public CLIHelper(VoiceService voiceService) { + this.calloutsService = voiceService.callouts(); + } + + @Override + public void run(String... args) { + + while (true) { + E164PhoneNumber phoneNumber = promptPhoneNumber(); + + proceedCallout(phoneNumber); + } + } + + void proceedCallout(E164PhoneNumber phoneNumber) { + var response = + calloutsService.custom( + CalloutRequestParametersCustom.builder() + .setIce( + SVAMLControl.builder() + .setAction( + ActionConnectPstn.builder() + .setNumber(phoneNumber) + .setCli(sinchNumber) + .setAnsweringMachineDetection( + AnsweringMachineDetection.builder().setEnabled(true).build()) + .build()) + .build()) + .build()); + + echo("Callout response: '%s'", response); + } + + private E164PhoneNumber promptPhoneNumber() { + String input; + boolean valid; + do { + input = prompt("\nEnter the phone number you want to call"); + valid = E164PhoneNumber.validate(input); + if (!valid) { + echo("Invalid number '%s'", input); + } + } while (!valid); + + return E164PhoneNumber.valueOf(input); + } + + private String prompt(String prompt) { + + String input = null; + Scanner scanner = new Scanner(System.in); + + while (StringUtil.isEmpty(input)) { + System.out.println(prompt + " ([Q] to quit): "); + input = scanner.nextLine(); + } + + if ("Q".equalsIgnoreCase(input)) { + System.out.println("Quit application"); + System.exit(0); + } + + return input.replaceAll(" ", ""); + } + + private void echo(String text, Object... args) { + System.out.println(" " + String.format(text, args)); + } +} diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java new file mode 100644 index 0000000..901c920 --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/Controller.java @@ -0,0 +1,81 @@ +package com.mycompany.app.voice; + +import com.sinch.sdk.domains.voice.VoiceService; +import com.sinch.sdk.domains.voice.WebHooksService; +import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; +import com.sinch.sdk.domains.voice.models.webhooks.AnsweredCallEvent; +import com.sinch.sdk.domains.voice.models.webhooks.PromptInputEvent; +import java.util.Map; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController("Voice") +public class Controller { + + private final WebHooksService webhooks; + private final ServerBusinessLogic webhooksBusinessLogic; + + @Autowired + public Controller(VoiceService voiceService, ServerBusinessLogic webhooksBusinessLogic) { + this.webhooks = voiceService.webhooks(); + this.webhooksBusinessLogic = webhooksBusinessLogic; + } + + @PostMapping( + value = "/VoiceEvent", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity VoiceEvent( + @RequestHeader Map headers, @RequestBody String body) { + + validateRequest(headers, body); + + // decode the request payload + var event = webhooks.unserializeWebhooksEvent(body); + + Optional response = Optional.empty(); + + // let business layer process the request + if (event instanceof AnsweredCallEvent e) { + response = Optional.of(webhooksBusinessLogic.answeredCallEvent(e)); + } + if (event instanceof PromptInputEvent e) { + response = Optional.of(webhooksBusinessLogic.promptInputEvent(e)); + } + + if (response.isEmpty()) { + return ResponseEntity.ok().body(""); + } + + String serializedResponse = webhooks.serializeWebhooksResponse(response.get()); + + return ResponseEntity.ok().body(serializedResponse); + } + + void validateRequest(Map headers, String body) { + + var validAuth = + webhooks.validateAuthenticatedRequest( + // The HTTP verb this controller is managing + "POST", + // The URI this controller is managing + "/VoiceEvent", + // request headers + headers, + // request payload body + body); + + // token validation failed + if (!validAuth) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java new file mode 100644 index 0000000..1771e14 --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/java/com/mycompany/app/voice/ServerBusinessLogic.java @@ -0,0 +1,167 @@ +package com.mycompany.app.voice; + +import com.sinch.sdk.domains.voice.models.DestinationSip; +import com.sinch.sdk.domains.voice.models.TransportType; +import com.sinch.sdk.domains.voice.models.svaml.ActionConnectSip; +import com.sinch.sdk.domains.voice.models.svaml.ActionHangUp; +import com.sinch.sdk.domains.voice.models.svaml.ActionRunMenu; +import com.sinch.sdk.domains.voice.models.svaml.InstructionSay; +import com.sinch.sdk.domains.voice.models.svaml.Menu; +import com.sinch.sdk.domains.voice.models.svaml.MenuOption; +import com.sinch.sdk.domains.voice.models.svaml.MenuOptionAction; +import com.sinch.sdk.domains.voice.models.svaml.MenuOptionActionType; +import com.sinch.sdk.domains.voice.models.svaml.SVAMLControl; +import com.sinch.sdk.domains.voice.models.webhooks.AmdAnswerStatusType; +import com.sinch.sdk.domains.voice.models.webhooks.AnsweredCallEvent; +import com.sinch.sdk.domains.voice.models.webhooks.PromptInputEvent; +import com.sinch.sdk.models.DualToneMultiFrequency; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component("VoiceServerBusinessLogic") +public class ServerBusinessLogic { + + private final String SIP_MENU = "sip"; + + private final String NON_SIP_MENU = "non-sip"; + + @Value("${sinch-number}") + String sinchNumber; + + @Value("${sip-address}") + String sipAddress; + + public SVAMLControl answeredCallEvent(AnsweredCallEvent event) { + + var amdResult = event.getAmd(); + + if (amdResult.getStatus() == AmdAnswerStatusType.MACHINE) { + return machineResponse(); + } + if (amdResult.getStatus() == AmdAnswerStatusType.HUMAN) { + return humanResponse(); + } + throw new IllegalStateException("Unexpected value: " + event); + } + + public SVAMLControl promptInputEvent(PromptInputEvent event) { + var menuResult = event.getMenuResult(); + + if (SIP_MENU.equals(menuResult.getValue())) { + return sipResponse(); + } + if (NON_SIP_MENU.equals(menuResult.getValue())) { + return nonSipResponse(); + } + return defaultResponse(); + } + + private SVAMLControl sipResponse() { + + String instruction = + "Thanks for agreeing to speak to one of our sales reps! We'll now connect your call."; + + return SVAMLControl.builder() + .setAction( + ActionConnectSip.builder() + .setDestination(DestinationSip.valueOf(sipAddress)) + .setCli(sinchNumber) + .setTransport(TransportType.TLS) + .build()) + .setInstructions( + Collections.singletonList(InstructionSay.builder().setText(instruction).build())) + .build(); + } + + private SVAMLControl nonSipResponse() { + + String instruction = + "Thank you for choosing to speak to one of our sales reps! If this were in production, at" + + " this point you would be connected to a sales rep on your sip network. Since you do" + + " not, you have now completed this tutorial. We hope you had fun and learned" + + " something new. Be sure to keep visiting https://developers.sinch.com for more great" + + " tutorials."; + + return SVAMLControl.builder() + .setAction(ActionHangUp.builder().build()) + .setInstructions( + Collections.singletonList(InstructionSay.builder().setText(instruction).build())) + .build(); + } + + private SVAMLControl defaultResponse() { + + String instruction = "Thank you for trying our tutorial! This call will now end."; + + return SVAMLControl.builder() + .setAction(ActionHangUp.builder().build()) + .setInstructions( + Collections.singletonList(InstructionSay.builder().setText(instruction).build())) + .build(); + } + + private SVAMLControl humanResponse() { + + String SIP_MENU_OPTION = "1"; + String NON_SIP_MENU_OPTION = "2"; + + String mainPrompt = + String.format( + "#tts[Hi, you awesome person! Press '%s' if you have performed this tutorial using a" + + " sip infrastructure. Press '%s' if you have not used a sip infrastructure. Press" + + " any other digit to end this call.]", + SIP_MENU_OPTION, NON_SIP_MENU_OPTION); + + String repeatPrompt = + String.format( + "#tts[Again, simply press '%s' if you have used sip, press '%s' if you have not, or" + + " press any other digit to end this call.]", + SIP_MENU_OPTION, NON_SIP_MENU_OPTION); + + MenuOption option1 = + MenuOption.builder() + .setDtfm(DualToneMultiFrequency.valueOf(SIP_MENU_OPTION)) + .setAction(MenuOptionAction.from(MenuOptionActionType.RETURN, SIP_MENU)) + .build(); + + MenuOption option2 = + MenuOption.builder() + .setDtfm(DualToneMultiFrequency.valueOf(NON_SIP_MENU_OPTION)) + .setAction(MenuOptionAction.from(MenuOptionActionType.RETURN, NON_SIP_MENU)) + .build(); + + Collection options = Arrays.asList(option1, option2); + + return SVAMLControl.builder() + .setAction( + ActionRunMenu.builder() + .setBarge(false) + .setMenus( + Collections.singletonList( + Menu.builder() + .setId("main") + .setMainPrompt(mainPrompt) + .setRepeatPrompt(repeatPrompt) + .setRepeats(2) + .setOptions(options) + .build())) + .build()) + .build(); + } + + private SVAMLControl machineResponse() { + + String instruction = + "Hi there! We tried to reach you to speak with you about our awesome products. We will try" + + " again later. Bye!"; + + return SVAMLControl.builder() + .setAction(ActionHangUp.builder().build()) + .setInstructions( + Collections.singletonList(InstructionSay.builder().setText(instruction).build())) + .build(); + } +} diff --git a/tutorials/voice/capture-leads-app/src/main/resources/application.yaml b/tutorials/voice/capture-leads-app/src/main/resources/application.yaml new file mode 100644 index 0000000..54512e2 --- /dev/null +++ b/tutorials/voice/capture-leads-app/src/main/resources/application.yaml @@ -0,0 +1,16 @@ +# springboot related config file + +logging: + level: + com: INFO + +server: + port: 8090 + +credentials: + application-api-key: + application-api-secret: + +# see README.md, "Required values" section for details about these settings +sinch-number: +sip-address: