From b11023297ff16e5fa53e1a1b4b4dac6bb9245044 Mon Sep 17 00:00:00 2001 From: Sina Madani Date: Wed, 16 Oct 2024 19:05:41 +0100 Subject: [PATCH] feat: DTMF listener endpoints --- .../client/voice/AddDtmfListenerRequest.java | 33 ++++++ .../com/vonage/client/voice/VoiceClient.java | 56 ++++++++-- .../vonage/client/voice/VoiceClientTest.java | 103 ++++++++++++++++-- .../client/voice/ncco/InputActionTest.java | 7 ++ 4 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/vonage/client/voice/AddDtmfListenerRequest.java diff --git a/src/main/java/com/vonage/client/voice/AddDtmfListenerRequest.java b/src/main/java/com/vonage/client/voice/AddDtmfListenerRequest.java new file mode 100644 index 000000000..e9f0f3597 --- /dev/null +++ b/src/main/java/com/vonage/client/voice/AddDtmfListenerRequest.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Vonage + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.vonage.client.voice; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.vonage.client.JsonableBaseObject; +import java.net.URI; +import java.util.Collection; +import java.util.Collections; + +class AddDtmfListenerRequest extends JsonableBaseObject { + @JsonIgnore final String uuid; + @JsonProperty("eventUrl") final Collection eventUrl; + + public AddDtmfListenerRequest(String uuid, URI eventUrl) { + this.uuid = uuid; + this.eventUrl = Collections.singletonList(eventUrl); + } +} diff --git a/src/main/java/com/vonage/client/voice/VoiceClient.java b/src/main/java/com/vonage/client/voice/VoiceClient.java index a6f24eaf8..b4a8f17e4 100644 --- a/src/main/java/com/vonage/client/voice/VoiceClient.java +++ b/src/main/java/com/vonage/client/voice/VoiceClient.java @@ -18,6 +18,7 @@ import com.vonage.client.*; import com.vonage.client.auth.JWTAuthMethod; import com.vonage.client.common.HttpMethod; +import com.vonage.client.voice.ncco.InputMode; import com.vonage.client.voice.ncco.Ncco; import com.vonage.jwt.Jwt; import java.io.IOException; @@ -41,6 +42,8 @@ public class VoiceClient { final RestEndpoint startTalk; final RestEndpoint stopTalk; final RestEndpoint sendDtmf; + final RestEndpoint addDtmfListener; + final RestEndpoint removeDtmfListener; final RestEndpoint downloadRecording; /** @@ -58,15 +61,10 @@ class Endpoint extends DynamicEndpoint { .requestMethod(method).wrapper(wrapper).pathGetter((de, req) -> { String base = de.getHttpWrapper().getHttpConfig().getVersionedApiBaseUri("v1"); String path = pathGetter.apply(req); - if (path.isEmpty()) { - return base + "/calls"; - } - else if (path.startsWith("http") && method == HttpMethod.GET) { + if (path.startsWith("http") && method == HttpMethod.GET) { return path; } - else { - return base + "/calls/" + pathGetter.apply(req); - } + return base + "/calls" + (path.isEmpty() ? "" : "/" + path); }) ); } @@ -81,6 +79,8 @@ else if (path.startsWith("http") && method == HttpMethod.GET) { startTalk = new Endpoint<>(req -> req.uuid + "/talk", HttpMethod.PUT); stopTalk = new Endpoint<>(uuid -> uuid + "/talk", HttpMethod.DELETE); sendDtmf = new Endpoint<>(req -> req.uuid + "/dtmf", HttpMethod.PUT); + addDtmfListener = new Endpoint<>(req -> req.uuid + "/input/dtmf", HttpMethod.PUT); + removeDtmfListener = new Endpoint<>(uuid -> uuid + "/input/dtmf", HttpMethod.DELETE); downloadRecording = new Endpoint<>(Function.identity(), HttpMethod.GET); } @@ -425,8 +425,8 @@ public TalkResponse startTalk(String uuid, String text, int loop) throws VonageR /** * Send a synthesized speech message to an ongoing call. * - * @param uuid The UUID of the call, obtained from the object returned by {@link #createCall(Call)}. This value - * can be obtained with {@link CallEvent#getUuid()}. + * @param uuid The UUID of the call, obtained from the object returned by {@link #createCall(Call)}. + * This value can be obtained with {@link CallEvent#getUuid()}. * @param text The message to be spoken to the call participants. * @param language The language to use for the text-to-speech. * @param style The language style to use for the text-to-speech. @@ -481,6 +481,44 @@ public TalkResponse stopTalk(String uuid) throws VonageResponseParseException, V return stopTalk.execute(validateUuid(uuid)); } + /** + * Add a listener for asynchronous DTMF events sent by a caller to an + * {@linkplain com.vonage.client.voice.ncco.InputAction} NCCO action, when the + * {@linkplain com.vonage.client.voice.ncco.InputAction.Builder#mode(InputMode)} is + * {@link com.vonage.client.voice.ncco.InputMode#ASYNCHRONOUS}. + * + * @param uuid The UUID of the call, obtained from the object returned by {@link #createCall(Call)}. + * This value can be obtained with {@link CallEvent#getUuid()}. + * + * @param eventUrl The URL to send asynchronous DTMF user input events to. + * + * @throws VoiceResponseException If the call does not exist or the listener could not be added, + * for example if the call's state or input mode are incompatible. + * + * @since 8.12.0 + */ + public void addDtmfListener(String uuid, String eventUrl) throws VoiceResponseException { + addDtmfListener.execute(new AddDtmfListenerRequest(validateUuid(uuid), URI.create(validateUrl(eventUrl)))); + } + + /** + * Remove the listener for asynchronous DTMF events sent by a caller to an + * {@linkplain com.vonage.client.voice.ncco.InputAction} NCCO, when the + * {@linkplain com.vonage.client.voice.ncco.InputAction.Builder#mode(InputMode)} is + * {@link com.vonage.client.voice.ncco.InputMode#ASYNCHRONOUS}. Calling this method + * stops sending DTMF events to the event URL set in {@link #addDtmfListener(String, String)}. + * + * @param uuid The UUID of the call, obtained from the object returned by {@link #createCall(Call)}. + * This value can be obtained with {@link CallEvent#getUuid()}. + * + * @throws VoiceResponseException If the call does not exist or have a listener attached. + * + * @since 8.12.0 + */ + public void removeDtmfListener(String uuid) throws VoiceResponseException { + removeDtmfListener.execute(validateUuid(uuid)); + } + /** * Download a recording. * diff --git a/src/test/java/com/vonage/client/voice/VoiceClientTest.java b/src/test/java/com/vonage/client/voice/VoiceClientTest.java index a20be054d..214d6f092 100644 --- a/src/test/java/com/vonage/client/voice/VoiceClientTest.java +++ b/src/test/java/com/vonage/client/voice/VoiceClientTest.java @@ -18,12 +18,14 @@ import com.vonage.client.AbstractClientTest; import com.vonage.client.RestEndpoint; import com.vonage.client.TestUtils; +import static com.vonage.client.TestUtils.testJsonableBaseObject; import com.vonage.client.common.HttpMethod; import com.vonage.client.voice.ncco.Ncco; import com.vonage.client.voice.ncco.TalkAction; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.function.Executable; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; @@ -58,13 +60,12 @@ public void testVerifySignature() { @Test public void testCreateCall() throws Exception { - stubResponse(200, - "{\n" + " \"conversation_uuid\": \"63f61863-4a51-4f6b-86e1-46edebio0391\",\n" - + " \"uuid\": \"" + SAMPLE_CALL_ID + "\",\n" - + " \"status\": \"started\",\n" + " \"direction\": \"outbound\"\n" + "}" - ); + String expectedJson = "{\n" + " \"conversation_uuid\": \"63f61863-4a51-4f6b-86e1-46edebio0391\",\n" + + " \"uuid\": \"" + SAMPLE_CALL_ID + "\",\n" + + " \"status\": \"started\",\n" + " \"direction\": \"outbound\"\n" + "}"; + stubResponse(200, expectedJson); CallEvent evt = client.createCall(new Call("447700900903", "447700900904", "http://api.example.com/answer")); - TestUtils.testJsonableBaseObject(evt); + assertEquals(CallEvent.fromJson(expectedJson), evt); assertEquals("63f61863-4a51-4f6b-86e1-46edebio0391", evt.getConversationUuid()); assertEquals(SAMPLE_CALL_ID, evt.getUuid()); assertEquals(CallDirection.OUTBOUND, evt.getDirection()); @@ -82,7 +83,7 @@ public void testListCallsNoFilter() throws Exception { + " }\n" + "}\n" ); CallInfoPage page = client.listCalls(); - TestUtils.testJsonableBaseObject(page); + testJsonableBaseObject(page); assertEquals(0, page.getCount()); assert401Response(client::listCalls); } @@ -119,7 +120,7 @@ public void testGetCallDetails() throws Exception { + " }\n" + " }\n" ); CallInfo call = client.getCallDetails("93137ee3-580e-45f7-a61a-e0b5716000ef"); - TestUtils.testJsonableBaseObject(call, true); + testJsonableBaseObject(call, true); assertEquals("93137ee3-580e-45f7-a61a-e0b5716000ef", call.getUuid()); assert401Response(() -> client.getCallDetails(SAMPLE_CALL_ID)); } @@ -132,7 +133,7 @@ public void testSendDtmf() throws Exception { ); DtmfResponse response = client.sendDtmf("944dd293-ca13-4a58-bc37-6252e11474be", "332393"); - TestUtils.testJsonableBaseObject(response); + testJsonableBaseObject(response); assertEquals("944dd293-ca13-4a58-bc37-6252e11474be", response.getUuid()); assertEquals("DTMF sent", response.getMessage()); assertThrows(IllegalArgumentException.class, () -> @@ -244,7 +245,7 @@ public void testStopStream() throws Exception { ); StreamResponse response = client.stopStream("944dd293-ca13-4a58-bc37-6252e11474be"); - TestUtils.testJsonableBaseObject(response); + testJsonableBaseObject(response); assertEquals("Stream stopped", response.getMessage()); assertEquals("944dd293-ca13-4a58-bc37-6252e11474be", response.getUuid()); assert401Response(() -> client.stopStream(SAMPLE_CALL_ID)); @@ -262,7 +263,7 @@ public void testStartTalkAllParamsModern() throws Exception { .style(1).level(-0.5).loop(3).premium(false) .language(TextToSpeechLanguage.FRENCH).build() ); - TestUtils.testJsonableBaseObject(response); + testJsonableBaseObject(response); assertEquals("Talk started", response.getMessage()); assertEquals("944dd293-ca13-4a58-bc37-6252e11474be", response.getUuid()); assertThrows(NullPointerException.class, () -> @@ -384,6 +385,24 @@ public void testStopTalk() throws Exception { assert401Response(() -> client.stopTalk(SAMPLE_CALL_ID)); } + @Test + public void testAddDtmfListener() throws Exception { + stubResponse(200); + var url = "https://example.app/webhooks/answer"; + client.addDtmfListener(SAMPLE_CALL_ID, url); + assertThrows(NullPointerException.class, () -> client.addDtmfListener(null, url)); + assertThrows(IllegalArgumentException.class, () -> client.addDtmfListener(SAMPLE_CALL_ID, null)); + assert401Response(() -> client.addDtmfListener(SAMPLE_CALL_ID, url)); + } + + @Test + public void removeDtmfListener() throws Exception { + stubResponse(204); + client.removeDtmfListener(SAMPLE_CALL_ID); + assertThrows(NullPointerException.class, () -> client.removeDtmfListener(null)); + assert401Response(() -> client.removeDtmfListener(SAMPLE_CALL_ID)); + } + @Test public void testDownloadRecording() throws Exception { String recordingId = UUID.randomUUID().toString(); @@ -426,6 +445,68 @@ public void testDownloadRecording() throws Exception { // ENDPOINT TESTS + @Test + public void testAddDtmfListenerEndpoint() throws Exception { + new VoiceEndpointTestSpec() { + private final String uuid = SAMPLE_CALL_ID; + private final String url = "https://example.org/webhooks/answer"; + + @Override + protected RestEndpoint endpoint() { + return client.addDtmfListener; + } + + @Override + protected HttpMethod expectedHttpMethod() { + return HttpMethod.PUT; + } + + @Override + protected String expectedEndpointUri(AddDtmfListenerRequest request) { + return "/v1/calls/" + request.uuid + "/input/dtmf"; + } + + @Override + protected AddDtmfListenerRequest sampleRequest() { + return new AddDtmfListenerRequest(uuid, URI.create(url)); + } + + @Override + protected String sampleRequestBodyString() { + return "{\"eventUrl\":[\""+url+"\"]}"; + } + } + .runTests(); + } + + @Test + public void testRemoveDtmfListenerEndpoint() throws Exception { + new VoiceEndpointTestSpec() { + private final String uuid = SAMPLE_CALL_ID; + + @Override + protected RestEndpoint endpoint() { + return client.removeDtmfListener; + } + + @Override + protected HttpMethod expectedHttpMethod() { + return HttpMethod.DELETE; + } + + @Override + protected String expectedEndpointUri(String request) { + return "/v1/calls/" + request + "/input/dtmf"; + } + + @Override + protected String sampleRequest() { + return uuid; + } + } + .runTests(); + } + @Test public void testDownloadRecordingEndpoint() throws Exception { new VoiceEndpointTestSpec() { diff --git a/src/test/java/com/vonage/client/voice/ncco/InputActionTest.java b/src/test/java/com/vonage/client/voice/ncco/InputActionTest.java index e12e4ff4c..49e2e685e 100644 --- a/src/test/java/com/vonage/client/voice/ncco/InputActionTest.java +++ b/src/test/java/com/vonage/client/voice/ncco/InputActionTest.java @@ -229,4 +229,11 @@ public void testEventMethodField() { String expectedJson = "[{\"type\":[\"dtmf\"],\"eventMethod\":\"POST\",\"action\":\"input\"}]"; assertEquals(expectedJson, new Ncco(input).toJson()); } + + @Test + public void testInputModeDeserialization() { + assertEquals(InputMode.SYNCHRONOUS, InputMode.fromString("synchronous")); + assertEquals(InputMode.ASYNCHRONOUS, InputMode.fromString("asynchronous")); + assertNull(InputMode.fromString("invalid")); + } }