ruby-utcp 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +22 -0
- data/Makefile +226 -0
- data/README.md +331 -0
- data/examples/basic.rb +33 -0
- data/examples/cli.rb +32 -0
- data/examples/generated/__init__.py +1 -0
- data/examples/generated/utcp_pb2.py +46 -0
- data/examples/generated/utcp_pb2_grpc.py +183 -0
- data/examples/graphql.rb +15 -0
- data/examples/grpc.rb +42 -0
- data/examples/grpc_python.py +52 -0
- data/examples/http.rb +17 -0
- data/examples/mcp.rb +28 -0
- data/examples/servers/graphql_server.rb +39 -0
- data/examples/servers/grpc_server.py +97 -0
- data/examples/servers/grpc_server.rb +62 -0
- data/examples/servers/http_helpers.rb +34 -0
- data/examples/servers/http_server.rb +28 -0
- data/examples/servers/mcp_stdio_server.rb +43 -0
- data/examples/servers/requirements-grpc.txt +2 -0
- data/examples/servers/sse_server.rb +36 -0
- data/examples/servers/streamable_http_server.rb +39 -0
- data/examples/servers/tcp_server.rb +58 -0
- data/examples/servers/udp_server.rb +33 -0
- data/examples/servers/webrtc_server.rb +78 -0
- data/examples/servers/websocket_server.rb +92 -0
- data/examples/sse.rb +16 -0
- data/examples/streamable_http.rb +17 -0
- data/examples/tcp.rb +20 -0
- data/examples/text.rb +23 -0
- data/examples/udp.rb +18 -0
- data/examples/webrtc.rb +19 -0
- data/examples/websocket.rb +17 -0
- data/lib/ruby-utcp.rb +4 -0
- data/lib/utcp/client.rb +217 -0
- data/lib/utcp/config.rb +79 -0
- data/lib/utcp/errors.rb +48 -0
- data/lib/utcp/migration.rb +88 -0
- data/lib/utcp/models.rb +794 -0
- data/lib/utcp/openapi_converter.rb +179 -0
- data/lib/utcp/protocols/base.rb +97 -0
- data/lib/utcp/protocols/cli.rb +186 -0
- data/lib/utcp/protocols/file.rb +52 -0
- data/lib/utcp/protocols/graphql.rb +277 -0
- data/lib/utcp/protocols/grpc.rb +207 -0
- data/lib/utcp/protocols/http.rb +340 -0
- data/lib/utcp/protocols/http_stream_support.rb +122 -0
- data/lib/utcp/protocols/mcp.rb +339 -0
- data/lib/utcp/protocols/socket_support.rb +51 -0
- data/lib/utcp/protocols/sse.rb +107 -0
- data/lib/utcp/protocols/streamable_http.rb +78 -0
- data/lib/utcp/protocols/tcp.rb +143 -0
- data/lib/utcp/protocols/text.rb +44 -0
- data/lib/utcp/protocols/udp.rb +61 -0
- data/lib/utcp/protocols/webrtc.rb +217 -0
- data/lib/utcp/protocols/websocket.rb +350 -0
- data/lib/utcp/registry.rb +67 -0
- data/lib/utcp/repository.rb +137 -0
- data/lib/utcp/serializer.rb +71 -0
- data/lib/utcp/utils.rb +118 -0
- data/lib/utcp/variables.rb +170 -0
- data/lib/utcp/version.rb +6 -0
- data/lib/utcp.rb +70 -0
- data/proto/utcp.proto +31 -0
- metadata +148 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class TCPProtocol < CommunicationProtocol
|
|
5
|
+
include SocketSupport
|
|
6
|
+
|
|
7
|
+
def initialize(socket_factory: nil)
|
|
8
|
+
@socket_factory = socket_factory || lambda do |host, port, timeout|
|
|
9
|
+
Socket.tcp(host, port, connect_timeout: timeout)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register_manual(client, template)
|
|
14
|
+
assert_template!(template)
|
|
15
|
+
response = exchange(template, JSON.generate("type" => "utcp"))
|
|
16
|
+
success(template, manual_from_payload(template, response, source: "TCP discovery response"))
|
|
17
|
+
rescue StandardError => error
|
|
18
|
+
client.logger.warn("Unable to register TCP manual #{template.name.inspect}: #{error.message}")
|
|
19
|
+
failure(template, error)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
23
|
+
assert_template!(template)
|
|
24
|
+
exchange(template, format_socket_message(template, tool_args))
|
|
25
|
+
rescue Error
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
raise ToolCallError.new("TCP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def assert_template!(template)
|
|
34
|
+
return if template.is_a?(TcpCallTemplate)
|
|
35
|
+
|
|
36
|
+
raise ValidationError, "TCP protocol requires a TcpCallTemplate"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exchange(template, message)
|
|
40
|
+
timeout = socket_timeout_seconds(template)
|
|
41
|
+
socket = @socket_factory.call(template.host, template.port, timeout)
|
|
42
|
+
socket.write(frame_message(message.to_s.b, template))
|
|
43
|
+
payload = read_framed(socket, template, timeout)
|
|
44
|
+
decode_socket_payload(payload, template.response_byte_format)
|
|
45
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => error
|
|
46
|
+
raise TimeoutError, "TCP request timed out: #{error.message}"
|
|
47
|
+
rescue Error
|
|
48
|
+
raise
|
|
49
|
+
rescue SocketError, IOError, SystemCallError => error
|
|
50
|
+
raise ToolCallError, "TCP request failed: #{error.message}"
|
|
51
|
+
ensure
|
|
52
|
+
socket.close if socket && !socket.closed?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def frame_message(message, template)
|
|
56
|
+
case template.framing_strategy
|
|
57
|
+
when "length_prefix"
|
|
58
|
+
pack = {
|
|
59
|
+
[1, "big"] => "C", [1, "little"] => "C",
|
|
60
|
+
[2, "big"] => "n", [2, "little"] => "v",
|
|
61
|
+
[4, "big"] => "N", [4, "little"] => "V",
|
|
62
|
+
[8, "big"] => "Q>", [8, "little"] => "Q<"
|
|
63
|
+
}.fetch([template.length_prefix_bytes, template.length_prefix_endian])
|
|
64
|
+
[message.bytesize].pack(pack) + message
|
|
65
|
+
when "delimiter"
|
|
66
|
+
message + escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences)
|
|
67
|
+
else
|
|
68
|
+
message
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_framed(socket, template, timeout)
|
|
73
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
74
|
+
case template.framing_strategy
|
|
75
|
+
when "length_prefix"
|
|
76
|
+
prefix = read_exact(socket, template.length_prefix_bytes, deadline)
|
|
77
|
+
unpack = {
|
|
78
|
+
[1, "big"] => "C", [1, "little"] => "C",
|
|
79
|
+
[2, "big"] => "n", [2, "little"] => "v",
|
|
80
|
+
[4, "big"] => "N", [4, "little"] => "V",
|
|
81
|
+
[8, "big"] => "Q>", [8, "little"] => "Q<"
|
|
82
|
+
}.fetch([template.length_prefix_bytes, template.length_prefix_endian])
|
|
83
|
+
length = prefix.unpack1(unpack)
|
|
84
|
+
raise ToolCallError, "TCP response exceeds max_response_size" if length > template.max_response_size
|
|
85
|
+
|
|
86
|
+
read_exact(socket, length, deadline)
|
|
87
|
+
when "delimiter"
|
|
88
|
+
read_until(socket, escaped_delimiter(template.message_delimiter, template.interpret_escape_sequences),
|
|
89
|
+
template.max_response_size, deadline)
|
|
90
|
+
when "fixed_length"
|
|
91
|
+
read_exact(socket, template.fixed_message_length, deadline)
|
|
92
|
+
when "stream"
|
|
93
|
+
read_stream(socket, template.max_response_size, deadline)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_exact(socket, length, deadline)
|
|
98
|
+
result = +"".b
|
|
99
|
+
while result.bytesize < length
|
|
100
|
+
wait_readable!(socket, deadline, "TCP read")
|
|
101
|
+
chunk = socket.readpartial(length - result.bytesize)
|
|
102
|
+
raise ToolCallError, "TCP connection closed before the complete response" if chunk.nil? || chunk.empty?
|
|
103
|
+
|
|
104
|
+
result << chunk
|
|
105
|
+
end
|
|
106
|
+
result
|
|
107
|
+
rescue EOFError
|
|
108
|
+
raise ToolCallError, "TCP connection closed before the complete response"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def read_until(socket, delimiter, maximum, deadline)
|
|
112
|
+
raise ValidationError, "message_delimiter cannot be empty" if delimiter.empty?
|
|
113
|
+
|
|
114
|
+
result = +"".b
|
|
115
|
+
until result.end_with?(delimiter)
|
|
116
|
+
raise ToolCallError, "TCP response exceeds max_response_size" if result.bytesize >= maximum
|
|
117
|
+
|
|
118
|
+
wait_readable!(socket, deadline, "TCP read")
|
|
119
|
+
result << socket.readpartial([4096, maximum - result.bytesize].min)
|
|
120
|
+
end
|
|
121
|
+
result.byteslice(0, result.bytesize - delimiter.bytesize)
|
|
122
|
+
rescue EOFError
|
|
123
|
+
raise ToolCallError, "TCP connection closed before the message delimiter"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def read_stream(socket, maximum, deadline)
|
|
127
|
+
result = +"".b
|
|
128
|
+
while result.bytesize < maximum
|
|
129
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
|
+
break unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
result << socket.readpartial([4096, maximum - result.bytesize].min)
|
|
134
|
+
rescue EOFError
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
result
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
TcpCommunicationProtocol = TCPProtocol
|
|
142
|
+
TCPCommunicationProtocol = TCPProtocol
|
|
143
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class TextProtocol < CommunicationProtocol
|
|
5
|
+
def register_manual(client, template)
|
|
6
|
+
assert_template!(template)
|
|
7
|
+
data = Utils.parse_document(template.content, source: "text manual")
|
|
8
|
+
manual = if openapi?(data)
|
|
9
|
+
OpenAPIConverter.new(
|
|
10
|
+
data,
|
|
11
|
+
spec_url: "text://content",
|
|
12
|
+
call_template_name: template.name,
|
|
13
|
+
auth_tools: template.auth_tools,
|
|
14
|
+
base_url: template.base_url
|
|
15
|
+
).convert
|
|
16
|
+
else
|
|
17
|
+
Manual.from_h(data)
|
|
18
|
+
end
|
|
19
|
+
success(template, manual)
|
|
20
|
+
rescue StandardError => error
|
|
21
|
+
client.logger.warn("Unable to register text manual #{template.name.inspect}: #{error.message}")
|
|
22
|
+
failure(template, error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call_tool(_client, _tool_name, _tool_args, template)
|
|
26
|
+
assert_template!(template)
|
|
27
|
+
template.content
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def assert_template!(template)
|
|
33
|
+
return if template.is_a?(TextCallTemplate)
|
|
34
|
+
|
|
35
|
+
raise ValidationError, "text protocol requires a TextCallTemplate"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def openapi?(data)
|
|
39
|
+
data.is_a?(Hash) && (data.key?("openapi") || data.key?("swagger") || data.key?("paths"))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
TextCommunicationProtocol = TextProtocol
|
|
43
|
+
end
|
|
44
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class UDPProtocol < CommunicationProtocol
|
|
5
|
+
include SocketSupport
|
|
6
|
+
|
|
7
|
+
def initialize(socket_factory: nil)
|
|
8
|
+
@socket_factory = socket_factory || -> { UDPSocket.new }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_manual(client, template)
|
|
12
|
+
assert_template!(template)
|
|
13
|
+
response = exchange(template, JSON.generate("type" => "utcp"), response_count: 1)
|
|
14
|
+
success(template, manual_from_payload(template, response, source: "UDP discovery response"))
|
|
15
|
+
rescue StandardError => error
|
|
16
|
+
client.logger.warn("Unable to register UDP manual #{template.name.inspect}: #{error.message}")
|
|
17
|
+
failure(template, error)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
21
|
+
assert_template!(template)
|
|
22
|
+
exchange(template, format_socket_message(template, tool_args),
|
|
23
|
+
response_count: template.number_of_response_datagrams)
|
|
24
|
+
rescue Error
|
|
25
|
+
raise
|
|
26
|
+
rescue StandardError => error
|
|
27
|
+
raise ToolCallError.new("UDP tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def assert_template!(template)
|
|
33
|
+
return if template.is_a?(UdpCallTemplate)
|
|
34
|
+
|
|
35
|
+
raise ValidationError, "UDP protocol requires a UdpCallTemplate"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exchange(template, message, response_count:)
|
|
39
|
+
socket = @socket_factory.call
|
|
40
|
+
socket.connect(template.host, template.port)
|
|
41
|
+
socket.write(message.to_s.b)
|
|
42
|
+
return nil if response_count.zero?
|
|
43
|
+
|
|
44
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + socket_timeout_seconds(template)
|
|
45
|
+
values = response_count.times.map do
|
|
46
|
+
wait_readable!(socket, deadline, "UDP read")
|
|
47
|
+
payload = socket.recv(65_535)
|
|
48
|
+
decode_socket_payload(payload, template.response_byte_format)
|
|
49
|
+
end
|
|
50
|
+
values.length == 1 ? values.first : values
|
|
51
|
+
rescue Error
|
|
52
|
+
raise
|
|
53
|
+
rescue SocketError, IOError, SystemCallError => error
|
|
54
|
+
raise ToolCallError, "UDP request failed: #{error.message}"
|
|
55
|
+
ensure
|
|
56
|
+
socket.close if socket && !socket.closed?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
UdpCommunicationProtocol = UDPProtocol
|
|
60
|
+
UDPCommunicationProtocol = UDPProtocol
|
|
61
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UTCP
|
|
4
|
+
class WebRTCPeer
|
|
5
|
+
def initialize(template)
|
|
6
|
+
gem "webrtc-ruby", ">= 1.0.0"
|
|
7
|
+
require "webrtc"
|
|
8
|
+
WebRTC.init
|
|
9
|
+
@template = template
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@condition = ConditionVariable.new
|
|
12
|
+
@responses = {}
|
|
13
|
+
@candidates = []
|
|
14
|
+
configuration = template.ice_servers.empty? ? nil : { ice_servers: template.ice_servers }
|
|
15
|
+
@connection = WebRTC::RTCPeerConnection.new(configuration)
|
|
16
|
+
install_candidate_handler
|
|
17
|
+
@channel = @connection.create_data_channel(template.data_channel_name)
|
|
18
|
+
install_channel_handlers
|
|
19
|
+
rescue LoadError => error
|
|
20
|
+
raise MissingDependencyError,
|
|
21
|
+
"WebRTC requires the optional 'webrtc-ruby' gem and libdatachannel: #{error.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def connect
|
|
25
|
+
offer = @connection.create_offer.await
|
|
26
|
+
@connection.set_local_description(offer).await
|
|
27
|
+
wait_for_ice_gathering
|
|
28
|
+
response = post_json("connect", "peer_id" => @template.peer_id, "sdp" => @connection.local_description.sdp)
|
|
29
|
+
answer = WebRTC::RTCSessionDescription.new(type: :answer, sdp: response.fetch("sdp"))
|
|
30
|
+
@connection.set_remote_description(answer).await
|
|
31
|
+
Array(response["candidates"]).each do |candidate|
|
|
32
|
+
@connection.add_ice_candidate(WebRTC::RTCIceCandidate.new(Utils.symbolize_keys(candidate))).await
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
@candidates.each { |candidate| post_candidate(candidate) }
|
|
37
|
+
wait_for_channel
|
|
38
|
+
response
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def request(payload, timeout: @template.timeout)
|
|
42
|
+
identifier = payload.fetch("id")
|
|
43
|
+
@channel.send_text(JSON.generate(payload))
|
|
44
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
until @responses.key?(identifier)
|
|
47
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
raise TimeoutError, "WebRTC response timed out" unless remaining.positive?
|
|
49
|
+
@condition.wait(@mutex, remaining)
|
|
50
|
+
end
|
|
51
|
+
@responses.delete(identifier)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def close
|
|
56
|
+
@channel.close if @channel
|
|
57
|
+
@channel.destroy if @channel&.respond_to?(:destroy)
|
|
58
|
+
@connection.close if @connection
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def install_candidate_handler
|
|
66
|
+
@connection.on_ice_candidate do |candidate|
|
|
67
|
+
@mutex.synchronize { @candidates << candidate } if candidate
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def install_channel_handlers
|
|
72
|
+
@channel_open = false
|
|
73
|
+
@channel.on_open do
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@channel_open = true
|
|
76
|
+
@condition.broadcast
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
@channel.on_message do |message|
|
|
80
|
+
envelope = JSON.parse(message.data)
|
|
81
|
+
identifier = envelope["id"]
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@responses[identifier] = envelope.key?("result") ? envelope["result"] : envelope
|
|
84
|
+
@condition.broadcast
|
|
85
|
+
end
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wait_for_ice_gathering
|
|
92
|
+
return unless @connection.respond_to?(:on_ice_gathering_state_change)
|
|
93
|
+
|
|
94
|
+
complete = @connection.ice_gathering_state == :complete
|
|
95
|
+
@connection.on_ice_gathering_state_change do
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
complete = @connection.ice_gathering_state == :complete
|
|
98
|
+
@condition.broadcast if complete
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
wait_for_flag(@template.timeout) { complete }
|
|
102
|
+
rescue TimeoutError
|
|
103
|
+
# Trickle ICE remains valid when a backend does not expose a reliable gathering event.
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_for_channel
|
|
108
|
+
wait_for_flag(@template.timeout) { @channel_open || @channel.ready_state == :open }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def wait_for_flag(timeout)
|
|
112
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
until yield
|
|
115
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
raise TimeoutError, "WebRTC connection timed out" unless remaining.positive?
|
|
117
|
+
@condition.wait(@mutex, remaining)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def post_candidate(candidate)
|
|
123
|
+
value = candidate.respond_to?(:to_h) ? candidate.to_h : candidate
|
|
124
|
+
post_json("candidate", "peer_id" => @template.peer_id, "candidate" => value)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def post_json(path, payload)
|
|
130
|
+
url = "#{@template.signaling_server.sub(%r{/+\z}, "")}/#{path}"
|
|
131
|
+
uri = URLSecurity.validate!(url, context: "WebRTC signaling")
|
|
132
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
133
|
+
request["Content-Type"] = "application/json"
|
|
134
|
+
request.body = JSON.generate(payload)
|
|
135
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
136
|
+
http.use_ssl = uri.scheme == "https"
|
|
137
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
138
|
+
http.open_timeout = [@template.timeout, 10].min
|
|
139
|
+
http.read_timeout = @template.timeout
|
|
140
|
+
response = http.start { |connection| connection.request(request) }
|
|
141
|
+
unless response.code.to_i.between?(200, 299)
|
|
142
|
+
raise ToolCallError.new("WebRTC signaling failed with status #{response.code}",
|
|
143
|
+
status: response.code.to_i, response_body: response.body)
|
|
144
|
+
end
|
|
145
|
+
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
146
|
+
rescue JSON::ParserError => error
|
|
147
|
+
raise SerializerValidationError, "Invalid WebRTC signaling response: #{error.message}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class WebRTCProtocol < CommunicationProtocol
|
|
152
|
+
def initialize(peer_factory: nil)
|
|
153
|
+
@peer_factory = peer_factory || ->(template) { WebRTCPeer.new(template) }
|
|
154
|
+
@peers = {}
|
|
155
|
+
@mutex = Mutex.new
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def register_manual(client, template)
|
|
159
|
+
assert_webrtc_template!(template)
|
|
160
|
+
response = peer_for(template).connect
|
|
161
|
+
payload = if response.is_a?(Hash) && response.key?("tools") && !response.key?("utcp_version")
|
|
162
|
+
{
|
|
163
|
+
"utcp_version" => VERSION,
|
|
164
|
+
"manual_version" => "1.0.0",
|
|
165
|
+
"tools" => response["tools"]
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
response
|
|
169
|
+
end
|
|
170
|
+
success(template, manual_from_payload(template, payload, source: "WebRTC signaling response"))
|
|
171
|
+
rescue StandardError => error
|
|
172
|
+
client.logger.warn("Unable to register WebRTC manual #{template.name.inspect}: #{error.message}")
|
|
173
|
+
failure(template, error)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def deregister_manual(_client, template)
|
|
177
|
+
peer = @mutex.synchronize { @peers.delete(peer_key(template)) }
|
|
178
|
+
peer&.close
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def call_tool(_client, tool_name, tool_args, template)
|
|
183
|
+
assert_webrtc_template!(template)
|
|
184
|
+
identifier = SecureRandom.uuid
|
|
185
|
+
peer_for(template).request(
|
|
186
|
+
{
|
|
187
|
+
"id" => identifier,
|
|
188
|
+
"tool" => tool_name.to_s.split(".").last,
|
|
189
|
+
"args" => Utils.stringify_keys(tool_args || {})
|
|
190
|
+
},
|
|
191
|
+
timeout: template.timeout
|
|
192
|
+
)
|
|
193
|
+
rescue Error
|
|
194
|
+
raise
|
|
195
|
+
rescue StandardError => error
|
|
196
|
+
raise ToolCallError.new("WebRTC tool #{tool_name.inspect} failed: #{error.message}", tool_name: tool_name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def assert_webrtc_template!(template)
|
|
202
|
+
return if template.is_a?(WebRtcCallTemplate)
|
|
203
|
+
|
|
204
|
+
raise ValidationError, "WebRTC protocol requires a WebRtcCallTemplate"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def peer_for(template)
|
|
208
|
+
@mutex.synchronize { @peers[peer_key(template)] ||= @peer_factory.call(template) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def peer_key(template)
|
|
212
|
+
[template.name, template.signaling_server, template.peer_id, template.data_channel_name].join("\0")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
WebrtcCommunicationProtocol = WebRTCProtocol
|
|
216
|
+
WebRTCCommunicationProtocol = WebRTCProtocol
|
|
217
|
+
end
|