Skip to content

Commit 724ca36

Browse files
committed
feat: Twilio Conversation Relay Handler
Uses Switch Expressions and Pattern Matching for Switch. Also introduces WebSocket utilities, and refactors JSON canonicalization.
1 parent a858745 commit 724ca36

27 files changed

+829
-152
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ java_library(
3030
"//java/dev/enola/common/jackson",
3131
"@maven//:com_fasterxml_jackson_core_jackson_annotations",
3232
"@maven//:com_fasterxml_jackson_core_jackson_databind",
33+
"@maven//:org_jspecify_jspecify",
3334
"@maven//:org_slf4j_slf4j_api",
3435
],
3536
)
@@ -41,6 +42,6 @@ junit_tests(
4142
),
4243
deps = [
4344
":relay",
44-
"//java/dev/enola/common/yamljson",
45+
"//java/dev/enola/common/jackson/testlib",
4546
],
4647
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.DTMF;
21+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Error;
22+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Interrupt;
23+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Prompt;
24+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Setup;
25+
26+
import org.jspecify.annotations.Nullable;
27+
import org.slf4j.Logger;
28+
import org.slf4j.LoggerFactory;
29+
30+
public interface ConversationHandler {
31+
32+
static final Logger logger = LoggerFactory.getLogger(ConversationHandler.class);
33+
34+
default @Nullable ConversationRelayResponse onSetup(Setup setup) {
35+
return null;
36+
}
37+
38+
default @Nullable ConversationRelayResponse onPrompt(Prompt prompt) {
39+
return null;
40+
}
41+
42+
default @Nullable ConversationRelayResponse onDTMF(DTMF dtmf) {
43+
return null;
44+
}
45+
46+
default @Nullable ConversationRelayResponse onInterrupt(Interrupt interrupt) {
47+
return null;
48+
}
49+
50+
default @Nullable ConversationRelayResponse onError(Error error) {
51+
logger.error("Error: {}", error.description());
52+
return null;
53+
}
54+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.DTMF;
21+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Error;
22+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Interrupt;
23+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Prompt;
24+
import dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Setup;
25+
26+
import org.jspecify.annotations.Nullable;
27+
28+
public final class ConversationRelay {
29+
30+
private final ConversationRelayIO io;
31+
private final ConversationHandler handler;
32+
33+
public ConversationRelay(ConversationHandler handler, ConversationRelayIO io) {
34+
this.handler = handler;
35+
this.io = io;
36+
}
37+
38+
public ConversationRelay(ConversationHandler handler) {
39+
this(handler, new ConversationRelayIO());
40+
}
41+
42+
public final @Nullable String handle(String json) {
43+
var request = io.read(json);
44+
var response =
45+
switch (request) {
46+
case Setup setup -> handler.onSetup(setup);
47+
case Prompt prompt -> handler.onPrompt(prompt);
48+
case DTMF dtmf -> handler.onDTMF(dtmf);
49+
case Interrupt interrupt -> handler.onInterrupt(interrupt);
50+
case Error error -> handler.onError(error);
51+
};
52+
if (response != null) {
53+
return io.write(response);
54+
} else {
55+
return null;
56+
}
57+
}
58+
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,19 @@
2929

3030
public class ConversationRelayIO {
3131

32-
private static final ObjectMapper objectMapper =
32+
private static final ObjectMapper DEFAULT_OBJECT_MAPPER =
3333
ObjectMappers.newObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
3434

35+
private final ObjectMapper objectMapper;
36+
37+
public ConversationRelayIO() {
38+
this(DEFAULT_OBJECT_MAPPER);
39+
}
40+
41+
public ConversationRelayIO(ObjectMapper objectMapper) {
42+
this.objectMapper = requireNonNull(objectMapper);
43+
}
44+
3545
public ConversationRelayRequest read(String json) {
3646
try {
3747
return requireNonNull(objectMapper.readValue(json, ConversationRelayRequest.class));

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,17 @@ record Setup(
5151
String to,
5252
String forwardedFrom,
5353
String callerName,
54+
// TODO Use direction enum; escalate to Twilio for missing Source of Truth documentation
5455
String direction,
56+
// TODO Use callType enum; escalate to Twilio for missing Source of Truth documentation
5557
String callType,
58+
// TODO Use https://www.twilio.com/docs/voice/api/call-resource#call-status-values enum
5659
String callStatus,
5760
String accountSid,
5861
Map<String, Object> customParameters)
5962
implements ConversationRelayRequest {}
6063

64+
// TODO Use Locale for lang
6165
record Prompt(String voicePrompt, String lang, boolean last)
6266
implements ConversationRelayRequest {}
6367

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,15 @@
4343
})
4444
public sealed interface ConversationRelayResponse {
4545

46-
record Text(String token, boolean last, String lang, boolean interruptible, boolean preemptible)
46+
record Text(String token, String lang, boolean last, boolean interruptible, boolean preemptible)
4747
implements ConversationRelayResponse {}
4848

4949
record Play(URI source, boolean interruptible, boolean preemptible)
5050
implements ConversationRelayResponse {}
5151

5252
record DTMF(String digits) implements ConversationRelayResponse {}
5353

54+
// TODO Use Locale for ttsLanguage and transcriptionLanguage
5455
record Language(String ttsLanguage, String transcriptionLanguage)
5556
implements ConversationRelayResponse {}
5657

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

Lines changed: 53 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,91 +17,78 @@
1717
*/
1818
package dev.enola.audio.voice.twilio.relay;
1919

20-
import static com.google.common.truth.Truth.assertThat;
21-
22-
import dev.enola.common.yamljson.JSON;
20+
import dev.enola.common.jackson.testlib.JsonTester;
2321

2422
import org.junit.Test;
2523

2624
public class ConversationRelayResponseTest {
2725
ConversationRelayIO io = new ConversationRelayIO();
2826

2927
@Test
30-
public void textMessage() {
31-
var response = new ConversationRelayResponse.Text("hello, world!", false, "en", true, true);
32-
var json = JSON.canonicalize(io.write(response), true);
33-
assertThat(json)
34-
.isEqualTo(
35-
"""
36-
{
37-
"interruptible": true,
38-
"lang": "en",
39-
"last": false,
40-
"preemptible": true,
41-
"token": "hello, world!",
42-
"type": "text"
43-
}\
44-
""");
28+
public void textMessage() throws Exception {
29+
JsonTester.assertEqualsTo(
30+
new ConversationRelayResponse.Text("hello, world!", "en", false, true, true),
31+
"""
32+
{
33+
"interruptible": true,
34+
"lang": "en",
35+
"last": false,
36+
"preemptible": true,
37+
"token": "hello, world!",
38+
"type": "text"
39+
}
40+
""");
4541
}
4642

4743
@Test
48-
public void playMessage() {
49-
var response =
44+
public void playMessage() throws Exception {
45+
JsonTester.assertEqualsTo(
5046
new ConversationRelayResponse.Play(
51-
java.net.URI.create("http://example.com/audio.mp3"), true, true);
52-
var json = JSON.canonicalize(io.write(response), true);
53-
assertThat(json)
54-
.isEqualTo(
55-
"""
56-
{
57-
"interruptible": true,
58-
"preemptible": true,
59-
"source": "http://example.com/audio.mp3",
60-
"type": "play"
61-
}\
62-
""");
47+
java.net.URI.create("http://example.com/audio.mp3"), true, true),
48+
"""
49+
{
50+
"interruptible": true,
51+
"preemptible": true,
52+
"source": "http://example.com/audio.mp3",
53+
"type": "play"
54+
}
55+
""");
6356
}
6457

6558
@Test
66-
public void dtmfMessage() {
67-
var response = new ConversationRelayResponse.DTMF("1234");
68-
var json = JSON.canonicalize(io.write(response), true);
69-
assertThat(json)
70-
.isEqualTo(
71-
"""
72-
{
73-
"digits": "1234",
74-
"type": "sendDigits"
75-
}\
76-
""");
59+
public void dtmfMessage() throws Exception {
60+
JsonTester.assertEqualsTo(
61+
new ConversationRelayResponse.DTMF("1234"),
62+
"""
63+
{
64+
"digits": "1234",
65+
"type": "sendDigits"
66+
}
67+
""");
7768
}
7869

7970
@Test
80-
public void languageMessage() {
81-
var response = new ConversationRelayResponse.Language("en-US", "en-US");
82-
var json = JSON.canonicalize(io.write(response), true);
83-
assertThat(json)
84-
.isEqualTo(
85-
"""
86-
{
87-
"transcriptionLanguage": "en-US",
88-
"ttsLanguage": "en-US",
89-
"type": "language"
90-
}\
91-
""");
71+
public void languageMessage() throws Exception {
72+
JsonTester.assertEqualsTo(
73+
new ConversationRelayResponse.Language("en-US", "en-US"),
74+
"""
75+
{
76+
"transcriptionLanguage": "en-US",
77+
"ttsLanguage": "en-US",
78+
"type": "language"
79+
}
80+
""");
9281
}
9382

9483
@Test
95-
public void endMessage() {
96-
var response = new ConversationRelayResponse.End("some-handoff-data");
97-
var json = JSON.canonicalize(io.write(response), true);
98-
assertThat(json)
99-
.isEqualTo(
100-
"""
101-
{
102-
"handoffData": "some-handoff-data",
103-
"type": "end"
104-
}\
105-
""");
84+
public void endMessage() throws Exception {
85+
JsonTester.assertEqualsTo(
86+
new ConversationRelayResponse.End("some-handoff-data"),
87+
"""
88+
{
89+
"handoffData": "some-handoff-data",
90+
"type": "end"
91+
}
92+
""");
10693
}
10794
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 dev.enola.audio.voice.twilio.relay.ConversationRelayRequest.Prompt;
21+
22+
public class EchoConversationHandler implements ConversationHandler {
23+
24+
@Override
25+
public ConversationRelayResponse onPrompt(Prompt prompt) {
26+
return new ConversationRelayResponse.Text(
27+
prompt.voicePrompt(), prompt.lang(), prompt.last(), true, true);
28+
}
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* Copyright 2024-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+
@NullMarked
19+
package dev.enola.audio.voice.twilio.relay;
20+
21+
import org.jspecify.annotations.NullMarked;

0 commit comments

Comments
 (0)