From e401a07bf96fed9e79822640b62ed1096af1f649 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 16 Oct 2026 10:24:38 +0200 Subject: [PATCH 01/17] DEVEXP-1473: Add Voice V2 Destination helper --- CHANGELOG.md | 6 + .../voice/models/v2/helpers/Destination.java | 172 ++++++++++++++ .../voice/models/v2/helpers/package-info.java | 6 + .../models/v2/helpers/DestinationTest.java | 211 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 064399fdc..c128f9f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ All notable changes to the **Sinch Java SDK** are documented in this file. > - `[design]` — API design change > - `[tech]` — technical improvement +--- +## v2.4.0 - Unreleased + +### Voice +- **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `originOf`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`) + --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java new file mode 100644 index 000000000..2a3a33bd7 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java @@ -0,0 +1,172 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; +import com.sinch.sdk.domains.voice.models.v2.destination.Phone; +import com.sinch.sdk.domains.voice.models.v2.destination.Sip; +import com.sinch.sdk.domains.voice.models.v2.destination.SipFrom; +import com.sinch.sdk.domains.voice.models.v2.destination.Stream; +import com.sinch.sdk.domains.voice.models.v2.destination.VoiceRelay; +import java.util.Objects; + +/** + * Factory helpers for the to and from values of a Voice V2 call + * + *

to takes a {@link CallDestination} and from a {@link CallOrigin}. + * + *

Each factory only sets the required fields. Use the model builders ({@link Sip#builder()}, + * {@link SipFrom#builder()}, {@link Stream#builder()}, {@link VoiceRelay#builder()}) to set the + * optional ones. + * + * @since 2.4 + */ +public final class Destination { + + static final String PHONE_PREFIX = "phone:"; + static final String STREAM_PREFIX = "stream:"; + static final String SIP_PREFIX = "sip:"; + static final String SIPS_PREFIX = "sips:"; + + private Destination() {} + + /** + * Create a call destination from a string, detecting its type from the prefix + * + *

+ * + *

A voice relay destination is not built here: it requires a TTS voice and an STT language, + * see {@link #voiceRelay(String, String, String)}. + * + * @param destination Destination, optionally prefixed with its type + * @return The phone, SIP or stream destination + * @see #originOf(String) + * @since 2.4 + */ + public static CallDestination of(String destination) { + Objects.requireNonNull(destination, "destination must not be null"); + if (isSip(destination)) { + return sip(destination); + } + if (destination.startsWith(STREAM_PREFIX)) { + return stream(destination.substring(STREAM_PREFIX.length())); + } + return phone(withoutPhonePrefix(destination)); + } + + /** + * Create a call origin from a string, detecting its type from the prefix + * + *

+ * + * @param origin Origin, optionally prefixed with its type + * @return The phone or SIP origin + * @throws IllegalArgumentException if origin is a stream: endpoint: a + * stream can only be a call destination + * @see #of(String) + * @since 2.4 + */ + public static CallOrigin originOf(String origin) { + Objects.requireNonNull(origin, "origin must not be null"); + if (isSip(origin)) { + return sipFrom(origin); + } + if (origin.startsWith(STREAM_PREFIX)) { + throw new IllegalArgumentException( + "A stream endpoint can only be a call destination, not an origin: " + origin); + } + return phone(withoutPhonePrefix(origin)); + } + + /** + * Create a phone number on the PSTN, valid as both call destination and call origin + * + * @param number Phone number in E.164 format + * @return The phone destination + * @since 2.4 + */ + public static Phone phone(String number) { + Objects.requireNonNull(number, "number must not be null"); + return Phone.builder().setNumber(number).build(); + } + + /** + * Create a SIP call destination. Only valid as call destination: use {@link #sipFrom(String)} for + * a call origin + * + * @param endpoint SIP URI of the destination endpoint (sip: or sips: + * scheme) + * @return The SIP destination + * @since 2.4 + */ + public static Sip sip(String endpoint) { + Objects.requireNonNull(endpoint, "endpoint must not be null"); + return Sip.builder().setEndpoint(endpoint).build(); + } + + /** + * Create a SIP call origin. Only valid as call origin: use {@link #sip(String)} for a call + * destination + * + * @param endpoint SIP URI of the originating endpoint (sip: or sips: + * scheme) + * @return The SIP origin + * @since 2.4 + */ + public static SipFrom sipFrom(String endpoint) { + Objects.requireNonNull(endpoint, "endpoint must not be null"); + return SipFrom.builder().setEndpoint(endpoint).build(); + } + + /** + * Create a WebSocket stream call destination + * + * @param endpoint WebSocket URL (ws:// or wss://) accepting the + * connection + * @return The stream destination + * @since 2.4 + */ + public static Stream stream(String endpoint) { + Objects.requireNonNull(endpoint, "endpoint must not be null"); + return Stream.builder().setEndpoint(endpoint).build(); + } + + /** + * Create a Voice Relay call destination + * + * @param endpoint URL of the server accepting the WebSocket request + * @param ttsVoice Default voice for speech synthesis + * @param sttLanguage BCP-47 language tag used to transcribe the inbound audio + * @return The Voice Relay destination + * @since 2.4 + */ + public static VoiceRelay voiceRelay(String endpoint, String ttsVoice, String sttLanguage) { + Objects.requireNonNull(endpoint, "endpoint must not be null"); + Objects.requireNonNull(ttsVoice, "ttsVoice must not be null"); + Objects.requireNonNull(sttLanguage, "sttLanguage must not be null"); + return VoiceRelay.builder() + .setEndpoint(endpoint) + .setTtsVoice(ttsVoice) + .setSttLanguage(sttLanguage) + .build(); + } + + private static boolean isSip(String value) { + return value.startsWith(SIP_PREFIX) || value.startsWith(SIPS_PREFIX); + } + + private static String withoutPhonePrefix(String value) { + return value.startsWith(PHONE_PREFIX) ? value.substring(PHONE_PREFIX.length()) : value; + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java new file mode 100644 index 000000000..9bbeeec5c --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java @@ -0,0 +1,6 @@ +/** + * Helpers to build Voice V2 payloads + * + * @since 2.4 + */ +package com.sinch.sdk.domains.voice.models.v2.helpers; diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java new file mode 100644 index 000000000..b9548a6cf --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java @@ -0,0 +1,211 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.BaseTest; +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; +import com.sinch.sdk.domains.voice.models.v2.destination.Phone; +import com.sinch.sdk.domains.voice.models.v2.destination.Sip; +import com.sinch.sdk.domains.voice.models.v2.destination.SipFrom; +import com.sinch.sdk.domains.voice.models.v2.destination.Stream; +import com.sinch.sdk.domains.voice.models.v2.destination.VoiceRelay; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +class DestinationTest extends BaseTest { + + static final String PHONE_JSON = "{\"type\":\"PHONE\",\"phone\":{\"number\":\"+15551234567\"}}"; + static final String SIP_JSON = + "{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:user@example.com\"}}"; + static final String SIPS_JSON = + "{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sips:user@example.com\"}}"; + static final String STREAM_JSON = + "{\"type\":\"STREAM\",\"stream\":{\"endpoint\":\"wss://example.com/audio\"}}"; + + void assertJson(Object value, String expected) throws JsonProcessingException, JSONException { + JSONAssert.assertEquals(expected, objectMapper.writeValueAsString(value), true); + } + + @Test + void phone() throws JsonProcessingException, JSONException { + Phone destination = Destination.phone("+15551234567"); + + assertJson(destination, PHONE_JSON); + } + + @Test + void sip() throws JsonProcessingException, JSONException { + Sip destination = Destination.sip("sip:user@example.com"); + + assertJson(destination, SIP_JSON); + } + + @Test + void sipKeepsEndpointWithoutScheme() throws JsonProcessingException, JSONException { + Sip destination = Destination.sip("user@example.com"); + + assertJson(destination, "{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"user@example.com\"}}"); + } + + @Test + void sipFrom() throws JsonProcessingException, JSONException { + SipFrom origin = Destination.sipFrom("sip:user@example.com"); + + assertJson(origin, SIP_JSON); + } + + @Test + void stream() throws JsonProcessingException, JSONException { + Stream destination = Destination.stream("wss://example.com/audio"); + + assertJson(destination, STREAM_JSON); + } + + @Test + void voiceRelay() throws JsonProcessingException, JSONException { + VoiceRelay destination = Destination.voiceRelay("wss://example.com/agent", "Emma", "en-US"); + + assertJson( + destination, + "{\"type\":\"VOICE_RELAY\",\"voiceRelay\":{\"endpoint\":\"wss://example.com/agent\"," + + "\"ttsVoice\":\"Emma\",\"sttLanguage\":\"en-US\"}}"); + } + + @Test + void voiceRelayAcceptsVoiceOutsideCatalog() { + VoiceRelay destination = + Destination.voiceRelay("wss://example.com/agent", "FutureVoice", "en-US"); + + Assertions.assertThat(destination.getTtsVoice()).isEqualTo("FutureVoice"); + } + + @Test + void ofUntaggedIsPhone() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("+15551234567"); + + Assertions.assertThat(destination).isInstanceOf(Phone.class); + assertJson(destination, PHONE_JSON); + } + + @Test + void ofStripsPhoneTag() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("phone:+15551234567"); + + Assertions.assertThat(destination).isInstanceOf(Phone.class); + assertJson(destination, PHONE_JSON); + } + + @Test + void ofKeepsSipEndpoint() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("sip:user@example.com"); + + Assertions.assertThat(destination).isInstanceOf(Sip.class); + assertJson(destination, SIP_JSON); + } + + @Test + void ofKeepsSipsEndpoint() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("sips:user@example.com"); + + Assertions.assertThat(destination).isInstanceOf(Sip.class); + assertJson(destination, SIPS_JSON); + } + + @Test + void ofStripsStreamTag() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("stream:wss://example.com/audio"); + + Assertions.assertThat(destination).isInstanceOf(Stream.class); + assertJson(destination, STREAM_JSON); + } + + @Test + void ofKeepsUnknownPrefixAsPhoneNumber() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("tel:+15551234567"); + + Assertions.assertThat(destination).isInstanceOf(Phone.class); + assertJson(destination, "{\"type\":\"PHONE\",\"phone\":{\"number\":\"tel:+15551234567\"}}"); + } + + @Test + void ofKeepsBatchPlaceholderAsPhoneNumber() throws JsonProcessingException, JSONException { + CallDestination destination = Destination.of("@toNumber"); + + assertJson(destination, "{\"type\":\"PHONE\",\"phone\":{\"number\":\"@toNumber\"}}"); + } + + @Test + void originOfUntaggedIsPhone() throws JsonProcessingException, JSONException { + CallOrigin origin = Destination.originOf("+15551234567"); + + Assertions.assertThat(origin).isInstanceOf(Phone.class); + assertJson(origin, PHONE_JSON); + } + + @Test + void originOfStripsPhoneTag() throws JsonProcessingException, JSONException { + CallOrigin origin = Destination.originOf("phone:+15551234567"); + + Assertions.assertThat(origin).isInstanceOf(Phone.class); + assertJson(origin, PHONE_JSON); + } + + @Test + void originOfKeepsSipEndpoint() throws JsonProcessingException, JSONException { + CallOrigin origin = Destination.originOf("sip:user@example.com"); + + Assertions.assertThat(origin).isInstanceOf(SipFrom.class); + assertJson(origin, SIP_JSON); + } + + @Test + void originOfKeepsSipsEndpoint() throws JsonProcessingException, JSONException { + CallOrigin origin = Destination.originOf("sips:user@example.com"); + + Assertions.assertThat(origin).isInstanceOf(SipFrom.class); + assertJson(origin, SIPS_JSON); + } + + @Test + void originOfRejectsStream() { + Assertions.assertThatThrownBy(() -> Destination.originOf("stream:wss://example.com/audio")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stream:wss://example.com/audio"); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> Destination.of(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.originOf(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.phone(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.sip(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.sipFrom(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.stream(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.voiceRelay("wss://example.com", "Emma", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void parsedValuesAreAcceptedAsDialFromAndTo() throws JsonProcessingException, JSONException { + DialCommand dial = + DialCommand.builder() + .setFrom(Destination.originOf("sip:15550001111@sip.sinch.com")) + .setTo(Destination.of("sip:15550002222@sip.sinch.com")) + .build(); + + assertJson( + dial, + "{\"command\":\"dial\"," + + "\"from\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550001111@sip.sinch.com\"}}," + + "\"to\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550002222@sip.sinch.com\"}}}"); + } +} From 73c3d7370d204881e05be4a2a8a975806cae8124 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 16 Oct 2026 16:10:54 +0200 Subject: [PATCH 02/17] DEVEXP-1473: Add Voice V2 Destination helper refactor --- CHANGELOG.md | 2 +- .../models/v2/helpers/BaseDestination.java | 15 +++ .../voice/models/v2/helpers/Destination.java | 108 +++++++++------- .../models/v2/helpers/DestinationTest.java | 117 ++++++++---------- 4 files changed, 127 insertions(+), 115 deletions(-) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/BaseDestination.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c128f9f5d..766aba5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ## v2.4.0 - Unreleased ### Voice -- **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `originOf`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`) +- **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/BaseDestination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/BaseDestination.java new file mode 100644 index 000000000..b646f4b3c --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/BaseDestination.java @@ -0,0 +1,15 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; + +/** + * Call endpoint built by {@link Destination#of(String)} + * + *

Valid as to ({@link CallDestination}) and from ({@link CallOrigin}). + * + *

A stream endpoint is only valid as to: the API rejects it as from. + * + * @since 2.4 + */ +public interface BaseDestination extends CallOrigin, CallDestination {} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java index 2a3a33bd7..77ebda304 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java @@ -1,5 +1,6 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; +import com.fasterxml.jackson.annotation.JsonValue; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; import com.sinch.sdk.domains.voice.models.v2.destination.Phone; @@ -13,6 +14,7 @@ * Factory helpers for the to and from values of a Voice V2 call * *

to takes a {@link CallDestination} and from a {@link CallOrigin}. + * {@link #of(String)} returns a {@link BaseDestination}, accepted by both. * *

Each factory only sets the required fields. Use the model builders ({@link Sip#builder()}, * {@link SipFrom#builder()}, {@link Stream#builder()}, {@link VoiceRelay#builder()}) to set the @@ -30,63 +32,34 @@ public final class Destination { private Destination() {} /** - * Create a call destination from a string, detecting its type from the prefix + * Create a call endpoint from a string, detecting its type from the prefix * *

* - *

A voice relay destination is not built here: it requires a TTS voice and an STT language, - * see {@link #voiceRelay(String, String, String)}. + *

The result is accepted as both to and from. It only carries the + * fields found in the string: use the dedicated factories, or the model builders, to set optional + * fields. A voice relay destination is not built here: it requires a TTS voice and an STT + * language, see {@link #voiceRelay(String, String, String)}. * - * @param destination Destination, optionally prefixed with its type - * @return The phone, SIP or stream destination - * @see #originOf(String) + * @param value Endpoint, optionally prefixed with its type + * @return The phone, SIP or stream endpoint * @since 2.4 */ - public static CallDestination of(String destination) { - Objects.requireNonNull(destination, "destination must not be null"); - if (isSip(destination)) { - return sip(destination); + public static BaseDestination of(String value) { + Objects.requireNonNull(value, "value must not be null"); + if (isSip(value)) { + return new Endpoint(sip(value)); } - if (destination.startsWith(STREAM_PREFIX)) { - return stream(destination.substring(STREAM_PREFIX.length())); + if (value.startsWith(STREAM_PREFIX)) { + return new Endpoint(stream(value.substring(STREAM_PREFIX.length()))); } - return phone(withoutPhonePrefix(destination)); - } - - /** - * Create a call origin from a string, detecting its type from the prefix - * - *

- * - * @param origin Origin, optionally prefixed with its type - * @return The phone or SIP origin - * @throws IllegalArgumentException if origin is a stream: endpoint: a - * stream can only be a call destination - * @see #of(String) - * @since 2.4 - */ - public static CallOrigin originOf(String origin) { - Objects.requireNonNull(origin, "origin must not be null"); - if (isSip(origin)) { - return sipFrom(origin); - } - if (origin.startsWith(STREAM_PREFIX)) { - throw new IllegalArgumentException( - "A stream endpoint can only be a call destination, not an origin: " + origin); - } - return phone(withoutPhonePrefix(origin)); + return new Endpoint(phone(withoutPhonePrefix(value))); } /** @@ -169,4 +142,43 @@ private static boolean isSip(String value) { private static String withoutPhonePrefix(String value) { return value.startsWith(PHONE_PREFIX) ? value.substring(PHONE_PREFIX.length()) : value; } + + /** + * {@link BaseDestination} around a generated destination model. Serialized as the wrapped model, + * so the JSON is the one of the {@link Phone}, {@link Sip} or {@link Stream} it holds. + */ + static final class Endpoint implements BaseDestination { + + private final Object destination; + + Endpoint(Object destination) { + this.destination = destination; + } + + @JsonValue + Object getDestination() { + return destination; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return destination.equals(((Endpoint) o).destination); + } + + @Override + public int hashCode() { + return destination.hashCode(); + } + + @Override + public String toString() { + return destination.toString(); + } + } } diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java index b9548a6cf..f9e73f785 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.sinch.sdk.BaseTest; +import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; import com.sinch.sdk.domains.voice.models.v2.destination.Phone; @@ -10,6 +11,7 @@ import com.sinch.sdk.domains.voice.models.v2.destination.Stream; import com.sinch.sdk.domains.voice.models.v2.destination.VoiceRelay; import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; +import java.util.Collections; import org.assertj.core.api.Assertions; import org.json.JSONException; import org.junit.jupiter.api.Test; @@ -84,104 +86,102 @@ void voiceRelayAcceptsVoiceOutsideCatalog() { @Test void ofUntaggedIsPhone() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("+15551234567"); - - Assertions.assertThat(destination).isInstanceOf(Phone.class); - assertJson(destination, PHONE_JSON); + assertJson(Destination.of("+15551234567"), PHONE_JSON); } @Test void ofStripsPhoneTag() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("phone:+15551234567"); - - Assertions.assertThat(destination).isInstanceOf(Phone.class); - assertJson(destination, PHONE_JSON); + assertJson(Destination.of("phone:+15551234567"), PHONE_JSON); } @Test void ofKeepsSipEndpoint() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("sip:user@example.com"); - - Assertions.assertThat(destination).isInstanceOf(Sip.class); - assertJson(destination, SIP_JSON); + assertJson(Destination.of("sip:user@example.com"), SIP_JSON); } @Test void ofKeepsSipsEndpoint() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("sips:user@example.com"); - - Assertions.assertThat(destination).isInstanceOf(Sip.class); - assertJson(destination, SIPS_JSON); + assertJson(Destination.of("sips:user@example.com"), SIPS_JSON); } @Test void ofStripsStreamTag() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("stream:wss://example.com/audio"); - - Assertions.assertThat(destination).isInstanceOf(Stream.class); - assertJson(destination, STREAM_JSON); + assertJson(Destination.of("stream:wss://example.com/audio"), STREAM_JSON); } @Test void ofKeepsUnknownPrefixAsPhoneNumber() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("tel:+15551234567"); - - Assertions.assertThat(destination).isInstanceOf(Phone.class); - assertJson(destination, "{\"type\":\"PHONE\",\"phone\":{\"number\":\"tel:+15551234567\"}}"); + assertJson( + Destination.of("tel:+15551234567"), + "{\"type\":\"PHONE\",\"phone\":{\"number\":\"tel:+15551234567\"}}"); } @Test void ofKeepsBatchPlaceholderAsPhoneNumber() throws JsonProcessingException, JSONException { - CallDestination destination = Destination.of("@toNumber"); - - assertJson(destination, "{\"type\":\"PHONE\",\"phone\":{\"number\":\"@toNumber\"}}"); + assertJson( + Destination.of("@toNumber"), "{\"type\":\"PHONE\",\"phone\":{\"number\":\"@toNumber\"}}"); } @Test - void originOfUntaggedIsPhone() throws JsonProcessingException, JSONException { - CallOrigin origin = Destination.originOf("+15551234567"); + void ofIsBothCallOriginAndCallDestination() { + BaseDestination endpoint = Destination.of("sip:user@example.com"); - Assertions.assertThat(origin).isInstanceOf(Phone.class); - assertJson(origin, PHONE_JSON); + Assertions.assertThat(endpoint).isInstanceOf(CallOrigin.class); + Assertions.assertThat(endpoint).isInstanceOf(CallDestination.class); } @Test - void originOfStripsPhoneTag() throws JsonProcessingException, JSONException { - CallOrigin origin = Destination.originOf("phone:+15551234567"); - - Assertions.assertThat(origin).isInstanceOf(Phone.class); - assertJson(origin, PHONE_JSON); + void ofComparesByValue() { + Assertions.assertThat(Destination.of("phone:+15551234567")) + .isEqualTo(Destination.of("+15551234567")) + .hasSameHashCodeAs(Destination.of("+15551234567")) + .isNotEqualTo(Destination.of("sip:user@example.com")); } @Test - void originOfKeepsSipEndpoint() throws JsonProcessingException, JSONException { - CallOrigin origin = Destination.originOf("sip:user@example.com"); - - Assertions.assertThat(origin).isInstanceOf(SipFrom.class); - assertJson(origin, SIP_JSON); + void ofToStringIsTheWrappedModel() { + Assertions.assertThat(Destination.of("+15551234567")) + .hasToString(Destination.phone("+15551234567").toString()); } @Test - void originOfKeepsSipsEndpoint() throws JsonProcessingException, JSONException { - CallOrigin origin = Destination.originOf("sips:user@example.com"); + void ofValuesAreAcceptedAsDialFromAndTo() throws JsonProcessingException, JSONException { + DialCommand dial = + DialCommand.builder() + .setFrom(Destination.of("sip:15550001111@sip.sinch.com")) + .setTo(Destination.of("sip:15550002222@sip.sinch.com")) + .build(); - Assertions.assertThat(origin).isInstanceOf(SipFrom.class); - assertJson(origin, SIPS_JSON); + assertJson( + dial, + "{\"command\":\"dial\"," + + "\"from\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550001111@sip.sinch.com\"}}," + + "\"to\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550002222@sip.sinch.com\"}}}"); } @Test - void originOfRejectsStream() { - Assertions.assertThatThrownBy(() -> Destination.originOf("stream:wss://example.com/audio")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("stream:wss://example.com/audio"); + void ofValuesAreSerializedInStartCallRequest() throws JsonProcessingException, JSONException { + StartCallRequest request = + StartCallRequest.builder() + .setCommands( + Collections.singletonList( + DialCommand.builder() + .setFrom(Destination.of("+15550001111")) + .setTo(Destination.of("stream:wss://example.com/audio")) + .build())) + .build(); + + assertJson( + request, + "{\"commands\":[{\"command\":\"dial\"," + + "\"from\":{\"type\":\"PHONE\",\"phone\":{\"number\":\"+15550001111\"}}," + + "\"to\":{\"type\":\"STREAM\",\"stream\":{\"endpoint\":\"wss://example.com/audio\"}}}]}"); } @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Destination.of(null)) .isInstanceOf(NullPointerException.class); - Assertions.assertThatThrownBy(() -> Destination.originOf(null)) - .isInstanceOf(NullPointerException.class); Assertions.assertThatThrownBy(() -> Destination.phone(null)) .isInstanceOf(NullPointerException.class); Assertions.assertThatThrownBy(() -> Destination.sip(null)) @@ -193,19 +193,4 @@ void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Destination.voiceRelay("wss://example.com", "Emma", null)) .isInstanceOf(NullPointerException.class); } - - @Test - void parsedValuesAreAcceptedAsDialFromAndTo() throws JsonProcessingException, JSONException { - DialCommand dial = - DialCommand.builder() - .setFrom(Destination.originOf("sip:15550001111@sip.sinch.com")) - .setTo(Destination.of("sip:15550002222@sip.sinch.com")) - .build(); - - assertJson( - dial, - "{\"command\":\"dial\"," - + "\"from\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550001111@sip.sinch.com\"}}," - + "\"to\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:15550002222@sip.sinch.com\"}}}"); - } } From ef20974b9baeca061dcd3893750d5bcdc1d573b8 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 19 Oct 2026 13:56:21 +0200 Subject: [PATCH 03/17] DEVEXP-1473: Add Voice V2 SVAML command helpers --- CHANGELOG.md | 1 + .../domains/voice/models/v2/helpers/Amd.java | 24 ++ .../voice/models/v2/helpers/AmdCreator.java | 151 +++++++++ .../voice/models/v2/helpers/Calls.java | 95 ++++++ .../voice/models/v2/helpers/Commands.java | 38 +++ .../voice/models/v2/helpers/CustomEvents.java | 53 ++++ .../voice/models/v2/helpers/DialCreator.java | 297 ++++++++++++++++++ .../voice/models/v2/helpers/Messages.java | 94 ++++++ .../models/v2/helpers/MessagesCreator.java | 125 ++++++++ .../voice/models/v2/helpers/Recording.java | 83 +++++ .../models/v2/helpers/RecordingCreator.java | 201 ++++++++++++ .../voice/models/v2/helpers/AmdTest.java | 42 +++ .../voice/models/v2/helpers/CallsTest.java | 148 +++++++++ .../models/v2/helpers/ComposedFlowTest.java | 71 +++++ .../models/v2/helpers/CustomEventsTest.java | 37 +++ .../models/v2/helpers/HelpersBaseTest.java | 16 + .../voice/models/v2/helpers/MessagesTest.java | 78 +++++ .../models/v2/helpers/RecordingTest.java | 139 ++++++++ 18 files changed, 1693 insertions(+) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Amd.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Calls.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/AmdTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallsTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/ComposedFlowTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEventsTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/HelpersBaseTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 766aba5b7..4720ec472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Voice - **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` +- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording` and `CustomEvents` --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Amd.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Amd.java new file mode 100644 index 000000000..a3f08ccc7 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Amd.java @@ -0,0 +1,24 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +/** + * Helpers to build the Answering Machine Detection SVAML command + * + * @since 2.4 + */ +public final class Amd { + + private Amd() {} + + /** + * Detect what answered the call: a human, a machine, a beep, or an unknown result + * + *

Detection runs in parallel with the next commands. Set the commands to run for each result + * on the returned creator. + * + * @return The creator of the amd command + * @since 2.4 + */ + public static AmdCreator activate() { + return new AmdCreator(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java new file mode 100644 index 000000000..1779c25fb --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java @@ -0,0 +1,151 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.amd.AmdCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.amd.AmdEvents; +import java.util.Collection; +import java.util.List; + +/** + * Fluent creator of an amd command, returned by {@link Amd#activate()} + * + *

Calling an event handler more than once appends the commands. + * + * @since 2.4 + */ +public final class AmdCreator { + + private List onHuman; + private List onMachine; + private List onBeep; + private List onUnknown; + + AmdCreator() {} + + /** + * Add commands to run when a human answers + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onHuman(SvamlCommand... commands) { + onHuman = Commands.append(onHuman, commands); + return this; + } + + /** + * Add commands to run when a human answers + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onHuman(Collection commands) { + onHuman = Commands.append(onHuman, commands); + return this; + } + + /** + * Add commands to run when a machine answers + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onMachine(SvamlCommand... commands) { + onMachine = Commands.append(onMachine, commands); + return this; + } + + /** + * Add commands to run when a machine answers + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onMachine(Collection commands) { + onMachine = Commands.append(onMachine, commands); + return this; + } + + /** + * Add commands to run when a beep is detected + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onBeep(SvamlCommand... commands) { + onBeep = Commands.append(onBeep, commands); + return this; + } + + /** + * Add commands to run when a beep is detected + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onBeep(Collection commands) { + onBeep = Commands.append(onBeep, commands); + return this; + } + + /** + * Add commands to run when the result is unknown + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onUnknown(SvamlCommand... commands) { + onUnknown = Commands.append(onUnknown, commands); + return this; + } + + /** + * Add commands to run when the result is unknown + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onUnknown(Collection commands) { + onUnknown = Commands.append(onUnknown, commands); + return this; + } + + /** + * Build the amd command + * + * @return The command. Its events are only set when a handler was given + * @since 2.4 + */ + public AmdCommand build() { + AmdCommand.Builder builder = AmdCommand.builder(); + if (null != onHuman || null != onMachine || null != onBeep || null != onUnknown) { + builder.setEvents(buildEvents()); + } + return builder.build(); + } + + private AmdEvents buildEvents() { + AmdEvents.Builder builder = AmdEvents.builder(); + if (null != onHuman) { + builder.setOnHuman(Commands.copy(onHuman)); + } + if (null != onMachine) { + builder.setOnMachine(Commands.copy(onMachine)); + } + if (null != onBeep) { + builder.setOnBeep(Commands.copy(onBeep)); + } + if (null != onUnknown) { + builder.setOnUnknown(Commands.copy(onUnknown)); + } + return builder.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Calls.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Calls.java new file mode 100644 index 000000000..b52c1333a --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Calls.java @@ -0,0 +1,95 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.AnswerCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.BridgeCallCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.PauseCommand; +import java.util.Objects; + +/** + * Helpers to build the call control SVAML commands + * + * @since 2.4 + */ +public final class Calls { + + private Calls() {} + + /** + * Answer the current call leg + * + * @return The answer command + * @since 2.4 + */ + public static AnswerCommand answer() { + return AnswerCommand.ANSWER_COMMAND; + } + + /** + * Join a bridge, created if no bridge with this name exists in the session + * + * @param name Name of the bridge, sent as bridgeName + * @return The bridgeCall command + * @since 2.4 + */ + public static BridgeCallCommand bridgeCall(String name) { + Objects.requireNonNull(name, "name must not be null"); + return BridgeCallCommand.builder().setBridgeName(name).build(); + } + + /** + * Start a new outbound call leg + * + * @param to Call destination + * @return The creator of the dial command + * @since 2.4 + */ + public static DialCreator dial(CallDestination to) { + return new DialCreator(to); + } + + /** + * Start a new outbound call leg + * + * @param to Call destination, parsed with {@link Destination#of(String)} + * @return The creator of the dial command + * @since 2.4 + */ + public static DialCreator dial(String to) { + return new DialCreator(Destination.of(to)); + } + + /** + * End the current call leg + * + * @return The hangup command + * @since 2.4 + */ + public static HangupCommand hangup() { + return HangupCommand.HANGUP_COMMAND; + } + + /** + * End a call leg + * + * @param name Name of the call leg to end, sent as callName + * @return The hangup command + * @since 2.4 + */ + public static HangupCommand hangup(String name) { + Objects.requireNonNull(name, "name must not be null"); + return HangupCommand.builder().setCallName(name).build(); + } + + /** + * Delay the next command in the sequence. The call audio is not affected + * + * @param durationMilliseconds Duration of the pause in milliseconds + * @return The pause command + * @since 2.4 + */ + public static PauseCommand pause(int durationMilliseconds) { + return PauseCommand.builder().setDurationMilliseconds(durationMilliseconds).build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java new file mode 100644 index 000000000..430748410 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java @@ -0,0 +1,38 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** Collects the commands given to the event handlers of the creators */ +final class Commands { + + private Commands() {} + + /** + * Append commands to a list, creating it on first use. Calling with no command still creates the + * list, so an explicitly empty handler is sent as an empty list. + */ + static List append(List current, SvamlCommand... commands) { + Objects.requireNonNull(commands, "commands must not be null"); + return append(current, Arrays.asList(commands)); + } + + static List append( + List current, Collection commands) { + Objects.requireNonNull(commands, "commands must not be null"); + List list = null == current ? new ArrayList<>() : current; + for (SvamlCommand command : commands) { + list.add(Objects.requireNonNull(command, "command must not be null")); + } + return list; + } + + /** Copy taken at build time, so a creator reused after build() does not alias it */ + static List copy(List commands) { + return null == commands ? null : new ArrayList<>(commands); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java new file mode 100644 index 000000000..18debc428 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java @@ -0,0 +1,53 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.customevents.CustomEventCommand; +import java.util.Objects; + +/** + * Helpers to build the custom event SVAML command + * + * @since 2.4 + */ +public final class CustomEvents { + + private CustomEvents() {} + + /** + * Send a mid-call event to the application backend, which answers with the commands that control + * the rest of the call + * + *

The sequence waits for the backend answer before running the next command. + * + * @param name Name of the custom event, sent as webhookName. The event received by + * the backend is named call.customEvent.<name> + * @param url URL the event is sent to + * @return The customEvent command + * @since 2.4 + */ + public static CustomEventCommand trigger(String name, String url) { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(url, "url must not be null"); + return CustomEventCommand.builder().setCustomEventName(name).setUrl(url).build(); + } + + /** + * Send a mid-call event to the application backend, with a fallback URL + * + * @param name Name of the custom event, sent as webhookName + * @param url URL the event is sent to + * @param fallbackUrl URL a failed request is sent to + * @return The customEvent command + * @see #trigger(String, String) + * @since 2.4 + */ + public static CustomEventCommand trigger(String name, String url, String fallbackUrl) { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(url, "url must not be null"); + Objects.requireNonNull(fallbackUrl, "fallbackUrl must not be null"); + return CustomEventCommand.builder() + .setCustomEventName(name) + .setUrl(url) + .setFallbackUrl(fallbackUrl) + .build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java new file mode 100644 index 000000000..e7d434268 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java @@ -0,0 +1,297 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.CallEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * Fluent creator of a dial command, returned by {@link Calls#dial(CallDestination)} + * + *

Calling an event handler more than once appends the commands. + * + * @since 2.4 + */ +public final class DialCreator { + + private final CallDestination to; + private CallOrigin from; + private String name; + private Integer timeoutDurationSeconds; + private Integer maxDurationSeconds; + private List onAnswer; + private List onBusy; + private List onReject; + private List onTimeout; + private List onHangup; + private List onFailure; + + DialCreator(CallDestination to) { + this.to = Objects.requireNonNull(to, "to must not be null"); + } + + /** + * Set the call origin + * + * @param from Call origin + * @return This creator + * @since 2.4 + */ + public DialCreator from(CallOrigin from) { + this.from = Objects.requireNonNull(from, "from must not be null"); + return this; + } + + /** + * Set the call origin from a string, parsed with {@link Destination#of(String)} + * + * @param from Call origin, optionally prefixed with its type + * @return This creator + * @since 2.4 + */ + public DialCreator from(String from) { + return from(Destination.of(from)); + } + + /** + * Set the name of the call leg, sent as callName + * + * @param name Name of the call leg, unique within the session + * @return This creator + * @since 2.4 + */ + public DialCreator name(String name) { + this.name = Objects.requireNonNull(name, "name must not be null"); + return this; + } + + /** + * Set how long to wait for an answer, sent as dialTimeoutDurationSeconds + * + * @param seconds Timeout in seconds + * @return This creator + * @since 2.4 + */ + public DialCreator timeoutDurationSeconds(int seconds) { + this.timeoutDurationSeconds = seconds; + return this; + } + + /** + * Set the maximum duration of the call, sent as maxCallDurationSeconds + * + * @param seconds Maximum duration in seconds + * @return This creator + * @since 2.4 + */ + public DialCreator maxDurationSeconds(int seconds) { + this.maxDurationSeconds = seconds; + return this; + } + + /** + * Add commands to run when the call is answered + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onAnswer(SvamlCommand... commands) { + onAnswer = Commands.append(onAnswer, commands); + return this; + } + + /** + * Add commands to run when the call is answered + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onAnswer(Collection commands) { + onAnswer = Commands.append(onAnswer, commands); + return this; + } + + /** + * Add commands to run when the callee is busy + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onBusy(SvamlCommand... commands) { + onBusy = Commands.append(onBusy, commands); + return this; + } + + /** + * Add commands to run when the callee is busy + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onBusy(Collection commands) { + onBusy = Commands.append(onBusy, commands); + return this; + } + + /** + * Add commands to run when the call is rejected + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onReject(SvamlCommand... commands) { + onReject = Commands.append(onReject, commands); + return this; + } + + /** + * Add commands to run when the call is rejected + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onReject(Collection commands) { + onReject = Commands.append(onReject, commands); + return this; + } + + /** + * Add commands to run when the call is not answered in time + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onTimeout(SvamlCommand... commands) { + onTimeout = Commands.append(onTimeout, commands); + return this; + } + + /** + * Add commands to run when the call is not answered in time + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onTimeout(Collection commands) { + onTimeout = Commands.append(onTimeout, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onHangup(SvamlCommand... commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onHangup(Collection commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call fails + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onFailure(SvamlCommand... commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + /** + * Add commands to run when the call fails + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onFailure(Collection commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + /** + * Build the dial command + * + * @return The command. Its events are only set when a handler was given + * @since 2.4 + */ + public DialCommand build() { + DialCommand.Builder builder = DialCommand.builder().setTo(to); + if (null != from) { + builder.setFrom(from); + } + if (null != name) { + builder.setCallName(name); + } + if (null != timeoutDurationSeconds) { + builder.setDialTimeoutDurationSeconds(timeoutDurationSeconds); + } + if (null != maxDurationSeconds) { + builder.setMaxCallDurationSeconds(maxDurationSeconds); + } + if (hasEvents()) { + builder.setEvents(buildEvents()); + } + return builder.build(); + } + + private boolean hasEvents() { + return null != onAnswer + || null != onBusy + || null != onReject + || null != onTimeout + || null != onHangup + || null != onFailure; + } + + private CallEvents buildEvents() { + CallEvents.Builder builder = CallEvents.builder(); + if (null != onAnswer) { + builder.setOnAnswer(Commands.copy(onAnswer)); + } + if (null != onBusy) { + builder.setOnBusy(Commands.copy(onBusy)); + } + if (null != onReject) { + builder.setOnReject(Commands.copy(onReject)); + } + if (null != onTimeout) { + builder.setOnTimeout(Commands.copy(onTimeout)); + } + if (null != onHangup) { + builder.setOnHangup(Commands.copy(onHangup)); + } + if (null != onFailure) { + builder.setOnFailure(Commands.copy(onFailure)); + } + return builder.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java new file mode 100644 index 000000000..8e83ca0f9 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java @@ -0,0 +1,94 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.PlayMessage; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.StopMessagesCommand; +import java.util.Objects; + +/** + * Helpers to build the message playback SVAML commands + * + *

{@link #text}, {@link #ssml} and {@link #play} are shortcuts for a messages + * command with a single message. Use {@link #start()} to play several messages, or to set a name or + * the commands to run once they have been played. + * + * @since 2.4 + */ +public final class Messages { + + private Messages() {} + + /** + * Play a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis + * @return The messages command, with this single message + * @since 2.4 + */ + public static MessagesCommand text(String text, String voiceName) { + return start().text(text, voiceName).build(); + } + + /** + * Play a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis + * @return The messages command, with this single message + * @since 2.4 + */ + public static MessagesCommand ssml(String ssml, String voiceName) { + return start().ssml(ssml, voiceName).build(); + } + + /** + * Play an audio file + * + * @param url URL of the media to play + * @return The messages command, with this single message + * @since 2.4 + */ + public static MessagesCommand play(String url) { + return start().play(url).build(); + } + + /** + * Play one or more messages, in the order they are added + * + * @return The creator of the messages command + * @since 2.4 + */ + public static MessagesCreator start() { + return new MessagesCreator(); + } + + /** + * Stop a named message sequence + * + * @param name Name of the message sequence, sent as messagesName + * @return The stopMessages command + * @since 2.4 + */ + public static StopMessagesCommand stop(String name) { + Objects.requireNonNull(name, "name must not be null"); + return StopMessagesCommand.builder().setMessagesName(name).build(); + } + + /** format is only set for SSML: plain text is the API default */ + static SayMessage sayMessage(String text, String voiceName, SayMessage.FormatEnum format) { + Objects.requireNonNull(text, "text must not be null"); + Objects.requireNonNull(voiceName, "voiceName must not be null"); + SayMessage.Builder builder = SayMessage.builder().setText(text).setVoiceName(voiceName); + if (null != format) { + builder.setFormat(format); + } + return builder.build(); + } + + static PlayMessage playMessage(String url) { + Objects.requireNonNull(url, "url must not be null"); + return PlayMessage.builder().setUrl(url).build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java new file mode 100644 index 000000000..0a104e089 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java @@ -0,0 +1,125 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.Message; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessageEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * Fluent creator of a messages command, returned by {@link Messages#start()} + * + *

Messages are played in the order they are added. Calling {@link #onFinish} more than once + * appends the commands. + * + * @since 2.4 + */ +public final class MessagesCreator { + + private final List messages = new ArrayList<>(); + private String name; + private List onFinish; + + MessagesCreator() {} + + /** + * Add a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @since 2.4 + */ + public MessagesCreator text(String text, String voiceName) { + messages.add(Messages.sayMessage(text, voiceName, null)); + return this; + } + + /** + * Add a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @since 2.4 + */ + public MessagesCreator ssml(String ssml, String voiceName) { + messages.add(Messages.sayMessage(ssml, voiceName, SayMessage.FormatEnum.SSML)); + return this; + } + + /** + * Add an audio file to play + * + * @param url URL of the media to play + * @return This creator + * @since 2.4 + */ + public MessagesCreator play(String url) { + messages.add(Messages.playMessage(url)); + return this; + } + + /** + * Set the name of the message sequence, sent as messagesName. A named sequence can + * be stopped with {@link Messages#stop(String)} + * + * @param name Name of the message sequence, unique within the session + * @return This creator + * @since 2.4 + */ + public MessagesCreator name(String name) { + this.name = Objects.requireNonNull(name, "name must not be null"); + return this; + } + + /** + * Add commands to run when all the messages have been played + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MessagesCreator onFinish(SvamlCommand... commands) { + onFinish = Commands.append(onFinish, commands); + return this; + } + + /** + * Add commands to run when all the messages have been played + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MessagesCreator onFinish(Collection commands) { + onFinish = Commands.append(onFinish, commands); + return this; + } + + /** + * Build the messages command + * + * @return The command + * @throws IllegalStateException if no message was added + * @since 2.4 + */ + public MessagesCommand build() { + if (messages.isEmpty()) { + throw new IllegalStateException("messages requires at least one text, ssml or play message"); + } + MessagesCommand.Builder builder = + MessagesCommand.builder().setMessages(new ArrayList<>(messages)); + if (null != name) { + builder.setMessagesName(name); + } + if (null != onFinish) { + builder.setEvents(MessageEvents.builder().setOnFinish(Commands.copy(onFinish)).build()); + } + return builder.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java new file mode 100644 index 000000000..923f1ae0a --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java @@ -0,0 +1,83 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.StopRecordingCommand; +import java.util.Objects; + +/** + * Helpers to build the call recording SVAML commands + * + * @since 2.4 + */ +public final class Recording { + + private Recording() {} + + /** + * Create the options to store a recording in a third party storage + * + *

Only the required fields are set. Set the format, the recording type and the transcription + * on the creator returned by {@link #start(RecordingOptions)}. + * + * @param destination Third party storage the recording is delivered to + * @param url Destination URL, sent as destinationUrl. Its scheme depends on the + * destination: s3, gs or azure + * @param credentials Credentials to the third party storage + * @return The recording options + * @since 2.4 + */ + public static RecordingOptions options( + RecordingDestinationType destination, String url, String credentials) { + Objects.requireNonNull(destination, "destination must not be null"); + Objects.requireNonNull(url, "url must not be null"); + Objects.requireNonNull(credentials, "credentials must not be null"); + return RecordingOptions.builder() + .setDestination(destination) + .setDestinationUrl(url) + .setCredentials(credentials) + .build(); + } + + /** + * Start recording the call, stored in a third party storage + * + *

Set the optional recording options (format, recording type, transcription) on the returned + * creator. + * + * @param destination Third party storage the recording is delivered to + * @param url Destination URL, sent as destinationUrl. Its scheme depends on the + * destination: s3, gs or azure + * @param credentials Credentials to the third party storage + * @return The creator of the startRecording command + * @since 2.4 + */ + public static RecordingCreator start( + RecordingDestinationType destination, String url, String credentials) { + return new RecordingCreator(destination, url, credentials); + } + + /** + * Start recording the call with existing recording options, e.g. shared by several recordings + * + * @param options Recording options, see {@link #options(RecordingDestinationType, String, + * String)} + * @return The creator of the startRecording command + * @since 2.4 + */ + public static RecordingCreator start(RecordingOptions options) { + return new RecordingCreator(options); + } + + /** + * Stop a named recording + * + * @param name Name of the recording, sent as recordingName + * @return The stopRecording command + * @since 2.4 + */ + public static StopRecordingCommand stop(String name) { + Objects.requireNonNull(name, "name must not be null"); + return StopRecordingCommand.builder().setRecordingName(name).build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java new file mode 100644 index 000000000..520d1e738 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java @@ -0,0 +1,201 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingFormatType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.StartRecordingCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.TranscriptionOptions; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * Fluent creator of a startRecording command + * + *

Returned by {@link Recording#start(RecordingDestinationType, String, String)}. + * + *

Also returned by {@link Recording#start(RecordingOptions)}: the optional recording options set + * here override the ones of the given {@link RecordingOptions}. + * + *

Calling an event handler more than once appends the commands. + * + * @since 2.4 + */ +public final class RecordingCreator { + + private final RecordingDestinationType destination; + private final String url; + private final String credentials; + private RecordingFormatType format; + private RecordingType recordingType; + private TranscriptionOptions transcriptionOptions; + private String name; + private List onFinish; + private List onFailure; + + RecordingCreator(RecordingDestinationType destination, String url, String credentials) { + this.destination = Objects.requireNonNull(destination, "destination must not be null"); + this.url = Objects.requireNonNull(url, "url must not be null"); + this.credentials = Objects.requireNonNull(credentials, "credentials must not be null"); + } + + RecordingCreator(RecordingOptions options) { + Objects.requireNonNull(options, "options must not be null"); + this.destination = options.getDestination(); + this.url = options.getDestinationUrl(); + this.credentials = options.getCredentials(); + this.format = options.getFormat(); + this.recordingType = options.getRecordingType(); + this.transcriptionOptions = options.getTranscriptionOptions(); + } + + /** + * Set the audio format of the recording. The API default is MP3 + * + * @param format Audio format + * @return This creator + * @since 2.4 + */ + public RecordingCreator format(RecordingFormatType format) { + this.format = Objects.requireNonNull(format, "format must not be null"); + return this; + } + + /** + * Set which voice streams are recorded. The API default is COMBINED + * + * @param recordingType Recorded voice streams + * @return This creator + * @since 2.4 + */ + public RecordingCreator recordingType(RecordingType recordingType) { + this.recordingType = Objects.requireNonNull(recordingType, "recordingType must not be null"); + return this; + } + + /** + * Set the speech-to-text transcription of the recording + * + * @param transcriptionOptions Transcription options + * @return This creator + * @since 2.4 + */ + public RecordingCreator transcriptionOptions(TranscriptionOptions transcriptionOptions) { + this.transcriptionOptions = + Objects.requireNonNull(transcriptionOptions, "transcriptionOptions must not be null"); + return this; + } + + /** + * Set the name of the recording, sent as recordingName. Without a name, the + * recording can only be stopped by ending the call + * + * @param name Name of the recording, unique within the session + * @return This creator + * @since 2.4 + */ + public RecordingCreator name(String name) { + this.name = Objects.requireNonNull(name, "name must not be null"); + return this; + } + + /** + * Add commands to run when the recording is stopped + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFinish(SvamlCommand... commands) { + onFinish = Commands.append(onFinish, commands); + return this; + } + + /** + * Add commands to run when the recording is stopped + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFinish(Collection commands) { + onFinish = Commands.append(onFinish, commands); + return this; + } + + /** + * Add commands to run when the recording fails to start + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFailure(SvamlCommand... commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + /** + * Add commands to run when the recording fails to start + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFailure(Collection commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + /** + * Build the startRecording command + * + * @return The command. Its events are only set when a handler was given + * @since 2.4 + */ + public StartRecordingCommand build() { + StartRecordingCommand.Builder builder = + StartRecordingCommand.builder().setRecordingOptions(buildOptions()); + if (null != name) { + builder.setRecordingName(name); + } + if (null != onFinish || null != onFailure) { + RecordingEvents.Builder events = RecordingEvents.builder(); + if (null != onFinish) { + events.setOnFinish(Commands.copy(onFinish)); + } + if (null != onFailure) { + events.setOnFailure(Commands.copy(onFailure)); + } + builder.setEvents(events.build()); + } + return builder.build(); + } + + /** Only the fields that have a value are set, so unset ones are left to the API defaults */ + private RecordingOptions buildOptions() { + RecordingOptions.Builder builder = RecordingOptions.builder(); + if (null != destination) { + builder.setDestination(destination); + } + if (null != url) { + builder.setDestinationUrl(url); + } + if (null != credentials) { + builder.setCredentials(credentials); + } + if (null != format) { + builder.setFormat(format); + } + if (null != recordingType) { + builder.setRecordingType(recordingType); + } + if (null != transcriptionOptions) { + builder.setTranscriptionOptions(transcriptionOptions); + } + return builder.build(); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/AmdTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/AmdTest.java new file mode 100644 index 000000000..6337b4059 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/AmdTest.java @@ -0,0 +1,42 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.Collections; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class AmdTest extends HelpersBaseTest { + + @Test + void activateWithoutHandlers() throws JsonProcessingException, JSONException { + assertJson(Amd.activate().build(), "{\"command\":\"amd\"}"); + } + + @Test + void activateAllHandlers() throws JsonProcessingException, JSONException { + assertJson( + Amd.activate() + .onHuman(Calls.bridgeCall("support")) + .onMachine(Calls.hangup()) + .onBeep(Calls.pause(1500)) + .onUnknown(Collections.singletonList(Calls.hangup("origin"))) + .build(), + "{\"command\":\"amd\",\"events\":{" + + "\"onHuman\":[{\"command\":\"bridgeCall\",\"bridgeName\":\"support\"}]," + + "\"onMachine\":[" + + HANGUP_JSON + + "]," + + "\"onBeep\":[{\"command\":\"pause\",\"durationMilliseconds\":1500}]," + + "\"onUnknown\":[{\"command\":\"hangup\",\"callName\":\"origin\"}]}}"); + } + + @Test + void handlersAppend() throws JsonProcessingException, JSONException { + assertJson( + Amd.activate().onMachine(Calls.pause(1200)).onMachine(Calls.hangup()).build(), + "{\"command\":\"amd\",\"events\":{\"onMachine\":[" + + "{\"command\":\"pause\",\"durationMilliseconds\":1200}," + + HANGUP_JSON + + "]}}"); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallsTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallsTest.java new file mode 100644 index 000000000..48c92e94b --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallsTest.java @@ -0,0 +1,148 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; +import java.util.Arrays; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class CallsTest extends HelpersBaseTest { + + @Test + void answer() throws JsonProcessingException, JSONException { + assertJson(Calls.answer(), "{\"command\":\"answer\"}"); + } + + @Test + void hangup() throws JsonProcessingException, JSONException { + assertJson(Calls.hangup(), HANGUP_JSON); + } + + @Test + void hangupNamedLeg() throws JsonProcessingException, JSONException { + assertJson(Calls.hangup("customer"), "{\"command\":\"hangup\",\"callName\":\"customer\"}"); + } + + @Test + void pause() throws JsonProcessingException, JSONException { + assertJson(Calls.pause(1200), "{\"command\":\"pause\",\"durationMilliseconds\":1200}"); + } + + @Test + void bridgeCall() throws JsonProcessingException, JSONException { + assertJson( + Calls.bridgeCall("support-queue"), + "{\"command\":\"bridgeCall\",\"bridgeName\":\"support-queue\"}"); + } + + @Test + void dialOnlySetsTo() throws JsonProcessingException, JSONException { + assertJson( + Calls.dial(Destination.phone("+15551234567")).build(), + "{\"command\":\"dial\",\"to\":" + PHONE_JSON + "}"); + } + + @Test + void dialParsesStringDestination() throws JsonProcessingException, JSONException { + assertJson( + Calls.dial("phone:+15551234567").build(), + "{\"command\":\"dial\",\"to\":" + PHONE_JSON + "}"); + } + + @Test + void dialAllFields() throws JsonProcessingException, JSONException { + DialCommand dial = + Calls.dial("sip:alice@example.com") + .from("+15551234567") + .name("customer") + .timeoutDurationSeconds(25) + .maxDurationSeconds(3600) + .onAnswer(Calls.answer()) + .onBusy(Calls.pause(100)) + .onReject(Calls.bridgeCall("queue")) + .onTimeout(Calls.hangup("other")) + .onHangup(Messages.stop("greeting")) + .onFailure(Calls.hangup()) + .build(); + + assertJson( + dial, + "{\"command\":\"dial\"," + + "\"to\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:alice@example.com\"}}," + + "\"from\":" + + PHONE_JSON + + ",\"callName\":\"customer\",\"dialTimeoutDurationSeconds\":25," + + "\"maxCallDurationSeconds\":3600," + + "\"events\":{" + + "\"onAnswer\":[{\"command\":\"answer\"}]," + + "\"onBusy\":[{\"command\":\"pause\",\"durationMilliseconds\":100}]," + + "\"onReject\":[{\"command\":\"bridgeCall\",\"bridgeName\":\"queue\"}]," + + "\"onTimeout\":[{\"command\":\"hangup\",\"callName\":\"other\"}]," + + "\"onHangup\":[{\"command\":\"stopMessages\",\"messagesName\":\"greeting\"}]," + + "\"onFailure\":[{\"command\":\"hangup\"}]}}"); + } + + @Test + void dialFromCallOrigin() throws JsonProcessingException, JSONException { + assertJson( + Calls.dial("+15550000000").from(Destination.sipFrom("sip:bob@example.com")).build(), + "{\"command\":\"dial\"," + + "\"to\":{\"type\":\"PHONE\",\"phone\":{\"number\":\"+15550000000\"}}," + + "\"from\":{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:bob@example.com\"}}}"); + } + + @Test + void dialHandlersAppend() throws JsonProcessingException, JSONException { + DialCommand dial = + Calls.dial("+15551234567") + .onAnswer(Calls.answer()) + .onAnswer(Arrays.asList(Calls.pause(500), Calls.hangup())) + .build(); + + assertJson( + dial, + "{\"command\":\"dial\",\"to\":" + + PHONE_JSON + + ",\"events\":{\"onAnswer\":[{\"command\":\"answer\"}," + + "{\"command\":\"pause\",\"durationMilliseconds\":500}," + + HANGUP_JSON + + "]}}"); + } + + @Test + void dialKeepsExplicitlyEmptyHandler() throws JsonProcessingException, JSONException { + assertJson( + Calls.dial("+15551234567").onBusy().build(), + "{\"command\":\"dial\",\"to\":" + PHONE_JSON + ",\"events\":{\"onBusy\":[]}}"); + } + + @Test + void dialBuildIsNotAffectedByLaterChanges() { + DialCreator creator = Calls.dial("+15551234567").onAnswer(Calls.answer()); + DialCommand first = creator.build(); + + creator.onAnswer(Calls.hangup()); + + Assertions.assertThat(first.getEvents().getOnAnswer()).hasSize(1); + Assertions.assertThat(creator.build().getEvents().getOnAnswer()).hasSize(2); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> Calls.dial((CallDestination) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Calls.dial((String) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Calls.dial("+15551234567").name(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Calls.dial("+15551234567").onAnswer((SvamlCommand) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Calls.bridgeCall(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Calls.hangup(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/ComposedFlowTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/ComposedFlowTest.java new file mode 100644 index 000000000..d2dd93c91 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/ComposedFlowTest.java @@ -0,0 +1,71 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import java.util.Collections; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +/** The "Voice V2 Helpers meeting discussion" Confluence example, built with the helpers */ +class ComposedFlowTest extends HelpersBaseTest { + + static final String EXPECTED = + "{\"commands\":[{\"command\":\"dial\",\"callName\":\"customer\"," + + "\"from\":{\"type\":\"PHONE\",\"phone\":{\"number\":\"+34910000000\"}}," + + "\"to\":{\"type\":\"PHONE\",\"phone\":{\"number\":\"+34600111222\"}}," + + "\"dialTimeoutDurationSeconds\":25," + + "\"events\":{" + + "\"onAnswer\":[{\"command\":\"amd\",\"events\":{" + + "\"onHuman\":[" + + "{\"command\":\"messages\",\"messages\":[{\"type\":\"SAY\",\"say\":" + + "{\"text\":\"Connecting you to support, one moment.\",\"voiceName\":\"Emma\"}}]}," + + "{\"command\":\"startRecording\",\"recordingName\":\"support-call\"," + + "\"recordingOptions\":{\"destination\":\"AWS\"," + + "\"destinationUrl\":\"s3://voice-recordings/support\"," + + "\"credentials\":\"key:secret:eu-west-1\"}}," + + "{\"command\":\"bridgeCall\",\"bridgeName\":\"support-queue\"}]," + + "\"onMachine\":[" + + "{\"command\":\"pause\",\"durationMilliseconds\":1200}," + + "{\"command\":\"messages\",\"messages\":[{\"type\":\"SAY\",\"say\":" + + "{\"text\":\"This is a call from support, please call us back.\"," + + "\"voiceName\":\"Emma\"}}]}," + + "{\"command\":\"hangup\"}]}}]," + + "\"onHangup\":[{\"command\":\"stopRecording\",\"recordingName\":\"support-call\"}]" + + "}}]}"; + + @Test + void supportCallFlow() throws JsonProcessingException, JSONException { + StartCallRequest request = + StartCallRequest.builder() + .setCommands( + Collections.singletonList( + Calls.dial("+34600111222") + .name("customer") + .from("+34910000000") + .timeoutDurationSeconds(25) + .onAnswer( + Amd.activate() + .onHuman( + Messages.text("Connecting you to support, one moment.", "Emma"), + Recording.start( + RecordingDestinationType.AWS, + "s3://voice-recordings/support", + "key:secret:eu-west-1") + .name("support-call") + .build(), + Calls.bridgeCall("support-queue")) + .onMachine( + Calls.pause(1200), + Messages.text( + "This is a call from support, please call us back.", + "Emma"), + Calls.hangup()) + .build()) + .onHangup(Recording.stop("support-call")) + .build())) + .build(); + + assertJson(request, EXPECTED); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEventsTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEventsTest.java new file mode 100644 index 000000000..efeb7ff63 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEventsTest.java @@ -0,0 +1,37 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class CustomEventsTest extends HelpersBaseTest { + + @Test + void trigger() throws JsonProcessingException, JSONException { + assertJson( + CustomEvents.trigger("my.custom.event", "https://example.com/events"), + "{\"command\":\"webhook\",\"webhookName\":\"my.custom.event\"," + + "\"url\":\"https://example.com/events\"}"); + } + + @Test + void triggerWithFallbackUrl() throws JsonProcessingException, JSONException { + assertJson( + CustomEvents.trigger( + "my.custom.event", "https://example.com/events", "https://fallback.example.com/events"), + "{\"command\":\"webhook\",\"webhookName\":\"my.custom.event\"," + + "\"url\":\"https://example.com/events\"," + + "\"fallbackUrl\":\"https://fallback.example.com/events\"}"); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> CustomEvents.trigger(null, "https://example.com")) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> CustomEvents.trigger("name", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> CustomEvents.trigger("name", "https://example.com", null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/HelpersBaseTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/HelpersBaseTest.java new file mode 100644 index 000000000..847ad73e7 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/HelpersBaseTest.java @@ -0,0 +1,16 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.BaseTest; +import org.json.JSONException; +import org.skyscreamer.jsonassert.JSONAssert; + +abstract class HelpersBaseTest extends BaseTest { + + static final String PHONE_JSON = "{\"type\":\"PHONE\",\"phone\":{\"number\":\"+15551234567\"}}"; + static final String HANGUP_JSON = "{\"command\":\"hangup\"}"; + + void assertJson(Object value, String expected) throws JsonProcessingException, JSONException { + JSONAssert.assertEquals(expected, objectMapper.writeValueAsString(value), true); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java new file mode 100644 index 000000000..d11a6746c --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java @@ -0,0 +1,78 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class MessagesTest extends HelpersBaseTest { + + static final String SAY_JSON = + "{\"type\":\"SAY\",\"say\":{\"text\":\"Hello\",\"voiceName\":\"Emma\"}}"; + static final String PLAY_JSON = + "{\"type\":\"PLAY\",\"play\":{\"url\":\"https://example.com/a.mp3\"}}"; + + @Test + void textLeavesFormatUnset() throws JsonProcessingException, JSONException { + assertJson( + Messages.text("Hello", "Emma"), + "{\"command\":\"messages\",\"messages\":[" + SAY_JSON + "]}"); + } + + @Test + void ssml() throws JsonProcessingException, JSONException { + assertJson( + Messages.ssml("Hello", "Emma"), + "{\"command\":\"messages\",\"messages\":[{\"type\":\"SAY\",\"say\":" + + "{\"text\":\"Hello\",\"voiceName\":\"Emma\",\"format\":\"SSML\"}}]}"); + } + + @Test + void play() throws JsonProcessingException, JSONException { + assertJson( + Messages.play("https://example.com/a.mp3"), + "{\"command\":\"messages\",\"messages\":[" + PLAY_JSON + "]}"); + } + + @Test + void startKeepsMessagesInOrder() throws JsonProcessingException, JSONException { + assertJson( + Messages.start() + .text("Hello", "Emma") + .play("https://example.com/a.mp3") + .name("greeting") + .onFinish(Calls.hangup()) + .build(), + "{\"command\":\"messages\",\"messagesName\":\"greeting\",\"messages\":[" + + SAY_JSON + + "," + + PLAY_JSON + + "],\"events\":{\"onFinish\":[" + + HANGUP_JSON + + "]}}"); + } + + @Test + void startRequiresAMessage() { + Assertions.assertThatThrownBy(() -> Messages.start().name("greeting").build()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void stop() throws JsonProcessingException, JSONException { + assertJson( + Messages.stop("greeting"), "{\"command\":\"stopMessages\",\"messagesName\":\"greeting\"}"); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> Messages.text(null, "Emma")) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Messages.text("Hello", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Messages.play(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Messages.stop(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java new file mode 100644 index 000000000..e97d97a4f --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java @@ -0,0 +1,139 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingFormatType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.TranscriptionOptions; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class RecordingTest extends HelpersBaseTest { + + static final String OPTIONS_JSON = + "{\"destination\":\"AWS\",\"destinationUrl\":\"s3://voice-recordings/support\"," + + "\"credentials\":\"key:secret:eu-west-1\"}"; + + static RecordingOptions awsOptions() { + return Recording.options( + RecordingDestinationType.AWS, "s3://voice-recordings/support", "key:secret:eu-west-1"); + } + + @Test + void options() throws JsonProcessingException, JSONException { + assertJson(awsOptions(), OPTIONS_JSON); + } + + @Test + void startWithDestinationOnlySetsRequiredOptions() throws JsonProcessingException, JSONException { + assertJson( + Recording.start( + RecordingDestinationType.AWS, + "s3://voice-recordings/support", + "key:secret:eu-west-1") + .build(), + "{\"command\":\"startRecording\",\"recordingOptions\":" + OPTIONS_JSON + "}"); + } + + @Test + void startWithDestinationAndOptionalOptions() throws JsonProcessingException, JSONException { + assertJson( + Recording.start( + RecordingDestinationType.AWS, + "s3://voice-recordings/support", + "key:secret:eu-west-1") + .format(RecordingFormatType.WAV) + .recordingType(RecordingType.INBOUND) + .transcriptionOptions( + TranscriptionOptions.builder().setIsEnabled(true).setLocale("es-ES").build()) + .name("support-call") + .build(), + "{\"command\":\"startRecording\",\"recordingName\":\"support-call\"," + + "\"recordingOptions\":{\"destination\":\"AWS\"," + + "\"destinationUrl\":\"s3://voice-recordings/support\"," + + "\"credentials\":\"key:secret:eu-west-1\",\"format\":\"WAV\"," + + "\"recordingType\":\"INBOUND\"," + + "\"transcriptionOptions\":{\"isEnabled\":true,\"locale\":\"es-ES\"}}}"); + } + + @Test + void creatorOptionsOverrideGivenOptions() throws JsonProcessingException, JSONException { + RecordingOptions options = + RecordingOptions.builder() + .setDestination(RecordingDestinationType.AZURE) + .setDestinationUrl("azure://container/path") + .setCredentials("creds") + .setFormat(RecordingFormatType.MP3) + .setRecordingType(RecordingType.OUTBOUND) + .build(); + + assertJson( + Recording.start(options).format(RecordingFormatType.WAV).build(), + "{\"command\":\"startRecording\",\"recordingOptions\":{\"destination\":\"AZURE\"," + + "\"destinationUrl\":\"azure://container/path\",\"credentials\":\"creds\"," + + "\"format\":\"WAV\",\"recordingType\":\"OUTBOUND\"}}"); + } + + @Test + void startOnlySetsOptions() throws JsonProcessingException, JSONException { + assertJson( + Recording.start(awsOptions()).build(), + "{\"command\":\"startRecording\",\"recordingOptions\":" + OPTIONS_JSON + "}"); + } + + @Test + void startAllFields() throws JsonProcessingException, JSONException { + assertJson( + Recording.start(awsOptions()) + .name("support-call") + .onFinish(Calls.hangup()) + .onFailure(Calls.hangup("origin")) + .build(), + "{\"command\":\"startRecording\",\"recordingName\":\"support-call\"," + + "\"recordingOptions\":" + + OPTIONS_JSON + + ",\"events\":{\"onFinish\":[" + + HANGUP_JSON + + "],\"onFailure\":[{\"command\":\"hangup\",\"callName\":\"origin\"}]}}"); + } + + @Test + void startAcceptsOptionsFromBuilder() throws JsonProcessingException, JSONException { + RecordingOptions options = + RecordingOptions.builder() + .setDestination(RecordingDestinationType.GCP) + .setDestinationUrl("gs://bucket/path") + .setCredentials("creds") + .setFormat(RecordingFormatType.WAV) + .build(); + + assertJson( + Recording.start(options).build(), + "{\"command\":\"startRecording\",\"recordingOptions\":{\"destination\":\"GCP\"," + + "\"destinationUrl\":\"gs://bucket/path\",\"credentials\":\"creds\"," + + "\"format\":\"WAV\"}}"); + } + + @Test + void stop() throws JsonProcessingException, JSONException { + assertJson( + Recording.stop("support-call"), + "{\"command\":\"stopRecording\",\"recordingName\":\"support-call\"}"); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> Recording.options(null, "s3://b", "c")) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Recording.start(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Recording.start(RecordingDestinationType.AWS, null, "c")) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Recording.start(awsOptions()).format(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Recording.stop(null)) + .isInstanceOf(NullPointerException.class); + } +} From ad85647887ee2b9440afdf2a7b032ab3b91aef70 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 19 Oct 2026 15:05:36 +0200 Subject: [PATCH 04/17] DEVEXP-1473: Add Voice V2 menu helpers --- CHANGELOG.md | 2 +- .../domains/voice/models/v2/helpers/Menu.java | 81 +++++ .../voice/models/v2/helpers/MenuCreator.java | 158 +++++++++ .../models/v2/helpers/MenuItemCreator.java | 267 +++++++++++++++ .../models/v2/helpers/MessagesCreator.java | 4 +- .../models/v2/helpers/PromptCreator.java | 85 +++++ .../voice/models/v2/helpers/MenuTest.java | 318 ++++++++++++++++++ 7 files changed, 912 insertions(+), 3 deletions(-) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4720ec472..7b0fb14c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Voice - **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` -- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording` and `CustomEvents` +- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents` and `Menu` --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java new file mode 100644 index 000000000..3a962dc4a --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java @@ -0,0 +1,81 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.GotoMenuCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuItem; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Helpers to build the interactive menu SVAML commands + * + * @since 2.4 + */ +public final class Menu { + + private Menu() {} + + /** + * Run an interactive menu that collects user input (e.g. DTMF digits) + * + *

The sequence waits for the menu to end before running the next command. + * + * @param startMenu Name of the item played first, 1 to 16 characters. It must be one of the items + * of the menu + * @return The creator of the menu command + * @since 2.4 + */ + public static MenuCreator create(String startMenu) { + return new MenuCreator(startMenu); + } + + /** + * Jump to another item of the enclosing menu + * + *

Only valid within a menu: in the match or onFailure commands of an + * item, including their nested event handlers. The target must be an item of that menu, which + * {@link MenuCreator#build()} checks. + * + *

Named after the API command: goto is a reserved word in Java. + * + * @param name Name of the menu item, 1 to 16 characters, sent as menuName + * @return The gotoMenu command + * @since 2.4 + */ + public static GotoMenuCommand gotoMenu(String name) { + Objects.requireNonNull(name, "name must not be null"); + return GotoMenuCommand.builder().setMenuName(name).build(); + } + + /** + * Create a menu prompt, e.g. to share it between several items or as repeat prompt + * + * @param prompt Lambda filling the prompt + * @return The menu prompt + * @throws IllegalStateException if the lambda adds no message + * @since 2.4 + */ + public static MenuPrompt prompt(Consumer prompt) { + Objects.requireNonNull(prompt, "prompt must not be null"); + PromptCreator creator = new PromptCreator(); + prompt.accept(creator); + return creator.build(); + } + + /** + * Create a menu item, e.g. to share it between several menus + * + *

Add it to a menu with {@link MenuCreator#item(String, MenuItem)}. Its gotoMenu + * targets are checked when that menu is built. + * + * @param item Lambda filling the item + * @return The menu item + * @since 2.4 + */ + public static MenuItem item(Consumer item) { + Objects.requireNonNull(item, "item must not be null"); + MenuItemCreator creator = new MenuItemCreator(); + item.accept(creator); + return creator.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java new file mode 100644 index 000000000..7638d6079 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java @@ -0,0 +1,158 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.amd.AmdCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.amd.AmdEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.CallEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.GotoMenuCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuItem; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.StartRecordingCommand; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Fluent creator of a menu command, returned by {@link Menu#create(String)} + * + *

{@link #build()} checks that the start menu and every {@link Menu#gotoMenu(String)} target are + * items of this menu. + * + * @since 2.4 + */ +public final class MenuCreator { + + private final String startMenu; + private final Map items = new LinkedHashMap<>(); + + MenuCreator(String startMenu) { + this.startMenu = Objects.requireNonNull(startMenu, "startMenu must not be null"); + } + + /** + * Add a menu item + * + * @param name Name of the item, 1 to 16 characters. Start menu and gotoMenu targets refer to it + * @param item Lambda filling the item + * @return This creator + * @throws IllegalArgumentException if an item with this name is already defined + * @since 2.4 + */ + public MenuCreator item(String name, Consumer item) { + checkNewItem(name); + return item(name, Menu.item(item)); + } + + /** + * Add a menu item + * + * @param name Name of the item, 1 to 16 characters. Start menu and gotoMenu targets refer to it + * @param item Menu item, e.g. built with {@link Menu#item(Consumer)} to share it + * @return This creator + * @throws IllegalArgumentException if an item with this name is already defined + * @since 2.4 + */ + public MenuCreator item(String name, MenuItem item) { + checkNewItem(name); + items.put(name, Objects.requireNonNull(item, "item must not be null")); + return this; + } + + /** + * Build the menu command + * + * @return The command + * @throws IllegalStateException if the start menu, or the target of a gotoMenu + * command of an item, is not an item of this menu + * @since 2.4 + */ + public MenuCommand build() { + checkMenu(startMenu, items); + return MenuCommand.builder() + .setStartMenu(startMenu) + .setMenus(new LinkedHashMap<>(items)) + .build(); + } + + private void checkNewItem(String name) { + Objects.requireNonNull(name, "name must not be null"); + if (items.containsKey(name)) { + throw new IllegalArgumentException( + String.format("menu item \"%s\" is already defined", name)); + } + } + + /** The start menu, and every gotoMenu target of the items, must be an item */ + static void checkMenu(String startMenu, Map menus) { + Map items = null == menus ? Collections.emptyMap() : menus; + if (!items.containsKey(startMenu)) { + throw new IllegalStateException(String.format("menu start \"%s\" is not defined", startMenu)); + } + for (MenuItem item : items.values()) { + if (null != item.getMatches()) { + for (List commands : item.getMatches().values()) { + checkGotoTargets(commands, items.keySet()); + } + } + checkGotoTargets(item.getOnFailure(), items.keySet()); + } + } + + /** + * A gotoMenu target must be an item of the enclosing menu. Nested event handlers are + * searched too. A nested menu is checked against its own items, so one built without + * this creator (e.g. with {@link MenuCommand#builder()}) is checked as well. + */ + static void checkGotoTargets(List commands, Set names) { + if (null == commands) { + return; + } + for (SvamlCommand command : commands) { + if (command instanceof GotoMenuCommand) { + String target = ((GotoMenuCommand) command).getMenuName(); + if (!names.contains(target)) { + throw new IllegalStateException(String.format("gotoMenu \"%s\" is not defined", target)); + } + } else if (command instanceof MenuCommand) { + MenuCommand menu = (MenuCommand) command; + checkMenu(menu.getStartMenu(), menu.getMenus()); + } else if (command instanceof DialCommand) { + CallEvents events = ((DialCommand) command).getEvents(); + if (null != events) { + checkGotoTargets(events.getOnAnswer(), names); + checkGotoTargets(events.getOnBusy(), names); + checkGotoTargets(events.getOnReject(), names); + checkGotoTargets(events.getOnTimeout(), names); + checkGotoTargets(events.getOnHangup(), names); + checkGotoTargets(events.getOnFailure(), names); + } + } else if (command instanceof AmdCommand) { + AmdEvents events = ((AmdCommand) command).getEvents(); + if (null != events) { + checkGotoTargets(events.getOnHuman(), names); + checkGotoTargets(events.getOnMachine(), names); + checkGotoTargets(events.getOnBeep(), names); + checkGotoTargets(events.getOnUnknown(), names); + } + } else if (command instanceof MessagesCommand) { + MessagesCommand messages = (MessagesCommand) command; + if (null != messages.getEvents()) { + checkGotoTargets(messages.getEvents().getOnFinish(), names); + } + } else if (command instanceof StartRecordingCommand) { + RecordingEvents events = ((StartRecordingCommand) command).getEvents(); + if (null != events) { + checkGotoTargets(events.getOnFinish(), names); + checkGotoTargets(events.getOnFailure(), names); + } + } + } + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java new file mode 100644 index 000000000..6acc46455 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java @@ -0,0 +1,267 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuItem; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Fluent creator of a menu item, given to the lambdas of {@link MenuCreator#item(String, Consumer)} + * and {@link Menu#item(Consumer)} + * + *

Calling {@link #match} more than once for the same input, or {@link #onFailure} more than + * once, appends the commands. + * + * @since 2.4 + */ +public final class MenuItemCreator { + + private MenuPrompt prompt; + private MenuPrompt repeatPrompt; + private Integer inputTimeoutDurationSeconds; + private Integer repeatCount; + private Integer minimumInputLength; + private Integer maximumInputLength; + private String terminatingSequence; + private List inputMethods; + private final Map> matches = new LinkedHashMap<>(); + private List onFailure; + + MenuItemCreator() {} + + /** + * Set the prompt played when the menu starts. It is also the repeat prompt when none is set + * + * @param prompt Lambda filling the prompt + * @return This creator + * @since 2.4 + */ + public MenuItemCreator prompt(Consumer prompt) { + return prompt(Menu.prompt(prompt)); + } + + /** + * Set the prompt played when the menu starts. It is also the repeat prompt when none is set + * + * @param prompt Prompt, e.g. built with {@link Menu#prompt(Consumer)} to share it + * @return This creator + * @since 2.4 + */ + public MenuItemCreator prompt(MenuPrompt prompt) { + this.prompt = Objects.requireNonNull(prompt, "prompt must not be null"); + return this; + } + + /** + * Set the prompt played when the menu is repeated, after a timeout or an input that does not + * match. The API only accepts it when a prompt is also set + * + * @param repeatPrompt Lambda filling the prompt + * @return This creator + * @since 2.4 + */ + public MenuItemCreator repeatPrompt(Consumer repeatPrompt) { + return repeatPrompt(Menu.prompt(repeatPrompt)); + } + + /** + * Set the prompt played when the menu is repeated, after a timeout or an input that does not + * match. The API only accepts it when a prompt is also set + * + * @param repeatPrompt Prompt, e.g. built with {@link Menu#prompt(Consumer)} to share it + * @return This creator + * @since 2.4 + */ + public MenuItemCreator repeatPrompt(MenuPrompt repeatPrompt) { + this.repeatPrompt = Objects.requireNonNull(repeatPrompt, "repeatPrompt must not be null"); + return this; + } + + /** + * Set how long to wait for input before the attempt times out + * + * @param seconds Timeout in seconds, from 1 to 60. The API default is 5 + * @return This creator + * @since 2.4 + */ + public MenuItemCreator inputTimeoutDurationSeconds(int seconds) { + this.inputTimeoutDurationSeconds = seconds; + return this; + } + + /** + * Set how many times the menu is repeated, after a timeout or an input that does not match + * + * @param repeatCount Maximum number of repetitions, from 0 to 10. The API default is 2 + * @return This creator + * @since 2.4 + */ + public MenuItemCreator repeatCount(int repeatCount) { + this.repeatCount = repeatCount; + return this; + } + + /** + * Set the number of input characters required before the input is evaluated + * + * @param length Minimum input length, from 1 to 100. The API default is 1 + * @return This creator + * @since 2.4 + */ + public MenuItemCreator minimumInputLength(int length) { + this.minimumInputLength = length; + return this; + } + + /** + * Set the number of input characters that triggers the evaluation of the input + * + * @param length Maximum input length, from 1 to 100. The API default is 1 + * @return This creator + * @since 2.4 + */ + public MenuItemCreator maximumInputLength(int length) { + this.maximumInputLength = length; + return this; + } + + /** + * Set the characters that end the input and trigger its evaluation, e.g. #. They are + * included in the evaluated input + * + * @param terminatingSequence Terminating sequence: digits, * and #, at + * most 10 characters + * @return This creator + * @since 2.4 + */ + public MenuItemCreator terminatingSequence(String terminatingSequence) { + this.terminatingSequence = + Objects.requireNonNull(terminatingSequence, "terminatingSequence must not be null"); + return this; + } + + /** + * Set the accepted input methods. The API default is DTMF + * + * @param inputMethods Input methods + * @return This creator + * @since 2.4 + */ + public MenuItemCreator inputMethods(MenuItem.InputMethodsEnum... inputMethods) { + Objects.requireNonNull(inputMethods, "inputMethods must not be null"); + List list = new ArrayList<>(); + for (MenuItem.InputMethodsEnum inputMethod : Arrays.asList(inputMethods)) { + list.add(Objects.requireNonNull(inputMethod, "inputMethod must not be null")); + } + this.inputMethods = list; + return this; + } + + /** + * Add commands to run when the input matches + * + *

The inputs are evaluated in the order they are first added: the first one that matches runs + * its commands. At most 50 inputs per item. + * + *

The input can be a regular expression, e.g. [2-4], so the star tone must be + * escaped: write "\\*" to match it. + * + * @param input Literal string or regular expression, 1 to 256 characters + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator match(String input, SvamlCommand... commands) { + Objects.requireNonNull(input, "input must not be null"); + matches.put(input, Commands.append(matches.get(input), commands)); + return this; + } + + /** + * Add commands to run when the input matches + * + *

The inputs are evaluated in the order they are first added: the first one that matches runs + * its commands. At most 50 inputs per item. + * + *

The input can be a regular expression, e.g. [2-4], so the star tone must be + * escaped: write "\\*" to match it. + * + * @param input Literal string or regular expression, 1 to 256 characters + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator match(String input, Collection commands) { + Objects.requireNonNull(input, "input must not be null"); + matches.put(input, Commands.append(matches.get(input), commands)); + return this; + } + + /** + * Add commands to run when no input matches once the repeat limit is reached + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator onFailure(SvamlCommand... commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + /** + * Add commands to run when no input matches once the repeat limit is reached + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator onFailure(Collection commands) { + onFailure = Commands.append(onFailure, commands); + return this; + } + + MenuItem build() { + MenuItem.Builder builder = MenuItem.builder(); + if (null != prompt) { + builder.setPrompt(prompt); + } + if (null != repeatPrompt) { + builder.setRepeatPrompt(repeatPrompt); + } + if (null != inputTimeoutDurationSeconds) { + builder.setInputTimeoutDurationSeconds(inputTimeoutDurationSeconds); + } + if (null != repeatCount) { + builder.setRepeatCount(repeatCount); + } + if (null != minimumInputLength) { + builder.setMinimumInputLength(minimumInputLength); + } + if (null != maximumInputLength) { + builder.setMaximumInputLength(maximumInputLength); + } + if (null != terminatingSequence) { + builder.setTerminatingSequence(terminatingSequence); + } + if (null != inputMethods) { + builder.setInputMethods(new ArrayList<>(inputMethods)); + } + if (!matches.isEmpty()) { + Map> copy = new LinkedHashMap<>(); + matches.forEach((input, commands) -> copy.put(input, Commands.copy(commands))); + builder.setMatches(copy); + } + if (null != onFailure) { + builder.setOnFailure(Commands.copy(onFailure)); + } + return builder.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java index 0a104e089..867ff509b 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java @@ -13,8 +13,8 @@ /** * Fluent creator of a messages command, returned by {@link Messages#start()} * - *

Messages are played in the order they are added. Calling {@link #onFinish} more than once - * appends the commands. + *

Messages are played in the order they are added, at most 10. Calling {@link #onFinish} more + * than once appends the commands. * * @since 2.4 */ diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java new file mode 100644 index 000000000..bafc27020 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java @@ -0,0 +1,85 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.Message; +import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; +import java.util.ArrayList; +import java.util.List; + +/** + * Fluent creator of a menu prompt, given to the lambdas of {@link MenuItemCreator#prompt} and + * {@link Menu#prompt} + * + *

Messages are played in the order they are added, at most 10. + * + * @since 2.4 + */ +public final class PromptCreator { + + private final List messages = new ArrayList<>(); + private Boolean allowBargeIn; + + PromptCreator() {} + + /** + * Add a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @since 2.4 + */ + public PromptCreator text(String text, String voiceName) { + messages.add(Messages.sayMessage(text, voiceName, null)); + return this; + } + + /** + * Add a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @since 2.4 + */ + public PromptCreator ssml(String ssml, String voiceName) { + messages.add(Messages.sayMessage(ssml, voiceName, SayMessage.FormatEnum.SSML)); + return this; + } + + /** + * Add an audio file to play + * + * @param url URL of the media to play + * @return This creator + * @since 2.4 + */ + public PromptCreator play(String url) { + messages.add(Messages.playMessage(url)); + return this; + } + + /** + * Set whether user input interrupts the prompt. The API default is true + * + * @param allowBargeIn false to play the whole prompt before evaluating the input, + * which is still collected during playback + * @return This creator + * @since 2.4 + */ + public PromptCreator allowBargeIn(boolean allowBargeIn) { + this.allowBargeIn = allowBargeIn; + return this; + } + + MenuPrompt build() { + if (messages.isEmpty()) { + throw new IllegalStateException("prompt requires at least one text, ssml or play message"); + } + MenuPrompt.Builder builder = MenuPrompt.builder().setMessages(new ArrayList<>(messages)); + if (null != allowBargeIn) { + builder.setAllowBargeIn(allowBargeIn); + } + return builder.build(); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java new file mode 100644 index 000000000..a6a0333e0 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java @@ -0,0 +1,318 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuItem; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class MenuTest extends HelpersBaseTest { + + static final String PROMPT_JSON = + "{\"messages\":[{\"type\":\"SAY\",\"say\":{\"text\":\"Press 1\",\"voiceName\":\"Emma\"}}]}"; + + static final Consumer PRESS_1 = prompt -> prompt.text("Press 1", "Emma"); + + @Test + void gotoMenu() throws JsonProcessingException, JSONException { + assertJson(Menu.gotoMenu("billing"), "{\"command\":\"gotoMenu\",\"menuName\":\"billing\"}"); + } + + @Test + void createWithOneItem() throws JsonProcessingException, JSONException { + assertJson( + Menu.create("main").item("main", item -> item.prompt(PRESS_1)).build(), + "{\"command\":\"menu\",\"startMenu\":\"main\",\"menus\":{\"main\":{\"prompt\":" + + PROMPT_JSON + + "}}}"); + } + + @Test + void itemAllFields() throws JsonProcessingException, JSONException { + MenuCommand menu = + Menu.create("main") + .item( + "main", + item -> + item.prompt( + prompt -> prompt.play("https://example.com/a.mp3").allowBargeIn(false)) + .repeatPrompt(PRESS_1) + .inputTimeoutDurationSeconds(10) + .repeatCount(3) + .minimumInputLength(1) + .maximumInputLength(4) + .terminatingSequence("#") + .inputMethods(MenuItem.InputMethodsEnum.DTMF) + .match("1", Menu.gotoMenu("main")) + .onFailure(Calls.hangup("origin"))) + .build(); + + assertJson( + menu, + "{\"command\":\"menu\",\"startMenu\":\"main\",\"menus\":{\"main\":{" + + "\"prompt\":{\"allowBargeIn\":false,\"messages\":[{\"type\":\"PLAY\"," + + "\"play\":{\"url\":\"https://example.com/a.mp3\"}}]}," + + "\"repeatPrompt\":" + + PROMPT_JSON + + ",\"inputTimeoutDurationSeconds\":10,\"repeatCount\":3," + + "\"minimumInputLength\":1,\"maximumInputLength\":4,\"terminatingSequence\":\"#\"," + + "\"inputMethods\":[\"DTMF\"]," + + "\"matches\":{\"1\":[{\"command\":\"gotoMenu\",\"menuName\":\"main\"}]}," + + "\"onFailure\":[{\"command\":\"hangup\",\"callName\":\"origin\"}]}}}"); + } + + @Test + void matchesAppendPerInput() throws JsonProcessingException, JSONException { + MenuCommand menu = + Menu.create("main") + .item( + "main", + item -> + item.match("1", Calls.pause(500)) + .match("1", Arrays.asList(Calls.hangup())) + .match("2", Calls.answer())) + .build(); + + assertJson( + menu, + "{\"command\":\"menu\",\"startMenu\":\"main\",\"menus\":{\"main\":{\"matches\":{" + + "\"1\":[{\"command\":\"pause\",\"durationMilliseconds\":500}," + + HANGUP_JSON + + "],\"2\":[{\"command\":\"answer\"}]}}}}"); + } + + @Test + void sharedPromptAndPrebuiltItem() throws JsonProcessingException, JSONException { + MenuPrompt shared = Menu.prompt(PRESS_1); + MenuItem support = MenuItem.builder().setPrompt(shared).build(); + + assertJson( + Menu.create("main") + .item("main", item -> item.prompt(shared).repeatPrompt(shared)) + .item("support", support) + .build(), + "{\"command\":\"menu\",\"startMenu\":\"main\",\"menus\":{" + + "\"main\":{\"prompt\":" + + PROMPT_JSON + + ",\"repeatPrompt\":" + + PROMPT_JSON + + "},\"support\":{\"prompt\":" + + PROMPT_JSON + + "}}}"); + } + + @Test + void gotoAcrossItemsIsValid() { + Assertions.assertThatCode( + () -> + Menu.create("main") + .item("main", item -> item.match("2", Menu.gotoMenu("support"))) + .item("support", item -> item.onFailure(Menu.gotoMenu("main"))) + .build()) + .doesNotThrowAnyException(); + } + + @Test + void promptRequiresAMessage() { + Assertions.assertThatThrownBy(() -> Menu.prompt(prompt -> prompt.allowBargeIn(true))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void rejectsDuplicatedItem() { + Assertions.assertThatThrownBy( + () -> Menu.create("main").item("main", item -> {}).item("main", item -> {})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"main\""); + } + + @Test + void duplicatedItemLambdaIsNotRun() { + AtomicBoolean run = new AtomicBoolean(); + MenuCreator menu = Menu.create("main").item("main", item -> {}); + + Assertions.assertThatThrownBy(() -> menu.item("main", item -> run.set(true))) + .isInstanceOf(IllegalArgumentException.class); + Assertions.assertThat(run).isFalse(); + } + + @Test + void promptWithSsml() throws JsonProcessingException, JSONException { + assertJson( + Menu.prompt(prompt -> prompt.ssml("Press 1", "Emma")), + "{\"messages\":[{\"type\":\"SAY\",\"say\":{\"text\":\"Press 1\"," + + "\"voiceName\":\"Emma\",\"format\":\"SSML\"}}]}"); + } + + @Test + void reusableItem() throws JsonProcessingException, JSONException { + MenuItem shared = Menu.item(item -> item.prompt(PRESS_1).onFailure(Calls.hangup())); + + assertJson( + Menu.create("main").item("main", shared).build(), + "{\"command\":\"menu\",\"startMenu\":\"main\",\"menus\":{\"main\":{\"prompt\":" + + PROMPT_JSON + + ",\"onFailure\":[" + + HANGUP_JSON + + "]}}}"); + } + + @Test + void rejectsUndefinedGotoTargetInPrebuiltItem() { + MenuItem item = + MenuItem.builder() + .setMatches( + Collections.singletonMap("1", Collections.singletonList(Menu.gotoMenu("missing")))) + .build(); + + Assertions.assertThatThrownBy(() -> Menu.create("main").item("main", item).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void rejectsUndefinedGotoTargetInMessagesOnFinish() { + Assertions.assertThatThrownBy( + () -> + Menu.create("main") + .item( + "main", + item -> + item.match( + "1", + Messages.start() + .text("Bye", "Emma") + .onFinish(Menu.gotoMenu("missing")) + .build())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void rejectsUndefinedGotoTargetInRecordingHandlers() { + Assertions.assertThatThrownBy( + () -> + Menu.create("main") + .item( + "main", + item -> + item.onFailure( + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .onFailure(Menu.gotoMenu("missing")) + .build())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + Assertions.assertThatThrownBy( + () -> + Menu.create("main") + .item( + "main", + item -> + item.onFailure( + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .onFinish(Menu.gotoMenu("missing")) + .build())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void rejectsHandBuiltNestedMenuWithUndefinedStartMenu() { + MenuCommand nested = + MenuCommand.builder() + .setStartMenu("other") + .setMenus(Collections.singletonMap("inner", MenuItem.builder().build())) + .build(); + + Assertions.assertThatThrownBy( + () -> Menu.create("main").item("main", item -> item.match("9", nested)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"other\""); + } + + @Test + void rejectsHandBuiltNestedMenuWithUndefinedGotoTarget() { + MenuItem inner = + MenuItem.builder().setOnFailure(Collections.singletonList(Menu.gotoMenu("main"))).build(); + MenuCommand nested = + MenuCommand.builder() + .setStartMenu("inner") + .setMenus(Collections.singletonMap("inner", inner)) + .build(); + + // "main" is an item of the outer menu, not of the nested one + Assertions.assertThatThrownBy( + () -> Menu.create("main").item("main", item -> item.match("9", nested)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"main\""); + } + + @Test + void rejectsUndefinedStartMenu() { + Assertions.assertThatThrownBy(() -> Menu.create("other").item("main", item -> {}).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"other\""); + } + + @Test + void rejectsUndefinedGotoTarget() { + Assertions.assertThatThrownBy( + () -> + Menu.create("main") + .item("main", item -> item.match("1", Menu.gotoMenu("missing"))) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void rejectsUndefinedGotoTargetInNestedHandler() { + Assertions.assertThatThrownBy( + () -> + Menu.create("main") + .item( + "main", + item -> + item.onFailure( + Calls.dial("+15551234567") + .onBusy( + Amd.activate().onMachine(Menu.gotoMenu("missing")).build()) + .build())) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void nestedMenuKeepsItsOwnItems() { + MenuCommand nested = + Menu.create("inner").item("inner", item -> item.match("1", Menu.gotoMenu("inner"))).build(); + + Assertions.assertThatCode( + () -> Menu.create("main").item("main", item -> item.match("9", nested)).build()) + .doesNotThrowAnyException(); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> Menu.create(null)).isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Menu.gotoMenu(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Menu.prompt(null)).isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Menu.create("main").item(null, item -> {})) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> Menu.create("main").item("main", item -> item.match(null, Calls.hangup()))) + .isInstanceOf(NullPointerException.class); + } +} From d4e9e35e4f6084cb5fe6bb271e2dfdec155374b6 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 20 Oct 2026 11:25:21 +0200 Subject: [PATCH 05/17] DEVEXP-1473: Add Voice V2 CommandsSequenceCreator --- CHANGELOG.md | 2 +- .../voice/models/v2/helpers/AmdCreator.java | 49 +++ .../voice/models/v2/helpers/Commands.java | 10 + .../v2/helpers/CommandsSequenceCreator.java | 402 ++++++++++++++++++ .../voice/models/v2/helpers/DialCreator.java | 73 ++++ .../voice/models/v2/helpers/MenuCreator.java | 7 +- .../models/v2/helpers/MenuItemCreator.java | 26 ++ .../models/v2/helpers/MessagesCreator.java | 13 + .../models/v2/helpers/RecordingCreator.java | 25 ++ .../helpers/CommandsSequenceCreatorTest.java | 338 +++++++++++++++ 10 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0fb14c4..84515e5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Voice - **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` -- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents` and `Menu` +- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu` and `CommandsSequenceCreator`. Event handlers accept a sequence lambda --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java index 1779c25fb..9d8ca2204 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/AmdCreator.java @@ -5,6 +5,7 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.amd.AmdEvents; import java.util.Collection; import java.util.List; +import java.util.function.Consumer; /** * Fluent creator of an amd command, returned by {@link Amd#activate()} @@ -46,6 +47,18 @@ public AmdCreator onHuman(Collection commands) { return this; } + /** + * Add commands to run when a human answers + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onHuman(Consumer sequence) { + onHuman = Commands.append(onHuman, sequence); + return this; + } + /** * Add commands to run when a machine answers * @@ -70,6 +83,18 @@ public AmdCreator onMachine(Collection commands) { return this; } + /** + * Add commands to run when a machine answers + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onMachine(Consumer sequence) { + onMachine = Commands.append(onMachine, sequence); + return this; + } + /** * Add commands to run when a beep is detected * @@ -94,6 +119,18 @@ public AmdCreator onBeep(Collection commands) { return this; } + /** + * Add commands to run when a beep is detected + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onBeep(Consumer sequence) { + onBeep = Commands.append(onBeep, sequence); + return this; + } + /** * Add commands to run when the result is unknown * @@ -118,6 +155,18 @@ public AmdCreator onUnknown(Collection commands) { return this; } + /** + * Add commands to run when the result is unknown + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public AmdCreator onUnknown(Consumer sequence) { + onUnknown = Commands.append(onUnknown, sequence); + return this; + } + /** * Build the amd command * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java index 430748410..f4d12584f 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Commands.java @@ -6,6 +6,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** Collects the commands given to the event handlers of the creators */ final class Commands { @@ -31,6 +32,15 @@ static List append( return list; } + /** Append the commands added by a lambda to a new sequence */ + static List append( + List current, Consumer sequence) { + Objects.requireNonNull(sequence, "sequence must not be null"); + CommandsSequenceCreator creator = new CommandsSequenceCreator(); + sequence.accept(creator); + return append(current, creator.commands()); + } + /** Copy taken at build time, so a creator reused after build() does not alias it */ static List copy(List commands) { return null == commands ? null : new ArrayList<>(commands); diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java new file mode 100644 index 000000000..53ab77204 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java @@ -0,0 +1,402 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Fluent creator of an ordered sequence of SVAML commands, e.g. the commands of a call + * or of a Sinch Event response + * + *

Each method adds one command, built as the helper of the same name would. The creators of the + * commands with events are given to a lambda, so no intermediate build() is needed: + * + *

{@code
+ * List commands =
+ *     CommandsSequenceCreator.create()
+ *         .dial("+34600111222", dial -> dial
+ *             .from("+34910000000")
+ *             .onAnswer(answered -> answered
+ *                 .text("Connecting you to support", "Emma")
+ *                 .bridgeCall("support-queue")))
+ *         .build();
+ * }
+ * + *

The event handlers of every creator also accept such a lambda. + * + * @since 2.4 + */ +public final class CommandsSequenceCreator { + + private final List commands = new ArrayList<>(); + + CommandsSequenceCreator() {} + + /** + * Start a new sequence + * + * @return The creator + * @since 2.4 + */ + public static CommandsSequenceCreator create() { + return new CommandsSequenceCreator(); + } + + /** + * Add an answer command + * + * @return This creator + * @see Calls#answer() + * @since 2.4 + */ + public CommandsSequenceCreator answer() { + return command(Calls.answer()); + } + + /** + * Add a bridgeCall command + * + * @param name Name of the bridge + * @return This creator + * @see Calls#bridgeCall(String) + * @since 2.4 + */ + public CommandsSequenceCreator bridgeCall(String name) { + return command(Calls.bridgeCall(name)); + } + + /** + * Add a dial command with no other field than its destination + * + * @param to Call destination + * @return This creator + * @see Calls#dial(CallDestination) + * @since 2.4 + */ + public CommandsSequenceCreator dial(CallDestination to) { + return command(Calls.dial(to).build()); + } + + /** + * Add a dial command with no other field than its destination + * + * @param to Call destination, parsed with {@link Destination#of(String)} + * @return This creator + * @see Calls#dial(String) + * @since 2.4 + */ + public CommandsSequenceCreator dial(String to) { + return command(Calls.dial(to).build()); + } + + /** + * Add a dial command + * + * @param to Call destination + * @param dial Lambda filling the other fields and the events + * @return This creator + * @see Calls#dial(CallDestination) + * @since 2.4 + */ + public CommandsSequenceCreator dial(CallDestination to, Consumer dial) { + DialCreator creator = Calls.dial(to); + Objects.requireNonNull(dial, "dial must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a dial command + * + * @param to Call destination, parsed with {@link Destination#of(String)} + * @param dial Lambda filling the other fields and the events + * @return This creator + * @see Calls#dial(String) + * @since 2.4 + */ + public CommandsSequenceCreator dial(String to, Consumer dial) { + return dial(Destination.of(to), dial); + } + + /** + * Add a hangup command ending the current call leg + * + * @return This creator + * @see Calls#hangup() + * @since 2.4 + */ + public CommandsSequenceCreator hangup() { + return command(Calls.hangup()); + } + + /** + * Add a hangup command + * + * @param name Name of the call leg to end + * @return This creator + * @see Calls#hangup(String) + * @since 2.4 + */ + public CommandsSequenceCreator hangup(String name) { + return command(Calls.hangup(name)); + } + + /** + * Add a pause command + * + * @param durationMilliseconds Duration of the pause in milliseconds + * @return This creator + * @see Calls#pause(int) + * @since 2.4 + */ + public CommandsSequenceCreator pause(int durationMilliseconds) { + return command(Calls.pause(durationMilliseconds)); + } + + /** + * Add an amd command + * + * @param amd Lambda filling the events + * @return This creator + * @see Amd#activate() + * @since 2.4 + */ + public CommandsSequenceCreator amd(Consumer amd) { + AmdCreator creator = Amd.activate(); + Objects.requireNonNull(amd, "amd must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a messages command playing a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @see Messages#text(String, String) + * @since 2.4 + */ + public CommandsSequenceCreator text(String text, String voiceName) { + return command(Messages.text(text, voiceName)); + } + + /** + * Add a messages command playing a text-to-speech message in SSML + * + * @param ssml SSML document to synthesize + * @param voiceName Voice used for speech synthesis + * @return This creator + * @see Messages#ssml(String, String) + * @since 2.4 + */ + public CommandsSequenceCreator ssml(String ssml, String voiceName) { + return command(Messages.ssml(ssml, voiceName)); + } + + /** + * Add a messages command playing an audio file + * + * @param url URL of the media to play + * @return This creator + * @see Messages#play(String) + * @since 2.4 + */ + public CommandsSequenceCreator play(String url) { + return command(Messages.play(url)); + } + + /** + * Add a messages command + * + * @param messages Lambda adding the messages, the name and the events + * @return This creator + * @see Messages#start() + * @since 2.4 + */ + public CommandsSequenceCreator messages(Consumer messages) { + MessagesCreator creator = Messages.start(); + Objects.requireNonNull(messages, "messages must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a stopMessages command + * + * @param name Name of the message sequence + * @return This creator + * @see Messages#stop(String) + * @since 2.4 + */ + public CommandsSequenceCreator stopMessages(String name) { + return command(Messages.stop(name)); + } + + /** + * Add a startRecording command with no other field than its recording options + * + * @param destination Third party storage the recording is delivered to + * @param url Destination URL + * @param credentials Credentials to the third party storage + * @return This creator + * @see Recording#start(RecordingDestinationType, String, String) + * @since 2.4 + */ + public CommandsSequenceCreator recording( + RecordingDestinationType destination, String url, String credentials) { + return command(Recording.start(destination, url, credentials).build()); + } + + /** + * Add a startRecording command + * + * @param destination Third party storage the recording is delivered to + * @param url Destination URL + * @param credentials Credentials to the third party storage + * @param recording Lambda filling the other fields and the events + * @return This creator + * @see Recording#start(RecordingDestinationType, String, String) + * @since 2.4 + */ + public CommandsSequenceCreator recording( + RecordingDestinationType destination, + String url, + String credentials, + Consumer recording) { + RecordingCreator creator = Recording.start(destination, url, credentials); + Objects.requireNonNull(recording, "recording must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a startRecording command with no other field than its recording options + * + * @param options Recording options + * @return This creator + * @see Recording#start(RecordingOptions) + * @since 2.4 + */ + public CommandsSequenceCreator recording(RecordingOptions options) { + return command(Recording.start(options).build()); + } + + /** + * Add a startRecording command + * + * @param options Recording options + * @param recording Lambda filling the other fields and the events + * @return This creator + * @see Recording#start(RecordingOptions) + * @since 2.4 + */ + public CommandsSequenceCreator recording( + RecordingOptions options, Consumer recording) { + RecordingCreator creator = Recording.start(options); + Objects.requireNonNull(recording, "recording must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a stopRecording command + * + * @param name Name of the recording + * @return This creator + * @see Recording#stop(String) + * @since 2.4 + */ + public CommandsSequenceCreator stopRecording(String name) { + return command(Recording.stop(name)); + } + + /** + * Add a customEvent command + * + * @param name Name of the custom event + * @param url URL the event is sent to + * @return This creator + * @see CustomEvents#trigger(String, String) + * @since 2.4 + */ + public CommandsSequenceCreator customEvents(String name, String url) { + return command(CustomEvents.trigger(name, url)); + } + + /** + * Add a customEvent command with a fallback URL + * + * @param name Name of the custom event + * @param url URL the event is sent to + * @param fallbackUrl URL a failed request is sent to + * @return This creator + * @see CustomEvents#trigger(String, String, String) + * @since 2.4 + */ + public CommandsSequenceCreator customEvents(String name, String url, String fallbackUrl) { + return command(CustomEvents.trigger(name, url, fallbackUrl)); + } + + /** + * Add a menu command + * + * @param startMenu Name of the item played first + * @param menu Lambda adding the items + * @return This creator + * @see Menu#create(String) + * @since 2.4 + */ + public CommandsSequenceCreator menu(String startMenu, Consumer menu) { + MenuCreator creator = Menu.create(startMenu); + Objects.requireNonNull(menu, "menu must not be null").accept(creator); + return command(creator.build()); + } + + /** + * Add a gotoMenu command. Only valid within a menu + * + * @param name Name of the menu item + * @return This creator + * @see Menu#gotoMenu(String) + * @since 2.4 + */ + public CommandsSequenceCreator gotoMenu(String name) { + return command(Menu.gotoMenu(name)); + } + + /** + * Add commands built by other means, e.g. with the model builders + * + * @param commands Commands to add + * @return This creator + * @since 2.4 + */ + public CommandsSequenceCreator command(SvamlCommand... commands) { + Commands.append(this.commands, commands); + return this; + } + + /** + * Build the sequence + * + * @return A new list with the commands, in the order they were added + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu of the sequence has a start menu or a gotoMenu target that is not one of + * its items + * @since 2.4 + */ + public List build() { + MenuCreator.checkGotoTargets(commands, Collections.emptySet()); + return commands(); + } + + /** + * The commands, not checked: a sequence filled in an event handler lambda may sit within a menu + * whose items are not known here. The enclosing menu or sequence checks them when built + */ + List commands() { + return new ArrayList<>(commands); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java index e7d434268..e5fec10c5 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/DialCreator.java @@ -8,6 +8,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * Fluent creator of a dial command, returned by {@link Calls#dial(CallDestination)} @@ -117,6 +118,18 @@ public DialCreator onAnswer(Collection commands) { return this; } + /** + * Add commands to run when the call is answered + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onAnswer(Consumer sequence) { + onAnswer = Commands.append(onAnswer, sequence); + return this; + } + /** * Add commands to run when the callee is busy * @@ -141,6 +154,18 @@ public DialCreator onBusy(Collection commands) { return this; } + /** + * Add commands to run when the callee is busy + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onBusy(Consumer sequence) { + onBusy = Commands.append(onBusy, sequence); + return this; + } + /** * Add commands to run when the call is rejected * @@ -165,6 +190,18 @@ public DialCreator onReject(Collection commands) { return this; } + /** + * Add commands to run when the call is rejected + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onReject(Consumer sequence) { + onReject = Commands.append(onReject, sequence); + return this; + } + /** * Add commands to run when the call is not answered in time * @@ -189,6 +226,18 @@ public DialCreator onTimeout(Collection commands) { return this; } + /** + * Add commands to run when the call is not answered in time + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onTimeout(Consumer sequence) { + onTimeout = Commands.append(onTimeout, sequence); + return this; + } + /** * Add commands to run when the call is hung up * @@ -213,6 +262,18 @@ public DialCreator onHangup(Collection commands) { return this; } + /** + * Add commands to run when the call is hung up + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onHangup(Consumer sequence) { + onHangup = Commands.append(onHangup, sequence); + return this; + } + /** * Add commands to run when the call fails * @@ -237,6 +298,18 @@ public DialCreator onFailure(Collection commands) { return this; } + /** + * Add commands to run when the call fails + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public DialCreator onFailure(Consumer sequence) { + onFailure = Commands.append(onFailure, sequence); + return this; + } + /** * Build the dial command * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java index 7638d6079..9afd7023a 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuCreator.java @@ -108,7 +108,8 @@ static void checkMenu(String startMenu, Map menus) { /** * A gotoMenu target must be an item of the enclosing menu. Nested event handlers are * searched too. A nested menu is checked against its own items, so one built without - * this creator (e.g. with {@link MenuCommand#builder()}) is checked as well. + * this creator (e.g. with {@link MenuCommand#builder()}) is checked as well. With no names, the + * commands are outside any menu, where gotoMenu is not valid. */ static void checkGotoTargets(List commands, Set names) { if (null == commands) { @@ -117,6 +118,10 @@ static void checkGotoTargets(List commands, Set names) { for (SvamlCommand command : commands) { if (command instanceof GotoMenuCommand) { String target = ((GotoMenuCommand) command).getMenuName(); + if (names.isEmpty()) { + throw new IllegalStateException( + String.format("gotoMenu \"%s\" is only valid within a menu", target)); + } if (!names.contains(target)) { throw new IllegalStateException(String.format("gotoMenu \"%s\" is not defined", target)); } diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java index 6acc46455..a41259af5 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MenuItemCreator.java @@ -204,6 +204,20 @@ public MenuItemCreator match(String input, Collection co return this; } + /** + * Add commands to run when the input matches + * + * @param input Literal string or regular expression, see {@link #match(String, SvamlCommand...)} + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator match(String input, Consumer sequence) { + Objects.requireNonNull(input, "input must not be null"); + matches.put(input, Commands.append(matches.get(input), sequence)); + return this; + } + /** * Add commands to run when no input matches once the repeat limit is reached * @@ -228,6 +242,18 @@ public MenuItemCreator onFailure(Collection commands) { return this; } + /** + * Add commands to run when no input matches once the repeat limit is reached + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public MenuItemCreator onFailure(Consumer sequence) { + onFailure = Commands.append(onFailure, sequence); + return this; + } + MenuItem build() { MenuItem.Builder builder = MenuItem.builder(); if (null != prompt) { diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java index 867ff509b..edf221e93 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java @@ -9,6 +9,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * Fluent creator of a messages command, returned by {@link Messages#start()} @@ -101,6 +102,18 @@ public MessagesCreator onFinish(Collection commands) { return this; } + /** + * Add commands to run when all the messages have been played + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public MessagesCreator onFinish(Consumer sequence) { + onFinish = Commands.append(onFinish, sequence); + return this; + } + /** * Build the messages command * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java index 520d1e738..df8b54208 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java @@ -11,6 +11,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * Fluent creator of a startRecording command @@ -126,6 +127,18 @@ public RecordingCreator onFinish(Collection commands) { return this; } + /** + * Add commands to run when the recording is stopped + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFinish(Consumer sequence) { + onFinish = Commands.append(onFinish, sequence); + return this; + } + /** * Add commands to run when the recording fails to start * @@ -150,6 +163,18 @@ public RecordingCreator onFailure(Collection commands) { return this; } + /** + * Add commands to run when the recording fails to start + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public RecordingCreator onFailure(Consumer sequence) { + onFailure = Commands.append(onFailure, sequence); + return this; + } + /** * Build the startRecording command * diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java new file mode 100644 index 000000000..85205c231 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java @@ -0,0 +1,338 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; +import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingFormatType; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class CommandsSequenceCreatorTest extends HelpersBaseTest { + + void assertSameJson(Object actual, Object expected) + throws JsonProcessingException, JSONException { + assertJson(actual, objectMapper.writeValueAsString(expected)); + } + + @Test + void simpleMethodsAddTheHelperCommand() throws JsonProcessingException, JSONException { + List sequence = + CommandsSequenceCreator.create() + .answer() + .bridgeCall("queue") + .dial("+15551234567") + .dial(Destination.phone("+15550000000")) + .hangup() + .hangup("leg") + .pause(100) + .text("Hello", "Emma") + .ssml("Hello", "Emma") + .play("https://example.com/a.mp3") + .stopMessages("greeting") + .recording(RecordingDestinationType.AWS, "s3://b", "c") + .recording(Recording.options(RecordingDestinationType.GCP, "gs://b", "c")) + .stopRecording("rec") + .customEvents("event", "https://example.com") + .customEvents("event", "https://example.com", "https://fallback.example.com") + .command(Calls.pause(200), Calls.answer()) + .build(); + + assertSameJson( + sequence, + Arrays.asList( + Calls.answer(), + Calls.bridgeCall("queue"), + Calls.dial("+15551234567").build(), + Calls.dial(Destination.phone("+15550000000")).build(), + Calls.hangup(), + Calls.hangup("leg"), + Calls.pause(100), + Messages.text("Hello", "Emma"), + Messages.ssml("Hello", "Emma"), + Messages.play("https://example.com/a.mp3"), + Messages.stop("greeting"), + Recording.start(RecordingDestinationType.AWS, "s3://b", "c").build(), + Recording.start(Recording.options(RecordingDestinationType.GCP, "gs://b", "c")).build(), + Recording.stop("rec"), + CustomEvents.trigger("event", "https://example.com"), + CustomEvents.trigger("event", "https://example.com", "https://fallback.example.com"), + Calls.pause(200), + Calls.answer())); + } + + @Test + void lambdaMethodsAddTheCreatorCommand() throws JsonProcessingException, JSONException { + List sequence = + CommandsSequenceCreator.create() + .dial("+15551234567", dial -> dial.name("customer").onBusy(Calls.hangup())) + .amd(amd -> amd.onMachine(Calls.hangup())) + .messages(messages -> messages.text("Hi", "Emma").name("greeting")) + .recording( + RecordingDestinationType.AWS, + "s3://b", + "c", + recording -> recording.format(RecordingFormatType.WAV).name("rec")) + .recording( + Recording.options(RecordingDestinationType.GCP, "gs://b", "c"), + recording -> recording.name("other")) + .menu("main", menu -> menu.item("main", item -> item.match("1", Calls.hangup()))) + .build(); + + assertSameJson( + sequence, + Arrays.asList( + Calls.dial("+15551234567").name("customer").onBusy(Calls.hangup()).build(), + Amd.activate().onMachine(Calls.hangup()).build(), + Messages.start().text("Hi", "Emma").name("greeting").build(), + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .format(RecordingFormatType.WAV) + .name("rec") + .build(), + Recording.start(Recording.options(RecordingDestinationType.GCP, "gs://b", "c")) + .name("other") + .build(), + Menu.create("main").item("main", item -> item.match("1", Calls.hangup())).build())); + } + + @Test + void handlersAcceptASequenceLambda() throws JsonProcessingException, JSONException { + assertSameJson( + Calls.dial("+15551234567") + .onAnswer(answered -> answered.answer().text("Hi", "Emma")) + .onAnswer(more -> more.hangup()) + .build(), + Calls.dial("+15551234567") + .onAnswer(Calls.answer(), Messages.text("Hi", "Emma"), Calls.hangup()) + .build()); + assertSameJson( + Amd.activate().onHuman(human -> human.bridgeCall("queue")).build(), + Amd.activate().onHuman(Calls.bridgeCall("queue")).build()); + assertSameJson( + Messages.start().play("https://example.com/a.mp3").onFinish(done -> done.hangup()).build(), + Messages.start().play("https://example.com/a.mp3").onFinish(Calls.hangup()).build()); + assertSameJson( + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .onFinish(done -> done.hangup()) + .onFailure(failed -> failed.pause(10)) + .build(), + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .onFinish(Calls.hangup()) + .onFailure(Calls.pause(10)) + .build()); + } + + @Test + void menuMatchAndOnFailureAcceptASequenceLambda() throws JsonProcessingException, JSONException { + assertSameJson( + Menu.create("main") + .item( + "main", + item -> + item.match("1", one -> one.text("Bye", "Emma").hangup()) + .match("2", two -> two.gotoMenu("support")) + .onFailure(failed -> failed.hangup())) + .item("support", item -> item.onFailure(failed -> failed.gotoMenu("main"))) + .build(), + Menu.create("main") + .item( + "main", + item -> + item.match("1", Messages.text("Bye", "Emma"), Calls.hangup()) + .match("2", Menu.gotoMenu("support")) + .onFailure(Calls.hangup())) + .item("support", item -> item.onFailure(Menu.gotoMenu("main"))) + .build()); + } + + @Test + void supportCallFlow() throws JsonProcessingException, JSONException { + StartCallRequest request = + StartCallRequest.builder() + .setCommands( + CommandsSequenceCreator.create() + .dial( + "+34600111222", + dial -> + dial.name("customer") + .from("+34910000000") + .timeoutDurationSeconds(25) + .onAnswer( + answered -> + answered.amd( + amd -> + amd.onHuman( + human -> + human + .text( + "Connecting you to support," + + " one moment.", + "Emma") + .recording( + RecordingDestinationType.AWS, + "s3://voice-recordings/support", + "key:secret:eu-west-1", + recording -> + recording.name( + "support-call")) + .bridgeCall("support-queue")) + .onMachine( + machine -> + machine + .pause(1200) + .text( + "This is a call from support," + + " please call us back.", + "Emma") + .hangup()))) + .onHangup(hungUp -> hungUp.stopRecording("support-call"))) + .build()) + .build(); + + assertJson(request, ComposedFlowTest.EXPECTED); + } + + @Test + void gotoMenuWithinAMenuOfTheSequenceIsValid() { + Assertions.assertThatCode( + () -> + CommandsSequenceCreator.create() + .menu( + "main", + menu -> + menu.item( + "main", + item -> + item.onFailure( + failed -> + failed.dial( + "+15551234567", + dial -> + dial.onBusy(busy -> busy.gotoMenu("main")))))) + .build()) + .doesNotThrowAnyException(); + } + + @Test + void rejectsGotoMenuOutsideAMenu() { + Assertions.assertThatThrownBy(() -> CommandsSequenceCreator.create().gotoMenu("main").build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("only valid within a menu"); + } + + @Test + void rejectsGotoMenuOutsideAMenuInNestedHandler() { + Assertions.assertThatThrownBy( + () -> + CommandsSequenceCreator.create() + .dial("+15551234567", dial -> dial.onAnswer(answered -> answered.gotoMenu("x"))) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("only valid within a menu"); + } + + @Test + void rejectsInvalidMenuAddedAsCommand() { + Assertions.assertThatThrownBy( + () -> + CommandsSequenceCreator.create() + .command(MenuCommand.builder().setStartMenu("missing").build()) + .build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("\"missing\""); + } + + @Test + void buildReturnsANewList() { + CommandsSequenceCreator creator = CommandsSequenceCreator.create().answer(); + List first = creator.build(); + + first.add(Calls.hangup()); + creator.hangup("leg"); + + Assertions.assertThat(first).hasSize(2); + Assertions.assertThat(creator.build()).hasSize(2); + } + + @Test + void everyDialHandlerAcceptsASequenceLambda() throws JsonProcessingException, JSONException { + assertSameJson( + Calls.dial("+15551234567") + .onAnswer(s -> s.answer()) + .onBusy(s -> s.pause(1)) + .onReject(s -> s.pause(2)) + .onTimeout(s -> s.pause(3)) + .onHangup(s -> s.pause(4)) + .onFailure(s -> s.hangup()) + .build(), + Calls.dial("+15551234567") + .onAnswer(Calls.answer()) + .onBusy(Calls.pause(1)) + .onReject(Calls.pause(2)) + .onTimeout(Calls.pause(3)) + .onHangup(Calls.pause(4)) + .onFailure(Calls.hangup()) + .build()); + } + + @Test + void everyAmdHandlerAcceptsASequenceLambda() throws JsonProcessingException, JSONException { + assertSameJson( + Amd.activate() + .onHuman(s -> s.answer()) + .onMachine(s -> s.hangup()) + .onBeep(s -> s.pause(1)) + .onUnknown(s -> s.pause(2)) + .build(), + Amd.activate() + .onHuman(Calls.answer()) + .onMachine(Calls.hangup()) + .onBeep(Calls.pause(1)) + .onUnknown(Calls.pause(2)) + .build()); + } + + @Test + void emptySequenceLambdaIsSentAsEmptyList() throws JsonProcessingException, JSONException { + assertJson( + Calls.dial("+15551234567").onBusy(s -> {}).build(), + "{\"command\":\"dial\",\"to\":" + PHONE_JSON + ",\"events\":{\"onBusy\":[]}}"); + } + + @Test + void rejectsNullSequenceLambda() { + Assertions.assertThatThrownBy( + () -> Calls.dial("+15551234567").onAnswer((Consumer) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> Amd.activate().onHuman((Consumer) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> Messages.start().onFinish((Consumer) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> + Recording.start(RecordingDestinationType.AWS, "s3://b", "c") + .onFailure((Consumer) null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> Menu.item(item -> item.match("1", (Consumer) null))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> CommandsSequenceCreator.create().amd(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> CommandsSequenceCreator.create().dial("+15551234567", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> CommandsSequenceCreator.create().command((SvamlCommand) null)) + .isInstanceOf(NullPointerException.class); + } +} From 505e74aca3583eafb9fa899de2b1644e23eac434 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 20 Oct 2026 12:15:49 +0200 Subject: [PATCH 06/17] DEVEXP-1473: Add Voice V2 call behavior and Sinch Event response helpers --- CHANGELOG.md | 1 + .../models/v2/helpers/CallBehaviorHelper.java | 102 ++++++++++++++ .../helpers/IncomingCallResponseCreator.java | 101 +++++++++++++ .../v2/helpers/SinchEventResponses.java | 100 +++++++++++++ .../v2/helpers/StaticCallBehaviorCreator.java | 101 +++++++++++++ .../voice/models/v2/helpers/package-info.java | 9 ++ .../v2/helpers/CallBehaviorHelperTest.java | 132 +++++++++++++++++ .../v2/helpers/SinchEventResponsesTest.java | 133 ++++++++++++++++++ 8 files changed, 679 insertions(+) create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelper.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/IncomingCallResponseCreator.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java create mode 100644 client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/StaticCallBehaviorCreator.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelperTest.java create mode 100644 client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 84515e5eb..a3da08bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Voice - **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` - **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu` and `CommandsSequenceCreator`. Event handlers accept a sequence lambda +- **[feature]** V2 helpers: `CallBehaviorHelper` for services and `SinchEventResponses` for Sinch Events --- ## v2.3.0 - 2026-10-15 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelper.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelper.java new file mode 100644 index 000000000..bf0519e0a --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelper.java @@ -0,0 +1,102 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.services.EventDestinationCallBehavior; +import com.sinch.sdk.domains.voice.models.v2.services.EventDestinationConfiguration; +import com.sinch.sdk.domains.voice.models.v2.services.NoneCallBehavior; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Collection; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Helpers to build the call behavior of a Voice V2 service, used to create or update it + * + * @since 2.4 + */ +public final class CallBehaviorHelper { + + private CallBehaviorHelper() {} + + /** + * No call behavior: incoming calls are not handled. Outbound calls can still be started with the + * API + * + * @return The NONE call behavior + * @since 2.4 + */ + public static NoneCallBehavior none() { + return NoneCallBehavior.NONE_CALL_BEHAVIOR; + } + + /** + * Calls are handled by sending Sinch Events to the backend, which answers with the commands that + * control the call + * + * @param url URL the events are sent to + * @return The event destination call behavior, sent as WEBHOOK + * @since 2.4 + */ + public static EventDestinationCallBehavior eventDestination(String url) { + Objects.requireNonNull(url, "url must not be null"); + return EventDestinationCallBehavior.builder() + .setEventDestination(EventDestinationConfiguration.builder().setUrl(url).build()) + .build(); + } + + /** + * Calls are handled by sending Sinch Events to the backend, with a fallback URL + * + * @param url URL the events are sent to + * @param fallbackUrl URL a failed request is sent to + * @return The event destination call behavior, sent as WEBHOOK + * @see #eventDestination(String) + * @since 2.4 + */ + public static EventDestinationCallBehavior eventDestination(String url, String fallbackUrl) { + Objects.requireNonNull(url, "url must not be null"); + Objects.requireNonNull(fallbackUrl, "fallbackUrl must not be null"); + return EventDestinationCallBehavior.builder() + .setEventDestination( + EventDestinationConfiguration.builder().setUrl(url).setFallbackUrl(fallbackUrl).build()) + .build(); + } + + /** + * Calls are handled by the same commands, with no backend involved + * + *

Named staticBehavior: static is a reserved word in Java. + * + * @param commands Commands run for every call of the service + * @return The creator of the STATIC call behavior + * @since 2.4 + */ + public static StaticCallBehaviorCreator staticBehavior(SvamlCommand... commands) { + return new StaticCallBehaviorCreator(Commands.append(null, commands)); + } + + /** + * Calls are handled by the same commands, with no backend involved + * + * @param commands Commands run for every call of the service + * @return The creator of the STATIC call behavior + * @see #staticBehavior(SvamlCommand...) + * @since 2.4 + */ + public static StaticCallBehaviorCreator staticBehavior( + Collection commands) { + return new StaticCallBehaviorCreator(Commands.append(null, commands)); + } + + /** + * Calls are handled by the same commands, with no backend involved + * + * @param sequence Lambda adding the commands run for every call of the service + * @return The creator of the STATIC call behavior + * @see #staticBehavior(SvamlCommand...) + * @since 2.4 + */ + public static StaticCallBehaviorCreator staticBehavior( + Consumer sequence) { + return new StaticCallBehaviorCreator(Commands.append(null, sequence)); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/IncomingCallResponseCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/IncomingCallResponseCreator.java new file mode 100644 index 000000000..445fee3f3 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/IncomingCallResponseCreator.java @@ -0,0 +1,101 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; +import com.sinch.sdk.domains.voice.models.v2.svaml.IncomingCallResponseEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Fluent creator of the response to a call.incoming Sinch Event + * + *

Returned by {@link SinchEventResponses#incomingCallResponse(SvamlCommand...)}. + * + *

Calling {@link #onHangup} more than once appends the commands. + * + * @since 2.4 + */ +public final class IncomingCallResponseCreator { + + private final List commands; + private String name; + private List onHangup; + + IncomingCallResponseCreator(List commands) { + this.commands = commands; + } + + /** + * Set the name of the incoming call, sent as callName + * + * @param name Name of the call, 1 to 32 characters without spaces + * @return This creator + * @since 2.4 + */ + public IncomingCallResponseCreator name(String name) { + this.name = Objects.requireNonNull(name, "name must not be null"); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public IncomingCallResponseCreator onHangup(SvamlCommand... commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public IncomingCallResponseCreator onHangup(Collection commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public IncomingCallResponseCreator onHangup(Consumer sequence) { + onHangup = Commands.append(onHangup, sequence); + return this; + } + + /** + * Build the response + * + * @return The response, to serialize with the Sinch Events service + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu has a start menu or a gotoMenu target that is not one of its items + * @since 2.4 + */ + public VoiceSinchEventResponse build() { + MenuCreator.checkGotoTargets(commands, Collections.emptySet()); + MenuCreator.checkGotoTargets(onHangup, Collections.emptySet()); + VoiceSinchEventResponse.Builder builder = + VoiceSinchEventResponse.builder().setCommands(Commands.copy(commands)); + if (null != name) { + builder.setCallName(name); + } + if (null != onHangup) { + builder.setEvents( + IncomingCallResponseEvents.builder().setOnHangup(Commands.copy(onHangup)).build()); + } + return builder.build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java new file mode 100644 index 000000000..0b1373422 --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java @@ -0,0 +1,100 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +/** + * Helpers to build the response returned to a Voice V2 Sinch Event + * + *

Serialize the response with the serializeResponse method of the Sinch Events + * service. + * + * @since 2.4 + */ +public final class SinchEventResponses { + + private SinchEventResponses() {} + + /** + * Build the response to a call.incoming event, which can also name the call and set + * the commands to run when it is hung up + * + * @param commands Commands to run + * @return The creator of the response + * @since 2.4 + */ + public static IncomingCallResponseCreator incomingCallResponse(SvamlCommand... commands) { + return new IncomingCallResponseCreator(Commands.append(null, commands)); + } + + /** + * Build the response to a call.incoming event + * + * @param commands Commands to run + * @return The creator of the response + * @see #incomingCallResponse(SvamlCommand...) + * @since 2.4 + */ + public static IncomingCallResponseCreator incomingCallResponse( + Collection commands) { + return new IncomingCallResponseCreator(Commands.append(null, commands)); + } + + /** + * Build the response to a call.incoming event + * + * @param sequence Lambda adding the commands to run + * @return The creator of the response + * @see #incomingCallResponse(SvamlCommand...) + * @since 2.4 + */ + public static IncomingCallResponseCreator incomingCallResponse( + Consumer sequence) { + return new IncomingCallResponseCreator(Commands.append(null, sequence)); + } + + /** + * Build the response to any other event: only the commands are taken into account + * + * @param commands Commands to run + * @return The response + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu has a start menu or a gotoMenu target that is not one of its items + * @since 2.4 + */ + public static VoiceSinchEventResponse response(SvamlCommand... commands) { + return response(Commands.append(null, commands)); + } + + /** + * Build the response to any other event: only the commands are taken into account + * + * @param commands Commands to run + * @return The response + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu has a start menu or a gotoMenu target that is not one of its items + * @since 2.4 + */ + public static VoiceSinchEventResponse response(Collection commands) { + List list = Commands.append(null, commands); + MenuCreator.checkGotoTargets(list, Collections.emptySet()); + return VoiceSinchEventResponse.builder().setCommands(list).build(); + } + + /** + * Build the response to any other event: only the commands are taken into account + * + * @param sequence Lambda adding the commands to run + * @return The response + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu has a start menu or a gotoMenu target that is not one of its items + * @since 2.4 + */ + public static VoiceSinchEventResponse response(Consumer sequence) { + return response(Commands.append(null, sequence)); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/StaticCallBehaviorCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/StaticCallBehaviorCreator.java new file mode 100644 index 000000000..62386b72d --- /dev/null +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/StaticCallBehaviorCreator.java @@ -0,0 +1,101 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.sinch.sdk.domains.voice.models.v2.services.StaticCallBehavior; +import com.sinch.sdk.domains.voice.models.v2.svaml.IncomingCallResponseEvents; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlInput; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Fluent creator of a STATIC service call behavior + * + *

Returned by {@link CallBehaviorHelper#staticBehavior(SvamlCommand...)}. + * + *

Calling {@link #onHangup} more than once appends the commands. + * + * @since 2.4 + */ +public final class StaticCallBehaviorCreator { + + private final List commands; + private String name; + private List onHangup; + + StaticCallBehaviorCreator(List commands) { + this.commands = commands; + } + + /** + * Set the name of the call, sent as callName + * + * @param name Name of the call, 1 to 32 characters without spaces + * @return This creator + * @since 2.4 + */ + public StaticCallBehaviorCreator name(String name) { + this.name = Objects.requireNonNull(name, "name must not be null"); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public StaticCallBehaviorCreator onHangup(SvamlCommand... commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param commands Commands to append + * @return This creator + * @since 2.4 + */ + public StaticCallBehaviorCreator onHangup(Collection commands) { + onHangup = Commands.append(onHangup, commands); + return this; + } + + /** + * Add commands to run when the call is hung up + * + * @param sequence Lambda adding the commands to append + * @return This creator + * @since 2.4 + */ + public StaticCallBehaviorCreator onHangup(Consumer sequence) { + onHangup = Commands.append(onHangup, sequence); + return this; + } + + /** + * Build the call behavior + * + * @return The STATIC call behavior + * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a + * menu has a start menu or a gotoMenu target that is not one of its items + * @since 2.4 + */ + public StaticCallBehavior build() { + MenuCreator.checkGotoTargets(commands, Collections.emptySet()); + MenuCreator.checkGotoTargets(onHangup, Collections.emptySet()); + SvamlInput.Builder svaml = SvamlInput.builder().setCommands(Commands.copy(commands)); + if (null != name) { + svaml.setCallName(name); + } + if (null != onHangup) { + svaml.setEvents( + IncomingCallResponseEvents.builder().setOnHangup(Commands.copy(onHangup)).build()); + } + return StaticCallBehavior.builder().setStatic(svaml.build()).build(); + } +} diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java index 9bbeeec5c..91ec7f48c 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java @@ -1,6 +1,15 @@ /** * Helpers to build Voice V2 payloads * + *

    + *
  • Call destinations and origins: {@link Destination} + *
  • SVAML commands: {@link Calls}, {@link Amd}, {@link Messages}, {@link Recording} + *
  • Custom events and menus: {@link CustomEvents} and {@link Menu} + *
  • Command sequences: {@link CommandsSequenceCreator} + *
  • Service call behaviors: {@link CallBehaviorHelper} + *
  • Sinch Event responses: {@link SinchEventResponses} + *
+ * * @since 2.4 */ package com.sinch.sdk.domains.voice.models.v2.helpers; diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelperTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelperTest.java new file mode 100644 index 000000000..57e3a2b5b --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CallBehaviorHelperTest.java @@ -0,0 +1,132 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.services.StaticCallBehavior; +import java.util.Collections; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class CallBehaviorHelperTest extends HelpersBaseTest { + + @Test + void none() throws JsonProcessingException, JSONException { + assertJson(CallBehaviorHelper.none(), "{\"type\":\"NONE\"}"); + } + + @Test + void eventDestination() throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.eventDestination("https://example.com/events"), + "{\"type\":\"WEBHOOK\",\"webhook\":{\"url\":\"https://example.com/events\"}}"); + } + + @Test + void eventDestinationWithFallbackUrl() throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.eventDestination( + "https://example.com/events", "https://fallback.example.com/events"), + "{\"type\":\"WEBHOOK\",\"webhook\":{\"url\":\"https://example.com/events\"," + + "\"fallbackUrl\":\"https://fallback.example.com/events\"}}"); + } + + @Test + void staticBehaviorOnlySetsCommands() throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.staticBehavior(Calls.answer(), Calls.hangup()).build(), + "{\"type\":\"STATIC\",\"static\":{\"commands\":[{\"command\":\"answer\"}," + + HANGUP_JSON + + "]}}"); + } + + @Test + void staticBehaviorAllFields() throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.staticBehavior(seq -> seq.answer().text("Hello", "Emma")) + .name("welcome") + .onHangup(Calls.hangup("origin")) + .onHangup(Collections.singletonList(Calls.pause(10))) + .build(), + "{\"type\":\"STATIC\",\"static\":{\"commands\":[{\"command\":\"answer\"}," + + "{\"command\":\"messages\",\"messages\":[{\"type\":\"SAY\",\"say\":" + + "{\"text\":\"Hello\",\"voiceName\":\"Emma\"}}]}]," + + "\"callName\":\"welcome\",\"events\":{\"onHangup\":[" + + "{\"command\":\"hangup\",\"callName\":\"origin\"}," + + "{\"command\":\"pause\",\"durationMilliseconds\":10}]}}}"); + } + + @Test + void staticBehaviorFromCollectionAndOnHangupLambda() + throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.staticBehavior(Collections.singletonList(Calls.answer())) + .onHangup(seq -> seq.hangup()) + .build(), + "{\"type\":\"STATIC\",\"static\":{\"commands\":[{\"command\":\"answer\"}]," + + "\"events\":{\"onHangup\":[" + + HANGUP_JSON + + "]}}}"); + } + + @Test + void staticBehaviorRejectsGotoMenuOutsideAMenu() { + Assertions.assertThatThrownBy( + () -> CallBehaviorHelper.staticBehavior(seq -> seq.gotoMenu("main")).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("only valid within a menu"); + Assertions.assertThatThrownBy( + () -> + CallBehaviorHelper.staticBehavior(Calls.answer()) + .onHangup(Menu.gotoMenu("main")) + .build()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void staticBehaviorAcceptsValidMenu() { + Assertions.assertThatCode( + () -> + CallBehaviorHelper.staticBehavior( + seq -> + seq.menu( + "main", + menu -> + menu.item( + "main", item -> item.onFailure(Menu.gotoMenu("main"))))) + .build()) + .doesNotThrowAnyException(); + } + + @Test + void staticBehaviorKeepsExplicitlyEmptyOnHangup() throws JsonProcessingException, JSONException { + assertJson( + CallBehaviorHelper.staticBehavior(Calls.answer()).onHangup().build(), + "{\"type\":\"STATIC\",\"static\":{\"commands\":[{\"command\":\"answer\"}]," + + "\"events\":{\"onHangup\":[]}}}"); + } + + @Test + void staticBehaviorBuildIsNotAffectedByLaterChanges() { + StaticCallBehaviorCreator creator = + CallBehaviorHelper.staticBehavior(Calls.answer()).onHangup(Calls.hangup()); + StaticCallBehavior first = creator.build(); + + creator.onHangup(Calls.pause(10)).name("later"); + + Assertions.assertThat(first.getStatic().getEvents().getOnHangup()).hasSize(1); + Assertions.assertThat(first.getStatic().getCallName()).isNull(); + Assertions.assertThat(creator.build().getStatic().getEvents().getOnHangup()).hasSize(2); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy(() -> CallBehaviorHelper.eventDestination(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> CallBehaviorHelper.eventDestination("https://example.com", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> CallBehaviorHelper.staticBehavior(Calls.answer()).name(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java new file mode 100644 index 000000000..f2d2589e2 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java @@ -0,0 +1,133 @@ +package com.sinch.sdk.domains.voice.models.v2.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; +import java.util.Collections; +import org.assertj.core.api.Assertions; +import org.json.JSONException; +import org.junit.jupiter.api.Test; + +class SinchEventResponsesTest extends HelpersBaseTest { + + @Test + void response() throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.response(Calls.answer(), Calls.hangup()), + "{\"commands\":[{\"command\":\"answer\"}," + HANGUP_JSON + "]}"); + } + + @Test + void responseFromCollectionAndLambda() throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.response(Collections.singletonList(Calls.hangup())), + "{\"commands\":[" + HANGUP_JSON + "]}"); + assertJson( + SinchEventResponses.response(seq -> seq.text("Bye", "Emma").hangup()), + "{\"commands\":[{\"command\":\"messages\",\"messages\":[{\"type\":\"SAY\",\"say\":" + + "{\"text\":\"Bye\",\"voiceName\":\"Emma\"}}]}," + + HANGUP_JSON + + "]}"); + } + + @Test + void responseAcceptsNoCommand() throws JsonProcessingException, JSONException { + assertJson(SinchEventResponses.response(), "{\"commands\":[]}"); + } + + @Test + void incomingCallResponseOnlySetsCommands() throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.incomingCallResponse(Calls.answer()).build(), + "{\"commands\":[{\"command\":\"answer\"}]}"); + } + + @Test + void incomingCallResponseAllFields() throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.incomingCallResponse(seq -> seq.answer().bridgeCall("support")) + .name("customer") + .onHangup(Calls.hangup("agent")) + .onHangup(seq -> seq.stopRecording("rec")) + .build(), + "{\"commands\":[{\"command\":\"answer\"}," + + "{\"command\":\"bridgeCall\",\"bridgeName\":\"support\"}]," + + "\"callName\":\"customer\",\"events\":{\"onHangup\":[" + + "{\"command\":\"hangup\",\"callName\":\"agent\"}," + + "{\"command\":\"stopRecording\",\"recordingName\":\"rec\"}]}}"); + } + + @Test + void incomingCallResponseFromCollection() throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.incomingCallResponse(Collections.singletonList(Calls.answer())) + .onHangup(Collections.singletonList(Calls.hangup())) + .build(), + "{\"commands\":[{\"command\":\"answer\"}],\"events\":{\"onHangup\":[" + + HANGUP_JSON + + "]}}"); + } + + @Test + void rejectsGotoMenuOutsideAMenu() { + Assertions.assertThatThrownBy(() -> SinchEventResponses.response(Menu.gotoMenu("main"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("only valid within a menu"); + Assertions.assertThatThrownBy( + () -> + SinchEventResponses.incomingCallResponse(Calls.answer()) + .onHangup(seq -> seq.gotoMenu("main")) + .build()) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void acceptsValidMenu() { + Assertions.assertThatCode( + () -> + SinchEventResponses.response( + seq -> + seq.menu( + "main", + menu -> + menu.item("main", item -> item.match("1", Menu.gotoMenu("main")))))) + .doesNotThrowAnyException(); + Assertions.assertThatCode( + () -> + SinchEventResponses.incomingCallResponse( + Menu.create("main") + .item("main", item -> item.onFailure(Menu.gotoMenu("main"))) + .build()) + .build()) + .doesNotThrowAnyException(); + } + + @Test + void incomingCallResponseKeepsExplicitlyEmptyOnHangup() + throws JsonProcessingException, JSONException { + assertJson( + SinchEventResponses.incomingCallResponse(Calls.answer()).onHangup().build(), + "{\"commands\":[{\"command\":\"answer\"}],\"events\":{\"onHangup\":[]}}"); + } + + @Test + void incomingCallResponseBuildIsNotAffectedByLaterChanges() { + IncomingCallResponseCreator creator = + SinchEventResponses.incomingCallResponse(Calls.answer()).onHangup(Calls.hangup()); + VoiceSinchEventResponse first = creator.build(); + + creator.onHangup(Calls.pause(10)).name("later"); + + Assertions.assertThat(first.getEvents().getOnHangup()).hasSize(1); + Assertions.assertThat(first.getCallName()).isNull(); + Assertions.assertThat(creator.build().getEvents().getOnHangup()).hasSize(2); + } + + @Test + void rejectsNullValues() { + Assertions.assertThatThrownBy( + () -> SinchEventResponses.incomingCallResponse(Calls.answer()).name(null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> SinchEventResponses.response(Calls.answer(), null)) + .isInstanceOf(NullPointerException.class); + } +} From 194988665d7f450f1c7ad663e54c71ac74b632a9 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 20 Oct 2026 12:33:13 +0200 Subject: [PATCH 07/17] DEVEXP-1473: Use the Voice V2 helpers in snippets, examples and README --- CHANGELOG.md | 4 +- README.md | 24 +++++++++ .../app/voice/v2/ServerBusinessLogic.java | 36 +++++-------- .../src/main/java/voice/v2/batches/Start.java | 52 +++++++----------- .../java/voice/v2/calls/InteractByCallId.java | 6 +-- .../voice/v2/calls/InteractByCallName.java | 6 +-- .../src/main/java/voice/v2/calls/Start.java | 53 ++++++++----------- .../main/java/voice/v2/svaml/Describe.java | 12 +---- .../main/java/voice/v2/svaml/Validate.java | 12 +---- 9 files changed, 87 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3da08bba..cd0261e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ## v2.4.0 - Unreleased ### Voice -- **[feature]** V2 helpers: `Destination` factories for call destinations and origins (`of`, `phone`, `sip`, `sipFrom`, `stream` and `voiceRelay`). `of` returns a `BaseDestination`, accepted as both `to` and `from` -- **[feature]** V2 helpers: SVAML command helpers `Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu` and `CommandsSequenceCreator`. Event handlers accept a sequence lambda +- **[feature]** V2 helpers: `Destination`, SVAML commands (`Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu`) and `CommandsSequenceCreator`. Event handlers accept a sequence lambda - **[feature]** V2 helpers: `CallBehaviorHelper` for services and `SinchEventResponses` for Sinch Events +- **[doc]** V2 snippets, `sinch-events` example and README use the helpers --- ## v2.3.0 - 2026-10-15 diff --git a/README.md b/README.md index 2397f8ffe..1c1517665 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,30 @@ You can find a complete example in [examples/sinch-events](https://github.com/si The Voice API v2 needs no extra parameters, use the [common client](#client-initialization) shown above. +The helpers in `com.sinch.sdk.domains.voice.models.v2.helpers` build the SVAML commands of a call, the call behavior of a service and the responses to Sinch Events. For example, to call a number, play a greeting once the call is answered, then hang up: + +```java +import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; +import com.sinch.sdk.domains.voice.models.v2.helpers.CommandsSequenceCreator; + +StartCallRequest request = + StartCallRequest.builder() + .setCommands( + CommandsSequenceCreator.create() + .dial("RECIPIENT_PHONE_NUMBER", dial -> dial + .from("SINCH_VIRTUAL_PHONE_NUMBER") + .onAnswer(answered -> answered.messages(messages -> messages + .text("Hello from the Sinch Java SDK!", "Emma") + .onFinish(Calls.hangup())))) + .build()) + .build(); + +client.voice().v2().calls().start(request); +``` + +The same commands answer a Sinch Event, with `SinchEventResponses.response(...)` or, for a `call.incoming` event, `SinchEventResponses.incomingCallResponse(...)`. + ### Your first request diff --git a/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java b/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java index 2148b47b2..73a1c9a00 100644 --- a/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java +++ b/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java @@ -1,12 +1,9 @@ package com.mycompany.app.voice.v2; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; +import com.sinch.sdk.domains.voice.models.v2.helpers.SinchEventResponses; import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEvent; import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessageEvents; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; -import java.util.Collections; import java.util.logging.Logger; import org.springframework.stereotype.Component; @@ -16,31 +13,22 @@ public class ServerBusinessLogic { private static final Logger LOGGER = Logger.getLogger(ServerBusinessLogic.class.getName()); // An empty list of commands takes no action - private static final VoiceSinchEventResponse NO_ACTION = - VoiceSinchEventResponse.builder().setCommands(Collections.emptyList()).build(); + private static final VoiceSinchEventResponse NO_ACTION = SinchEventResponses.response(); public VoiceSinchEventResponse incoming(VoiceSinchEvent event) { LOGGER.info("Handle event: " + event); // play a greeting, then hang up - // "callName" and "events" only take effect in responses to "call.incoming" events - return VoiceSinchEventResponse.builder() - .setCallName("incoming") - .setCommands( - Collections.singletonList( - MessagesCommand.builder() - .setMessages( - Collections.singletonList( - SayMessage.builder() - .setText("Thank you for calling. Goodbye.") - .setVoiceName("Emma") - .build())) - .setEvents( - MessageEvents.builder() - .setOnFinish(Collections.singletonList(HangupCommand.builder().build())) - .build()) - .build())) + // the call name only takes effect in responses to "call.incoming" events + return SinchEventResponses.incomingCallResponse( + commands -> + commands.messages( + messages -> + messages + .text("Thank you for calling. Goodbye.", "Emma") + .onFinish(Calls.hangup()))) + .name("incoming") .build(); } diff --git a/examples/snippets/src/main/java/voice/v2/batches/Start.java b/examples/snippets/src/main/java/voice/v2/batches/Start.java index 9c43c408b..ea9819b29 100644 --- a/examples/snippets/src/main/java/voice/v2/batches/Start.java +++ b/examples/snippets/src/main/java/voice/v2/batches/Start.java @@ -11,13 +11,8 @@ import com.sinch.sdk.domains.voice.api.v2.BatchesService; import com.sinch.sdk.domains.voice.models.v2.batches.request.StartBatchRequest; import com.sinch.sdk.domains.voice.models.v2.batches.response.StartBatchResponse; -import com.sinch.sdk.domains.voice.models.v2.destination.Phone; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.CallEvents; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessageEvents; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; +import com.sinch.sdk.domains.voice.models.v2.helpers.CommandsSequenceCreator; import com.sinch.sdk.models.Configuration; import java.util.Arrays; import java.util.Collections; @@ -56,34 +51,27 @@ public static void main(String[] args) { BatchesService batchesService = client.voice().v2().batches(); - // Played to each recipient once their call is answered, then the call is hung up - MessagesCommand greeting = - MessagesCommand.builder() - .setMessages( - Collections.singletonList( - SayMessage.builder() - .setText("Hello, your call is now connected.") - .setVoiceName("Emma") - .build())) - .setEvents( - MessageEvents.builder() - .setOnFinish(Collections.singletonList(HangupCommand.HANGUP_COMMAND)) - .build()) - .build(); - + // Dial each recipient: once the call is answered, play a greeting, then hang up. // "@toNumber" is a placeholder, replaced in each call by that call's parameter value - DialCommand dial = - DialCommand.builder() - .setCallName("Java_SDK_Snippet_Call") - .setFrom(Phone.builder().setNumber(sinchPhoneNumber).build()) - .setTo(Phone.builder().setNumber("@toNumber").build()) - .setEvents( - CallEvents.builder().setOnAnswer(Collections.singletonList(greeting)).build()) - .build(); - StartBatchRequest request = StartBatchRequest.builder() - .setCommands(Collections.singletonList(dial)) + .setCommands( + CommandsSequenceCreator.create() + .dial( + "@toNumber", + dial -> + dial.name("Java_SDK_Snippet_Call") + .from(sinchPhoneNumber) + .onAnswer( + answered -> + answered.messages( + messages -> + messages + .text( + "Hello, your call is now connected.", + "Emma") + .onFinish(Calls.hangup())))) + .build()) .setParameters(parameters) .build(); diff --git a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java index 14716beee..f648477b2 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java +++ b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java @@ -10,7 +10,7 @@ import com.sinch.sdk.SinchClient; import com.sinch.sdk.domains.voice.api.v2.CallsService; import com.sinch.sdk.domains.voice.models.v2.calls.request.CallPatchRequest; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; import com.sinch.sdk.models.Configuration; import java.util.Collections; import java.util.logging.Logger; @@ -41,9 +41,7 @@ public static void main(String[] args) { CallsService callsService = client.voice().v2().calls(); CallPatchRequest request = - CallPatchRequest.builder() - .setCommands(Collections.singletonList(HangupCommand.HANGUP_COMMAND)) - .build(); + CallPatchRequest.builder().setCommands(Collections.singletonList(Calls.hangup())).build(); LOGGER.info(String.format("Interact with call with ID '%s'", callId)); diff --git a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java index 3208c2931..c53f31a36 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java +++ b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java @@ -10,7 +10,7 @@ import com.sinch.sdk.SinchClient; import com.sinch.sdk.domains.voice.api.v2.CallsService; import com.sinch.sdk.domains.voice.models.v2.calls.request.CallPatchRequest; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; import com.sinch.sdk.models.Configuration; import java.util.Collections; import java.util.logging.Logger; @@ -44,9 +44,7 @@ public static void main(String[] args) { CallsService callsService = client.voice().v2().calls(); CallPatchRequest request = - CallPatchRequest.builder() - .setCommands(Collections.singletonList(HangupCommand.HANGUP_COMMAND)) - .build(); + CallPatchRequest.builder().setCommands(Collections.singletonList(Calls.hangup())).build(); LOGGER.info( String.format("Interact with call named '%s' in session '%s'", callName, sessionId)); diff --git a/examples/snippets/src/main/java/voice/v2/calls/Start.java b/examples/snippets/src/main/java/voice/v2/calls/Start.java index 775928a00..43067f5c8 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/Start.java +++ b/examples/snippets/src/main/java/voice/v2/calls/Start.java @@ -11,15 +11,9 @@ import com.sinch.sdk.domains.voice.api.v2.CallsService; import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; import com.sinch.sdk.domains.voice.models.v2.calls.response.StartCallResponse; -import com.sinch.sdk.domains.voice.models.v2.destination.Phone; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.CallEvents; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessageEvents; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; +import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; +import com.sinch.sdk.domains.voice.models.v2.helpers.CommandsSequenceCreator; import com.sinch.sdk.models.Configuration; -import java.util.Collections; import java.util.logging.Logger; import utils.Settings; @@ -49,33 +43,28 @@ public static void main(String[] args) { CallsService callsService = client.voice().v2().calls(); - // Played to the recipient once the call is answered, then the call is hung up - MessagesCommand greeting = - MessagesCommand.builder() - .setMessages( - Collections.singletonList( - SayMessage.builder() - .setText("Hello, your call is now connected.") - .setVoiceName("Emma") - .build())) - .setEvents( - MessageEvents.builder() - .setOnFinish(Collections.singletonList(HangupCommand.HANGUP_COMMAND)) + // Dial the recipient: once the call is answered, play a greeting, then hang up + StartCallRequest request = + StartCallRequest.builder() + .setCommands( + CommandsSequenceCreator.create() + .dial( + recipientPhoneNumber, + dial -> + dial.name("Java_SDK_Snippet_Call") + .from(sinchPhoneNumber) + .onAnswer( + answered -> + answered.messages( + messages -> + messages + .text( + "Hello, your call is now connected.", + "Emma") + .onFinish(Calls.hangup())))) .build()) .build(); - DialCommand dial = - DialCommand.builder() - .setCallName("Java_SDK_Snippet_Call") - .setFrom(Phone.builder().setNumber(sinchPhoneNumber).build()) - .setTo(Phone.builder().setNumber(recipientPhoneNumber).build()) - .setEvents( - CallEvents.builder().setOnAnswer(Collections.singletonList(greeting)).build()) - .build(); - - StartCallRequest request = - StartCallRequest.builder().setCommands(Collections.singletonList(dial)).build(); - LOGGER.info("Start a call to: " + recipientPhoneNumber); StartCallResponse response = callsService.start(request); diff --git a/examples/snippets/src/main/java/voice/v2/svaml/Describe.java b/examples/snippets/src/main/java/voice/v2/svaml/Describe.java index 647173dbe..62c5a700c 100644 --- a/examples/snippets/src/main/java/voice/v2/svaml/Describe.java +++ b/examples/snippets/src/main/java/voice/v2/svaml/Describe.java @@ -9,9 +9,8 @@ import com.sinch.sdk.SinchClient; import com.sinch.sdk.domains.voice.api.v2.SvamlService; +import com.sinch.sdk.domains.voice.models.v2.helpers.Messages; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlInput; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; import com.sinch.sdk.domains.voice.models.v2.svaml.request.DescribeSvamlRequest; import com.sinch.sdk.domains.voice.models.v2.svaml.response.SvamlDescriptionResponse; import com.sinch.sdk.models.Configuration; @@ -46,14 +45,7 @@ public static void main(String[] args) { SvamlInput.builder() .setCommands( Collections.singletonList( - MessagesCommand.builder() - .setMessages( - Collections.singletonList( - SayMessage.builder() - .setText("Hello, your call is now connected.") - .setVoiceName("Emma") - .build())) - .build())) + Messages.text("Hello, your call is now connected.", "Emma"))) .build()) .build(); diff --git a/examples/snippets/src/main/java/voice/v2/svaml/Validate.java b/examples/snippets/src/main/java/voice/v2/svaml/Validate.java index 50f3279a0..28b54e2b3 100644 --- a/examples/snippets/src/main/java/voice/v2/svaml/Validate.java +++ b/examples/snippets/src/main/java/voice/v2/svaml/Validate.java @@ -9,9 +9,8 @@ import com.sinch.sdk.SinchClient; import com.sinch.sdk.domains.voice.api.v2.SvamlService; +import com.sinch.sdk.domains.voice.models.v2.helpers.Messages; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlInput; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; -import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; import com.sinch.sdk.domains.voice.models.v2.svaml.request.ValidateSvamlRequest; import com.sinch.sdk.domains.voice.models.v2.svaml.response.ValidateSvamlResponse; import com.sinch.sdk.models.Configuration; @@ -46,14 +45,7 @@ public static void main(String[] args) { SvamlInput.builder() .setCommands( Collections.singletonList( - MessagesCommand.builder() - .setMessages( - Collections.singletonList( - SayMessage.builder() - .setText("Hello, your call is now connected.") - .setVoiceName("Emma") - .build())) - .build())) + Messages.text("Hello, your call is now connected.", "Emma"))) .build()) .build(); From 8f17d4491ad8caf5a45262e6dd91d1a4c41d12c0 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 20 Oct 2026 13:17:29 +0200 Subject: [PATCH 08/17] DEVEXP-1473: improvements for the helpers --- .../v2/helpers/CommandsSequenceCreator.java | 66 ++++++++++++++++++- .../voice/models/v2/helpers/CustomEvents.java | 6 +- .../voice/models/v2/helpers/Destination.java | 4 ++ .../domains/voice/models/v2/helpers/Menu.java | 7 +- .../voice/models/v2/helpers/Messages.java | 50 +++++++++++++- .../models/v2/helpers/RecordingCreator.java | 12 +++- .../v2/helpers/SinchEventResponses.java | 17 ++--- .../voice/models/v2/helpers/package-info.java | 3 + .../helpers/CommandsSequenceCreatorTest.java | 23 +++++++ .../voice/models/v2/helpers/MessagesTest.java | 38 +++++++++++ .../models/v2/helpers/RecordingTest.java | 35 ++++++++++ .../v2/helpers/SinchEventResponsesTest.java | 13 +++- 12 files changed, 255 insertions(+), 19 deletions(-) diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java index 53ab77204..b461144da 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java @@ -5,6 +5,7 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -14,8 +15,11 @@ * Fluent creator of an ordered sequence of SVAML commands, e.g. the commands of a call * or of a Sinch Event response * - *

Each method adds one command, built as the helper of the same name would. The creators of the - * commands with events are given to a lambda, so no intermediate build() is needed: + *

Each method adds one command, built with the matching helper: e.g. amd uses + * {@link Amd} and recording uses {@link Recording}. + * + *

The creators of the commands with events are given to a lambda, so they need no intermediate + * build: * *

{@code
  * List commands =
@@ -210,6 +214,52 @@ public CommandsSequenceCreator play(String url) {
     return command(Messages.play(url));
   }
 
+  /**
+   * Add a messages command playing a text-to-speech message in plain text, with a name
+   * or commands run once it is played
+   *
+   * @param text Text to synthesize
+   * @param voiceName Voice used for speech synthesis
+   * @param messages Lambda setting the name, the commands run once played, or more messages
+   * @return This creator
+   * @see Messages#text(String, String, Consumer)
+   * @since 2.4
+   */
+  public CommandsSequenceCreator text(
+      String text, String voiceName, Consumer messages) {
+    return command(Messages.text(text, voiceName, messages));
+  }
+
+  /**
+   * Add a messages command playing a text-to-speech message in SSML, with a name or
+   * commands run once it is played
+   *
+   * @param ssml SSML document to synthesize
+   * @param voiceName Voice used for speech synthesis
+   * @param messages Lambda setting the name, the commands run once played, or more messages
+   * @return This creator
+   * @see Messages#ssml(String, String, Consumer)
+   * @since 2.4
+   */
+  public CommandsSequenceCreator ssml(
+      String ssml, String voiceName, Consumer messages) {
+    return command(Messages.ssml(ssml, voiceName, messages));
+  }
+
+  /**
+   * Add a messages command playing an audio file, with a name or commands run once it
+   * is played
+   *
+   * @param url URL of the media to play
+   * @param messages Lambda setting the name, the commands run once played, or more messages
+   * @return This creator
+   * @see Messages#play(String, Consumer)
+   * @since 2.4
+   */
+  public CommandsSequenceCreator play(String url, Consumer messages) {
+    return command(Messages.play(url, messages));
+  }
+
   /**
    * Add a messages command
    *
@@ -378,6 +428,18 @@ public CommandsSequenceCreator command(SvamlCommand... commands) {
     return this;
   }
 
+  /**
+   * Add commands built by other means, e.g. with the model builders
+   *
+   * @param commands Commands to add
+   * @return This creator
+   * @since 2.4
+   */
+  public CommandsSequenceCreator command(Collection commands) {
+    Commands.append(this.commands, commands);
+    return this;
+  }
+
   /**
    * Build the sequence
    *
diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java
index 18debc428..f425c765b 100644
--- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java
+++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CustomEvents.java
@@ -18,8 +18,10 @@ private CustomEvents() {}
    *
    * 

The sequence waits for the backend answer before running the next command. * - * @param name Name of the custom event, sent as webhookName. The event received by - * the backend is named call.customEvent.<name> + *

The backend receives a call.webhook.<name> event. Once parsed by the SDK, + * its type is call.customEvent.<name>. + * + * @param name Name of the custom event, sent as webhookName * @param url URL the event is sent to * @return The customEvent command * @since 2.4 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java index 77ebda304..718e59e9a 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java @@ -47,6 +47,10 @@ private Destination() {} * fields. A voice relay destination is not built here: it requires a TTS voice and an STT * language, see {@link #voiceRelay(String, String, String)}. * + *

The result is meant to build requests. It wraps the generated model and serializes as it, + * but it is neither an instance of {@link Phone}, {@link Sip} or {@link Stream}, nor equal to the + * model returned by the dedicated factories. Use them when the typed model is needed. + * * @param value Endpoint, optionally prefixed with its type * @return The phone, SIP or stream endpoint * @since 2.4 diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java index 3a962dc4a..b5edda833 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Menu.java @@ -33,8 +33,11 @@ public static MenuCreator create(String startMenu) { * Jump to another item of the enclosing menu * *

Only valid within a menu: in the match or onFailure commands of an - * item, including their nested event handlers. The target must be an item of that menu, which - * {@link MenuCreator#build()} checks. + * item, including their nested event handlers. The target must be an item of that menu. + * + *

The target is checked when one of these is built: a menu ({@link MenuCreator}), a sequence + * ({@link CommandsSequenceCreator}), a static call behavior or a response to an incoming call. + * Building another creator alone, e.g. {@link DialCreator}, does not check it. * *

Named after the API command: goto is a reserved word in Java. * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java index 8e83ca0f9..44e5a772b 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java @@ -5,13 +5,14 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.StopMessagesCommand; import java.util.Objects; +import java.util.function.Consumer; /** * Helpers to build the message playback SVAML commands * *

{@link #text}, {@link #ssml} and {@link #play} are shortcuts for a messages - * command with a single message. Use {@link #start()} to play several messages, or to set a name or - * the commands to run once they have been played. + * command with a single message. Their lambda overloads also set a name or the commands to run once + * played. Use {@link #start()} to play several messages. * * @since 2.4 */ @@ -54,6 +55,51 @@ public static MessagesCommand play(String url) { return start().play(url).build(); } + /** + * Play a text-to-speech message in plain text, with a name or commands run once it is played + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return The messages command + * @since 2.4 + */ + public static MessagesCommand text( + String text, String voiceName, Consumer messages) { + return fill(start().text(text, voiceName), messages); + } + + /** + * Play a text-to-speech message in SSML, with a name or commands run once it is played + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return The messages command + * @since 2.4 + */ + public static MessagesCommand ssml( + String ssml, String voiceName, Consumer messages) { + return fill(start().ssml(ssml, voiceName), messages); + } + + /** + * Play an audio file, with a name or commands run once it is played + * + * @param url URL of the media to play + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return The messages command + * @since 2.4 + */ + public static MessagesCommand play(String url, Consumer messages) { + return fill(start().play(url), messages); + } + + private static MessagesCommand fill(MessagesCreator creator, Consumer messages) { + Objects.requireNonNull(messages, "messages must not be null").accept(creator); + return creator.build(); + } + /** * Play one or more messages, in the order they are added * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java index df8b54208..a7d3fcdf8 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingCreator.java @@ -27,6 +27,9 @@ */ public final class RecordingCreator { + // Options given as is: sent unchanged unless an optional field is overridden + private final RecordingOptions options; + private boolean overridden; private final RecordingDestinationType destination; private final String url; private final String credentials; @@ -38,13 +41,14 @@ public final class RecordingCreator { private List onFailure; RecordingCreator(RecordingDestinationType destination, String url, String credentials) { + this.options = null; this.destination = Objects.requireNonNull(destination, "destination must not be null"); this.url = Objects.requireNonNull(url, "url must not be null"); this.credentials = Objects.requireNonNull(credentials, "credentials must not be null"); } RecordingCreator(RecordingOptions options) { - Objects.requireNonNull(options, "options must not be null"); + this.options = Objects.requireNonNull(options, "options must not be null"); this.destination = options.getDestination(); this.url = options.getDestinationUrl(); this.credentials = options.getCredentials(); @@ -62,6 +66,7 @@ public final class RecordingCreator { */ public RecordingCreator format(RecordingFormatType format) { this.format = Objects.requireNonNull(format, "format must not be null"); + overridden = true; return this; } @@ -74,6 +79,7 @@ public RecordingCreator format(RecordingFormatType format) { */ public RecordingCreator recordingType(RecordingType recordingType) { this.recordingType = Objects.requireNonNull(recordingType, "recordingType must not be null"); + overridden = true; return this; } @@ -87,6 +93,7 @@ public RecordingCreator recordingType(RecordingType recordingType) { public RecordingCreator transcriptionOptions(TranscriptionOptions transcriptionOptions) { this.transcriptionOptions = Objects.requireNonNull(transcriptionOptions, "transcriptionOptions must not be null"); + overridden = true; return this; } @@ -202,6 +209,9 @@ public StartRecordingCommand build() { /** Only the fields that have a value are set, so unset ones are left to the API defaults */ private RecordingOptions buildOptions() { + if (null != options && !overridden) { + return options; + } RecordingOptions.Builder builder = RecordingOptions.builder(); if (null != destination) { builder.setDestination(destination); diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java index 0b1373422..748b1d320 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponses.java @@ -3,7 +3,6 @@ import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; import java.util.Collection; -import java.util.Collections; import java.util.List; import java.util.function.Consumer; @@ -60,10 +59,11 @@ public static IncomingCallResponseCreator incomingCallResponse( /** * Build the response to any other event: only the commands are taken into account * + *

The commands are not checked for gotoMenu: the event may have been triggered + * within a menu, where it is valid. + * * @param commands Commands to run * @return The response - * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a - * menu has a start menu or a gotoMenu target that is not one of its items * @since 2.4 */ public static VoiceSinchEventResponse response(SvamlCommand... commands) { @@ -73,25 +73,26 @@ public static VoiceSinchEventResponse response(SvamlCommand... commands) { /** * Build the response to any other event: only the commands are taken into account * + *

The commands are not checked for gotoMenu: the event may have been triggered + * within a menu, where it is valid. + * * @param commands Commands to run * @return The response - * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a - * menu has a start menu or a gotoMenu target that is not one of its items * @since 2.4 */ public static VoiceSinchEventResponse response(Collection commands) { List list = Commands.append(null, commands); - MenuCreator.checkGotoTargets(list, Collections.emptySet()); return VoiceSinchEventResponse.builder().setCommands(list).build(); } /** * Build the response to any other event: only the commands are taken into account * + *

The commands are not checked for gotoMenu: the event may have been triggered + * within a menu, where it is valid. + * * @param sequence Lambda adding the commands to run * @return The response - * @throws IllegalStateException if a gotoMenu command is outside a menu, or if a - * menu has a start menu or a gotoMenu target that is not one of its items * @since 2.4 */ public static VoiceSinchEventResponse response(Consumer sequence) { diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java index 91ec7f48c..058a2c323 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/package-info.java @@ -10,6 +10,9 @@ *

  • Sinch Event responses: {@link SinchEventResponses} * * + *

    The limits of the API (lengths, ranges, number of items) are documented on the methods but not + * checked by the helpers: the API checks them. + * * @since 2.4 */ package com.sinch.sdk.domains.voice.models.v2.helpers; diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java index 85205c231..c15c6bd6e 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java @@ -325,6 +325,29 @@ void rejectsNullSequenceLambda() { .isInstanceOf(NullPointerException.class); } + @Test + void messageShortcutsAcceptNameAndOnFinish() throws JsonProcessingException, JSONException { + assertSameJson( + CommandsSequenceCreator.create() + .text("Hello", "Emma", m -> m.name("greeting").onFinish(Calls.hangup())) + .ssml("Hi", "Emma", m -> m.name("ssml")) + .play("https://example.com/a.mp3", m -> m.onFinish(s -> s.hangup())) + .build(), + Arrays.asList( + Messages.text("Hello", "Emma", m -> m.name("greeting").onFinish(Calls.hangup())), + Messages.ssml("Hi", "Emma", m -> m.name("ssml")), + Messages.play("https://example.com/a.mp3", m -> m.onFinish(Calls.hangup())))); + } + + @Test + void commandAcceptsACollection() throws JsonProcessingException, JSONException { + assertSameJson( + CommandsSequenceCreator.create() + .command(Arrays.asList(Calls.answer(), Calls.hangup())) + .build(), + Arrays.asList(Calls.answer(), Calls.hangup())); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> CommandsSequenceCreator.create().amd(null)) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java index d11a6746c..3a8ac9021 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java @@ -64,6 +64,44 @@ void stop() throws JsonProcessingException, JSONException { Messages.stop("greeting"), "{\"command\":\"stopMessages\",\"messagesName\":\"greeting\"}"); } + @Test + void shortcutsAcceptNameAndOnFinish() throws JsonProcessingException, JSONException { + assertJson( + Messages.text("Hello", "Emma", m -> m.name("greeting").onFinish(Calls.hangup())), + "{\"command\":\"messages\",\"messagesName\":\"greeting\",\"messages\":[" + + SAY_JSON + + "],\"events\":{\"onFinish\":[" + + HANGUP_JSON + + "]}}"); + assertJson( + Messages.ssml("Hello", "Emma", m -> m.name("greeting")), + "{\"command\":\"messages\",\"messagesName\":\"greeting\",\"messages\":[{\"type\":\"SAY\"," + + "\"say\":{\"text\":\"Hello\",\"voiceName\":\"Emma\"," + + "\"format\":\"SSML\"}}]}"); + assertJson( + Messages.play("https://example.com/a.mp3", m -> m.onFinish(s -> s.hangup())), + "{\"command\":\"messages\",\"messages\":[" + + PLAY_JSON + + "],\"events\":{\"onFinish\":[" + + HANGUP_JSON + + "]}}"); + } + + @Test + void shortcutLambdaCanAddMessages() throws JsonProcessingException, JSONException { + assertJson( + Messages.text("Hello", "Emma", m -> m.play("https://example.com/a.mp3")), + "{\"command\":\"messages\",\"messages\":[" + SAY_JSON + "," + PLAY_JSON + "]}"); + } + + @Test + void rejectsNullShortcutLambda() { + Assertions.assertThatThrownBy(() -> Messages.text("Hello", "Emma", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Messages.play("https://example.com/a.mp3", null)) + .isInstanceOf(NullPointerException.class); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Messages.text(null, "Emma")) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java index e97d97a4f..c6ae0a8b3 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java @@ -123,6 +123,41 @@ void stop() throws JsonProcessingException, JSONException { "{\"command\":\"stopRecording\",\"recordingName\":\"support-call\"}"); } + @Test + void givenOptionsAreSentUnchangedWithoutOverride() { + RecordingOptions options = + RecordingOptions.builder() + .setDestination(RecordingDestinationType.AWS) + .setDestinationUrl("s3://b") + .setCredentials("c") + .setTranscriptionOptions(TranscriptionOptions.builder().setIsEnabled(true).build()) + .build(); + + Assertions.assertThat(Recording.start(options).name("rec").build().getRecordingOptions()) + .isSameAs(options); + Assertions.assertThat( + Recording.start(options).format(RecordingFormatType.WAV).build().getRecordingOptions()) + .isNotSameAs(options); + } + + /** + * RecordingCreator copies every field of the given options when one is overridden: a new field in + * RecordingOptions must be copied too + */ + @Test + void recordingOptionsFieldsAreAllCopied() { + Assertions.assertThat(RecordingOptions.class.getDeclaredMethods()) + .filteredOn(method -> method.getName().startsWith("get")) + .extracting(java.lang.reflect.Method::getName) + .containsExactlyInAnyOrder( + "getFormat", + "getRecordingType", + "getDestination", + "getDestinationUrl", + "getCredentials", + "getTranscriptionOptions"); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Recording.options(null, "s3://b", "c")) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java index f2d2589e2..0a43cd343 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/SinchEventResponsesTest.java @@ -68,8 +68,17 @@ void incomingCallResponseFromCollection() throws JsonProcessingException, JSONEx } @Test - void rejectsGotoMenuOutsideAMenu() { - Assertions.assertThatThrownBy(() -> SinchEventResponses.response(Menu.gotoMenu("main"))) + void responseAcceptsGotoMenu() throws JsonProcessingException, JSONException { + // the event may have been triggered within a menu, where gotoMenu is valid + assertJson( + SinchEventResponses.response(Menu.gotoMenu("main")), + "{\"commands\":[{\"command\":\"gotoMenu\",\"menuName\":\"main\"}]}"); + } + + @Test + void incomingCallResponseRejectsGotoMenuOutsideAMenu() { + Assertions.assertThatThrownBy( + () -> SinchEventResponses.incomingCallResponse(Menu.gotoMenu("main")).build()) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("only valid within a menu"); Assertions.assertThatThrownBy( From 9da987d5d2171e27f331c8b4eba59c790d5431f3 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 22 Oct 2026 12:01:09 +0200 Subject: [PATCH 09/17] Address comments for the helpers --- .../voice/models/v2/helpers/Destination.java | 100 +++++++++++++++--- .../voice/models/v2/helpers/Recording.java | 39 +++++-- .../models/v2/helpers/DestinationTest.java | 89 ++++++++++++++++ .../models/v2/helpers/RecordingTest.java | 39 +++++++ .../app/voice/v2/ServerBusinessLogic.java | 9 +- .../src/main/java/voice/v2/batches/Start.java | 11 +- .../src/main/java/voice/v2/calls/Start.java | 11 +- 7 files changed, 258 insertions(+), 40 deletions(-) diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java index 718e59e9a..4bfb7ed90 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java @@ -9,6 +9,7 @@ import com.sinch.sdk.domains.voice.models.v2.destination.Stream; import com.sinch.sdk.domains.voice.models.v2.destination.VoiceRelay; import java.util.Objects; +import java.util.function.Consumer; /** * Factory helpers for the to and from values of a Voice V2 call @@ -16,9 +17,8 @@ *

    to takes a {@link CallDestination} and from a {@link CallOrigin}. * {@link #of(String)} returns a {@link BaseDestination}, accepted by both. * - *

    Each factory only sets the required fields. Use the model builders ({@link Sip#builder()}, - * {@link SipFrom#builder()}, {@link Stream#builder()}, {@link VoiceRelay#builder()}) to set the - * optional ones. + *

    Each factory takes the required fields. Its lambda overload sets the optional ones on the + * model builder, e.g. sip(endpoint, sip -> sip.setTransport(...)). * * @since 2.4 */ @@ -43,9 +43,10 @@ private Destination() {} * * *

    The result is accepted as both to and from. It only carries the - * fields found in the string: use the dedicated factories, or the model builders, to set optional - * fields. A voice relay destination is not built here: it requires a TTS voice and an STT - * language, see {@link #voiceRelay(String, String, String)}. + * fields found in the string: use the dedicated factories to set optional fields. + * + *

    A voice relay destination is not built here, as it requires a TTS voice and an STT language: + * use the voiceRelay factories. * *

    The result is meant to build requests. It wraps the generated model and serializes as it, * but it is neither an instance of {@link Phone}, {@link Sip} or {@link Stream}, nor equal to the @@ -88,8 +89,24 @@ public static Phone phone(String number) { * @since 2.4 */ public static Sip sip(String endpoint) { + return sip(endpoint, sip -> {}); + } + + /** + * Create a SIP call destination with optional fields, e.g. the transport or the call headers + * + * @param endpoint SIP URI of the destination endpoint (sip: or sips: + * scheme) + * @param sip Lambda setting the optional fields on the model builder, which already holds the + * endpoint + * @return The SIP destination + * @since 2.4 + */ + public static Sip sip(String endpoint, Consumer sip) { Objects.requireNonNull(endpoint, "endpoint must not be null"); - return Sip.builder().setEndpoint(endpoint).build(); + Sip.Builder builder = Sip.builder().setEndpoint(endpoint); + Objects.requireNonNull(sip, "sip must not be null").accept(builder); + return builder.build(); } /** @@ -102,8 +119,24 @@ public static Sip sip(String endpoint) { * @since 2.4 */ public static SipFrom sipFrom(String endpoint) { + return sipFrom(endpoint, sipFrom -> {}); + } + + /** + * Create a SIP call origin with optional fields, e.g. the display name + * + * @param endpoint SIP URI of the originating endpoint (sip: or sips: + * scheme) + * @param sipFrom Lambda setting the optional fields on the model builder, which already holds the + * endpoint + * @return The SIP origin + * @since 2.4 + */ + public static SipFrom sipFrom(String endpoint, Consumer sipFrom) { Objects.requireNonNull(endpoint, "endpoint must not be null"); - return SipFrom.builder().setEndpoint(endpoint).build(); + SipFrom.Builder builder = SipFrom.builder().setEndpoint(endpoint); + Objects.requireNonNull(sipFrom, "sipFrom must not be null").accept(builder); + return builder.build(); } /** @@ -115,8 +148,25 @@ public static SipFrom sipFrom(String endpoint) { * @since 2.4 */ public static Stream stream(String endpoint) { + return stream(endpoint, stream -> {}); + } + + /** + * Create a WebSocket stream call destination with optional fields, e.g. the stream options or the + * call headers + * + * @param endpoint WebSocket URL (ws:// or wss://) accepting the + * connection + * @param stream Lambda setting the optional fields on the model builder, which already holds the + * endpoint + * @return The stream destination + * @since 2.4 + */ + public static Stream stream(String endpoint, Consumer stream) { Objects.requireNonNull(endpoint, "endpoint must not be null"); - return Stream.builder().setEndpoint(endpoint).build(); + Stream.Builder builder = Stream.builder().setEndpoint(endpoint); + Objects.requireNonNull(stream, "stream must not be null").accept(builder); + return builder.build(); } /** @@ -129,14 +179,36 @@ public static Stream stream(String endpoint) { * @since 2.4 */ public static VoiceRelay voiceRelay(String endpoint, String ttsVoice, String sttLanguage) { + return voiceRelay(endpoint, ttsVoice, sttLanguage, voiceRelay -> {}); + } + + /** + * Create a Voice Relay call destination with optional fields, e.g. the interruptions or the call + * headers + * + * @param endpoint URL of the server accepting the WebSocket request + * @param ttsVoice Default voice for speech synthesis + * @param sttLanguage BCP-47 language tag used to transcribe the inbound audio + * @param voiceRelay Lambda setting the optional fields on the model builder, which already holds + * the required ones + * @return The Voice Relay destination + * @since 2.4 + */ + public static VoiceRelay voiceRelay( + String endpoint, + String ttsVoice, + String sttLanguage, + Consumer voiceRelay) { Objects.requireNonNull(endpoint, "endpoint must not be null"); Objects.requireNonNull(ttsVoice, "ttsVoice must not be null"); Objects.requireNonNull(sttLanguage, "sttLanguage must not be null"); - return VoiceRelay.builder() - .setEndpoint(endpoint) - .setTtsVoice(ttsVoice) - .setSttLanguage(sttLanguage) - .build(); + VoiceRelay.Builder builder = + VoiceRelay.builder() + .setEndpoint(endpoint) + .setTtsVoice(ttsVoice) + .setSttLanguage(sttLanguage); + Objects.requireNonNull(voiceRelay, "voiceRelay must not be null").accept(builder); + return builder.build(); } private static boolean isSip(String value) { diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java index 923f1ae0a..c3f173eaf 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Recording.java @@ -4,6 +4,7 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingOptions; import com.sinch.sdk.domains.voice.models.v2.svaml.recording.StopRecordingCommand; import java.util.Objects; +import java.util.function.Consumer; /** * Helpers to build the call recording SVAML commands @@ -17,8 +18,9 @@ private Recording() {} /** * Create the options to store a recording in a third party storage * - *

    Only the required fields are set. Set the format, the recording type and the transcription - * on the creator returned by {@link #start(RecordingOptions)}. + *

    Only the required fields are set. To set the format, the recording type or the + * transcription, use {@link #options(RecordingDestinationType, String, String, Consumer)}, or the + * creator returned by {@link #start(RecordingDestinationType, String, String)}. * * @param destination Third party storage the recording is delivered to * @param url Destination URL, sent as destinationUrl. Its scheme depends on the @@ -29,14 +31,37 @@ private Recording() {} */ public static RecordingOptions options( RecordingDestinationType destination, String url, String credentials) { + return options(destination, url, credentials, options -> {}); + } + + /** + * Create the options to store a recording in a third party storage, with optional fields, e.g. to + * share them between several recordings + * + * @param destination Third party storage the recording is delivered to + * @param url Destination URL, sent as destinationUrl. Its scheme depends on the + * destination: s3, gs or azure + * @param credentials Credentials to the third party storage + * @param options Lambda setting the optional fields (format, recording type, transcription) on + * the model builder, which already holds the required ones + * @return The recording options + * @since 2.4 + */ + public static RecordingOptions options( + RecordingDestinationType destination, + String url, + String credentials, + Consumer options) { Objects.requireNonNull(destination, "destination must not be null"); Objects.requireNonNull(url, "url must not be null"); Objects.requireNonNull(credentials, "credentials must not be null"); - return RecordingOptions.builder() - .setDestination(destination) - .setDestinationUrl(url) - .setCredentials(credentials) - .build(); + RecordingOptions.Builder builder = + RecordingOptions.builder() + .setDestination(destination) + .setDestinationUrl(url) + .setCredentials(credentials); + Objects.requireNonNull(options, "options must not be null").accept(builder); + return builder.build(); } /** diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java index f9e73f785..73d388a4c 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java @@ -4,11 +4,14 @@ import com.sinch.sdk.BaseTest; import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; +import com.sinch.sdk.domains.voice.models.v2.destination.CallHeader; import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; import com.sinch.sdk.domains.voice.models.v2.destination.Phone; import com.sinch.sdk.domains.voice.models.v2.destination.Sip; +import com.sinch.sdk.domains.voice.models.v2.destination.SipCallHeader; import com.sinch.sdk.domains.voice.models.v2.destination.SipFrom; import com.sinch.sdk.domains.voice.models.v2.destination.Stream; +import com.sinch.sdk.domains.voice.models.v2.destination.StreamOptions; import com.sinch.sdk.domains.voice.models.v2.destination.VoiceRelay; import com.sinch.sdk.domains.voice.models.v2.svaml.calls.DialCommand; import java.util.Collections; @@ -178,6 +181,92 @@ void ofValuesAreSerializedInStartCallRequest() throws JsonProcessingException, J + "\"to\":{\"type\":\"STREAM\",\"stream\":{\"endpoint\":\"wss://example.com/audio\"}}}]}"); } + @Test + void sipWithOptionalFields() throws JsonProcessingException, JSONException { + Sip destination = + Destination.sip( + "sip:user@example.com", + sip -> + sip.setTransport(Sip.TransportEnum.TCP) + .setCallHeaders( + Collections.singletonList( + SipCallHeader.builder().setKey("X-Id").setValue("1").build()))); + + assertJson( + destination, + "{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:user@example.com\",\"transport\":\"TCP\"," + + "\"callHeaders\":[{\"key\":\"X-Id\",\"value\":\"1\"}]}}"); + } + + @Test + void sipFromWithOptionalFields() throws JsonProcessingException, JSONException { + SipFrom origin = + Destination.sipFrom("sip:user@example.com", sip -> sip.setDisplayName("Alice")); + + assertJson( + origin, + "{\"type\":\"SIP\",\"sip\":{\"endpoint\":\"sip:user@example.com\"," + + "\"displayName\":\"Alice\"}}"); + } + + @Test + void streamWithOptionalFields() throws JsonProcessingException, JSONException { + Stream destination = + Destination.stream( + "wss://example.com/audio", + stream -> + stream + .setStreamOptions( + StreamOptions.builder() + .setSampleRate(StreamOptions.SampleRateEnum.HZ_16000) + .build()) + .setCallHeaders( + Collections.singletonList( + CallHeader.builder().setKey("X-Id").setValue("1").build()))); + + assertJson( + destination, + "{\"type\":\"STREAM\",\"stream\":{\"endpoint\":\"wss://example.com/audio\"," + + "\"streamOptions\":{\"sampleRate\":16000}," + + "\"callHeaders\":[{\"key\":\"X-Id\",\"value\":\"1\"}]}}"); + } + + @Test + void voiceRelayWithOptionalFields() throws JsonProcessingException, JSONException { + VoiceRelay destination = + Destination.voiceRelay( + "wss://example.com/agent", + "Emma", + "en-US", + relay -> relay.setEnableInterruptions(false)); + + assertJson( + destination, + "{\"type\":\"VOICE_RELAY\",\"voiceRelay\":{\"endpoint\":\"wss://example.com/agent\"," + + "\"ttsVoice\":\"Emma\",\"sttLanguage\":\"en-US\",\"enableInterruptions\":false}}"); + } + + @Test + void emptyLambdaIsTheSameAsTheSimpleFactory() { + Assertions.assertThat(Destination.sip("sip:user@example.com", sip -> {})) + .isEqualTo(Destination.sip("sip:user@example.com")); + Assertions.assertThat(Destination.stream("wss://example.com/audio", stream -> {})) + .isEqualTo(Destination.stream("wss://example.com/audio")); + } + + @Test + void rejectsNullLambda() { + Assertions.assertThatThrownBy(() -> Destination.sip("sip:user@example.com", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.sipFrom("sip:user@example.com", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy(() -> Destination.stream("wss://example.com/audio", null)) + .isInstanceOf(NullPointerException.class); + Assertions.assertThatThrownBy( + () -> Destination.voiceRelay("wss://example.com", "Emma", "en-US", null)) + .isInstanceOf(NullPointerException.class); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Destination.of(null)) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java index c6ae0a8b3..b17828f0f 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/RecordingTest.java @@ -158,6 +158,45 @@ void recordingOptionsFieldsAreAllCopied() { "getTranscriptionOptions"); } + @Test + void optionsWithOptionalFields() throws JsonProcessingException, JSONException { + RecordingOptions options = + Recording.options( + RecordingDestinationType.AWS, + "s3://voice-recordings/support", + "key:secret:eu-west-1", + o -> + o.setFormat(RecordingFormatType.WAV) + .setRecordingType(RecordingType.INBOUND) + .setTranscriptionOptions( + TranscriptionOptions.builder().setIsEnabled(true).build())); + + assertJson( + options, + "{\"destination\":\"AWS\",\"destinationUrl\":\"s3://voice-recordings/support\"," + + "\"credentials\":\"key:secret:eu-west-1\",\"format\":\"WAV\"," + + "\"recordingType\":\"INBOUND\",\"transcriptionOptions\":{\"isEnabled\":true}}"); + } + + @Test + void sharedOptionsWithOptionalFieldsAreSentUnchanged() { + RecordingOptions shared = + Recording.options( + RecordingDestinationType.AWS, "s3://b", "c", o -> o.setFormat(RecordingFormatType.WAV)); + + Assertions.assertThat(Recording.start(shared).name("first").build().getRecordingOptions()) + .isSameAs(shared); + Assertions.assertThat(Recording.start(shared).name("second").build().getRecordingOptions()) + .isSameAs(shared); + } + + @Test + void rejectsNullOptionsLambda() { + Assertions.assertThatThrownBy( + () -> Recording.options(RecordingDestinationType.AWS, "s3://b", "c", null)) + .isInstanceOf(NullPointerException.class); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Recording.options(null, "s3://b", "c")) diff --git a/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java b/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java index 73a1c9a00..17162dc16 100644 --- a/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java +++ b/examples/sinch-events/src/main/java/com/mycompany/app/voice/v2/ServerBusinessLogic.java @@ -23,11 +23,10 @@ public VoiceSinchEventResponse incoming(VoiceSinchEvent event) { // the call name only takes effect in responses to "call.incoming" events return SinchEventResponses.incomingCallResponse( commands -> - commands.messages( - messages -> - messages - .text("Thank you for calling. Goodbye.", "Emma") - .onFinish(Calls.hangup()))) + commands.text( + "Thank you for calling. Goodbye.", + "Emma", + goodbye -> goodbye.onFinish(Calls.hangup()))) .name("incoming") .build(); } diff --git a/examples/snippets/src/main/java/voice/v2/batches/Start.java b/examples/snippets/src/main/java/voice/v2/batches/Start.java index ea9819b29..a07b4297b 100644 --- a/examples/snippets/src/main/java/voice/v2/batches/Start.java +++ b/examples/snippets/src/main/java/voice/v2/batches/Start.java @@ -64,13 +64,10 @@ public static void main(String[] args) { .from(sinchPhoneNumber) .onAnswer( answered -> - answered.messages( - messages -> - messages - .text( - "Hello, your call is now connected.", - "Emma") - .onFinish(Calls.hangup())))) + answered.text( + "Hello, your call is now connected.", + "Emma", + greeting -> greeting.onFinish(Calls.hangup())))) .build()) .setParameters(parameters) .build(); diff --git a/examples/snippets/src/main/java/voice/v2/calls/Start.java b/examples/snippets/src/main/java/voice/v2/calls/Start.java index 43067f5c8..00b4a3cb6 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/Start.java +++ b/examples/snippets/src/main/java/voice/v2/calls/Start.java @@ -55,13 +55,10 @@ public static void main(String[] args) { .from(sinchPhoneNumber) .onAnswer( answered -> - answered.messages( - messages -> - messages - .text( - "Hello, your call is now connected.", - "Emma") - .onFinish(Calls.hangup())))) + answered.text( + "Hello, your call is now connected.", + "Emma", + greeting -> greeting.onFinish(Calls.hangup())))) .build()) .build(); From 8b21e78ecd31485d2968a040964933ea8415b108 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 22 Oct 2026 12:04:11 +0200 Subject: [PATCH 10/17] DEVEXP-1473: Add the Voice V2 VoiceName catalog to the helpers --- CHANGELOG.md | 1 + .../v2/helpers/CommandsSequenceCreator.java | 59 + .../voice/models/v2/helpers/Destination.java | 34 + .../voice/models/v2/helpers/Messages.java | 53 + .../models/v2/helpers/MessagesCreator.java | 25 + .../models/v2/helpers/PromptCreator.java | 25 + .../helpers/CommandsSequenceCreatorTest.java | 18 + .../models/v2/helpers/DestinationTest.java | 20 + .../voice/models/v2/helpers/MenuTest.java | 12 + .../voice/models/v2/helpers/MessagesTest.java | 27 +- .../domains/voice/models/v2/VoiceName.java | 3125 +++++++++++++++++ .../voice/models/v2/VoiceNameTest.java | 32 + 12 files changed, 3430 insertions(+), 1 deletion(-) create mode 100644 openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/VoiceName.java create mode 100644 openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/VoiceNameTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0261e0b..643a14aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Voice - **[feature]** V2 helpers: `Destination`, SVAML commands (`Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu`) and `CommandsSequenceCreator`. Event handlers accept a sequence lambda - **[feature]** V2 helpers: `CallBehaviorHelper` for services and `SinchEventResponses` for Sinch Events +- **[feature]** V2 helpers: `VoiceName` catalog of text-to-speech voices - **[doc]** V2 snippets, `sinch-events` example and README use the helpers --- diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java index b461144da..12a167157 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreator.java @@ -1,5 +1,6 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; import com.sinch.sdk.domains.voice.models.v2.svaml.recording.RecordingDestinationType; @@ -189,6 +190,19 @@ public CommandsSequenceCreator text(String text, String voiceName) { return command(Messages.text(text, voiceName)); } + /** + * Add a messages command playing a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @see Messages#text(String, String) + * @since 2.4 + */ + public CommandsSequenceCreator text(String text, VoiceName voiceName) { + return text(text, VoiceName.valueOf(voiceName)); + } + /** * Add a messages command playing a text-to-speech message in SSML * @@ -202,6 +216,19 @@ public CommandsSequenceCreator ssml(String ssml, String voiceName) { return command(Messages.ssml(ssml, voiceName)); } + /** + * Add a messages command playing a text-to-speech message in SSML + * + * @param ssml SSML document to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @see Messages#ssml(String, String) + * @since 2.4 + */ + public CommandsSequenceCreator ssml(String ssml, VoiceName voiceName) { + return ssml(ssml, VoiceName.valueOf(voiceName)); + } + /** * Add a messages command playing an audio file * @@ -230,6 +257,22 @@ public CommandsSequenceCreator text( return command(Messages.text(text, voiceName, messages)); } + /** + * Add a messages command playing a text-to-speech message in plain text, with a name + * or commands run once it is played + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return This creator + * @see Messages#text(String, String, Consumer) + * @since 2.4 + */ + public CommandsSequenceCreator text( + String text, VoiceName voiceName, Consumer messages) { + return text(text, VoiceName.valueOf(voiceName), messages); + } + /** * Add a messages command playing a text-to-speech message in SSML, with a name or * commands run once it is played @@ -246,6 +289,22 @@ public CommandsSequenceCreator ssml( return command(Messages.ssml(ssml, voiceName, messages)); } + /** + * Add a messages command playing a text-to-speech message in SSML, with a name or + * commands run once it is played + * + * @param ssml SSML document to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return This creator + * @see Messages#ssml(String, String, Consumer) + * @since 2.4 + */ + public CommandsSequenceCreator ssml( + String ssml, VoiceName voiceName, Consumer messages) { + return ssml(ssml, VoiceName.valueOf(voiceName), messages); + } + /** * Add a messages command playing an audio file, with a name or commands run once it * is played diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java index 4bfb7ed90..10eab01ab 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Destination.java @@ -1,6 +1,7 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; import com.fasterxml.jackson.annotation.JsonValue; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; import com.sinch.sdk.domains.voice.models.v2.destination.CallOrigin; import com.sinch.sdk.domains.voice.models.v2.destination.Phone; @@ -182,6 +183,19 @@ public static VoiceRelay voiceRelay(String endpoint, String ttsVoice, String stt return voiceRelay(endpoint, ttsVoice, sttLanguage, voiceRelay -> {}); } + /** + * Create a Voice Relay call destination + * + * @param endpoint URL of the server accepting the WebSocket request + * @param ttsVoice Default voice for speech synthesis, from the catalog + * @param sttLanguage BCP-47 language tag used to transcribe the inbound audio + * @return The Voice Relay destination + * @since 2.4 + */ + public static VoiceRelay voiceRelay(String endpoint, VoiceName ttsVoice, String sttLanguage) { + return voiceRelay(endpoint, VoiceName.valueOf(ttsVoice), sttLanguage); + } + /** * Create a Voice Relay call destination with optional fields, e.g. the interruptions or the call * headers @@ -211,6 +225,26 @@ public static VoiceRelay voiceRelay( return builder.build(); } + /** + * Create a Voice Relay call destination with optional fields, e.g. the interruptions or the call + * headers + * + * @param endpoint URL of the server accepting the WebSocket request + * @param ttsVoice Default voice for speech synthesis, from the catalog + * @param sttLanguage BCP-47 language tag used to transcribe the inbound audio + * @param voiceRelay Lambda setting the optional fields on the model builder, which already holds + * the required ones + * @return The Voice Relay destination + * @since 2.4 + */ + public static VoiceRelay voiceRelay( + String endpoint, + VoiceName ttsVoice, + String sttLanguage, + Consumer voiceRelay) { + return voiceRelay(endpoint, VoiceName.valueOf(ttsVoice), sttLanguage, voiceRelay); + } + private static boolean isSip(String value) { return value.startsWith(SIP_PREFIX) || value.startsWith(SIPS_PREFIX); } diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java index 44e5a772b..d4bc745ee 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/Messages.java @@ -1,5 +1,6 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessagesCommand; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.PlayMessage; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; @@ -32,6 +33,18 @@ public static MessagesCommand text(String text, String voiceName) { return start().text(text, voiceName).build(); } + /** + * Play a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return The messages command, with this single message + * @since 2.4 + */ + public static MessagesCommand text(String text, VoiceName voiceName) { + return text(text, VoiceName.valueOf(voiceName)); + } + /** * Play a text-to-speech message in SSML * @@ -44,6 +57,18 @@ public static MessagesCommand ssml(String ssml, String voiceName) { return start().ssml(ssml, voiceName).build(); } + /** + * Play a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return The messages command, with this single message + * @since 2.4 + */ + public static MessagesCommand ssml(String ssml, VoiceName voiceName) { + return ssml(ssml, VoiceName.valueOf(voiceName)); + } + /** * Play an audio file * @@ -69,6 +94,20 @@ public static MessagesCommand text( return fill(start().text(text, voiceName), messages); } + /** + * Play a text-to-speech message in plain text, with a name or commands run once it is played + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return The messages command + * @since 2.4 + */ + public static MessagesCommand text( + String text, VoiceName voiceName, Consumer messages) { + return text(text, VoiceName.valueOf(voiceName), messages); + } + /** * Play a text-to-speech message in SSML, with a name or commands run once it is played * @@ -83,6 +122,20 @@ public static MessagesCommand ssml( return fill(start().ssml(ssml, voiceName), messages); } + /** + * Play a text-to-speech message in SSML, with a name or commands run once it is played + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @param messages Lambda setting the name, the commands run once played, or more messages + * @return The messages command + * @since 2.4 + */ + public static MessagesCommand ssml( + String ssml, VoiceName voiceName, Consumer messages) { + return ssml(ssml, VoiceName.valueOf(voiceName), messages); + } + /** * Play an audio file, with a name or commands run once it is played * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java index edf221e93..9e07e3375 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesCreator.java @@ -1,5 +1,6 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.Message; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.MessageEvents; @@ -40,6 +41,18 @@ public MessagesCreator text(String text, String voiceName) { return this; } + /** + * Add a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @since 2.4 + */ + public MessagesCreator text(String text, VoiceName voiceName) { + return text(text, VoiceName.valueOf(voiceName)); + } + /** * Add a text-to-speech message in SSML * @@ -53,6 +66,18 @@ public MessagesCreator ssml(String ssml, String voiceName) { return this; } + /** + * Add a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @since 2.4 + */ + public MessagesCreator ssml(String ssml, VoiceName voiceName) { + return ssml(ssml, VoiceName.valueOf(voiceName)); + } + /** * Add an audio file to play * diff --git a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java index bafc27020..620f7a097 100644 --- a/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java +++ b/client/src/main/com/sinch/sdk/domains/voice/models/v2/helpers/PromptCreator.java @@ -1,5 +1,6 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.Message; import com.sinch.sdk.domains.voice.models.v2.svaml.playback.SayMessage; @@ -34,6 +35,18 @@ public PromptCreator text(String text, String voiceName) { return this; } + /** + * Add a text-to-speech message in plain text + * + * @param text Text to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @since 2.4 + */ + public PromptCreator text(String text, VoiceName voiceName) { + return text(text, VoiceName.valueOf(voiceName)); + } + /** * Add a text-to-speech message in SSML * @@ -47,6 +60,18 @@ public PromptCreator ssml(String ssml, String voiceName) { return this; } + /** + * Add a text-to-speech message in SSML + * + * @param ssml SSML document (<speak>...</speak>) to synthesize + * @param voiceName Voice used for speech synthesis, from the catalog + * @return This creator + * @since 2.4 + */ + public PromptCreator ssml(String ssml, VoiceName voiceName) { + return ssml(ssml, VoiceName.valueOf(voiceName)); + } + /** * Add an audio file to play * diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java index c15c6bd6e..b003d7630 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/CommandsSequenceCreatorTest.java @@ -1,6 +1,7 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuCommand; @@ -348,6 +349,23 @@ void commandAcceptsACollection() throws JsonProcessingException, JSONException { Arrays.asList(Calls.answer(), Calls.hangup())); } + @Test + void messageShortcutsAcceptAVoiceNameFromTheCatalog() + throws JsonProcessingException, JSONException { + assertSameJson( + CommandsSequenceCreator.create() + .text("Hello", VoiceName.EMMA) + .ssml("Hi", VoiceName.EMMA) + .text("Bye", VoiceName.EMMA, m -> m.onFinish(Calls.hangup())) + .ssml("Bye", VoiceName.EMMA, m -> m.name("bye")) + .build(), + Arrays.asList( + Messages.text("Hello", "Emma"), + Messages.ssml("Hi", "Emma"), + Messages.text("Bye", "Emma", m -> m.onFinish(Calls.hangup())), + Messages.ssml("Bye", "Emma", m -> m.name("bye")))); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> CommandsSequenceCreator.create().amd(null)) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java index 73d388a4c..9bb8cd294 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/DestinationTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.sinch.sdk.BaseTest; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; import com.sinch.sdk.domains.voice.models.v2.destination.CallDestination; import com.sinch.sdk.domains.voice.models.v2.destination.CallHeader; @@ -267,6 +268,25 @@ void rejectsNullLambda() { .isInstanceOf(NullPointerException.class); } + @Test + void voiceRelayAcceptsAVoiceNameFromTheCatalog() { + Assertions.assertThat( + Destination.voiceRelay("wss://example.com/agent", VoiceName.EMMA, "en-US")) + .isEqualTo(Destination.voiceRelay("wss://example.com/agent", "Emma", "en-US")); + Assertions.assertThat( + Destination.voiceRelay( + "wss://example.com/agent", + VoiceName.EMMA, + "en-US", + relay -> relay.setEnableInterruptions(false))) + .isEqualTo( + Destination.voiceRelay( + "wss://example.com/agent", + "Emma", + "en-US", + relay -> relay.setEnableInterruptions(false))); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Destination.of(null)) diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java index a6a0333e0..b02485a0d 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MenuTest.java @@ -1,6 +1,7 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuCommand; import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuItem; import com.sinch.sdk.domains.voice.models.v2.svaml.menu.MenuPrompt; @@ -303,6 +304,17 @@ void nestedMenuKeepsItsOwnItems() { .doesNotThrowAnyException(); } + @Test + void promptAcceptsAVoiceNameFromTheCatalog() throws JsonProcessingException, JSONException { + assertJson( + Menu.prompt( + prompt -> + prompt.text("Press 1", VoiceName.EMMA).ssml("1", VoiceName.EMMA)), + objectMapper.writeValueAsString( + Menu.prompt( + prompt -> prompt.text("Press 1", "Emma").ssml("1", "Emma")))); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Menu.create(null)).isInstanceOf(NullPointerException.class); diff --git a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java index 3a8ac9021..db09fa53b 100644 --- a/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java +++ b/client/src/test/java/com/sinch/sdk/domains/voice/models/v2/helpers/MessagesTest.java @@ -1,6 +1,7 @@ package com.sinch.sdk.domains.voice.models.v2.helpers; import com.fasterxml.jackson.core.JsonProcessingException; +import com.sinch.sdk.domains.voice.models.v2.VoiceName; import org.assertj.core.api.Assertions; import org.json.JSONException; import org.junit.jupiter.api.Test; @@ -102,11 +103,35 @@ void rejectsNullShortcutLambda() { .isInstanceOf(NullPointerException.class); } + @Test + void acceptsAVoiceNameFromTheCatalog() throws JsonProcessingException, JSONException { + assertJson( + Messages.text("Hello", VoiceName.EMMA), + objectMapper.writeValueAsString(Messages.text("Hello", "Emma"))); + assertJson( + Messages.ssml("Hello", VoiceName.EMMA, m -> m.name("greeting")), + objectMapper.writeValueAsString( + Messages.ssml("Hello", "Emma", m -> m.name("greeting")))); + assertJson( + Messages.start() + .text("Hello", VoiceName.EMMA) + .ssml("Hi", VoiceName.EMMA_3) + .build(), + objectMapper.writeValueAsString( + Messages.start().text("Hello", "Emma").ssml("Hi", "Emma_3").build())); + } + + @Test + void rejectsNullVoiceName() { + Assertions.assertThatThrownBy(() -> Messages.text("Hello", (VoiceName) null)) + .isInstanceOf(NullPointerException.class); + } + @Test void rejectsNullValues() { Assertions.assertThatThrownBy(() -> Messages.text(null, "Emma")) .isInstanceOf(NullPointerException.class); - Assertions.assertThatThrownBy(() -> Messages.text("Hello", null)) + Assertions.assertThatThrownBy(() -> Messages.text("Hello", (String) null)) .isInstanceOf(NullPointerException.class); Assertions.assertThatThrownBy(() -> Messages.play(null)) .isInstanceOf(NullPointerException.class); diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/VoiceName.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/VoiceName.java new file mode 100644 index 000000000..1b2f1c189 --- /dev/null +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/VoiceName.java @@ -0,0 +1,3125 @@ +package com.sinch.sdk.domains.voice.models.v2; + +import com.sinch.sdk.core.utils.EnumDynamic; +import com.sinch.sdk.core.utils.EnumSupportDynamic; +import java.util.Arrays; +import java.util.stream.Stream; + +/** + * Sinch Voice Name to use for text-to-speech. Each voice is tied to a specific language variant, + * gender, and pricing model (Free/Standard or Paid/Generative). See the + * text-to-speech voices for details + */ +public class VoiceName extends EnumDynamic { + + /** Male voice for Hindi (India) (Free voice) */ + public static final VoiceName AARAV = new VoiceName("Aarav"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName AARAV_2 = new VoiceName("Aarav_2"); + + /** Female voice for Marathi (India) (Free voice) */ + public static final VoiceName AAROHI = new VoiceName("Aarohi"); + + /** Female voice for Hindi (India) (Free voice) */ + public static final VoiceName AARTI = new VoiceName("Aarti"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName AARTI_2 = new VoiceName("Aarti_2"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName AARTI_3 = new VoiceName("Aarti_3"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName AASHI = new VoiceName("Aashi"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ABBI = new VoiceName("Abbi"); + + /** Male voice for Arabic (Oman) (Free voice) */ + public static final VoiceName ABDULLAH = new VoiceName("Abdullah"); + + /** Male voice for English (Nigeria) (Free voice) */ + public static final VoiceName ABEO = new VoiceName("Abeo"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ABRIL = new VoiceName("Abril"); + + /** Female voice for Thai (Thailand) (Free voice) */ + public static final VoiceName ACHARA = new VoiceName("Achara"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ADA = new VoiceName("Ada"); + + /** Female voice for English (United Kingdom) (Paid voice) */ + public static final VoiceName ADA_2 = new VoiceName("Ada_2"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ADAM = new VoiceName("Adam"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName ADAM_2 = new VoiceName("Adam_2"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName ADITI = new VoiceName("Aditi"); + + /** Female voice for Afrikaans (South Africa) (Free voice) */ + public static final VoiceName ADRI = new VoiceName("Adri"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName ADRIANO = new VoiceName("Adriano"); + + /** Female voice for Polish (Poland) (Free voice) */ + public static final VoiceName AGNIESZKA = new VoiceName("Agnieszka"); + + /** Male voice for Turkish (Türkiye) (Free voice) */ + public static final VoiceName AHMET = new VoiceName("Ahmet"); + + /** Female voice for Kazakh (Kazakhstan) (Free voice) */ + public static final VoiceName AIGUL = new VoiceName("Aigul"); + + /** Female voice for Basque (Free voice) */ + public static final VoiceName AINHOA = new VoiceName("Ainhoa"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName ALAIN = new VoiceName("Alain"); + + /** Female voice for Catalan (Free voice) */ + public static final VoiceName ALBA = new VoiceName("Alba"); + + /** Male voice for Welsh (United Kingdom) (Free voice) */ + public static final VoiceName ALED = new VoiceName("Aled"); + + /** Male voice for Macedonian (North Macedonia) (Free voice) */ + public static final VoiceName ALEKSANDAR = new VoiceName("Aleksandar"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName ALESSIO = new VoiceName("Alessio"); + + /** Male voice for Italian (Italy) (Paid voice) */ + public static final VoiceName ALESSIO_2 = new VoiceName("Alessio_2"); + + /** Male voice for Spanish (Peru) (Free voice) */ + public static final VoiceName ALEX = new VoiceName("Alex"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ALFIE = new VoiceName("Alfie"); + + /** Male voice for Arabic (Bahrain) (Free voice) */ + public static final VoiceName ALI = new VoiceName("Ali"); + + /** Female voice for Romanian (Romania) (Free voice) */ + public static final VoiceName ALINA = new VoiceName("Alina"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ALLOY = new VoiceName("Alloy"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName ALLOY_2 = new VoiceName("Alloy_2"); + + /** Male voice for Spanish (United States) (Free voice) */ + public static final VoiceName ALONSO = new VoiceName("Alonso"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ALVARO = new VoiceName("Alvaro"); + + /** Female voice for Arabic (Qatar) (Free voice) */ + public static final VoiceName AMAL = new VoiceName("Amal"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName AMALA = new VoiceName("Amala"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName AMANDA = new VoiceName("Amanda"); + + /** Female voice for Arabic (Syria) (Free voice) */ + public static final VoiceName AMANY = new VoiceName("Amany"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName AMBER = new VoiceName("Amber"); + + /** Female voice for French (France) (Paid voice) */ + public static final VoiceName AMBRE = new VoiceName("Ambre"); + + /** Male voice for Amharic (Ethiopia) (Free voice) */ + public static final VoiceName AMEHA = new VoiceName("Ameha"); + + /** Female voice for Arabic (Algeria) (Free voice) */ + public static final VoiceName AMINA = new VoiceName("Amina"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName AMY = new VoiceName("Amy"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName AMY_2 = new VoiceName("Amy_2"); + + /** Female voice for English (United Kingdom) (Paid voice) */ + public static final VoiceName AMY_3 = new VoiceName("Amy_3"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName ANA = new VoiceName("Ana"); + + /** Female voice for Armenian (Armenia) (Free voice) */ + public static final VoiceName ANAHIT = new VoiceName("Anahit"); + + /** Female voice for Hindi (India) (Free voice) */ + public static final VoiceName ANANYA = new VoiceName("Ananya"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName ANANYA_2 = new VoiceName("Ananya_2"); + + /** Male voice for Tamil (Singapore) (Free voice) */ + public static final VoiceName ANBU = new VoiceName("Anbu"); + + /** Male voice for Basque (Free voice) */ + public static final VoiceName ANDER = new VoiceName("Ander"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName ANDRE = new VoiceName("Andre"); + + /** Male voice for Spanish (Mexico) (Paid voice) */ + public static final VoiceName ANDRE_2 = new VoiceName("Andre_2"); + + /** Female voice for Spanish (Ecuador) (Free voice) */ + public static final VoiceName ANDREA = new VoiceName("Andrea"); + + /** Male voice for Spanish (Guatemala) (Free voice) */ + public static final VoiceName ANDRES = new VoiceName("Andres"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ANDREW = new VoiceName("Andrew"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ANDREW_2 = new VoiceName("Andrew_2"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName ANDREW_3 = new VoiceName("Andrew_3"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName ANDREW_4 = new VoiceName("Andrew_4"); + + /** Male voice for Filipino (Philippines) (Free voice) */ + public static final VoiceName ANGELO = new VoiceName("Angelo"); + + /** Female voice for Albanian (Albania) (Free voice) */ + public static final VoiceName ANILA = new VoiceName("Anila"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName ANNETTE = new VoiceName("Annette"); + + /** Male voice for French (Canada) (Free voice) */ + public static final VoiceName ANTOINE = new VoiceName("Antoine"); + + /** Male voice for Czech (Czechia) (Free voice) */ + public static final VoiceName ANTONIN = new VoiceName("Antonin"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName ANTONIO = new VoiceName("Antonio"); + + /** Female voice for Estonian (Estonia) (Free voice) */ + public static final VoiceName ANU = new VoiceName("Anu"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName AOI = new VoiceName("Aoi"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ARABELLA = new VoiceName("Arabella"); + + /** Male voice for Indonesian (Indonesia) (Free voice) */ + public static final VoiceName ARDI = new VoiceName("Ardi"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName ARIA = new VoiceName("Aria"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName ARIA_2 = new VoiceName("Aria_2"); + + /** Female voice for French (Switzerland) (Free voice) */ + public static final VoiceName ARIANE = new VoiceName("Ariane"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName ARJUN = new VoiceName("Arjun"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName ARJUN_2 = new VoiceName("Arjun_2"); + + /** Male voice for Hindi (India) (Free voice) */ + public static final VoiceName ARJUN_3 = new VoiceName("Arjun_3"); + + /** Female voice for Catalan (Free voice) */ + public static final VoiceName ARLET = new VoiceName("Arlet"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ARNAU = new VoiceName("Arnau"); + + /** Male voice for Dutch (Belgium) (Free voice) */ + public static final VoiceName ARNAUD = new VoiceName("Arnaud"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ARTHUR = new VoiceName("Arthur"); + + /** Female voice for English (New Zealand) (Free voice) */ + public static final VoiceName ARYA = new VoiceName("Arya"); + + /** Female voice for English (New Zealand) (Paid voice) */ + public static final VoiceName ARYA_2 = new VoiceName("Arya_2"); + + /** Male voice for Urdu (Pakistan) (Free voice) */ + public static final VoiceName ASAD = new VoiceName("Asad"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName ASHLEY = new VoiceName("Ashley"); + + /** Female voice for English (Kenya) (Free voice) */ + public static final VoiceName ASILIA = new VoiceName("Asilia"); + + /** Female voice for Swedish (Sweden) (Free voice) */ + public static final VoiceName ASTRID = new VoiceName("Astrid"); + + /** Female voice for Greek (Greece) (Free voice) */ + public static final VoiceName ATHINA = new VoiceName("Athina"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName AVA = new VoiceName("Ava"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName AVA_2 = new VoiceName("Ava_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName AVA_3 = new VoiceName("Ava_3"); + + /** Male voice for Hebrew (Israel) (Free voice) */ + public static final VoiceName AVRI = new VoiceName("Avri"); + + /** Female voice for English (South Africa) (Free voice) */ + public static final VoiceName AYANDA = new VoiceName("Ayanda"); + + /** Female voice for English (South Africa) (Paid voice) */ + public static final VoiceName AYANDA_2 = new VoiceName("Ayanda_2"); + + /** Female voice for Arabic (Oman) (Free voice) */ + public static final VoiceName AYSHA = new VoiceName("Aysha"); + + /** Male voice for Azerbaijani (Latin, Azerbaijan) (Free voice) */ + public static final VoiceName BABEK = new VoiceName("Babek"); + + /** Female voice for Azerbaijani (Latin, Azerbaijan) (Free voice) */ + public static final VoiceName BANU = new VoiceName("Banu"); + + /** Male voice for Bengali (India) (Free voice) */ + public static final VoiceName BASHKAR = new VoiceName("Bashkar"); + + /** Male voice for Arabic (Iraq) (Free voice) */ + public static final VoiceName BASSEL = new VoiceName("Bassel"); + + /** Male voice for Mongolian (Mongolia) (Free voice) */ + public static final VoiceName BATAA = new VoiceName("Bataa"); + + /** Female voice for Italian (Italy) (Paid voice) */ + public static final VoiceName BEATRICE = new VoiceName("Beatrice"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName BEATRIZ = new VoiceName("Beatriz"); + + /** Female voice for Spanish (Cuba) (Free voice) */ + public static final VoiceName BELKYS = new VoiceName("Belkys"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName BELLA = new VoiceName("Bella"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName BENIGNO = new VoiceName("Benigno"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName BERND = new VoiceName("Bernd"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName BIANCA = new VoiceName("Bianca"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName BIANCA_2 = new VoiceName("Bianca_2"); + + /** Female voice for Italian (Italy) (Paid voice) */ + public static final VoiceName BIANCA_3 = new VoiceName("Bianca_3"); + + /** Female voice for Filipino (Philippines) (Free voice) */ + public static final VoiceName BLESSICA = new VoiceName("Blessica"); + + /** Male voice for Korean (Korea) (Free voice) */ + public static final VoiceName BONG_JIN = new VoiceName("BongJin"); + + /** Male voice for Bulgarian (Bulgaria) (Free voice) */ + public static final VoiceName BORISLAV = new VoiceName("Borislav"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName BRANDON = new VoiceName("Brandon"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName BRANDON_2 = new VoiceName("Brandon_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName BREE = new VoiceName("Bree"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName BRENDA = new VoiceName("Brenda"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName BRIAN = new VoiceName("Brian"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName BRIAN_2 = new VoiceName("Brian_2"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName BRIAN_3 = new VoiceName("Brian_3"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName BRIGITTE = new VoiceName("Brigitte"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName BRYAN = new VoiceName("Bryan"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName BRYAN_2 = new VoiceName("Bryan_2"); + + /** Male voice for English (United Kingdom) (Paid voice) */ + public static final VoiceName BRYAN_3 = new VoiceName("Bryan_3"); + + /** Female voice for Turkish (Türkiye) (Free voice) */ + public static final VoiceName BURCU = new VoiceName("Burcu"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName CALIMERO = new VoiceName("Calimero"); + + /** Female voice for Spanish (Peru) (Free voice) */ + public static final VoiceName CAMILA = new VoiceName("Camila"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName CAMILLA = new VoiceName("Camilla"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName CAMILLA_2 = new VoiceName("Camilla_2"); + + /** Female voice for Portuguese (Brazil) (Paid voice) */ + public static final VoiceName CAMILLA_3 = new VoiceName("Camilla_3"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName CANDELA = new VoiceName("Candela"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName CARLA = new VoiceName("Carla"); + + /** Male voice for Spanish (Honduras) (Free voice) */ + public static final VoiceName CARLOS = new VoiceName("Carlos"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName CARLOTA = new VoiceName("Carlota"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName CARLY = new VoiceName("Carly"); + + /** Female voice for Romanian (Romania) (Free voice) */ + public static final VoiceName CARMEN = new VoiceName("Carmen"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName CATALDO = new VoiceName("Cataldo"); + + /** Female voice for Spanish (Chile) (Free voice) */ + public static final VoiceName CATALINA = new VoiceName("Catalina"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName CECILIO = new VoiceName("Cecilio"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName CELESTE = new VoiceName("Celeste"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName CELINE = new VoiceName("Celine"); + + /** Female voice for French (France) (Paid voice) */ + public static final VoiceName CELINE_2 = new VoiceName("Celine_2"); + + /** Female voice for French (Canada) (Free voice) */ + public static final VoiceName CHANTAL = new VoiceName("Chantal"); + + /** Male voice for Lao (Laos) (Free voice) */ + public static final VoiceName CHANTHAVONG = new VoiceName("Chanthavong"); + + /** Female voice for French (Belgium) (Free voice) */ + public static final VoiceName CHARLINE = new VoiceName("Charline"); + + /** Male voice for English (Kenya) (Free voice) */ + public static final VoiceName CHILEMBA = new VoiceName("Chilemba"); + + /** Female voice for Danish (Denmark) (Free voice) */ + public static final VoiceName CHRISTEL = new VoiceName("Christel"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName CHRISTOPH = new VoiceName("Christoph"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName CHRISTOPHER = new VoiceName("Christopher"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName CHRISTOPHER_2 = new VoiceName("Christopher_2"); + + /** Female voice for English (Canada) (Free voice) */ + public static final VoiceName CLARA = new VoiceName("Clara"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName CLAUDE = new VoiceName("Claude"); + + /** Female voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName COLETTE = new VoiceName("Colette"); + + /** Male voice for Irish (Ireland) (Free voice) */ + public static final VoiceName COLM = new VoiceName("Colm"); + + /** Female voice for Spanish (Castilian) (Free voice) */ + public static final VoiceName CONCHITA = new VoiceName("Conchita"); + + /** Male voice for English (Ireland) (Free voice) */ + public static final VoiceName CONNOR = new VoiceName("Connor"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName CONRAD = new VoiceName("Conrad"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName CORA = new VoiceName("Cora"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName CORA_2 = new VoiceName("Cora_2"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName CORALIE = new VoiceName("Coralie"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName CRISTIANO = new VoiceName("Cristiano"); + + /** Male voice for Japanese (Japan) (Free voice) */ + public static final VoiceName DAICHI = new VoiceName("Daichi"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName DALIA = new VoiceName("Dalia"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName DALIA_2 = new VoiceName("Dalia_2"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName DANIEL = new VoiceName("Daniel"); + + /** Male voice for German (Germany) (Paid voice) */ + public static final VoiceName DANIEL_2 = new VoiceName("Daniel_2"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName DANIELLE = new VoiceName("Danielle"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName DANIELLE_2 = new VoiceName("Danielle_2"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName DARIO = new VoiceName("Dario"); + + /** Female voice for Russian (Russia) (Free voice) */ + public static final VoiceName DARIYA = new VoiceName("Dariya"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName DARREN = new VoiceName("Darren"); + + /** Male voice for Swahili (Tanzania) (Free voice) */ + public static final VoiceName DAUDI = new VoiceName("Daudi"); + + /** Male voice for Kazakh (Kazakhstan) (Free voice) */ + public static final VoiceName DAULET = new VoiceName("Daulet"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName DAVIS = new VoiceName("Davis"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName DAVIS_2 = new VoiceName("Davis_2"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName DAVIS_3 = new VoiceName("Davis_3"); + + /** Female voice for Dutch (Belgium) (Free voice) */ + public static final VoiceName DENA = new VoiceName("Dena"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName DENISE = new VoiceName("Denise"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName DEREK = new VoiceName("Derek"); + + /** Female voice for Gujarati (India) (Free voice) */ + public static final VoiceName DHWANI = new VoiceName("Dhwani"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName DIEGO = new VoiceName("Diego"); + + /** Female voice for Persian (Iran) (Free voice) */ + public static final VoiceName DILARA = new VoiceName("Dilara"); + + /** Male voice for Javanese (Latin, Indonesia) (Free voice) */ + public static final VoiceName DIMAS = new VoiceName("Dimas"); + + /** Male voice for Russian (Russia) (Free voice) */ + public static final VoiceName DMITRY = new VoiceName("Dmitry"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName DONATO = new VoiceName("Donato"); + + /** Female voice for Icelandic (Iceland) (Free voice) */ + public static final VoiceName DORA = new VoiceName("Dora"); + + /** Male voice for Portuguese (Portugal) (Free voice) */ + public static final VoiceName DUARTE = new VoiceName("Duarte"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName DUNCAN = new VoiceName("Duncan"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName DUSTIN = new VoiceName("Dustin"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ECHO = new VoiceName("Echo"); + + /** Female voice for Georgian (Georgia) (Free voice) */ + public static final VoiceName EKA = new VoiceName("Eka"); + + /** Female voice for Spanish (Argentina) (Free voice) */ + public static final VoiceName ELENA = new VoiceName("Elena"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ELIAS = new VoiceName("Elias"); + + /** Male voice for English (Tanzania) (Free voice) */ + public static final VoiceName ELIMU = new VoiceName("Elimu"); + + /** Female voice for Swedish (Sweden) (Free voice) */ + public static final VoiceName ELIN = new VoiceName("Elin"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName ELIZABETH = new VoiceName("Elizabeth"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName ELKE = new VoiceName("Elke"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ELLIOT = new VoiceName("Elliot"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName ELOISE = new VoiceName("Eloise"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName ELSA = new VoiceName("Elsa"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName ELSIE = new VoiceName("Elsie"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ELVIRA = new VoiceName("Elvira"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName ELZA = new VoiceName("Elza"); + + /** Female voice for Turkish (Türkiye) (Free voice) */ + public static final VoiceName EMEL = new VoiceName("Emel"); + + /** Male voice for Romanian (Romania) (Free voice) */ + public static final VoiceName EMIL = new VoiceName("Emil"); + + /** Male voice for Spanish (Dominican Republic) (Free voice) */ + public static final VoiceName EMILIO = new VoiceName("Emilio"); + + /** Female voice for English (Ireland) (Free voice) */ + public static final VoiceName EMILY = new VoiceName("Emily"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName EMMA = new VoiceName("Emma"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName EMMA_2 = new VoiceName("Emma_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName EMMA_3 = new VoiceName("Emma_3"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName EMMA_4 = new VoiceName("Emma_4"); + + /** Male voice for Catalan (Free voice) */ + public static final VoiceName ENRIC = new VoiceName("Enric"); + + /** Male voice for Spanish (Castilian) (Free voice) */ + public static final VoiceName ENRIQUE = new VoiceName("Enrique"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ERIC = new VoiceName("Eric"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ESTRELLA = new VoiceName("Estrella"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName ETHAN = new VoiceName("Ethan"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName EVELYN = new VoiceName("Evelyn"); + + /** Female voice for Latvian (Latvia) (Free voice) */ + public static final VoiceName EVERITA = new VoiceName("Everita"); + + /** Female voice for Polish (Poland) (Free voice) */ + public static final VoiceName EWA = new VoiceName("Ewa"); + + /** Female voice for Polish (Poland) (Paid voice) */ + public static final VoiceName EWA_2 = new VoiceName("Ewa_2"); + + /** Female voice for English (Nigeria) (Free voice) */ + public static final VoiceName EZINNE = new VoiceName("Ezinne"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName FABIO = new VoiceName("Fabio"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName FABIOLA = new VoiceName("Fabiola"); + + /** Neutral voice for English (United States) (Free voice) */ + public static final VoiceName FABLE = new VoiceName("Fable"); + + /** Male voice for French (Switzerland) (Free voice) */ + public static final VoiceName FABRICE = new VoiceName("Fabrice"); + + /** Male voice for Arabic (Kuwait) (Free voice) */ + public static final VoiceName FAHED = new VoiceName("Fahed"); + + /** Male voice for Persian (Iran) (Free voice) */ + public static final VoiceName FARID = new VoiceName("Farid"); + + /** Female voice for Arabic (United Arab Emirates) (Free voice) */ + public static final VoiceName FATIMA = new VoiceName("Fatima"); + + /** Male voice for Spanish (Nicaragua) (Free voice) */ + public static final VoiceName FEDERICO = new VoiceName("Federico"); + + /** Female voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName FENNA = new VoiceName("Fenna"); + + /** Female voice for Portuguese (Portugal) (Free voice) */ + public static final VoiceName FERNANDA = new VoiceName("Fernanda"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName FIAMMA = new VoiceName("Fiamma"); + + /** Female voice for Turkish (Türkiye) (Free voice) */ + public static final VoiceName FILIZ = new VoiceName("Filiz"); + + /** Male voice for Norwegian (Norway) (Free voice) */ + public static final VoiceName FINN = new VoiceName("Finn"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName FLORIAN = new VoiceName("Florian"); + + /** Male voice for German (Germany) (Paid voice) */ + public static final VoiceName FLORIAN_2 = new VoiceName("Florian_2"); + + /** Male voice for French (France) (Paid voice) */ + public static final VoiceName FLORIN = new VoiceName("Florin"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName FRANCISCA = new VoiceName("Francisca"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName FREYA = new VoiceName("Freya"); + + /** Female voice for French (Canada) (Free voice) */ + public static final VoiceName GABRIELLE = new VoiceName("Gabrielle"); + + /** Female voice for French (Canada) (Paid voice) */ + public static final VoiceName GABRIELLE_2 = new VoiceName("Gabrielle_2"); + + /** Female voice for Croatian (Croatia) (Free voice) */ + public static final VoiceName GABRIJELA = new VoiceName("Gabrijela"); + + /** Female voice for Indonesian (Indonesia) (Free voice) */ + public static final VoiceName GADIS = new VoiceName("Gadis"); + + /** Male voice for Kannada (India) (Free voice) */ + public static final VoiceName GAGAN = new VoiceName("Gagan"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName GEMMA = new VoiceName("Gemma"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName GEMMA_2 = new VoiceName("Gemma_2"); + + /** Male voice for Welsh (United Kingdom) (Free voice) */ + public static final VoiceName GERAINT = new VoiceName("Geraint"); + + /** Male voice for French (Belgium) (Free voice) */ + public static final VoiceName GERARD = new VoiceName("Gerard"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName GERARDO = new VoiceName("Gerardo"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName GIANNI = new VoiceName("Gianni"); + + /** Male voice for Georgian (Georgia) (Free voice) */ + public static final VoiceName GIORGI = new VoiceName("Giorgi"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName GIORGIO = new VoiceName("Giorgio"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName GIOVANNA = new VoiceName("Giovanna"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName GISELA = new VoiceName("Gisela"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName GIUSEPPE = new VoiceName("Giuseppe"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName GIUSEPPE_2 = new VoiceName("Giuseppe_2"); + + /** Male voice for Spanish (Colombia) (Free voice) */ + public static final VoiceName GONZALO = new VoiceName("Gonzalo"); + + /** Male voice for Korean (Korea) (Free voice) */ + public static final VoiceName GOOK_MIN = new VoiceName("GookMin"); + + /** Male voice for Bosnian (Bosnia and Herzegovina) (Free voice) */ + public static final VoiceName GORAN = new VoiceName("Goran"); + + /** Female voice for Maltese (Malta) (Free voice) */ + public static final VoiceName GRACE = new VoiceName("Grace"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName GREGORY = new VoiceName("Gregory"); + + /** Female voice for Icelandic (Iceland) (Free voice) */ + public static final VoiceName GUDRUN = new VoiceName("Gudrun"); + + /** Female voice for Urdu (India) (Free voice) */ + public static final VoiceName GUL = new VoiceName("Gul"); + + /** Male voice for Pashto (Afghanistan) (Free voice) */ + public static final VoiceName GUL_NAWAZ = new VoiceName("GulNawaz"); + + /** Male voice for Icelandic (Iceland) (Free voice) */ + public static final VoiceName GUNNAR = new VoiceName("Gunnar"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName GUY = new VoiceName("Guy"); + + /** Female voice for Welsh (United Kingdom) (Free voice) */ + public static final VoiceName GWYNETH = new VoiceName("Gwyneth"); + + /** Female voice for Arabic (Saudi Arabia) (Free voice) */ + public static final VoiceName HALA = new VoiceName("Hala"); + + /** Male voice for Arabic (United Arab Emirates) (Free voice) */ + public static final VoiceName HAMDAN = new VoiceName("Hamdan"); + + /** Male voice for Arabic (Saudi Arabia) (Free voice) */ + public static final VoiceName HAMED = new VoiceName("Hamed"); + + /** Female voice for German (Austria) (Free voice) */ + public static final VoiceName HANNAH = new VoiceName("Hannah"); + + /** Female voice for German (Austria) (Paid voice) */ + public static final VoiceName HANNAH_2 = new VoiceName("Hannah_2"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName HANS = new VoiceName("Hans"); + + /** Male voice for Finnish (Finland) (Free voice) */ + public static final VoiceName HARRI = new VoiceName("Harri"); + + /** Male voice for Armenian (Armenia) (Free voice) */ + public static final VoiceName HAYK = new VoiceName("Hayk"); + + /** Male voice for Arabic (Tunisia) (Free voice) */ + public static final VoiceName HEDI = new VoiceName("Hedi"); + + /** Female voice for Nepali (Nepal) (Free voice) */ + public static final VoiceName HEMKALA = new VoiceName("Hemkala"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName HENRI = new VoiceName("Henri"); + + /** Female voice for Hebrew (Israel) (Free voice) */ + public static final VoiceName HILA = new VoiceName("Hila"); + + /** Female voice for Swedish (Sweden) (Free voice) */ + public static final VoiceName HILLEVI = new VoiceName("Hillevi"); + + /** Female voice for Chinese (Cantonese, Traditional) (Free voice) */ + public static final VoiceName HIU_GAAI = new VoiceName("HiuGaai"); + + /** Female voice for Chinese (Cantonese) (Free voice) */ + public static final VoiceName HIUJIN = new VoiceName("Hiujin"); + + /** Female voice for Chinese (Cantonese, Traditional) (Free voice) */ + public static final VoiceName HIU_MAAN = new VoiceName("HiuMaan"); + + /** Female voice for Vietnamese (Vietnam) (Free voice) */ + public static final VoiceName HOAI_MY = new VoiceName("HoaiMy"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName HOLLIE = new VoiceName("Hollie"); + + /** Female voice for Chinese (Taiwanese Mandarin, Traditional) (Free voice) */ + public static final VoiceName HSIAO_CHEN = new VoiceName("HsiaoChen"); + + /** Female voice for Chinese (Taiwanese Mandarin, Traditional) (Free voice) */ + public static final VoiceName HSIAO_YU = new VoiceName("HsiaoYu"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName HUMBERTO = new VoiceName("Humberto"); + + /** Male voice for Korean (Korea) (Free voice) */ + public static final VoiceName HYUNSU = new VoiceName("Hyunsu"); + + /** Male voice for Korean (Korea) (Free voice) */ + public static final VoiceName HYUNSU_2 = new VoiceName("Hyunsu_2"); + + /** Male voice for Korean (Korea) (Paid voice) */ + public static final VoiceName HYUNSU_3 = new VoiceName("Hyunsu_3"); + + /** Female voice for Norwegian (Norway) (Free voice) */ + public static final VoiceName IDA = new VoiceName("Ida"); + + /** Male voice for Albanian (Albania) (Free voice) */ + public static final VoiceName ILIR = new VoiceName("Ilir"); + + /** Female voice for Arabic (Libya) (Free voice) */ + public static final VoiceName IMAN = new VoiceName("Iman"); + + /** Female voice for English (Tanzania) (Free voice) */ + public static final VoiceName IMANI = new VoiceName("Imani"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName IMELDA = new VoiceName("Imelda"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName INES = new VoiceName("Ines"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName INES_2 = new VoiceName("Ines_2"); + + /** Female voice for German (Austria) (Free voice) */ + public static final VoiceName INGRID = new VoiceName("Ingrid"); + + /** Male voice for Korean (Korea) (Free voice) */ + public static final VoiceName IN_JOON = new VoiceName("InJoon"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName IRENE = new VoiceName("Irene"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName IRMA = new VoiceName("Irma"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName ISABELLA = new VoiceName("Isabella"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName ISABELLA_2 = new VoiceName("Isabella_2"); + + /** Female voice for Italian (Italy) (Paid voice) */ + public static final VoiceName ISABELLA_3 = new VoiceName("Isabella_3"); + + /** Female voice for French (Belgium) (Free voice) */ + public static final VoiceName ISABELLE = new VoiceName("Isabelle"); + + /** Female voice for French (Belgium) (Paid voice) */ + public static final VoiceName ISABELLE_2 = new VoiceName("Isabelle_2"); + + /** Female voice for Norwegian (Norway) (Free voice) */ + public static final VoiceName ISELIN = new VoiceName("Iselin"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName ISIDORA = new VoiceName("Isidora"); + + /** Male voice for Arabic (Algeria) (Free voice) */ + public static final VoiceName ISMAEL = new VoiceName("Ismael"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName IVY = new VoiceName("Ivy"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName IVY_2 = new VoiceName("Ivy_2"); + + /** Male voice for Polish (Poland) (Free voice) */ + public static final VoiceName JACEK = new VoiceName("Jacek"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JACOB = new VoiceName("Jacob"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName JACQUELINE = new VoiceName("Jacqueline"); + + /** Male voice for Sundanese (Indonesia) (Free voice) */ + public static final VoiceName JAJANG = new VoiceName("Jajang"); + + /** Male voice for Arabic (Morocco) (Free voice) */ + public static final VoiceName JAMAL = new VoiceName("Jamal"); + + /** Male voice for English (Philippines) (Free voice) */ + public static final VoiceName JAMES = new VoiceName("James"); + + /** Male voice for German (Switzerland) (Free voice) */ + public static final VoiceName JAN = new VoiceName("Jan"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName JANE = new VoiceName("Jane"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName JANE_2 = new VoiceName("Jane_2"); + + /** Male voice for Polish (Poland) (Free voice) */ + public static final VoiceName JANN = new VoiceName("Jann"); + + /** Female voice for English (Singapore) (Free voice) */ + public static final VoiceName JASMINE = new VoiceName("Jasmine"); + + /** Female voice for English (Singapore) (Paid voice) */ + public static final VoiceName JASMINE_2 = new VoiceName("Jasmine_2"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JASON = new VoiceName("Jason"); + + /** Male voice for Spanish (Equatorial Guinea) (Free voice) */ + public static final VoiceName JAVIER = new VoiceName("Javier"); + + /** Male voice for French (Canada) (Free voice) */ + public static final VoiceName JEAN = new VoiceName("Jean"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName JENNY = new VoiceName("Jenny"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName JENNY_2 = new VoiceName("Jenny_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName JENNY_3 = new VoiceName("Jenny_3"); + + /** Male voice for Danish (Denmark) (Free voice) */ + public static final VoiceName JEPPE = new VoiceName("Jeppe"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName JEROME = new VoiceName("Jerome"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName JIHYE = new VoiceName("Jihye"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName JI_MIN = new VoiceName("JiMin"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JIMMIE = new VoiceName("Jimmie"); + + /** Female voice for Czech (Czechia) (Free voice) */ + public static final VoiceName JITKA = new VoiceName("Jitka"); + + /** Female voice for Catalan (Free voice) */ + public static final VoiceName JOANA = new VoiceName("Joana"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName JOANNA = new VoiceName("Joanna"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName JOANNA_2 = new VoiceName("Joanna_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName JOANNA_3 = new VoiceName("Joanna_3"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName JOANNE = new VoiceName("Joanne"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JOEY = new VoiceName("Joey"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JOEY_2 = new VoiceName("Joey_2"); + + /** Male voice for German (Austria) (Free voice) */ + public static final VoiceName JONAS = new VoiceName("Jonas"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName JORGE = new VoiceName("Jorge"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName JORGE_2 = new VoiceName("Jorge_2"); + + /** Male voice for Maltese (Malta) (Free voice) */ + public static final VoiceName JOSEPH = new VoiceName("Joseph"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName JOSEPHINE = new VoiceName("Josephine"); + + /** Male voice for Spanish (Costa Rica) (Free voice) */ + public static final VoiceName JUAN = new VoiceName("Juan"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName JULIO = new VoiceName("Julio"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JUSTIN = new VoiceName("Justin"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName JUSTIN_2 = new VoiceName("Justin_2"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName KAI = new VoiceName("Kai"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName KAJAL = new VoiceName("Kajal"); + + /** Female voice for English (India) (Paid voice) */ + public static final VoiceName KAJAL_2 = new VoiceName("Kajal_2"); + + /** Female voice for Bulgarian (Bulgaria) (Free voice) */ + public static final VoiceName KALINA = new VoiceName("Kalina"); + + /** Female voice for Tamil (Malaysia) (Free voice) */ + public static final VoiceName KANI = new VoiceName("Kani"); + + /** Female voice for Spanish (Puerto Rico) (Free voice) */ + public static final VoiceName KARINA = new VoiceName("Karina"); + + /** Male voice for Icelandic (Iceland) (Free voice) */ + public static final VoiceName KARL = new VoiceName("Karl"); + + /** Female voice for Spanish (Honduras) (Free voice) */ + public static final VoiceName KARLA = new VoiceName("Karla"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName KASPER = new VoiceName("Kasper"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName KATJA = new VoiceName("Katja"); + + /** Female voice for Hindi (India) (Free voice) */ + public static final VoiceName KAVYA = new VoiceName("Kavya"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName KAVYA_2 = new VoiceName("Kavya_2"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName KAZUHA = new VoiceName("Kazuha"); + + /** Male voice for Japanese (Japan) (Free voice) */ + public static final VoiceName KEITA = new VoiceName("Keita"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName KEN = new VoiceName("Ken"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName KENDRA = new VoiceName("Kendra"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName KENDRA_2 = new VoiceName("Kendra_2"); + + /** Female voice for Lao (Laos) (Free voice) */ + public static final VoiceName KEOMANY = new VoiceName("Keomany"); + + /** Male voice for Estonian (Estonia) (Free voice) */ + public static final VoiceName KERT = new VoiceName("Kert"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName KEVIN = new VoiceName("Kevin"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName KILLIAN = new VoiceName("Killian"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName KIM = new VoiceName("Kim"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName KIMBERLY = new VoiceName("Kimberly"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName KIMBERLY_2 = new VoiceName("Kimberly_2"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName KLARISSA = new VoiceName("Klarissa"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName KLAUS = new VoiceName("Klaus"); + + /** Male voice for Tamil (Sri Lanka) (Free voice) */ + public static final VoiceName KUMAR = new VoiceName("Kumar"); + + /** Male voice for Hindi (India) (Free voice) */ + public static final VoiceName KUNAL = new VoiceName("Kunal"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName KUNAL_2 = new VoiceName("Kunal_2"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName LAIA = new VoiceName("Laia"); + + /** Female voice for Arabic (Bahrain) (Free voice) */ + public static final VoiceName LAILA = new VoiceName("Laila"); + + /** Male voice for Arabic (Syria) (Free voice) */ + public static final VoiceName LAITH = new VoiceName("Laith"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName LARISSA = new VoiceName("Larissa"); + + /** Female voice for Pashto (Afghanistan) (Free voice) */ + public static final VoiceName LATIFA = new VoiceName("Latifa"); + + /** Female voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName LAURA = new VoiceName("Laura"); + + /** Female voice for Dutch (Netherlands) (Paid voice) */ + public static final VoiceName LAURA_2 = new VoiceName("Laura_2"); + + /** Female voice for Arabic (Lebanon) (Free voice) */ + public static final VoiceName LAYLA = new VoiceName("Layla"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName LEA = new VoiceName("Lea"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName LEA_2 = new VoiceName("Lea_2"); + + /** Female voice for French (France) (Paid voice) */ + public static final VoiceName LEA_3 = new VoiceName("Lea_3"); + + /** Female voice for English (South Africa) (Free voice) */ + public static final VoiceName LEAH = new VoiceName("Leah"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName LEILA = new VoiceName("Leila"); + + /** Female voice for German (Switzerland) (Free voice) */ + public static final VoiceName LENI = new VoiceName("Leni"); + + /** Male voice for German (Germany) (Paid voice) */ + public static final VoiceName LENNART = new VoiceName("Lennart"); + + /** Male voice for Lithuanian (Lithuania) (Free voice) */ + public static final VoiceName LEONAS = new VoiceName("Leonas"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName LETICIA = new VoiceName("Leticia"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName LEWIS = new VoiceName("Lewis"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName LIA = new VoiceName("Lia"); + + /** Male voice for English (Canada) (Free voice) */ + public static final VoiceName LIAM = new VoiceName("Liam"); + + /** Male voice for French (Canada) (Free voice) */ + public static final VoiceName LIAN = new VoiceName("Lian"); + + /** Male voice for French (Canada) (Paid voice) */ + public static final VoiceName LIAN_2 = new VoiceName("Lian_2"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName LIBBY = new VoiceName("Libby"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName LIBERTO = new VoiceName("Liberto"); + + /** Female voice for Dutch (Belgium) (Free voice) */ + public static final VoiceName LISA = new VoiceName("Lisa"); + + /** Female voice for Dutch (Belgium) (Paid voice) */ + public static final VoiceName LISA_2 = new VoiceName("Lisa_2"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName LISANDRO = new VoiceName("Lisandro"); + + /** Female voice for Norwegian (Norway) (Free voice) */ + public static final VoiceName LIV = new VoiceName("Liv"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName LIVIA = new VoiceName("Livia"); + + /** Female voice for English (Australia) (Paid voice) */ + public static final VoiceName LIVIA_2 = new VoiceName("Livia_2"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName LOLA = new VoiceName("Lola"); + + /** Female voice for Spanish (El Salvador) (Free voice) */ + public static final VoiceName LORENA = new VoiceName("Lorena"); + + /** Male voice for Italian (Italy) (Paid voice) */ + public static final VoiceName LORENZIO = new VoiceName("Lorenzio"); + + /** Male voice for Spanish (Chile) (Free voice) */ + public static final VoiceName LORENZO = new VoiceName("Lorenzo"); + + /** Female voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName LOTTE = new VoiceName("Lotte"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName LOUISA = new VoiceName("Louisa"); + + /** Female voice for Spanish (Castilian) (Free voice) */ + public static final VoiceName LUCIA = new VoiceName("Lucia"); + + /** Female voice for Spanish (Castilian) (Free voice) */ + public static final VoiceName LUCIA_2 = new VoiceName("Lucia_2"); + + /** Female voice for Spanish (Castilian) (Paid voice) */ + public static final VoiceName LUCIA_3 = new VoiceName("Lucia_3"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName LUCIANO = new VoiceName("Luciano"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName LUCIEN = new VoiceName("Lucien"); + + /** Male voice for Spanish (Ecuador) (Free voice) */ + public static final VoiceName LUIS = new VoiceName("Luis"); + + /** Male voice for Slovak (Slovakia) (Free voice) */ + public static final VoiceName LUKAS = new VoiceName("Lukas"); + + /** Male voice for English (South Africa) (Free voice) */ + public static final VoiceName LUKE = new VoiceName("Luke"); + + /** Female voice for English (Singapore) (Free voice) */ + public static final VoiceName LUNA = new VoiceName("Luna"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName LUNA_2 = new VoiceName("Luna_2"); + + /** Female voice for Spanish (United States) (Free voice) */ + public static final VoiceName LUPE = new VoiceName("Lupe"); + + /** Female voice for Spanish (United States) (Free voice) */ + public static final VoiceName LUPE_2 = new VoiceName("Lupe_2"); + + /** Female voice for Spanish (United States) (Paid voice) */ + public static final VoiceName LUPE_3 = new VoiceName("Lupe_3"); + + /** Male voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName MAARTEN = new VoiceName("Maarten"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName MACERIO = new VoiceName("Macerio"); + + /** Male voice for Portuguese (Brazil) (Paid voice) */ + public static final VoiceName MACERIO_2 = new VoiceName("Macerio_2"); + + /** Male voice for Hindi (India) (Free voice) */ + public static final VoiceName MADHUR = new VoiceName("Madhur"); + + /** Female voice for Uzbek (Latin, Uzbekistan) (Free voice) */ + public static final VoiceName MADINA = new VoiceName("Madina"); + + /** Male voice for Danish (Denmark) (Free voice) */ + public static final VoiceName MADS = new VoiceName("Mads"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName MAISIE = new VoiceName("Maisie"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName MAJA = new VoiceName("Maja"); + + /** Male voice for Marathi (India) (Free voice) */ + public static final VoiceName MANOHAR = new VoiceName("Manohar"); + + /** Male voice for Spanish (Cuba) (Free voice) */ + public static final VoiceName MANUEL = new VoiceName("Manuel"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName MANUELA = new VoiceName("Manuela"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName MARCELLO = new VoiceName("Marcello"); + + /** Male voice for Spanish (Bolivia) (Free voice) */ + public static final VoiceName MARCELO = new VoiceName("Marcelo"); + + /** Male voice for Polish (Poland) (Free voice) */ + public static final VoiceName MAREK = new VoiceName("Marek"); + + /** Female voice for Spanish (Panama) (Free voice) */ + public static final VoiceName MARGARITA = new VoiceName("Margarita"); + + /** Female voice for Spanish (Costa Rica) (Free voice) */ + public static final VoiceName MARIA = new VoiceName("Maria"); + + /** Female voice for Macedonian (North Macedonia) (Free voice) */ + public static final VoiceName MARIJA = new VoiceName("Marija"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName MARINA = new VoiceName("Marina"); + + /** Male voice for Spanish (Paraguay) (Free voice) */ + public static final VoiceName MARIO = new VoiceName("Mario"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName MARLENE = new VoiceName("Marlene"); + + /** Female voice for Spanish (Guatemala) (Free voice) */ + public static final VoiceName MARTA = new VoiceName("Marta"); + + /** Female voice for Arabic (Yemen) (Free voice) */ + public static final VoiceName MARYAM = new VoiceName("Maryam"); + + /** Male voice for Japanese (Japan) (Paid voice) */ + public static final VoiceName MASARU = new VoiceName("Masaru"); + + /** Male voice for Spanish (Uruguay) (Free voice) */ + public static final VoiceName MATEO = new VoiceName("Mateo"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName MATHIEU = new VoiceName("Mathieu"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName MATTHEW = new VoiceName("Matthew"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName MATTHEW_2 = new VoiceName("Matthew_2"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName MATTHEW_3 = new VoiceName("Matthew_3"); + + /** Male voice for Swedish (Sweden) (Free voice) */ + public static final VoiceName MATTIAS = new VoiceName("Mattias"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName MAURICE = new VoiceName("Maurice"); + + /** Male voice for Russian (Russia) (Free voice) */ + public static final VoiceName MAXIM = new VoiceName("Maxim"); + + /** Female voice for Polish (Poland) (Free voice) */ + public static final VoiceName MAYA = new VoiceName("Maya"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName MAYU = new VoiceName("Mayu"); + + /** Female voice for Amharic (Ethiopia) (Free voice) */ + public static final VoiceName MEKDES = new VoiceName("Mekdes"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName MIA = new VoiceName("Mia"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName MIA_2 = new VoiceName("Mia_2"); + + /** Female voice for Spanish (Mexico) (Paid voice) */ + public static final VoiceName MIA_3 = new VoiceName("Mia_3"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName MICHELLE = new VoiceName("Michelle"); + + /** Male voice for Malayalam (India) (Free voice) */ + public static final VoiceName MIDHUN = new VoiceName("Midhun"); + + /** Male voice for Spanish (United States) (Free voice) */ + public static final VoiceName MIGUEL = new VoiceName("Miguel"); + + /** Male voice for English (New Zealand) (Free voice) */ + public static final VoiceName MITCHELL = new VoiceName("Mitchell"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName MIZUKI = new VoiceName("Mizuki"); + + /** Male voice for Arabic (Qatar) (Free voice) */ + public static final VoiceName MOAZ = new VoiceName("Moaz"); + + /** Male voice for Telugu (India) (Free voice) */ + public static final VoiceName MOHAN = new VoiceName("Mohan"); + + /** Female voice for English (New Zealand) (Free voice) */ + public static final VoiceName MOLLY = new VoiceName("Molly"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName MONICA = new VoiceName("Monica"); + + /** Female voice for Arabic (Morocco) (Free voice) */ + public static final VoiceName MOUNA = new VoiceName("Mouna"); + + /** Male voice for Somali (Somalia) (Free voice) */ + public static final VoiceName MUUSE = new VoiceName("Muuse"); + + /** Female voice for Bangla (Bangladesh) (Free voice) */ + public static final VoiceName NABANITA = new VoiceName("Nabanita"); + + /** Female voice for Danish (Denmark) (Free voice) */ + public static final VoiceName NAJA = new VoiceName("Naja"); + + /** Male voice for Vietnamese (Vietnam) (Free voice) */ + public static final VoiceName NAM_MINH = new VoiceName("NamMinh"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName NANAMI = new VoiceName("Nanami"); + + /** Female voice for Japanese (Japan) (Paid voice) */ + public static final VoiceName NANAMI_2 = new VoiceName("Nanami_2"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName NANCY = new VoiceName("Nancy"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName NANCY_2 = new VoiceName("Nancy_2"); + + /** Male voice for Japanese (Japan) (Free voice) */ + public static final VoiceName NAOKI = new VoiceName("Naoki"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName NATASHA = new VoiceName("Natasha"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName NEERJA = new VoiceName("Neerja"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName NEERJA_2 = new VoiceName("Neerja_2"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName NEIL = new VoiceName("Neil"); + + /** Male voice for Greek (Greece) (Free voice) */ + public static final VoiceName NESTORAS = new VoiceName("Nestoras"); + + /** Female voice for Welsh (United Kingdom) (Free voice) */ + public static final VoiceName NIA = new VoiceName("Nia"); + + /** Female voice for English (Ireland) (Free voice) */ + public static final VoiceName NIAMH = new VoiceName("Niamh"); + + /** Female voice for English (Ireland) (Paid voice) */ + public static final VoiceName NIAMH_2 = new VoiceName("Niamh_2"); + + /** Male voice for Serbian (Cyrillic, Serbia) (Free voice) */ + public static final VoiceName NICHOLAS = new VoiceName("Nicholas"); + + /** Male voice for Serbian (Latin, Serbia) (Free voice) */ + public static final VoiceName NICHOLAS_2 = new VoiceName("Nicholas_2"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName NICOLAU = new VoiceName("Nicolau"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName NICOLE = new VoiceName("Nicole"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName NIL = new VoiceName("Nil"); + + /** Female voice for Burmese (Myanmar) (Free voice) */ + public static final VoiceName NILAR = new VoiceName("Nilar"); + + /** Male voice for Latvian (Latvia) (Free voice) */ + public static final VoiceName NILS = new VoiceName("Nils"); + + /** Male voice for Gujarati (India) (Free voice) */ + public static final VoiceName NIRANJAN = new VoiceName("Niranjan"); + + /** Male voice for Thai (Thailand) (Free voice) */ + public static final VoiceName NIWAT = new VoiceName("Niwat"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName NOAH = new VoiceName("Noah"); + + /** Female voice for Hungarian (Hungary) (Free voice) */ + public static final VoiceName NOEMI = new VoiceName("Noemi"); + + /** Female voice for Finnish (Finland) (Free voice) */ + public static final VoiceName NOORA = new VoiceName("Noora"); + + /** Female voice for Arabic (Kuwait) (Free voice) */ + public static final VoiceName NOURA = new VoiceName("Noura"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName NOVA = new VoiceName("Nova"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName NOVA_2 = new VoiceName("Nova_2"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName NURIA = new VoiceName("Nuria"); + + /** Male voice for Punjabi (India) (Free voice) */ + public static final VoiceName OJAS = new VoiceName("Ojas"); + + /** Female voice for Polish (Poland) (Free voice) */ + public static final VoiceName OLA = new VoiceName("Ola"); + + /** Female voice for Polish (Poland) (Paid voice) */ + public static final VoiceName OLA_2 = new VoiceName("Ola_2"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName OLIVER = new VoiceName("Oliver"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName OLIVIA = new VoiceName("Olivia"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName OLLIE = new VoiceName("Ollie"); + + /** Male voice for English (United Kingdom) (Paid voice) */ + public static final VoiceName OLLIE_2 = new VoiceName("Ollie_2"); + + /** Male voice for Arabic (Libya) (Free voice) */ + public static final VoiceName OMAR = new VoiceName("Omar"); + + /** Female voice for Lithuanian (Lithuania) (Free voice) */ + public static final VoiceName ONA = new VoiceName("Ona"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ONYX = new VoiceName("Onyx"); + + /** Female voice for Irish (Ireland) (Free voice) */ + public static final VoiceName ORLA = new VoiceName("Orla"); + + /** Male voice for Malay (Malaysia) (Free voice) */ + public static final VoiceName OSMAN = new VoiceName("Osman"); + + /** Male voice for Ukrainian (Ukraine) (Free voice) */ + public static final VoiceName OSTAP = new VoiceName("Ostap"); + + /** Female voice for Tamil (India) (Free voice) */ + public static final VoiceName PALLAVI = new VoiceName("Pallavi"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName PALMIRA = new VoiceName("Palmira"); + + /** Female voice for Spanish (United States) (Free voice) */ + public static final VoiceName PALOMA = new VoiceName("Paloma"); + + /** Female voice for Spanish (Venezuela) (Free voice) */ + public static final VoiceName PAOLA = new VoiceName("Paola"); + + /** Male voice for Spanish (United States) (Free voice) */ + public static final VoiceName PEDRO = new VoiceName("Pedro"); + + /** Male voice for Spanish (United States) (Paid voice) */ + public static final VoiceName PEDRO_2 = new VoiceName("Pedro_2"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName PELAYO = new VoiceName("Pelayo"); + + /** Female voice for Spanish (United States) (Free voice) */ + public static final VoiceName PENELOPE = new VoiceName("Penelope"); + + /** Female voice for Norwegian (Norway) (Free voice) */ + public static final VoiceName PERNILLE = new VoiceName("Pernille"); + + /** Female voice for Slovenian (Slovenia) (Free voice) */ + public static final VoiceName PETRA = new VoiceName("Petra"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName PHOEBE = new VoiceName("Phoebe"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName PHOEBE_2 = new VoiceName("Phoebe_2"); + + /** Female voice for Italian (Italy) (Free voice) */ + public static final VoiceName PIERINA = new VoiceName("Pierina"); + + /** Male voice for Khmer (Cambodia) (Free voice) */ + public static final VoiceName PISETH = new VoiceName("Piseth"); + + /** Female voice for Ukrainian (Ukraine) (Free voice) */ + public static final VoiceName POLINA = new VoiceName("Polina"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName PRABHAT = new VoiceName("Prabhat"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName PRABHAT_2 = new VoiceName("Prabhat_2"); + + /** Male voice for Bangla (Bangladesh) (Free voice) */ + public static final VoiceName PRADEEP = new VoiceName("Pradeep"); + + /** Female voice for Thai (Thailand) (Free voice) */ + public static final VoiceName PREMWADEE = new VoiceName("Premwadee"); + + /** Male voice for Assamese (India) (Free voice) */ + public static final VoiceName PRIYOM = new VoiceName("Priyom"); + + /** Male voice for Swahili (Kenya) (Free voice) */ + public static final VoiceName RAFIKI = new VoiceName("Rafiki"); + + /** Male voice for German (Germany) (Free voice) */ + public static final VoiceName RALF = new VoiceName("Ralf"); + + /** Male voice for Arabic (Lebanon) (Free voice) */ + public static final VoiceName RAMI = new VoiceName("Rami"); + + /** Female voice for Spanish (Dominican Republic) (Free voice) */ + public static final VoiceName RAMONA = new VoiceName("Ramona"); + + /** Female voice for Arabic (Iraq) (Free voice) */ + public static final VoiceName RANA = new VoiceName("Rana"); + + /** Female voice for Portuguese (Portugal) (Free voice) */ + public static final VoiceName RAQUEL = new VoiceName("Raquel"); + + /** Female voice for English (India) (Free voice) */ + public static final VoiceName RAVEENA = new VoiceName("Raveena"); + + /** Female voice for Arabic (Tunisia) (Free voice) */ + public static final VoiceName REEM = new VoiceName("Reem"); + + /** Male voice for Hindi (India) (Free voice) */ + public static final VoiceName REHAAN = new VoiceName("Rehaan"); + + /** Male voice for English (India) (Free voice) */ + public static final VoiceName REHAAN_2 = new VoiceName("Rehaan_2"); + + /** Female voice for Swahili (Tanzania) (Free voice) */ + public static final VoiceName REHEMA = new VoiceName("Rehema"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName REMI = new VoiceName("Remi"); + + /** Male voice for French (France) (Paid voice) */ + public static final VoiceName REMI_2 = new VoiceName("Remi_2"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName REMY = new VoiceName("Remy"); + + /** Male voice for French (France) (Paid voice) */ + public static final VoiceName REMY_2 = new VoiceName("Remy_2"); + + /** Female voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName RENATA = new VoiceName("Renata"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName RICARDO = new VoiceName("Ricardo"); + + /** Male voice for Italian (Italy) (Free voice) */ + public static final VoiceName RINALDO = new VoiceName("Rinaldo"); + + /** Male voice for Spanish (Panama) (Free voice) */ + public static final VoiceName ROBERTO = new VoiceName("Roberto"); + + /** Male voice for Spanish (El Salvador) (Free voice) */ + public static final VoiceName RODRIGO = new VoiceName("Rodrigo"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName ROGER = new VoiceName("Roger"); + + /** Male voice for Galician (Free voice) */ + public static final VoiceName ROI = new VoiceName("Roi"); + + /** Male voice for Slovenian (Slovenia) (Free voice) */ + public static final VoiceName ROK = new VoiceName("Rok"); + + /** Female voice for English (Philippines) (Free voice) */ + public static final VoiceName ROSA = new VoiceName("Rosa"); + + /** Male voice for Dutch (Netherlands) (Free voice) */ + public static final VoiceName RUBEN = new VoiceName("Ruben"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName RUSSELL = new VoiceName("Russell"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName RUTH = new VoiceName("Ruth"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName RUTH_2 = new VoiceName("Ruth_2"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName RYAN = new VoiceName("Ryan"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName RYAN_2 = new VoiceName("Ryan_2"); + + /** Female voice for Galician (Free voice) */ + public static final VoiceName SABELA = new VoiceName("Sabela"); + + /** Female voice for German (Switzerland) (Free voice) */ + public static final VoiceName SABRINA = new VoiceName("Sabrina"); + + /** Female voice for German (Switzerland) (Paid voice) */ + public static final VoiceName SABRINA_2 = new VoiceName("Sabrina_2"); + + /** Male voice for Nepali (Nepal) (Free voice) */ + public static final VoiceName SAGAR = new VoiceName("Sagar"); + + /** Male voice for Arabic (Yemen) (Free voice) */ + public static final VoiceName SALEH = new VoiceName("Saleh"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName SALLI = new VoiceName("Salli"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName SALLI_2 = new VoiceName("Salli_2"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName SALLI_3 = new VoiceName("Salli_3"); + + /** Female voice for Arabic (Egypt) (Free voice) */ + public static final VoiceName SALMA = new VoiceName("Salma"); + + /** Male voice for Urdu (India) (Free voice) */ + public static final VoiceName SALMAN = new VoiceName("Salman"); + + /** Female voice for Spanish (Colombia) (Free voice) */ + public static final VoiceName SALOME = new VoiceName("Salome"); + + /** Male voice for English (Hong Kong SAR) (Free voice) */ + public static final VoiceName SAM = new VoiceName("Sam"); + + /** Male voice for Sinhala (Sri Lanka) (Free voice) */ + public static final VoiceName SAMEERA = new VoiceName("Sameera"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName SAMUEL = new VoiceName("Samuel"); + + /** Female voice for Arabic (Jordan) (Free voice) */ + public static final VoiceName SANA = new VoiceName("Sana"); + + /** Female voice for Kannada (India) (Free voice) */ + public static final VoiceName SAPNA = new VoiceName("Sapna"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName SARA = new VoiceName("Sara"); + + /** Female voice for Tamil (Sri Lanka) (Free voice) */ + public static final VoiceName SARANYA = new VoiceName("Saranya"); + + /** Male voice for Uzbek (Latin, Uzbekistan) (Free voice) */ + public static final VoiceName SARDOR = new VoiceName("Sardor"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName SAUL = new VoiceName("Saul"); + + /** Male voice for Spanish (Venezuela) (Free voice) */ + public static final VoiceName SEBASTIAN = new VoiceName("Sebastian"); + + /** Female voice for Finnish (Finland) (Free voice) */ + public static final VoiceName SELMA = new VoiceName("Selma"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName SEO_HYEON = new VoiceName("SeoHyeon"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName SEOYEON = new VoiceName("Seoyeon"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName SEOYEON_2 = new VoiceName("Seoyeon_2"); + + /** Female voice for Korean (Korea) (Paid voice) */ + public static final VoiceName SEOYEON_3 = new VoiceName("Seoyeon_3"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName SERAPHINA = new VoiceName("Seraphina"); + + /** Female voice for German (Germany) (Paid voice) */ + public static final VoiceName SERAPHINA_2 = new VoiceName("Seraphina_2"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName SERENA = new VoiceName("Serena"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName SERENA_2 = new VoiceName("Serena_2"); + + /** Male voice for Spanish (Castilian) (Free voice) */ + public static final VoiceName SERGIO = new VoiceName("Sergio"); + + /** Male voice for Spanish (Castilian) (Paid voice) */ + public static final VoiceName SERGIO_2 = new VoiceName("Sergio_2"); + + /** Male voice for Arabic (Egypt) (Free voice) */ + public static final VoiceName SHAKIR = new VoiceName("Shakir"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName SHIMMER = new VoiceName("Shimmer"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName SHIORI = new VoiceName("Shiori"); + + /** Female voice for Telugu (India) (Free voice) */ + public static final VoiceName SHRUTI = new VoiceName("Shruti"); + + /** Female voice for Inuktitut (Syllabics, Canada) (Free voice) */ + public static final VoiceName SIQINIQ = new VoiceName("Siqiniq"); + + /** Female voice for Inuktitut (Latin, Canada) (Free voice) */ + public static final VoiceName SIQINIQ_2 = new VoiceName("Siqiniq_2"); + + /** Female voice for Javanese (Latin, Indonesia) (Free voice) */ + public static final VoiceName SITI = new VoiceName("Siti"); + + /** Female voice for Malayalam (India) (Free voice) */ + public static final VoiceName SOBHANA = new VoiceName("Sobhana"); + + /** Female voice for Danish (Denmark) (Free voice) */ + public static final VoiceName SOFFIE = new VoiceName("Soffie"); + + /** Female voice for Spanish (Bolivia) (Free voice) */ + public static final VoiceName SOFIA = new VoiceName("Sofia"); + + /** Female voice for Swedish (Sweden) (Free voice) */ + public static final VoiceName SOFIE = new VoiceName("Sofie"); + + /** Female voice for English (United Kingdom) (Free voice) */ + public static final VoiceName SONIA = new VoiceName("Sonia"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName SOON_BOK = new VoiceName("SoonBok"); + + /** Female voice for Serbian (Cyrillic, Serbia) (Free voice) */ + public static final VoiceName SOPHIE = new VoiceName("Sophie"); + + /** Female voice for Serbian (Latin, Serbia) (Free voice) */ + public static final VoiceName SOPHIE_2 = new VoiceName("Sophie_2"); + + /** Male voice for Croatian (Croatia) (Free voice) */ + public static final VoiceName SRECKO = new VoiceName("Srecko"); + + /** Female voice for Khmer (Cambodia) (Free voice) */ + public static final VoiceName SREYMOM = new VoiceName("Sreymom"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName STEFFAN = new VoiceName("Steffan"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName STEFFAN_2 = new VoiceName("Steffan_2"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName STEFFAN_3 = new VoiceName("Steffan_3"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName STEPHEN = new VoiceName("Stephen"); + + /** Male voice for English (United States) (Paid voice) */ + public static final VoiceName STEPHEN_2 = new VoiceName("Stephen_2"); + + /** Female voice for Odia (India) (Free voice) */ + public static final VoiceName SUBHASINI = new VoiceName("Subhasini"); + + /** Male voice for Odia (India) (Free voice) */ + public static final VoiceName SUKANT = new VoiceName("Sukant"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName SUN_HI = new VoiceName("SunHi"); + + /** Female voice for Korean (Korea) (Paid voice) */ + public static final VoiceName SUN_HI_2 = new VoiceName("SunHi_2"); + + /** Male voice for Tamil (Malaysia) (Free voice) */ + public static final VoiceName SURYA = new VoiceName("Surya"); + + /** Female voice for Finnish (Finland) (Free voice) */ + public static final VoiceName SUVI = new VoiceName("Suvi"); + + /** Female voice for Russian (Russia) (Free voice) */ + public static final VoiceName SVETLANA = new VoiceName("Svetlana"); + + /** Female voice for Hindi (India) (Free voice) */ + public static final VoiceName SWARA = new VoiceName("Swara"); + + /** Female voice for French (Canada) (Free voice) */ + public static final VoiceName SYLVIE = new VoiceName("Sylvie"); + + /** Female voice for French (Canada) (Paid voice) */ + public static final VoiceName SYLVIE_2 = new VoiceName("Sylvie_2"); + + /** Male voice for Arabic (Jordan) (Free voice) */ + public static final VoiceName TAIM = new VoiceName("Taim"); + + /** Male voice for Japanese (Japan) (Free voice) */ + public static final VoiceName TAKUMI = new VoiceName("Takumi"); + + /** Male voice for Japanese (Japan) (Free voice) */ + public static final VoiceName TAKUMI_2 = new VoiceName("Takumi_2"); + + /** Male voice for Hungarian (Hungary) (Free voice) */ + public static final VoiceName TAMAS = new VoiceName("Tamas"); + + /** Female voice for Spanish (Paraguay) (Free voice) */ + public static final VoiceName TANIA = new VoiceName("Tania"); + + /** Female voice for Bengali (India) (Free voice) */ + public static final VoiceName TANISHAA = new VoiceName("Tanishaa"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName TANJA = new VoiceName("Tanja"); + + /** Male voice for Inuktitut (Syllabics, Canada) (Free voice) */ + public static final VoiceName TAQQIQ = new VoiceName("Taqqiq"); + + /** Male voice for Inuktitut (Latin, Canada) (Free voice) */ + public static final VoiceName TAQQIQ_2 = new VoiceName("Taqqiq_2"); + + /** Female voice for Russian (Russia) (Free voice) */ + public static final VoiceName TATYANA = new VoiceName("Tatyana"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName TEO = new VoiceName("Teo"); + + /** Female voice for Spanish (Equatorial Guinea) (Free voice) */ + public static final VoiceName TERESA = new VoiceName("Teresa"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName THALITA = new VoiceName("Thalita"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName THALITA_2 = new VoiceName("Thalita_2"); + + /** Female voice for Portuguese (Brazil) (Paid voice) */ + public static final VoiceName THALITA_3 = new VoiceName("Thalita_3"); + + /** Female voice for Zulu (South Africa) (Free voice) */ + public static final VoiceName THANDO = new VoiceName("Thando"); + + /** Male voice for Zulu (South Africa) (Free voice) */ + public static final VoiceName THEMBA = new VoiceName("Themba"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName THIAGO = new VoiceName("Thiago"); + + /** Male voice for French (Canada) (Free voice) */ + public static final VoiceName THIERRY = new VoiceName("Thierry"); + + /** Male voice for French (Canada) (Paid voice) */ + public static final VoiceName THIERRY_2 = new VoiceName("Thierry_2"); + + /** Male voice for Burmese (Myanmar) (Free voice) */ + public static final VoiceName THIHA = new VoiceName("Thiha"); + + /** Female voice for Sinhala (Sri Lanka) (Free voice) */ + public static final VoiceName THILINI = new VoiceName("Thilini"); + + /** Male voice for English (United Kingdom) (Free voice) */ + public static final VoiceName THOMAS = new VoiceName("Thomas"); + + /** Female voice for English (United States) (Free voice) */ + public static final VoiceName TIANA = new VoiceName("Tiana"); + + /** Female voice for English (United States) (Paid voice) */ + public static final VoiceName TIFFANY = new VoiceName("Tiffany"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName TIM = new VoiceName("Tim"); + + /** Female voice for English (Australia) (Free voice) */ + public static final VoiceName TINA = new VoiceName("Tina"); + + /** Male voice for Spanish (Argentina) (Free voice) */ + public static final VoiceName TOMAS = new VoiceName("Tomas"); + + /** Female voice for Japanese (Japan) (Free voice) */ + public static final VoiceName TOMOKO = new VoiceName("Tomoko"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName TONY = new VoiceName("Tony"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName TRIANA = new VoiceName("Triana"); + + /** Male voice for Spanish (Mexico) (Paid voice) */ + public static final VoiceName TRISTAN = new VoiceName("Tristan"); + + /** Male voice for Spanish (Spain) (Free voice) */ + public static final VoiceName TRISTAN_ES = new VoiceName("Tristan_ES"); + + /** Male voice for Spanish (Spain) (Paid voice) */ + public static final VoiceName TRISTAN_ES_2 = new VoiceName("Tristan_ES_2"); + + /** Female voice for Sundanese (Indonesia) (Free voice) */ + public static final VoiceName TUTI = new VoiceName("Tuti"); + + /** Male voice for English (United States) (Free voice) */ + public static final VoiceName TYLER = new VoiceName("Tyler"); + + /** Female voice for Somali (Somalia) (Free voice) */ + public static final VoiceName UBAX = new VoiceName("Ubax"); + + /** Female voice for Urdu (Pakistan) (Free voice) */ + public static final VoiceName UZMA = new VoiceName("Uzma"); + + /** Female voice for Punjabi (India) (Free voice) */ + public static final VoiceName VAANI = new VoiceName("Vaani"); + + /** Female voice for Spanish (Uruguay) (Free voice) */ + public static final VoiceName VALENTINA = new VoiceName("Valentina"); + + /** Male voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName VALERIO = new VoiceName("Valerio"); + + /** Male voice for Tamil (India) (Free voice) */ + public static final VoiceName VALLUVAR = new VoiceName("Valluvar"); + + /** Female voice for Tamil (Singapore) (Free voice) */ + public static final VoiceName VENBA = new VoiceName("Venba"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName VERA = new VoiceName("Vera"); + + /** Female voice for Bosnian (Bosnia and Herzegovina) (Free voice) */ + public static final VoiceName VESNA = new VoiceName("Vesna"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName VICKI = new VoiceName("Vicki"); + + /** Female voice for German (Germany) (Free voice) */ + public static final VoiceName VICKI_2 = new VoiceName("Vicki_2"); + + /** Female voice for German (Germany) (Paid voice) */ + public static final VoiceName VICKI_3 = new VoiceName("Vicki_3"); + + /** Male voice for Spanish (Puerto Rico) (Free voice) */ + public static final VoiceName VICTOR = new VoiceName("Victor"); + + /** Female voice for Slovak (Slovakia) (Free voice) */ + public static final VoiceName VIKTORIA = new VoiceName("Viktoria"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName VITORIA = new VoiceName("Vitoria"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName VITORIA_2 = new VoiceName("Vitoria_2"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName VIVIENNE = new VoiceName("Vivienne"); + + /** Female voice for French (France) (Paid voice) */ + public static final VoiceName VIVIENNE_2 = new VoiceName("Vivienne_2"); + + /** Female voice for Czech (Czechia) (Free voice) */ + public static final VoiceName VLASTA = new VoiceName("Vlasta"); + + /** Male voice for Chinese (Cantonese, Traditional) (Free voice) */ + public static final VoiceName WAN_LUNG = new VoiceName("WanLung"); + + /** Male voice for English (Singapore) (Free voice) */ + public static final VoiceName WAYNE = new VoiceName("Wayne"); + + /** Male voice for Afrikaans (South Africa) (Free voice) */ + public static final VoiceName WILLEM = new VoiceName("Willem"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName WILLIAM = new VoiceName("William"); + + /** Male voice for English (Australia) (Free voice) */ + public static final VoiceName WILLIAM_2 = new VoiceName("William_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOCHEN = new VoiceName("Xiaochen"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOCHEN_2 = new VoiceName("Xiaochen_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOCHEN_3 = new VoiceName("Xiaochen_3"); + + /** Female voice for Chinese (Mandarin, Simplified) (Paid voice) */ + public static final VoiceName XIAOCHEN_4 = new VoiceName("Xiaochen_4"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOHAN = new VoiceName("Xiaohan"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOHAN_2 = new VoiceName("Xiaohan_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOMENG = new VoiceName("Xiaomeng"); + + /** Female voice for Chinese (Cantonese, Simplified) (Free voice) */ + public static final VoiceName XIAO_MIN = new VoiceName("XiaoMin"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOMO = new VoiceName("Xiaomo"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOQIU = new VoiceName("Xiaoqiu"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOROU = new VoiceName("Xiaorou"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAORUI = new VoiceName("Xiaorui"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOSHUANG = new VoiceName("Xiaoshuang"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOSHUANG_2 = new VoiceName("Xiaoshuang_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOSHUANG_3 = new VoiceName("Xiaoshuang_3"); + + /** Female voice for Chinese (Wu, Simplified) (Free voice) */ + public static final VoiceName XIAOTONG = new VoiceName("Xiaotong"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOXIAO = new VoiceName("Xiaoxiao"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOXIAO_2 = new VoiceName("Xiaoxiao_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOXIAO_3 = new VoiceName("Xiaoxiao_3"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOXIAO_4 = new VoiceName("Xiaoxiao_4"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOXIAO_5 = new VoiceName("Xiaoxiao_5"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYAN = new VoiceName("Xiaoyan"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYI = new VoiceName("Xiaoyi"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYI_2 = new VoiceName("Xiaoyi_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYOU = new VoiceName("Xiaoyou"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYOU_2 = new VoiceName("Xiaoyou_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYOU_3 = new VoiceName("Xiaoyou_3"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYU = new VoiceName("Xiaoyu"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOYU_2 = new VoiceName("Xiaoyu_2"); + + /** Female voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName XIAOZHEN = new VoiceName("Xiaozhen"); + + /** Female voice for Spanish (Mexico) (Paid voice) */ + public static final VoiceName XIMENA = new VoiceName("Ximena"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName XIMENA_ES = new VoiceName("Ximena_ES"); + + /** Female voice for Spanish (Spain) (Free voice) */ + public static final VoiceName XIMENA_ES_2 = new VoiceName("Ximena_ES_2"); + + /** Female voice for Spanish (Spain) (Paid voice) */ + public static final VoiceName XIMENA_ES_3 = new VoiceName("Ximena_ES_3"); + + /** Male voice for Spanish (Mexico) (Free voice) */ + public static final VoiceName YAGO = new VoiceName("Yago"); + + /** Female voice for English (Hong Kong SAR) (Free voice) */ + public static final VoiceName YAN = new VoiceName("Yan"); + + /** Female voice for Portuguese (Brazil) (Free voice) */ + public static final VoiceName YARA = new VoiceName("Yara"); + + /** Female voice for Assamese (India) (Free voice) */ + public static final VoiceName YASHICA = new VoiceName("Yashica"); + + /** Female voice for Malay (Malaysia) (Free voice) */ + public static final VoiceName YASMIN = new VoiceName("Yasmin"); + + /** Female voice for Mongolian (Mongolia) (Free voice) */ + public static final VoiceName YESUI = new VoiceName("Yesui"); + + /** Female voice for Spanish (Nicaragua) (Free voice) */ + public static final VoiceName YOLANDA = new VoiceName("Yolanda"); + + /** Female voice for Korean (Korea) (Free voice) */ + public static final VoiceName YU_JIN = new VoiceName("YuJin"); + + /** Male voice for Chinese (Zhongyuan Mandarin Henan, Simplified) (Free voice) */ + public static final VoiceName YUNDENG = new VoiceName("Yundeng"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNFAN = new VoiceName("Yunfan"); + + /** Male voice for Chinese (Mandarin, Simplified) (Paid voice) */ + public static final VoiceName YUNFAN_2 = new VoiceName("Yunfan_2"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNFENG = new VoiceName("Yunfeng"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNHAN = new VoiceName("Yunhan"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNHAO = new VoiceName("Yunhao"); + + /** Male voice for Chinese (Taiwanese Mandarin, Traditional) (Free voice) */ + public static final VoiceName YUN_JHE = new VoiceName("YunJhe"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNJIAN = new VoiceName("Yunjian"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNJIE = new VoiceName("Yunjie"); + + /** Male voice for Chinese (Cantonese, Simplified) (Free voice) */ + public static final VoiceName YUN_SONG = new VoiceName("YunSong"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXI = new VoiceName("Yunxi"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXI_2 = new VoiceName("Yunxi_2"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXIA = new VoiceName("Yunxia"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXIA_2 = new VoiceName("Yunxia_2"); + + /** Male voice for Chinese (Jilu Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXIANG = new VoiceName("Yunxiang"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXIAO = new VoiceName("Yunxiao"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNXIAO_2 = new VoiceName("Yunxiao_2"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNYANG = new VoiceName("Yunyang"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNYE = new VoiceName("Yunye"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNYE_2 = new VoiceName("Yunye_2"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNYI = new VoiceName("Yunyi"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNYI_2 = new VoiceName("Yunyi_2"); + + /** Male voice for Chinese (Mandarin, Simplified) (Free voice) */ + public static final VoiceName YUNZE = new VoiceName("Yunze"); + + /** Male voice for Chinese (Wu, Simplified) (Free voice) */ + public static final VoiceName YUNZHE = new VoiceName("Yunzhe"); + + /** Male voice for French (France) (Free voice) */ + public static final VoiceName YVES = new VoiceName("Yves"); + + /** Female voice for French (France) (Free voice) */ + public static final VoiceName YVETTE = new VoiceName("Yvette"); + + /** Female voice for Arabic (Saudi Arabia) (Free voice) */ + public static final VoiceName ZARIYAH = new VoiceName("Zariyah"); + + /** Male voice for Arabic (Saudi Arabia) (Free voice) */ + public static final VoiceName ZAYD = new VoiceName("Zayd"); + + /** Female voice for Arabic (Saudi Arabia) (Free voice) */ + public static final VoiceName ZEINA = new VoiceName("Zeina"); + + /** Female voice for Chinese (Mandarin) (Free voice) */ + public static final VoiceName ZHIYU = new VoiceName("Zhiyu"); + + /** Female voice for Chinese (Mandarin) (Free voice) */ + public static final VoiceName ZHIYU_2 = new VoiceName("Zhiyu_2"); + + /** Female voice for Polish (Poland) (Free voice) */ + public static final VoiceName ZOFIA = new VoiceName("Zofia"); + + /** Female voice for Swahili (Kenya) (Free voice) */ + public static final VoiceName ZURI = new VoiceName("Zuri"); + + private static final EnumSupportDynamic ENUM_SUPPORT = + new EnumSupportDynamic<>( + VoiceName.class, + VoiceName::new, + Arrays.asList( + AARAV, + AARAV_2, + AAROHI, + AARTI, + AARTI_2, + AARTI_3, + AASHI, + ABBI, + ABDULLAH, + ABEO, + ABRIL, + ACHARA, + ADA, + ADA_2, + ADAM, + ADAM_2, + ADITI, + ADRI, + ADRIANO, + AGNIESZKA, + AHMET, + AIGUL, + AINHOA, + ALAIN, + ALBA, + ALED, + ALEKSANDAR, + ALESSIO, + ALESSIO_2, + ALEX, + ALFIE, + ALI, + ALINA, + ALLOY, + ALLOY_2, + ALONSO, + ALVARO, + AMAL, + AMALA, + AMANDA, + AMANY, + AMBER, + AMBRE, + AMEHA, + AMINA, + AMY, + AMY_2, + AMY_3, + ANA, + ANAHIT, + ANANYA, + ANANYA_2, + ANBU, + ANDER, + ANDRE, + ANDRE_2, + ANDREA, + ANDRES, + ANDREW, + ANDREW_2, + ANDREW_3, + ANDREW_4, + ANGELO, + ANILA, + ANNETTE, + ANTOINE, + ANTONIN, + ANTONIO, + ANU, + AOI, + ARABELLA, + ARDI, + ARIA, + ARIA_2, + ARIANE, + ARJUN, + ARJUN_2, + ARJUN_3, + ARLET, + ARNAU, + ARNAUD, + ARTHUR, + ARYA, + ARYA_2, + ASAD, + ASHLEY, + ASILIA, + ASTRID, + ATHINA, + AVA, + AVA_2, + AVA_3, + AVRI, + AYANDA, + AYANDA_2, + AYSHA, + BABEK, + BANU, + BASHKAR, + BASSEL, + BATAA, + BEATRICE, + BEATRIZ, + BELKYS, + BELLA, + BENIGNO, + BERND, + BIANCA, + BIANCA_2, + BIANCA_3, + BLESSICA, + BONG_JIN, + BORISLAV, + BRANDON, + BRANDON_2, + BREE, + BRENDA, + BRIAN, + BRIAN_2, + BRIAN_3, + BRIGITTE, + BRYAN, + BRYAN_2, + BRYAN_3, + BURCU, + CALIMERO, + CAMILA, + CAMILLA, + CAMILLA_2, + CAMILLA_3, + CANDELA, + CARLA, + CARLOS, + CARLOTA, + CARLY, + CARMEN, + CATALDO, + CATALINA, + CECILIO, + CELESTE, + CELINE, + CELINE_2, + CHANTAL, + CHANTHAVONG, + CHARLINE, + CHILEMBA, + CHRISTEL, + CHRISTOPH, + CHRISTOPHER, + CHRISTOPHER_2, + CLARA, + CLAUDE, + COLETTE, + COLM, + CONCHITA, + CONNOR, + CONRAD, + CORA, + CORA_2, + CORALIE, + CRISTIANO, + DAICHI, + DALIA, + DALIA_2, + DANIEL, + DANIEL_2, + DANIELLE, + DANIELLE_2, + DARIO, + DARIYA, + DARREN, + DAUDI, + DAULET, + DAVIS, + DAVIS_2, + DAVIS_3, + DENA, + DENISE, + DEREK, + DHWANI, + DIEGO, + DILARA, + DIMAS, + DMITRY, + DONATO, + DORA, + DUARTE, + DUNCAN, + DUSTIN, + ECHO, + EKA, + ELENA, + ELIAS, + ELIMU, + ELIN, + ELIZABETH, + ELKE, + ELLIOT, + ELOISE, + ELSA, + ELSIE, + ELVIRA, + ELZA, + EMEL, + EMIL, + EMILIO, + EMILY, + EMMA, + EMMA_2, + EMMA_3, + EMMA_4, + ENRIC, + ENRIQUE, + ERIC, + ESTRELLA, + ETHAN, + EVELYN, + EVERITA, + EWA, + EWA_2, + EZINNE, + FABIO, + FABIOLA, + FABLE, + FABRICE, + FAHED, + FARID, + FATIMA, + FEDERICO, + FENNA, + FERNANDA, + FIAMMA, + FILIZ, + FINN, + FLORIAN, + FLORIAN_2, + FLORIN, + FRANCISCA, + FREYA, + GABRIELLE, + GABRIELLE_2, + GABRIJELA, + GADIS, + GAGAN, + GEMMA, + GEMMA_2, + GERAINT, + GERARD, + GERARDO, + GIANNI, + GIORGI, + GIORGIO, + GIOVANNA, + GISELA, + GIUSEPPE, + GIUSEPPE_2, + GONZALO, + GOOK_MIN, + GORAN, + GRACE, + GREGORY, + GUDRUN, + GUL, + GUL_NAWAZ, + GUNNAR, + GUY, + GWYNETH, + HALA, + HAMDAN, + HAMED, + HANNAH, + HANNAH_2, + HANS, + HARRI, + HAYK, + HEDI, + HEMKALA, + HENRI, + HILA, + HILLEVI, + HIU_GAAI, + HIUJIN, + HIU_MAAN, + HOAI_MY, + HOLLIE, + HSIAO_CHEN, + HSIAO_YU, + HUMBERTO, + HYUNSU, + HYUNSU_2, + HYUNSU_3, + IDA, + ILIR, + IMAN, + IMANI, + IMELDA, + INES, + INES_2, + INGRID, + IN_JOON, + IRENE, + IRMA, + ISABELLA, + ISABELLA_2, + ISABELLA_3, + ISABELLE, + ISABELLE_2, + ISELIN, + ISIDORA, + ISMAEL, + IVY, + IVY_2, + JACEK, + JACOB, + JACQUELINE, + JAJANG, + JAMAL, + JAMES, + JAN, + JANE, + JANE_2, + JANN, + JASMINE, + JASMINE_2, + JASON, + JAVIER, + JEAN, + JENNY, + JENNY_2, + JENNY_3, + JEPPE, + JEROME, + JIHYE, + JI_MIN, + JIMMIE, + JITKA, + JOANA, + JOANNA, + JOANNA_2, + JOANNA_3, + JOANNE, + JOEY, + JOEY_2, + JONAS, + JORGE, + JORGE_2, + JOSEPH, + JOSEPHINE, + JUAN, + JULIO, + JUSTIN, + JUSTIN_2, + KAI, + KAJAL, + KAJAL_2, + KALINA, + KANI, + KARINA, + KARL, + KARLA, + KASPER, + KATJA, + KAVYA, + KAVYA_2, + KAZUHA, + KEITA, + KEN, + KENDRA, + KENDRA_2, + KEOMANY, + KERT, + KEVIN, + KILLIAN, + KIM, + KIMBERLY, + KIMBERLY_2, + KLARISSA, + KLAUS, + KUMAR, + KUNAL, + KUNAL_2, + LAIA, + LAILA, + LAITH, + LARISSA, + LATIFA, + LAURA, + LAURA_2, + LAYLA, + LEA, + LEA_2, + LEA_3, + LEAH, + LEILA, + LENI, + LENNART, + LEONAS, + LETICIA, + LEWIS, + LIA, + LIAM, + LIAN, + LIAN_2, + LIBBY, + LIBERTO, + LISA, + LISA_2, + LISANDRO, + LIV, + LIVIA, + LIVIA_2, + LOLA, + LORENA, + LORENZIO, + LORENZO, + LOTTE, + LOUISA, + LUCIA, + LUCIA_2, + LUCIA_3, + LUCIANO, + LUCIEN, + LUIS, + LUKAS, + LUKE, + LUNA, + LUNA_2, + LUPE, + LUPE_2, + LUPE_3, + MAARTEN, + MACERIO, + MACERIO_2, + MADHUR, + MADINA, + MADS, + MAISIE, + MAJA, + MANOHAR, + MANUEL, + MANUELA, + MARCELLO, + MARCELO, + MAREK, + MARGARITA, + MARIA, + MARIJA, + MARINA, + MARIO, + MARLENE, + MARTA, + MARYAM, + MASARU, + MATEO, + MATHIEU, + MATTHEW, + MATTHEW_2, + MATTHEW_3, + MATTIAS, + MAURICE, + MAXIM, + MAYA, + MAYU, + MEKDES, + MIA, + MIA_2, + MIA_3, + MICHELLE, + MIDHUN, + MIGUEL, + MITCHELL, + MIZUKI, + MOAZ, + MOHAN, + MOLLY, + MONICA, + MOUNA, + MUUSE, + NABANITA, + NAJA, + NAM_MINH, + NANAMI, + NANAMI_2, + NANCY, + NANCY_2, + NAOKI, + NATASHA, + NEERJA, + NEERJA_2, + NEIL, + NESTORAS, + NIA, + NIAMH, + NIAMH_2, + NICHOLAS, + NICHOLAS_2, + NICOLAU, + NICOLE, + NIL, + NILAR, + NILS, + NIRANJAN, + NIWAT, + NOAH, + NOEMI, + NOORA, + NOURA, + NOVA, + NOVA_2, + NURIA, + OJAS, + OLA, + OLA_2, + OLIVER, + OLIVIA, + OLLIE, + OLLIE_2, + OMAR, + ONA, + ONYX, + ORLA, + OSMAN, + OSTAP, + PALLAVI, + PALMIRA, + PALOMA, + PAOLA, + PEDRO, + PEDRO_2, + PELAYO, + PENELOPE, + PERNILLE, + PETRA, + PHOEBE, + PHOEBE_2, + PIERINA, + PISETH, + POLINA, + PRABHAT, + PRABHAT_2, + PRADEEP, + PREMWADEE, + PRIYOM, + RAFIKI, + RALF, + RAMI, + RAMONA, + RANA, + RAQUEL, + RAVEENA, + REEM, + REHAAN, + REHAAN_2, + REHEMA, + REMI, + REMI_2, + REMY, + REMY_2, + RENATA, + RICARDO, + RINALDO, + ROBERTO, + RODRIGO, + ROGER, + ROI, + ROK, + ROSA, + RUBEN, + RUSSELL, + RUTH, + RUTH_2, + RYAN, + RYAN_2, + SABELA, + SABRINA, + SABRINA_2, + SAGAR, + SALEH, + SALLI, + SALLI_2, + SALLI_3, + SALMA, + SALMAN, + SALOME, + SAM, + SAMEERA, + SAMUEL, + SANA, + SAPNA, + SARA, + SARANYA, + SARDOR, + SAUL, + SEBASTIAN, + SELMA, + SEO_HYEON, + SEOYEON, + SEOYEON_2, + SEOYEON_3, + SERAPHINA, + SERAPHINA_2, + SERENA, + SERENA_2, + SERGIO, + SERGIO_2, + SHAKIR, + SHIMMER, + SHIORI, + SHRUTI, + SIQINIQ, + SIQINIQ_2, + SITI, + SOBHANA, + SOFFIE, + SOFIA, + SOFIE, + SONIA, + SOON_BOK, + SOPHIE, + SOPHIE_2, + SRECKO, + SREYMOM, + STEFFAN, + STEFFAN_2, + STEFFAN_3, + STEPHEN, + STEPHEN_2, + SUBHASINI, + SUKANT, + SUN_HI, + SUN_HI_2, + SURYA, + SUVI, + SVETLANA, + SWARA, + SYLVIE, + SYLVIE_2, + TAIM, + TAKUMI, + TAKUMI_2, + TAMAS, + TANIA, + TANISHAA, + TANJA, + TAQQIQ, + TAQQIQ_2, + TATYANA, + TEO, + TERESA, + THALITA, + THALITA_2, + THALITA_3, + THANDO, + THEMBA, + THIAGO, + THIERRY, + THIERRY_2, + THIHA, + THILINI, + THOMAS, + TIANA, + TIFFANY, + TIM, + TINA, + TOMAS, + TOMOKO, + TONY, + TRIANA, + TRISTAN, + TRISTAN_ES, + TRISTAN_ES_2, + TUTI, + TYLER, + UBAX, + UZMA, + VAANI, + VALENTINA, + VALERIO, + VALLUVAR, + VENBA, + VERA, + VESNA, + VICKI, + VICKI_2, + VICKI_3, + VICTOR, + VIKTORIA, + VITORIA, + VITORIA_2, + VIVIENNE, + VIVIENNE_2, + VLASTA, + WAN_LUNG, + WAYNE, + WILLEM, + WILLIAM, + WILLIAM_2, + XIAOCHEN, + XIAOCHEN_2, + XIAOCHEN_3, + XIAOCHEN_4, + XIAOHAN, + XIAOHAN_2, + XIAOMENG, + XIAO_MIN, + XIAOMO, + XIAOQIU, + XIAOROU, + XIAORUI, + XIAOSHUANG, + XIAOSHUANG_2, + XIAOSHUANG_3, + XIAOTONG, + XIAOXIAO, + XIAOXIAO_2, + XIAOXIAO_3, + XIAOXIAO_4, + XIAOXIAO_5, + XIAOYAN, + XIAOYI, + XIAOYI_2, + XIAOYOU, + XIAOYOU_2, + XIAOYOU_3, + XIAOYU, + XIAOYU_2, + XIAOZHEN, + XIMENA, + XIMENA_ES, + XIMENA_ES_2, + XIMENA_ES_3, + YAGO, + YAN, + YARA, + YASHICA, + YASMIN, + YESUI, + YOLANDA, + YU_JIN, + YUNDENG, + YUNFAN, + YUNFAN_2, + YUNFENG, + YUNHAN, + YUNHAO, + YUN_JHE, + YUNJIAN, + YUNJIE, + YUN_SONG, + YUNXI, + YUNXI_2, + YUNXIA, + YUNXIA_2, + YUNXIANG, + YUNXIAO, + YUNXIAO_2, + YUNYANG, + YUNYE, + YUNYE_2, + YUNYI, + YUNYI_2, + YUNZE, + YUNZHE, + YVES, + YVETTE, + ZARIYAH, + ZAYD, + ZEINA, + ZHIYU, + ZHIYU_2, + ZOFIA, + ZURI)); + + private VoiceName(String value) { + super(value); + } + + public static Stream values() { + return ENUM_SUPPORT.values(); + } + + public static VoiceName from(String value) { + return ENUM_SUPPORT.from(value); + } + + public static String valueOf(VoiceName e) { + return ENUM_SUPPORT.valueOf(e); + } +} diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/VoiceNameTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/VoiceNameTest.java new file mode 100644 index 000000000..6e04fcb68 --- /dev/null +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/VoiceNameTest.java @@ -0,0 +1,32 @@ +package com.sinch.sdk.domains.voice.models.v2; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class VoiceNameTest { + + @Test + void declaresEveryVoiceOfTheSpec() { + Assertions.assertThat(VoiceName.values()).hasSize(772); + } + + @Test + void constantsKeepTheWireValue() { + Assertions.assertThat(VoiceName.valueOf(VoiceName.EMMA)).isEqualTo("Emma"); + Assertions.assertThat(VoiceName.valueOf(VoiceName.AARAV_2)).isEqualTo("Aarav_2"); + Assertions.assertThat(VoiceName.valueOf(VoiceName.BONG_JIN)).isEqualTo("BongJin"); + Assertions.assertThat(VoiceName.valueOf(VoiceName.SUN_HI_2)).isEqualTo("SunHi_2"); + } + + @Test + void fromReturnsTheDeclaredConstant() { + Assertions.assertThat(VoiceName.from("Emma_3")).isSameAs(VoiceName.EMMA_3); + } + + @Test + void fromAcceptsAVoiceOutsideTheCatalog() { + VoiceName future = VoiceName.from("FutureVoice"); + + Assertions.assertThat(VoiceName.valueOf(future)).isEqualTo("FutureVoice"); + } +} From 3be0eafd7cf7fd78e352b0eeebd99200c41f1a81 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 22 Oct 2026 12:52:11 +0200 Subject: [PATCH 11/17] DEVEXP-1473: Voice V2 setCommands accepts one or more commands --- CHANGELOG.md | 1 + .../java/voice/v2/calls/InteractByCallId.java | 4 +- .../voice/v2/calls/InteractByCallName.java | 4 +- .../main/java/voice/v2/svaml/Describe.java | 5 +- .../main/java/voice/v2/svaml/Validate.java | 5 +- .../v2/batches/request/StartBatchRequest.java | 12 +++ .../v2/calls/request/CallPatchRequest.java | 12 +++ .../v2/calls/request/StartCallRequest.java | 12 +++ .../sinchevents/VoiceSinchEventResponse.java | 12 +++ .../voice/models/v2/svaml/SvamlInput.java | 12 +++ .../models/v2/SingleCommandBuilderTest.java | 82 +++++++++++++++++++ 11 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/SingleCommandBuilderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 643a14aeb..49e88ba61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. - **[feature]** V2 helpers: `Destination`, SVAML commands (`Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu`) and `CommandsSequenceCreator`. Event handlers accept a sequence lambda - **[feature]** V2 helpers: `CallBehaviorHelper` for services and `SinchEventResponses` for Sinch Events - **[feature]** V2 helpers: `VoiceName` catalog of text-to-speech voices +- **[feature]** V2 `setCommands(...)` accepts one or more commands - **[doc]** V2 snippets, `sinch-events` example and README use the helpers --- diff --git a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java index f648477b2..1bc3e47ec 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java +++ b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallId.java @@ -12,7 +12,6 @@ import com.sinch.sdk.domains.voice.models.v2.calls.request.CallPatchRequest; import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; import com.sinch.sdk.models.Configuration; -import java.util.Collections; import java.util.logging.Logger; import utils.Settings; @@ -40,8 +39,7 @@ public static void main(String[] args) { CallsService callsService = client.voice().v2().calls(); - CallPatchRequest request = - CallPatchRequest.builder().setCommands(Collections.singletonList(Calls.hangup())).build(); + CallPatchRequest request = CallPatchRequest.builder().setCommands(Calls.hangup()).build(); LOGGER.info(String.format("Interact with call with ID '%s'", callId)); diff --git a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java index c53f31a36..a77c5cfd2 100644 --- a/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java +++ b/examples/snippets/src/main/java/voice/v2/calls/InteractByCallName.java @@ -12,7 +12,6 @@ import com.sinch.sdk.domains.voice.models.v2.calls.request.CallPatchRequest; import com.sinch.sdk.domains.voice.models.v2.helpers.Calls; import com.sinch.sdk.models.Configuration; -import java.util.Collections; import java.util.logging.Logger; import utils.Settings; @@ -43,8 +42,7 @@ public static void main(String[] args) { CallsService callsService = client.voice().v2().calls(); - CallPatchRequest request = - CallPatchRequest.builder().setCommands(Collections.singletonList(Calls.hangup())).build(); + CallPatchRequest request = CallPatchRequest.builder().setCommands(Calls.hangup()).build(); LOGGER.info( String.format("Interact with call named '%s' in session '%s'", callName, sessionId)); diff --git a/examples/snippets/src/main/java/voice/v2/svaml/Describe.java b/examples/snippets/src/main/java/voice/v2/svaml/Describe.java index 62c5a700c..95cd3d0e1 100644 --- a/examples/snippets/src/main/java/voice/v2/svaml/Describe.java +++ b/examples/snippets/src/main/java/voice/v2/svaml/Describe.java @@ -14,7 +14,6 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.request.DescribeSvamlRequest; import com.sinch.sdk.domains.voice.models.v2.svaml.response.SvamlDescriptionResponse; import com.sinch.sdk.models.Configuration; -import java.util.Collections; import java.util.logging.Logger; import utils.Settings; @@ -43,9 +42,7 @@ public static void main(String[] args) { DescribeSvamlRequest.builder() .setSvaml( SvamlInput.builder() - .setCommands( - Collections.singletonList( - Messages.text("Hello, your call is now connected.", "Emma"))) + .setCommands(Messages.text("Hello, your call is now connected.", "Emma")) .build()) .build(); diff --git a/examples/snippets/src/main/java/voice/v2/svaml/Validate.java b/examples/snippets/src/main/java/voice/v2/svaml/Validate.java index 28b54e2b3..2c4b21c75 100644 --- a/examples/snippets/src/main/java/voice/v2/svaml/Validate.java +++ b/examples/snippets/src/main/java/voice/v2/svaml/Validate.java @@ -14,7 +14,6 @@ import com.sinch.sdk.domains.voice.models.v2.svaml.request.ValidateSvamlRequest; import com.sinch.sdk.domains.voice.models.v2.svaml.response.ValidateSvamlResponse; import com.sinch.sdk.models.Configuration; -import java.util.Collections; import java.util.logging.Logger; import utils.Settings; @@ -43,9 +42,7 @@ public static void main(String[] args) { ValidateSvamlRequest.builder() .setSvaml( SvamlInput.builder() - .setCommands( - Collections.singletonList( - Messages.text("Hello, your call is now connected.", "Emma"))) + .setCommands(Messages.text("Hello, your call is now connected.", "Emma")) .build()) .build(); diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/batches/request/StartBatchRequest.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/batches/request/StartBatchRequest.java index e3b9ac391..646293d43 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/batches/request/StartBatchRequest.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/batches/request/StartBatchRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -90,6 +91,17 @@ interface Builder { */ Builder setCommands(List commands); + /** + * Set the commands from one or more commands, e.g. a single one + * + * @param commands Commands, in the order they run + * @return Current builder + * @see #setCommands(List) + */ + default Builder setCommands(SvamlCommand... commands) { + return setCommands(Arrays.asList(commands)); + } + /** * see getter * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/CallPatchRequest.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/CallPatchRequest.java index 3bd36b73a..ab2e711a1 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/CallPatchRequest.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/CallPatchRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Arrays; import java.util.List; /** Request body for patching an ongoing call with SVAML commands. */ @@ -64,6 +65,17 @@ interface Builder { */ Builder setCommands(List commands); + /** + * Set the commands from one or more commands, e.g. a single one + * + * @param commands Commands, in the order they run + * @return Current builder + * @see #setCommands(List) + */ + default Builder setCommands(SvamlCommand... commands) { + return setCommands(Arrays.asList(commands)); + } + /** * see getter * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/StartCallRequest.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/StartCallRequest.java index 9cfcfd75a..cd4812f91 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/StartCallRequest.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/calls/request/StartCallRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Arrays; import java.util.List; /** Request to start a single outbound call. */ @@ -64,6 +65,17 @@ interface Builder { */ Builder setCommands(List commands); + /** + * Set the commands from one or more commands, e.g. a single one + * + * @param commands Commands, in the order they run + * @return Current builder + * @see #setCommands(List) + */ + default Builder setCommands(SvamlCommand... commands) { + return setCommands(Arrays.asList(commands)); + } + /** * see getter * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/sinchevents/VoiceSinchEventResponse.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/sinchevents/VoiceSinchEventResponse.java index 126ccff67..74936971b 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/sinchevents/VoiceSinchEventResponse.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/sinchevents/VoiceSinchEventResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.sinch.sdk.domains.voice.models.v2.svaml.IncomingCallResponseEvents; import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import java.util.Arrays; import java.util.List; /** @@ -78,6 +79,17 @@ interface Builder { */ Builder setCommands(List commands); + /** + * Set the commands from one or more commands, e.g. a single one + * + * @param commands Commands, in the order they run + * @return Current builder + * @see #setCommands(List) + */ + default Builder setCommands(SvamlCommand... commands) { + return setCommands(Arrays.asList(commands)); + } + /** * see getter * diff --git a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/svaml/SvamlInput.java b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/svaml/SvamlInput.java index fb5757a94..12a70f2ed 100644 --- a/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/svaml/SvamlInput.java +++ b/openapi-contracts/src/main/com/sinch/sdk/domains/voice/models/v2/svaml/SvamlInput.java @@ -1,6 +1,7 @@ package com.sinch.sdk.domains.voice.models.v2.svaml; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Arrays; import java.util.List; /** @@ -67,6 +68,17 @@ interface Builder { */ Builder setCommands(List commands); + /** + * Set the commands from one or more commands, e.g. a single one + * + * @param commands Commands, in the order they run + * @return Current builder + * @see #setCommands(List) + */ + default Builder setCommands(SvamlCommand... commands) { + return setCommands(Arrays.asList(commands)); + } + /** * see getter * diff --git a/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/SingleCommandBuilderTest.java b/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/SingleCommandBuilderTest.java new file mode 100644 index 000000000..e5584af79 --- /dev/null +++ b/openapi-contracts/src/test/java/com/sinch/sdk/domains/voice/models/v2/SingleCommandBuilderTest.java @@ -0,0 +1,82 @@ +package com.sinch.sdk.domains.voice.models.v2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sinch.sdk.core.utils.databind.Mapper; +import com.sinch.sdk.domains.voice.models.v2.batches.request.StartBatchRequest; +import com.sinch.sdk.domains.voice.models.v2.calls.request.CallPatchRequest; +import com.sinch.sdk.domains.voice.models.v2.calls.request.StartCallRequest; +import com.sinch.sdk.domains.voice.models.v2.sinchevents.VoiceSinchEventResponse; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.SvamlInput; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.AnswerCommand; +import com.sinch.sdk.domains.voice.models.v2.svaml.calls.HangupCommand; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +/** The Voice V2 models holding SVAML commands accept one or more commands, as well as a list */ +class SingleCommandBuilderTest { + + static final SvamlCommand HANGUP = HangupCommand.HANGUP_COMMAND; + static final List ONE = Collections.singletonList(HANGUP); + static final List TWO = Arrays.asList(AnswerCommand.ANSWER_COMMAND, HANGUP); + + @Test + void startCallRequest() { + Assertions.assertThat(StartCallRequest.builder().setCommands(HANGUP).build()) + .isEqualTo(StartCallRequest.builder().setCommands(ONE).build()); + Assertions.assertThat( + StartCallRequest.builder() + .setCommands(AnswerCommand.ANSWER_COMMAND, HANGUP) + .build() + .getCommands()) + .isEqualTo(TWO); + } + + @Test + void startBatchRequest() { + Assertions.assertThat(StartBatchRequest.builder().setCommands(HANGUP).build()) + .isEqualTo(StartBatchRequest.builder().setCommands(ONE).build()); + } + + @Test + void callPatchRequest() { + Assertions.assertThat(CallPatchRequest.builder().setCommands(HANGUP).build()) + .isEqualTo(CallPatchRequest.builder().setCommands(ONE).build()); + } + + @Test + void svamlInput() { + Assertions.assertThat(SvamlInput.builder().setCommands(HANGUP).build()) + .isEqualTo(SvamlInput.builder().setCommands(ONE).build()); + } + + @Test + void voiceSinchEventResponse() { + Assertions.assertThat(VoiceSinchEventResponse.builder().setCommands(HANGUP).build()) + .isEqualTo(VoiceSinchEventResponse.builder().setCommands(ONE).build()); + } + + /** Jackson still deserializes the builders: the overload is not a second setter for it */ + @Test + void deserializationIsUnchanged() throws Exception { + ObjectMapper mapper = Mapper.getInstance(); + for (Object model : + Arrays.asList( + StartCallRequest.builder().setCommands(HANGUP).build(), + StartBatchRequest.builder() + .setCommands(HANGUP) + .setParameters(Collections.singletonList(Collections.singletonMap("to", "+1"))) + .build(), + CallPatchRequest.builder().setCommands(HANGUP).build(), + SvamlInput.builder().setCommands(HANGUP).build(), + VoiceSinchEventResponse.builder().setCommands(HANGUP).build())) { + String json = mapper.writeValueAsString(model); + Object read = mapper.readValue(json, model.getClass().getInterfaces()[0]); + + Assertions.assertThat(mapper.writeValueAsString(read)).isEqualTo(json); + } + } +} From b0b69ae845d61b1589a7c0f72e860e0d56f5f160 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 22 Oct 2026 13:00:51 +0200 Subject: [PATCH 12/17] Updated README --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c1517665..b31ffd7c3 100644 --- a/README.md +++ b/README.md @@ -382,9 +382,10 @@ StartCallRequest request = CommandsSequenceCreator.create() .dial("RECIPIENT_PHONE_NUMBER", dial -> dial .from("SINCH_VIRTUAL_PHONE_NUMBER") - .onAnswer(answered -> answered.messages(messages -> messages - .text("Hello from the Sinch Java SDK!", "Emma") - .onFinish(Calls.hangup())))) + .onAnswer(answered -> answered.text( + "Hello from the Sinch Java SDK!", + "Emma", + greeting -> greeting.onFinish(Calls.hangup())))) .build()) .build(); From 9c2f76f668ccc699996f99a7d6db897072fc4135 Mon Sep 17 00:00:00 2001 From: git Date: Wed, 28 Oct 2026 10:56:59 +0000 Subject: [PATCH 13/17] build (release): Bump version to 2.4.0 for sources --- client/src/main/com/sinch/sdk/SDK.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/com/sinch/sdk/SDK.java b/client/src/main/com/sinch/sdk/SDK.java index 88ad4d526..a30124c34 100644 --- a/client/src/main/com/sinch/sdk/SDK.java +++ b/client/src/main/com/sinch/sdk/SDK.java @@ -3,6 +3,6 @@ public class SDK { public static final String NAME = "Sinch Java SDK"; - public static final String VERSION = "2.4.0-dev"; + public static final String VERSION = "2.4.0"; public static final String AUXILIARY_FLAG = ""; } From b44ad776fd9d15202d128415103a601581d96cbf Mon Sep 17 00:00:00 2001 From: git Date: Wed, 28 Oct 2026 10:59:16 +0000 Subject: [PATCH 14/17] [release] Set release & tag: 2.4.0 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 87a92996e..a86f7c1d4 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.sinch.sdk sinch-sdk-java - 2.4.0-SNAPSHOT + 2.4.0 Sinch Java SDK @@ -37,7 +37,7 @@ https://github.com/sinch/sinch-sdk-java.git scm:git:${project.scm.url} scm:git:${project.scm.url} - HEAD + v2.4.0 From f6d712a0ac4e8949bc0eec3f1ce07e0ffe841fc5 Mon Sep 17 00:00:00 2001 From: git Date: Wed, 28 Oct 2026 10:59:19 +0000 Subject: [PATCH 15/17] [release] Set next version: 2.5.0-SNAPSHOT --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a86f7c1d4..a860fed31 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.sinch.sdk sinch-sdk-java - 2.4.0 + 2.5.0-SNAPSHOT Sinch Java SDK @@ -37,7 +37,7 @@ https://github.com/sinch/sinch-sdk-java.git scm:git:${project.scm.url} scm:git:${project.scm.url} - v2.4.0 + HEAD From 2eb55cd55740cc7ff11c999bd116c8e23c7ee7bb Mon Sep 17 00:00:00 2001 From: git Date: Wed, 28 Oct 2026 11:01:50 +0000 Subject: [PATCH 16/17] build (release): Set next version to 2.5.0-dev for sources --- client/src/main/com/sinch/sdk/SDK.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/com/sinch/sdk/SDK.java b/client/src/main/com/sinch/sdk/SDK.java index a30124c34..1cc793ba6 100644 --- a/client/src/main/com/sinch/sdk/SDK.java +++ b/client/src/main/com/sinch/sdk/SDK.java @@ -3,6 +3,6 @@ public class SDK { public static final String NAME = "Sinch Java SDK"; - public static final String VERSION = "2.4.0"; + public static final String VERSION = "2.5.0-dev"; public static final String AUXILIARY_FLAG = ""; } From c8dc925cc59008eb1437746ca9bb1b5e524074f4 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Wed, 28 Oct 2026 12:08:41 +0100 Subject: [PATCH 17/17] Update CHANGELOG with date and version --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e88ba61..d33671415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. > - `[tech]` — technical improvement --- -## v2.4.0 - Unreleased +## v2.4.0 - 2026-10-28 ### Voice - **[feature]** V2 helpers: `Destination`, SVAML commands (`Calls`, `Amd`, `Messages`, `Recording`, `CustomEvents`, `Menu`) and `CommandsSequenceCreator`. Event handlers accept a sequence lambda