Skip to content

Commit a858745

Browse files
feat: Twilio Conversation Relay Request & Response IO
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Michael Vorburger <[email protected]>
1 parent d9d6dd9 commit a858745

File tree

10 files changed

+404
-49
lines changed

10 files changed

+404
-49
lines changed

java/dev/enola/audio/voice/twilio/relay/BUILD

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ java_library(
2727
),
2828
visibility = ["//:__subpackages__"],
2929
deps = [
30+
"//java/dev/enola/common/jackson",
31+
"@maven//:com_fasterxml_jackson_core_jackson_annotations",
32+
"@maven//:com_fasterxml_jackson_core_jackson_databind",
3033
"@maven//:org_slf4j_slf4j_api",
3134
],
3235
)
@@ -38,5 +41,6 @@ junit_tests(
3841
),
3942
deps = [
4043
":relay",
44+
"//java/dev/enola/common/yamljson",
4145
],
4246
)

java/dev/enola/audio/voice/twilio/relay/ConversationRelay.java

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright 2025 The Enola <https://enola.dev> Authors
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package dev.enola.audio.voice.twilio.relay;
19+
20+
import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
21+
22+
import static java.util.Objects.requireNonNull;
23+
24+
import com.fasterxml.jackson.databind.ObjectMapper;
25+
26+
import dev.enola.common.jackson.ObjectMappers;
27+
28+
import java.io.IOException;
29+
30+
public class ConversationRelayIO {
31+
32+
private static final ObjectMapper objectMapper =
33+
ObjectMappers.newObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
34+
35+
public ConversationRelayRequest read(String json) {
36+
try {
37+
return requireNonNull(objectMapper.readValue(json, ConversationRelayRequest.class));
38+
} catch (IOException e) {
39+
// Intentionally not including full json in exception, as it may contain sensitive data
40+
// If it's required for debugging in the future, then log it at DEBUG level
41+
throw new IllegalArgumentException("Invalid JSON", e);
42+
}
43+
}
44+
45+
public String write(ConversationRelayResponse response) {
46+
try {
47+
return objectMapper.writeValueAsString(response);
48+
} catch (IOException e) {
49+
// Intentionally not including full response, as it may contain sensitive data
50+
// If it's required for debugging in the future, then log it at DEBUG level
51+
throw new IllegalArgumentException(
52+
"JSON marshalling failed for: " + response.getClass().getName(), e);
53+
}
54+
}
55+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright 2025 The Enola <https://enola.dev> Authors
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package dev.enola.audio.voice.twilio.relay;
19+
20+
import com.fasterxml.jackson.annotation.JsonSubTypes;
21+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
22+
23+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.DTMF;
24+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Error;
25+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Interrupt;
26+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Prompt;
27+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Setup;
28+
29+
import java.util.Map;
30+
31+
/**
32+
* Twilio's <a
33+
* href="https://www.twilio.com/docs/voice/conversationrelay/websocket-messages">ConversationRelay
34+
* request messages</a> JSON format.
35+
*/
36+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
37+
@JsonSubTypes({
38+
@JsonSubTypes.Type(value = Setup.class, name = "setup"),
39+
@JsonSubTypes.Type(value = Prompt.class, name = "prompt"),
40+
@JsonSubTypes.Type(value = DTMF.class, name = "dtmf"),
41+
@JsonSubTypes.Type(value = Interrupt.class, name = "interrupt"),
42+
@JsonSubTypes.Type(value = Error.class, name = "error")
43+
})
44+
public sealed interface ConversationRelayRequest {
45+
46+
record Setup(
47+
String sessionId,
48+
String callSid,
49+
String parentCallSid,
50+
String from,
51+
String to,
52+
String forwardedFrom,
53+
String callerName,
54+
String direction,
55+
String callType,
56+
String callStatus,
57+
String accountSid,
58+
Map<String, Object> customParameters)
59+
implements ConversationRelayRequest {}
60+
61+
record Prompt(String voicePrompt, String lang, boolean last)
62+
implements ConversationRelayRequest {}
63+
64+
record DTMF(String digit) implements ConversationRelayRequest {}
65+
66+
record Interrupt(String utteranceUntilInterrupt, int durationUntilInterruptMs)
67+
implements ConversationRelayRequest {}
68+
69+
record Error(String description) implements ConversationRelayRequest {}
70+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright 2025 The Enola <https://enola.dev> Authors
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package dev.enola.audio.voice.twilio.relay;
19+
20+
import static com.google.common.truth.Truth.assertThat;
21+
22+
import org.junit.Test;
23+
24+
public class ConversationRelayRequestTest {
25+
26+
ConversationRelayIO reader = new ConversationRelayIO();
27+
28+
@Test
29+
public void setupMessage() {
30+
var setup =
31+
(ConversationRelayRequest.Setup)
32+
reader.read(
33+
"""
34+
{"type":"setup","sessionId":"VX3808c8bfc80b383891e28f18d8609ff0","callSid":"CAcd760b1a71b2d557efcf785870e6f09e","parentCallSid":"","from":"+41791234567","to":"+14087153333","forwardedFrom":"+14087153333","callerName":"","direction":"inbound","callType":"PSTN","callStatus":"RINGING","accountSid":"SECRET!","customParameters":{}}
35+
""");
36+
assertThat(setup.sessionId()).isEqualTo("VX3808c8bfc80b383891e28f18d8609ff0");
37+
assertThat(setup.from()).isEqualTo("+41791234567");
38+
}
39+
40+
@Test
41+
public void promptMessage() {
42+
var prompt =
43+
(ConversationRelayRequest.Prompt)
44+
reader.read(
45+
"""
46+
{"type":"prompt","voicePrompt":"Hi. Can I make a reservation, please? Hello. I'd like to make a reservation.","lang":"en-US","last":true}
47+
""");
48+
assertThat(prompt.voicePrompt()).startsWith("Hi. Can I");
49+
assertThat(prompt.lang()).isEqualTo("en-US");
50+
assertThat(prompt.last()).isTrue();
51+
}
52+
53+
@Test
54+
public void promptMessageWithUnknownExtraField() {
55+
var prompt =
56+
(ConversationRelayRequest.Prompt)
57+
reader.read(
58+
"""
59+
{"type":"prompt","voicePrompt":"Hi. Can I make a reservation, please? Hello. I'd like to make a reservation.","lang":"en-US","last":true,"extraField":"extraValue"}
60+
""");
61+
assertThat(prompt.voicePrompt()).startsWith("Hi. Can I");
62+
assertThat(prompt.lang()).isEqualTo("en-US");
63+
assertThat(prompt.last()).isTrue();
64+
}
65+
66+
@Test
67+
public void dtmfMessage() {
68+
var dtmf =
69+
(ConversationRelayRequest.DTMF)
70+
reader.read(
71+
"""
72+
{"type":"dtmf","digit":"5"}
73+
""");
74+
assertThat(dtmf.digit()).isEqualTo("5");
75+
}
76+
77+
@Test
78+
public void interruptMessage() {
79+
var interrupt =
80+
(ConversationRelayRequest.Interrupt)
81+
reader.read(
82+
"""
83+
{"type":"interrupt","utteranceUntilInterrupt":"Hello world","durationUntilInterruptMs":1234}
84+
""");
85+
assertThat(interrupt.utteranceUntilInterrupt()).isEqualTo("Hello world");
86+
assertThat(interrupt.durationUntilInterruptMs()).isEqualTo(1234);
87+
}
88+
89+
@Test
90+
public void errorMessage() {
91+
var error =
92+
(ConversationRelayRequest.Error)
93+
reader.read(
94+
"""
95+
{"type":"error","description":"Something went wrong"}
96+
""");
97+
assertThat(error.description()).isEqualTo("Something went wrong");
98+
}
99+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright 2025 The Enola <https://enola.dev> Authors
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package dev.enola.audio.voice.twilio.relay;
19+
20+
import com.fasterxml.jackson.annotation.JsonSubTypes;
21+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
22+
23+
import dev.enola.audio.voice.twilio.relay.ConversationRelayResponse.DTMF;
24+
import dev.enola.audio.voice.twilio.relay.ConversationRelayResponse.End;
25+
import dev.enola.audio.voice.twilio.relay.ConversationRelayResponse.Language;
26+
import dev.enola.audio.voice.twilio.relay.ConversationRelayResponse.Play;
27+
import dev.enola.audio.voice.twilio.relay.ConversationRelayResponse.Text;
28+
29+
import java.net.URI;
30+
31+
/**
32+
* Twilio's <a
33+
* href="https://www.twilio.com/docs/voice/conversationrelay/websocket-messages">ConversationRelay
34+
* response messages</a> JSON format.
35+
*/
36+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
37+
@JsonSubTypes({
38+
@JsonSubTypes.Type(value = Text.class, name = "text"),
39+
@JsonSubTypes.Type(value = Play.class, name = "play"),
40+
@JsonSubTypes.Type(value = DTMF.class, name = "sendDigits"),
41+
@JsonSubTypes.Type(value = Language.class, name = "language"),
42+
@JsonSubTypes.Type(value = End.class, name = "end")
43+
})
44+
public sealed interface ConversationRelayResponse {
45+
46+
record Text(String token, boolean last, String lang, boolean interruptible, boolean preemptible)
47+
implements ConversationRelayResponse {}
48+
49+
record Play(URI source, boolean interruptible, boolean preemptible)
50+
implements ConversationRelayResponse {}
51+
52+
record DTMF(String digits) implements ConversationRelayResponse {}
53+
54+
record Language(String ttsLanguage, String transcriptionLanguage)
55+
implements ConversationRelayResponse {}
56+
57+
// TODO Should handoffData be Map<String, Object> instead of String?
58+
record End(String handoffData) implements ConversationRelayResponse {}
59+
}

0 commit comments

Comments
 (0)