Skip to content

Commit 18ec5ed

Browse files
authored
feat: Voice API Updates Q4 2025 (#341)
* Implementing wait NCCO action * Implementing transfer NCCO * Adding support for 24K audio in connect NCCO
1 parent 6d0d8fa commit 18ec5ed

File tree

9 files changed

+302
-3
lines changed

9 files changed

+302
-3
lines changed

lib/vonage/voice/actions/connect.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def verify_endpoint
6565
raise ClientError.new("'user' must be defined") unless endpoint[:user]
6666
when 'websocket'
6767
raise ClientError.new("Expected 'uri' value to be a valid URI") unless URI.parse(endpoint[:uri]).kind_of?(URI::Generic)
68-
raise ClientError.new("Expected 'content-type' parameter to be either 'audio/116;rate=16000' or 'audio/116;rate=8000") unless endpoint[:'content-type'] == 'audio/116;rate=16000' || endpoint[:'content-type'] == 'audio/116;rate=8000'
68+
raise ClientError.new("Expected 'content-type' parameter to be either 'audio/l16;rate=16000', 'audio/l16;rate=8000', or 'audio/l16;rate=24000'") unless ['audio/l16;rate=16000', 'audio/l16;rate=8000', 'audio/l16;rate=24000'].include?(endpoint[:'content-type'])
6969
when 'sip'
7070
raise ClientError.new("Expected 'uri' value to be a valid URI") unless URI.parse(endpoint[:uri]).kind_of?(URI::Generic) if endpoint[:uri]
7171
raise ClientError.new("`uri` must not be combined with `user` and `domain`") if endpoint[:uri] && (endpoint[:user] || endpoint[:domain])
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# typed: true
2+
# typed: true
3+
# frozen_string_literal: true
4+
5+
module Vonage
6+
class Voice::Actions::Transfer
7+
attr_accessor :conversation_id, :can_hear, :can_speak, :mute
8+
9+
def initialize(attributes = {})
10+
@conversation_id = attributes.fetch(:conversation_id)
11+
@can_hear = attributes.fetch(:can_hear, nil)
12+
@can_speak = attributes.fetch(:can_speak, nil)
13+
@mute = attributes.fetch(:mute, nil)
14+
15+
after_initialize!
16+
end
17+
18+
def after_initialize!
19+
validate_conversation_id
20+
validate_can_hear if self.can_hear
21+
validate_can_speak if self.can_speak
22+
validate_mute if self.mute != nil
23+
end
24+
25+
def validate_conversation_id
26+
conversation_id = self.conversation_id
27+
28+
raise ClientError.new("Expected 'conversation_id' parameter to be a string") unless conversation_id.is_a?(String)
29+
30+
self.conversation_id
31+
end
32+
33+
def validate_can_hear
34+
can_hear = self.can_hear
35+
unless can_hear.is_a?(Array) && can_hear.all? { |item| item.is_a?(String) }
36+
raise ClientError.new("Expected 'can_hear' parameter to be an array of strings")
37+
end
38+
39+
self.can_hear
40+
end
41+
42+
def validate_can_speak
43+
can_speak = self.can_speak
44+
unless can_speak.is_a?(Array) && can_speak.all? { |item| item.is_a?(String) }
45+
raise ClientError.new("Expected 'can_speak' parameter to be an array of strings")
46+
end
47+
48+
self.can_speak
49+
end
50+
51+
def validate_mute
52+
mute = self.mute
53+
unless [true, false].include?(mute)
54+
raise ClientError.new("Expected 'mute' parameter to be a boolean")
55+
end
56+
57+
if self.mute && self.can_speak
58+
raise ClientError.new("The 'mute' parameter is not supported if 'can_speak' is also set")
59+
end
60+
61+
self.mute
62+
end
63+
64+
def action
65+
create_transfer!(self)
66+
end
67+
68+
def create_transfer!(builder)
69+
ncco = [
70+
{
71+
action: 'transfer',
72+
conversation_id: builder.conversation_id,
73+
}
74+
]
75+
76+
ncco[0].merge!(canHear: builder.can_hear) if builder.can_hear
77+
ncco[0].merge!(canSpeak: builder.can_speak) if builder.can_speak
78+
ncco[0].merge!(mute: builder.mute) if builder.mute != nil
79+
80+
ncco
81+
end
82+
end
83+
end

lib/vonage/voice/actions/wait.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# typed: true
2+
# typed: true
3+
# frozen_string_literal: true
4+
5+
module Vonage
6+
class Voice::Actions::Wait
7+
attr_accessor :timeout
8+
9+
def initialize(attributes = {})
10+
@timeout = attributes.fetch(:timeout, nil)
11+
12+
after_initialize!
13+
end
14+
15+
def after_initialize!
16+
validate_timeout if self.timeout
17+
end
18+
19+
def validate_timeout
20+
timeout = self.timeout
21+
22+
raise ClientError.new("Expected 'timeout' parameter to be a number") unless timeout.is_a?(Numeric)
23+
24+
self.timeout
25+
end
26+
27+
def action
28+
create_wait!(self)
29+
end
30+
31+
def create_wait!(builder)
32+
ncco = [
33+
{
34+
action: 'wait'
35+
}
36+
]
37+
38+
ncco[0].merge!(timeout: builder.timeout) if builder.timeout
39+
40+
ncco
41+
end
42+
end
43+
end

lib/vonage/voice/ncco.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ class Voice::Ncco
1010
notify: Vonage::Voice::Actions::Notify,
1111
record: Vonage::Voice::Actions::Record,
1212
stream: Vonage::Voice::Actions::Stream,
13-
talk: Vonage::Voice::Actions::Talk
13+
talk: Vonage::Voice::Actions::Talk,
14+
wait: Vonage::Voice::Actions::Wait,
15+
transfer: Vonage::Voice::Actions::Transfer
1416
}
1517

1618
class << self

test/vonage/test.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,10 @@ def conversation_id
398398
"CON-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
399399
end
400400

401+
def conversation_leg_uuid
402+
"aaaaaaaa-bbbb-4ccc-8ddd-0123456789ab"
403+
end
404+
401405
def call_id
402406
"CALL-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
403407
end

test/vonage/voice/actions/connect_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@ def test_create_endpoint_with_app
3030
assert_equal expected, connect.create_endpoint(connect)
3131
end
3232

33+
def test_create_endpoint_with_websocket
34+
expected = { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=8000' }
35+
connect = Vonage::Voice::Actions::Connect.new(endpoint: { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=8000' })
36+
37+
assert_equal expected, connect.create_endpoint(connect)
38+
end
39+
40+
def test_create_endpoint_with_websocket_with_audio_rate_8000
41+
expected = { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=8000' }
42+
connect = Vonage::Voice::Actions::Connect.new(endpoint: { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=8000' })
43+
44+
assert_equal expected, connect.create_endpoint(connect)
45+
end
46+
47+
def test_create_endpoint_with_websocket_with_audio_rate_16000
48+
expected = { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=16000' }
49+
connect = Vonage::Voice::Actions::Connect.new(endpoint: { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=16000' })
50+
51+
assert_equal expected, connect.create_endpoint(connect)
52+
end
53+
54+
def test_create_endpoint_with_websocket_with_audio_rate_24000
55+
expected = { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=24000' }
56+
connect = Vonage::Voice::Actions::Connect.new(endpoint: { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=24000' })
57+
58+
assert_equal expected, connect.create_endpoint(connect)
59+
end
60+
61+
def create_endpoint_with_websocket_with_invalid_audio_rate
62+
exception = assert_raises { Vonage::Voice::Actions::Connect.new(endpoint: { type: 'websocket', uri: 'wss://example.com/socket', :'content-type' => 'audio/l16;rate=32000' }) }
63+
assert_match "Expected 'content-type' parameter to be either 'audio/l16;rate=16000', 'audio/l16;rate=8000', or 'audio/l16;rate=24000'", exception.message
64+
end
65+
3366
def test_create_endpoint_with_sip_uri
3467
expected = { type: 'sip', uri: 'sip:joe@domain.com' }
3568
connect = Vonage::Voice::Actions::Connect.new(endpoint: { type: 'sip', uri: 'sip:joe@domain.com' })
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# typed: false
2+
3+
4+
class Vonage::Voice::Actions::TransferTest < Vonage::Test
5+
def test_transfer_initialize
6+
transfer = Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id)
7+
8+
assert_kind_of Vonage::Voice::Actions::Transfer, transfer
9+
end
10+
11+
def test_create_transfer
12+
expected = [{ action: 'transfer', conversation_id: conversation_id }]
13+
transfer = Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id)
14+
15+
assert_equal expected, transfer.create_transfer!(transfer)
16+
end
17+
18+
def test_create_transfer_with_optional_params
19+
expected = [{ action: 'transfer', conversation_id: conversation_id, canHear: [conversation_leg_uuid], canSpeak: [conversation_leg_uuid] }]
20+
transfer = Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_hear: [conversation_leg_uuid], can_speak: [conversation_leg_uuid])
21+
22+
assert_equal expected, transfer.create_transfer!(transfer)
23+
end
24+
25+
def test_create_transfer_with_mute_option_set_to_true
26+
expected = [{ action: 'transfer', conversation_id: conversation_id, mute: true }]
27+
transfer = Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, mute: true)
28+
29+
assert_equal expected, transfer.create_transfer!(transfer)
30+
end
31+
32+
def test_create_transfer_with_mute_option_set_to_false
33+
expected = [{ action: 'transfer', conversation_id: conversation_id, mute: false }]
34+
transfer = Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, mute: false)
35+
36+
assert_equal expected, transfer.create_transfer!(transfer)
37+
end
38+
39+
def test_transfer_with_invalid_conversation_id
40+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: 123) }
41+
42+
assert_match "Expected 'conversation_id' parameter to be a string", exception.message
43+
end
44+
45+
def test_transfer_with_invalid_can_hear_type
46+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_hear: conversation_leg_uuid) }
47+
48+
assert_match "Expected 'can_hear' parameter to be an array of strings", exception.message
49+
end
50+
51+
def test_transfer_with_invalid_can_hear_contents
52+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_hear: [123]) }
53+
54+
assert_match "Expected 'can_hear' parameter to be an array of strings", exception.message
55+
end
56+
57+
def test_transfer_with_invalid_can_speak_type
58+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_speak: conversation_leg_uuid) }
59+
60+
assert_match "Expected 'can_speak' parameter to be an array of strings", exception.message
61+
end
62+
63+
def test_transfer_with_invalid_can_speak_contents
64+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_speak: [123]) }
65+
66+
assert_match "Expected 'can_speak' parameter to be an array of strings", exception.message
67+
end
68+
69+
def test_transfer_with_invalid_mute_type
70+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, mute: 'true') }
71+
72+
assert_match "Expected 'mute' parameter to be a boolean", exception.message
73+
end
74+
75+
def test_transfer_with_invalid_mute_combination
76+
exception = assert_raises { Vonage::Voice::Actions::Transfer.new(conversation_id: conversation_id, can_speak: [conversation_leg_uuid], mute: true) }
77+
78+
assert_match "The 'mute' parameter is not supported if 'can_speak' is also set", exception.message
79+
end
80+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# typed: false
2+
3+
4+
class Vonage::Voice::Actions::WaitTest < Vonage::Test
5+
def test_wait_initialize
6+
wait = Vonage::Voice::Actions::Wait.new
7+
8+
assert_kind_of Vonage::Voice::Actions::Wait, wait
9+
end
10+
11+
def test_create_wait
12+
expected = [{ action: 'wait' }]
13+
wait = Vonage::Voice::Actions::Wait.new
14+
15+
assert_equal expected, wait.create_wait!(wait)
16+
end
17+
18+
def test_create_wait_with_optional_params
19+
expected = [{ action: 'wait', timeout: 10 }]
20+
wait = Vonage::Voice::Actions::Wait.new(timeout: 10)
21+
22+
assert_equal expected, wait.create_wait!(wait)
23+
end
24+
25+
def test_create_wait_with_timeout_as_integer
26+
expected = [{ action: 'wait', timeout: 10 }]
27+
wait = Vonage::Voice::Actions::Wait.new(timeout: 10)
28+
29+
assert_equal expected, wait.create_wait!(wait)
30+
end
31+
32+
def test_create_wait_with_timeout_as_float
33+
expected = [{ action: 'wait', timeout: 10.5 }]
34+
wait = Vonage::Voice::Actions::Wait.new(timeout: 10.5)
35+
36+
assert_equal expected, wait.create_wait!(wait)
37+
end
38+
39+
def test_wait_with_invalid_timeout
40+
exception = assert_raises { Vonage::Voice::Actions::Wait.new(timeout: 'invalid') }
41+
42+
assert_match "Expected 'timeout' parameter to be a number", exception.message
43+
end
44+
end

test/vonage/voice/ncco_test.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,17 @@ def test_ncco_with_invalid_action
6060
assert_match "NCCO action must be one of the valid options. Please refer to https://developer.nexmo.com/voice/voice-api/ncco-reference#ncco-actions for a complete list.", exception.message
6161
end
6262

63-
Vonage::Voice::Ncco::ACTIONS.keys.each do |method_name|
63+
[
64+
:connect,
65+
:conversation,
66+
:input,
67+
:notify,
68+
:record,
69+
:stream,
70+
:talk,
71+
:wait,
72+
:transfer
73+
].each do |method_name|
6474
define_method "test_ncco_#{method_name}_defined_class_method" do
6575
assert_respond_to ncco, method_name
6676
refute_respond_to ncco.class, method_name

0 commit comments

Comments
 (0)