signalwire-sdk 2.0.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/LICENSE +21 -0
- data/README.md +259 -0
- data/bin/swaig-test +872 -0
- data/lib/signalwire/agent/agent_base.rb +2134 -0
- data/lib/signalwire/contexts/context_builder.rb +861 -0
- data/lib/signalwire/core/logging_config.rb +54 -0
- data/lib/signalwire/datamap/data_map.rb +315 -0
- data/lib/signalwire/logging.rb +92 -0
- data/lib/signalwire/pom/prompt_object_model.rb +269 -0
- data/lib/signalwire/pom/section.rb +202 -0
- data/lib/signalwire/prefabs/concierge.rb +92 -0
- data/lib/signalwire/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire/prefabs/receptionist.rb +74 -0
- data/lib/signalwire/prefabs/survey.rb +75 -0
- data/lib/signalwire/relay/action.rb +291 -0
- data/lib/signalwire/relay/call.rb +523 -0
- data/lib/signalwire/relay/client.rb +789 -0
- data/lib/signalwire/relay/constants.rb +124 -0
- data/lib/signalwire/relay/message.rb +137 -0
- data/lib/signalwire/relay/relay_event.rb +670 -0
- data/lib/signalwire/rest/http_client.rb +159 -0
- data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
- data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
- data/lib/signalwire/rest/namespaces/project.rb +33 -0
- data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire/rest/namespaces/video.rb +129 -0
- data/lib/signalwire/rest/pagination.rb +89 -0
- data/lib/signalwire/rest/phone_call_handler.rb +56 -0
- data/lib/signalwire/rest/rest_client.rb +114 -0
- data/lib/signalwire/runtime.rb +98 -0
- data/lib/signalwire/security/session_manager.rb +124 -0
- data/lib/signalwire/security/webhook_middleware.rb +191 -0
- data/lib/signalwire/security/webhook_validator.rb +327 -0
- data/lib/signalwire/server/agent_server.rb +413 -0
- data/lib/signalwire/serverless/lambda_handler.rb +251 -0
- data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
- data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
- data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire/skills/builtin/joke.rb +65 -0
- data/lib/signalwire/skills/builtin/math.rb +176 -0
- data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire/skills/builtin/spider.rb +169 -0
- data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
- data/lib/signalwire/skills/builtin/web_search.rb +141 -0
- data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
- data/lib/signalwire/skills/skill_base.rb +82 -0
- data/lib/signalwire/skills/skill_manager.rb +97 -0
- data/lib/signalwire/skills/skill_registry.rb +258 -0
- data/lib/signalwire/swaig/function_result.rb +777 -0
- data/lib/signalwire/swml/document.rb +84 -0
- data/lib/signalwire/swml/schema.json +12250 -0
- data/lib/signalwire/swml/schema.rb +81 -0
- data/lib/signalwire/swml/service.rb +650 -0
- data/lib/signalwire/utils/schema_utils.rb +298 -0
- data/lib/signalwire/utils/serverless.rb +19 -0
- data/lib/signalwire/utils/url_validator.rb +138 -0
- data/lib/signalwire/version.rb +5 -0
- data/lib/signalwire.rb +114 -0
- metadata +225 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'websocket-client-simple'
|
|
6
|
+
|
|
7
|
+
require_relative 'constants'
|
|
8
|
+
require_relative 'relay_event'
|
|
9
|
+
require_relative 'action'
|
|
10
|
+
require_relative 'call'
|
|
11
|
+
require_relative 'message'
|
|
12
|
+
|
|
13
|
+
module SignalWire
|
|
14
|
+
module Relay
|
|
15
|
+
# Raised for RELAY JSON-RPC errors.
|
|
16
|
+
class RelayError < StandardError
|
|
17
|
+
attr_reader :code, :error_message
|
|
18
|
+
|
|
19
|
+
def initialize(code, message)
|
|
20
|
+
@code = code
|
|
21
|
+
@error_message = message
|
|
22
|
+
super("RELAY error #{code}: #{message}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# RelayClient -- WebSocket + JSON-RPC 2.0 protocol + event dispatch.
|
|
27
|
+
#
|
|
28
|
+
# One instance = one persistent WebSocket connection to SignalWire RELAY.
|
|
29
|
+
#
|
|
30
|
+
# Implements the 4 correlation mechanisms:
|
|
31
|
+
# 1. JSON-RPC id -> pending hash with ConditionVariable
|
|
32
|
+
# 2. call_id -> Call routing
|
|
33
|
+
# 3. control_id -> Action tracking per Call
|
|
34
|
+
# 4. tag -> dial correlation
|
|
35
|
+
class Client
|
|
36
|
+
attr_reader :project_id, :protocol, :host, :max_active_calls
|
|
37
|
+
|
|
38
|
+
# Python parity:
|
|
39
|
+
# ``RelayClient(project=None, token=None, jwt_token=None,
|
|
40
|
+
# host=None, contexts=None, max_active_calls=None)``. Ruby v1
|
|
41
|
+
# accepted ``space:`` for the same purpose; both keyword names
|
|
42
|
+
# are honoured for backwards compat. ``host`` is the canonical
|
|
43
|
+
# Python name and now drives the WebSocket endpoint.
|
|
44
|
+
#
|
|
45
|
+
# @param project [String, nil] project ID (env: SIGNALWIRE_PROJECT_ID)
|
|
46
|
+
# @param token [String, nil] API token (env: SIGNALWIRE_API_TOKEN)
|
|
47
|
+
# @param jwt_token [String, nil] JWT token alternative
|
|
48
|
+
# @param host [String, nil] RELAY host (env: SIGNALWIRE_SPACE).
|
|
49
|
+
# Either a bare space subdomain (``myspace``) or full hostname
|
|
50
|
+
# (``myspace.signalwire.com``).
|
|
51
|
+
# @param contexts [Array<String>] context names to subscribe to
|
|
52
|
+
# @param max_active_calls [Integer, nil] cap on simultaneous
|
|
53
|
+
# active inbound calls. ``nil`` means unlimited (Python parity:
|
|
54
|
+
# matches ``RELAY_MAX_ACTIVE_CALLS`` env override).
|
|
55
|
+
# @param space [String, nil] backwards-compat alias for ``host``.
|
|
56
|
+
def initialize(project: nil, token: nil, jwt_token: nil, host: nil,
|
|
57
|
+
contexts: ['default'], max_active_calls: nil,
|
|
58
|
+
space: nil)
|
|
59
|
+
@project_id = project || ENV['SIGNALWIRE_PROJECT_ID'] || ''
|
|
60
|
+
@token = token || ENV['SIGNALWIRE_API_TOKEN'] || ''
|
|
61
|
+
@jwt_token = jwt_token
|
|
62
|
+
# Accept either `host:` (Python parity) or legacy `space:`.
|
|
63
|
+
host_arg = host || space
|
|
64
|
+
@space = host_arg || ENV['SIGNALWIRE_SPACE'] || ''
|
|
65
|
+
@contexts = contexts
|
|
66
|
+
|
|
67
|
+
# Python parity: max_active_calls override + RELAY_MAX_ACTIVE_CALLS env.
|
|
68
|
+
if max_active_calls.nil?
|
|
69
|
+
env_val = ENV['RELAY_MAX_ACTIVE_CALLS']
|
|
70
|
+
@max_active_calls = env_val && !env_val.empty? ? Integer(env_val) : nil
|
|
71
|
+
else
|
|
72
|
+
@max_active_calls = [1, Integer(max_active_calls)].max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, 'project is required' if @project_id.empty?
|
|
76
|
+
if @token.empty? && @jwt_token.nil?
|
|
77
|
+
raise ArgumentError, 'token or jwt_token is required'
|
|
78
|
+
end
|
|
79
|
+
raise ArgumentError, 'host is required' if @space.empty?
|
|
80
|
+
|
|
81
|
+
@host = @space.include?('.') ? @space : "#{@space}.signalwire.com"
|
|
82
|
+
|
|
83
|
+
# Correlation mechanisms
|
|
84
|
+
@pending = {} # id -> { mutex:, cv:, result:, error: }
|
|
85
|
+
@pending_mutex = Mutex.new
|
|
86
|
+
@calls = {} # call_id -> Call
|
|
87
|
+
@calls_mutex = Mutex.new
|
|
88
|
+
@pending_dials = {} # tag -> { mutex:, cv:, call:, error: }
|
|
89
|
+
@dials_mutex = Mutex.new
|
|
90
|
+
@messages = {} # message_id -> Message
|
|
91
|
+
@messages_mutex = Mutex.new
|
|
92
|
+
|
|
93
|
+
# Session state
|
|
94
|
+
@protocol = nil
|
|
95
|
+
@authorization_state = nil
|
|
96
|
+
@ws = nil
|
|
97
|
+
@running = false
|
|
98
|
+
@connected = false
|
|
99
|
+
@ws_mutex = Mutex.new
|
|
100
|
+
|
|
101
|
+
# Handlers
|
|
102
|
+
@on_call_handler = nil
|
|
103
|
+
@on_message_handler = nil
|
|
104
|
+
@on_event_handler = nil
|
|
105
|
+
|
|
106
|
+
# Reconnection
|
|
107
|
+
@reconnect_delay = RECONNECT_MIN_DELAY
|
|
108
|
+
@should_restart = false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Register inbound call handler.
|
|
112
|
+
def on_call(&block)
|
|
113
|
+
@on_call_handler = block
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Register inbound message handler.
|
|
117
|
+
def on_message(&block)
|
|
118
|
+
@on_message_handler = block
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Register a generic inbound-event handler. Called for every
|
|
122
|
+
# +signalwire.event+ frame BEFORE the type-specific handlers
|
|
123
|
+
# (call/message/dial) run. Used by integration probes (e.g. the
|
|
124
|
+
# audit harness) that need to react to raw events.
|
|
125
|
+
def on_event(&block)
|
|
126
|
+
@on_event_handler = block
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Send an arbitrary JSON-RPC frame to the server. Public surface for
|
|
130
|
+
# tests, the audit harness, and one-off RELAY methods that don't
|
|
131
|
+
# have a high-level wrapper. Returns nothing; outbound failures are
|
|
132
|
+
# silently ignored (matching +_send_json+ semantics).
|
|
133
|
+
def send_json(msg)
|
|
134
|
+
_send_json(msg)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return the current call_id -> Call registry (a snapshot copy).
|
|
138
|
+
# Test/audit-only surface for asserting on internal routing state;
|
|
139
|
+
# the Python reference exposes the same via +RelayClient._calls+.
|
|
140
|
+
def _calls_snapshot
|
|
141
|
+
@calls_mutex.synchronize { @calls.dup }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Test/reconnect surface: stamp a previously issued protocol
|
|
145
|
+
# string before calling +run+ so the next signalwire.connect frame
|
|
146
|
+
# carries it (the production server replies with
|
|
147
|
+
# +session_restored: true+). Mirrors Python's +RelayClient._relay_protocol = ...+.
|
|
148
|
+
def _set_protocol(value)
|
|
149
|
+
@protocol = value
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Return the SDK's tracked authorization-state blob (Python parity:
|
|
153
|
+
# +RelayClient._authorization_state+). Captured from
|
|
154
|
+
# +signalwire.authorization.state+ events for use on reconnect.
|
|
155
|
+
def _authorization_state
|
|
156
|
+
@authorization_state
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# True when the client believes the WebSocket is open. Exposed for
|
|
160
|
+
# tests that need to assert the recv loop is still alive after an
|
|
161
|
+
# injected error / handler exception.
|
|
162
|
+
def _connected?
|
|
163
|
+
@ws_mutex.synchronize { @connected }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Connect, authenticate, subscribe, and enter the read loop.
|
|
167
|
+
# Blocks until stop is called.
|
|
168
|
+
def run
|
|
169
|
+
@running = true
|
|
170
|
+
while @running
|
|
171
|
+
begin
|
|
172
|
+
_connect_and_run
|
|
173
|
+
rescue => e
|
|
174
|
+
$stderr.puts "[RELAY] Connection error: #{e.message}"
|
|
175
|
+
end
|
|
176
|
+
break unless @running
|
|
177
|
+
|
|
178
|
+
# Reject all pending requests
|
|
179
|
+
_reject_all_pending('Disconnected')
|
|
180
|
+
|
|
181
|
+
# Exponential backoff reconnect
|
|
182
|
+
$stderr.puts "[RELAY] Reconnecting in #{@reconnect_delay}s..."
|
|
183
|
+
sleep(@reconnect_delay)
|
|
184
|
+
@reconnect_delay = [
|
|
185
|
+
@reconnect_delay * RECONNECT_BACKOFF_FACTOR,
|
|
186
|
+
RECONNECT_MAX_DELAY
|
|
187
|
+
].min
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Graceful shutdown.
|
|
192
|
+
def stop
|
|
193
|
+
@running = false
|
|
194
|
+
# Snapshot under the mutex, close outside it. The websocket-client
|
|
195
|
+
# gem fires the `:close` callback synchronously inside `close`,
|
|
196
|
+
# which re-enters _on_ws_close → tries to take @ws_mutex and
|
|
197
|
+
# deadlocks if we're still holding it.
|
|
198
|
+
ws_to_close = nil
|
|
199
|
+
@ws_mutex.synchronize do
|
|
200
|
+
ws_to_close = @ws if @connected
|
|
201
|
+
end
|
|
202
|
+
ws_to_close&.close
|
|
203
|
+
_reject_all_pending('Client stopped')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
# Outbound dial
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
# Dial outbound call(s). Returns a Call object.
|
|
211
|
+
def dial(devices, timeout: 120, tag: nil, **kwargs)
|
|
212
|
+
dial_tag = tag || SecureRandom.uuid
|
|
213
|
+
|
|
214
|
+
# Register pending dial BEFORE sending RPC
|
|
215
|
+
entry = { mutex: Mutex.new, cv: ConditionVariable.new, call: nil, error: nil }
|
|
216
|
+
@dials_mutex.synchronize { @pending_dials[dial_tag] = entry }
|
|
217
|
+
|
|
218
|
+
begin
|
|
219
|
+
params = { 'tag' => dial_tag, 'devices' => devices }
|
|
220
|
+
kwargs.each { |k, v| params[k.to_s] = v }
|
|
221
|
+
execute('calling.dial', params)
|
|
222
|
+
rescue => e
|
|
223
|
+
@dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
|
|
224
|
+
raise
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Wait for calling.call.dial event
|
|
228
|
+
entry[:mutex].synchronize do
|
|
229
|
+
deadline = Time.now + timeout
|
|
230
|
+
while entry[:call].nil? && entry[:error].nil?
|
|
231
|
+
remaining = deadline - Time.now
|
|
232
|
+
if remaining <= 0
|
|
233
|
+
@dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
|
|
234
|
+
raise ActionTimeoutError, "Dial timed out after #{timeout}s"
|
|
235
|
+
end
|
|
236
|
+
entry[:cv].wait(entry[:mutex], remaining)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
@dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
|
|
241
|
+
raise RelayError.new(-1, entry[:error]) if entry[:error]
|
|
242
|
+
entry[:call]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Outbound message
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
# Send an SMS/MMS message. Returns a Message object.
|
|
250
|
+
#
|
|
251
|
+
# Mirrors Python's RelayClient.send_message keyword-only signature
|
|
252
|
+
# exactly. At least one of body: or media: is required.
|
|
253
|
+
def send_message(to_number:, from_number:, context: nil, body: nil,
|
|
254
|
+
media: nil, tags: nil, region: nil, on_completed: nil)
|
|
255
|
+
if (body.nil? || body.empty?) && (media.nil? || media.empty?)
|
|
256
|
+
raise ArgumentError, 'body or media is required'
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
msg_context = context || @contexts.first || 'default'
|
|
260
|
+
params = {
|
|
261
|
+
'context' => msg_context,
|
|
262
|
+
'to_number' => to_number,
|
|
263
|
+
'from_number' => from_number
|
|
264
|
+
}
|
|
265
|
+
params['body'] = body if body
|
|
266
|
+
params['media'] = media if media
|
|
267
|
+
params['tags'] = tags if tags
|
|
268
|
+
params['region'] = region if region
|
|
269
|
+
|
|
270
|
+
result = execute('messaging.send', params)
|
|
271
|
+
message_id = result['message_id'] || ''
|
|
272
|
+
|
|
273
|
+
msg = Message.new(
|
|
274
|
+
message_id: message_id,
|
|
275
|
+
context: msg_context,
|
|
276
|
+
direction: 'outbound',
|
|
277
|
+
from_number: from_number,
|
|
278
|
+
to_number: to_number,
|
|
279
|
+
body: body || '',
|
|
280
|
+
media: media || [],
|
|
281
|
+
state: 'queued',
|
|
282
|
+
tags: tags || []
|
|
283
|
+
)
|
|
284
|
+
msg._set_on_completed(on_completed) if on_completed
|
|
285
|
+
@messages_mutex.synchronize { @messages[message_id] = msg } unless message_id.empty?
|
|
286
|
+
msg
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
# Dynamic context subscription
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def receive(contexts)
|
|
294
|
+
execute('signalwire.receive', { 'contexts' => contexts })
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def unreceive(contexts)
|
|
298
|
+
execute('signalwire.unreceive', { 'contexts' => contexts })
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# JSON-RPC execute
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
# Send a JSON-RPC request and wait for the response.
|
|
306
|
+
# Returns the result hash. Raises RelayError on error.
|
|
307
|
+
def execute(method, params = {})
|
|
308
|
+
id = SecureRandom.uuid
|
|
309
|
+
|
|
310
|
+
# Add protocol to params if we have one (except for signalwire.connect)
|
|
311
|
+
if @protocol && method != METHOD_SIGNALWIRE_CONNECT
|
|
312
|
+
params = params.dup
|
|
313
|
+
params['protocol'] = @protocol
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
msg = {
|
|
317
|
+
'jsonrpc' => '2.0',
|
|
318
|
+
'id' => id,
|
|
319
|
+
'method' => method,
|
|
320
|
+
'params' => params
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
entry = { mutex: Mutex.new, cv: ConditionVariable.new, result: nil, error: nil }
|
|
324
|
+
@pending_mutex.synchronize { @pending[id] = entry }
|
|
325
|
+
|
|
326
|
+
_send_json(msg)
|
|
327
|
+
|
|
328
|
+
# Wait for response (10s timeout to detect half-open connections)
|
|
329
|
+
entry[:mutex].synchronize do
|
|
330
|
+
deadline = Time.now + 10
|
|
331
|
+
while entry[:result].nil? && entry[:error].nil?
|
|
332
|
+
remaining = deadline - Time.now
|
|
333
|
+
if remaining <= 0
|
|
334
|
+
@pending_mutex.synchronize { @pending.delete(id) }
|
|
335
|
+
raise RelayError.new(-1, "Request #{method} timed out")
|
|
336
|
+
end
|
|
337
|
+
entry[:cv].wait(entry[:mutex], remaining)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
@pending_mutex.synchronize { @pending.delete(id) }
|
|
342
|
+
raise entry[:error] if entry[:error]
|
|
343
|
+
|
|
344
|
+
result = entry[:result]
|
|
345
|
+
|
|
346
|
+
# Check result code for non-connect methods
|
|
347
|
+
if method != METHOD_SIGNALWIRE_CONNECT
|
|
348
|
+
code = result['code']
|
|
349
|
+
if code && !code.to_s.match?(/\A2\d\d\z/)
|
|
350
|
+
raise RelayError.new(code, result['message'] || 'Unknown error')
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
result
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
# ------------------------------------------------------------------
|
|
360
|
+
# WebSocket connection lifecycle
|
|
361
|
+
# ------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _connect_and_run
|
|
364
|
+
# In production we connect to wss://{space}. The audit fixture
|
|
365
|
+
# binds an ephemeral port on 127.0.0.1 and serves plain ws://; the
|
|
366
|
+
# SIGNALWIRE_RELAY_HOST and SIGNALWIRE_RELAY_SCHEME env vars let
|
|
367
|
+
# the audit harness redirect the client there without touching the
|
|
368
|
+
# production credential resolution.
|
|
369
|
+
scheme = ENV['SIGNALWIRE_RELAY_SCHEME']
|
|
370
|
+
scheme = scheme.nil? || scheme.empty? ? 'wss' : scheme
|
|
371
|
+
host_override = ENV['SIGNALWIRE_RELAY_HOST']
|
|
372
|
+
endpoint_host = (host_override.nil? || host_override.empty?) ? @host : host_override
|
|
373
|
+
url = "#{scheme}://#{endpoint_host}"
|
|
374
|
+
ready_mutex = Mutex.new
|
|
375
|
+
ready_cv = ConditionVariable.new
|
|
376
|
+
ready_flag = false
|
|
377
|
+
ws_error = nil
|
|
378
|
+
|
|
379
|
+
client_ref = self
|
|
380
|
+
|
|
381
|
+
@ws = WebSocket::Client::Simple.connect(url) do |ws|
|
|
382
|
+
ws.on :open do
|
|
383
|
+
client_ref.send(:_on_ws_open)
|
|
384
|
+
ready_mutex.synchronize do
|
|
385
|
+
ready_flag = true
|
|
386
|
+
ready_cv.signal
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
ws.on :message do |msg|
|
|
391
|
+
client_ref.send(:_on_ws_message, msg.data)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
ws.on :error do |e|
|
|
395
|
+
ws_error = e
|
|
396
|
+
ready_mutex.synchronize do
|
|
397
|
+
ready_flag = true
|
|
398
|
+
ready_cv.signal
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
ws.on :close do |_e|
|
|
403
|
+
client_ref.send(:_on_ws_close)
|
|
404
|
+
ready_mutex.synchronize do
|
|
405
|
+
ready_flag = true
|
|
406
|
+
ready_cv.signal
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Wait for connection to open
|
|
412
|
+
ready_mutex.synchronize do
|
|
413
|
+
ready_cv.wait(ready_mutex, 15) until ready_flag
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
raise ws_error if ws_error
|
|
417
|
+
|
|
418
|
+
@ws_mutex.synchronize { @connected = true }
|
|
419
|
+
@reconnect_delay = RECONNECT_MIN_DELAY
|
|
420
|
+
|
|
421
|
+
# Authenticate
|
|
422
|
+
_authenticate
|
|
423
|
+
|
|
424
|
+
# Keep reading until disconnected
|
|
425
|
+
while @running && @connected
|
|
426
|
+
sleep 1
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def _on_ws_open
|
|
431
|
+
# Connection opened
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def _on_ws_message(data)
|
|
435
|
+
return if data.nil? || data.empty?
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
msg = JSON.parse(data)
|
|
439
|
+
rescue JSON::ParserError => e
|
|
440
|
+
$stderr.puts "[RELAY] Failed to parse message: #{e.message}"
|
|
441
|
+
return
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
_handle_message(msg)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def _on_ws_close
|
|
448
|
+
@ws_mutex.synchronize { @connected = false }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def _send_json(msg)
|
|
452
|
+
@ws_mutex.synchronize do
|
|
453
|
+
return unless @ws && @connected
|
|
454
|
+
|
|
455
|
+
@ws.send(JSON.generate(msg))
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
# Authentication
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def _authenticate
|
|
464
|
+
params = {
|
|
465
|
+
'version' => PROTOCOL_VERSION,
|
|
466
|
+
'agent' => AGENT_STRING,
|
|
467
|
+
'event_acks' => true
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if @jwt_token
|
|
471
|
+
params['authentication'] = { 'jwt_token' => @jwt_token }
|
|
472
|
+
else
|
|
473
|
+
params['authentication'] = {
|
|
474
|
+
'project' => @project_id,
|
|
475
|
+
'token' => @token
|
|
476
|
+
}
|
|
477
|
+
# Audit fixtures and Blade-aware servers also accept the
|
|
478
|
+
# credentials at the top level. Python's RELAY emits them in
|
|
479
|
+
# `authentication`; the audit harness watches the top level.
|
|
480
|
+
# Emit both to satisfy both consumers.
|
|
481
|
+
params['project'] = @project_id
|
|
482
|
+
params['token'] = @token
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
params['contexts'] = @contexts unless @contexts.empty?
|
|
486
|
+
params['protocol'] = @protocol if @protocol && !@should_restart
|
|
487
|
+
params['authorization_state'] = @authorization_state if @authorization_state && !@should_restart
|
|
488
|
+
|
|
489
|
+
if @should_restart
|
|
490
|
+
@protocol = nil
|
|
491
|
+
@authorization_state = nil
|
|
492
|
+
@should_restart = false
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
result = execute(METHOD_SIGNALWIRE_CONNECT, params)
|
|
496
|
+
@protocol = result['protocol'] if result['protocol']
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# ------------------------------------------------------------------
|
|
500
|
+
# Message dispatch
|
|
501
|
+
# ------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def _handle_message(msg)
|
|
504
|
+
method = msg['method']
|
|
505
|
+
id = msg['id']
|
|
506
|
+
|
|
507
|
+
if method.nil?
|
|
508
|
+
# This is a response to a pending request
|
|
509
|
+
_handle_response(msg)
|
|
510
|
+
return
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
case method
|
|
514
|
+
when METHOD_SIGNALWIRE_EVENT
|
|
515
|
+
_handle_event(msg)
|
|
516
|
+
when METHOD_SIGNALWIRE_PING
|
|
517
|
+
_send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} })
|
|
518
|
+
when METHOD_SIGNALWIRE_DISCONNECT
|
|
519
|
+
_handle_disconnect(msg)
|
|
520
|
+
else
|
|
521
|
+
# Unknown method, send empty result
|
|
522
|
+
_send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def _handle_response(msg)
|
|
527
|
+
id = msg['id']
|
|
528
|
+
return unless id
|
|
529
|
+
|
|
530
|
+
entry = @pending_mutex.synchronize { @pending[id] }
|
|
531
|
+
return unless entry
|
|
532
|
+
|
|
533
|
+
if msg['error']
|
|
534
|
+
err = msg['error']
|
|
535
|
+
entry[:mutex].synchronize do
|
|
536
|
+
entry[:error] = RelayError.new(err['code'], err['message'] || 'Unknown error')
|
|
537
|
+
entry[:cv].signal
|
|
538
|
+
end
|
|
539
|
+
else
|
|
540
|
+
entry[:mutex].synchronize do
|
|
541
|
+
entry[:result] = msg['result'] || {}
|
|
542
|
+
entry[:cv].signal
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def _handle_event(msg)
|
|
548
|
+
id = msg['id']
|
|
549
|
+
outer_params = msg['params'] || {}
|
|
550
|
+
|
|
551
|
+
# ACK the event immediately
|
|
552
|
+
_send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
|
|
553
|
+
|
|
554
|
+
event_type = outer_params['event_type'] || ''
|
|
555
|
+
event_params = outer_params['params'] || {}
|
|
556
|
+
call_id = event_params['call_id'] || ''
|
|
557
|
+
|
|
558
|
+
# Generic event hook (audit harnesses, integration tests). Runs
|
|
559
|
+
# BEFORE type-specific dispatch so a probe can observe every
|
|
560
|
+
# event the SDK actually saw.
|
|
561
|
+
if @on_event_handler
|
|
562
|
+
begin
|
|
563
|
+
@on_event_handler.call(event_type, event_params, outer_params)
|
|
564
|
+
rescue => e
|
|
565
|
+
$stderr.puts "[RELAY] Error in on_event handler: #{e.message}"
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Authorization state
|
|
570
|
+
if event_type == EVENT_AUTHORIZATION_STATE
|
|
571
|
+
@authorization_state = event_params['authorization_state']
|
|
572
|
+
return
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Inbound call
|
|
576
|
+
if event_type == EVENT_CALL_RECEIVE
|
|
577
|
+
_handle_inbound_call(outer_params)
|
|
578
|
+
return
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Dial completion -- call_id is NESTED at params.call.call_id
|
|
582
|
+
if event_type == EVENT_CALL_DIAL
|
|
583
|
+
_handle_dial_event(outer_params)
|
|
584
|
+
return
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Inbound message
|
|
588
|
+
if event_type == EVENT_MESSAGING_RECEIVE
|
|
589
|
+
_handle_inbound_message(outer_params)
|
|
590
|
+
return
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Outbound message state
|
|
594
|
+
if event_type == EVENT_MESSAGING_STATE
|
|
595
|
+
_handle_message_state(outer_params)
|
|
596
|
+
return
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# State events during dial -- call not registered yet
|
|
600
|
+
if event_type == EVENT_CALL_STATE
|
|
601
|
+
tag = event_params['tag'] || ''
|
|
602
|
+
has_pending = @dials_mutex.synchronize { @pending_dials.key?(tag) }
|
|
603
|
+
if !tag.empty? && has_pending
|
|
604
|
+
has_call = @calls_mutex.synchronize { @calls.key?(call_id) }
|
|
605
|
+
unless has_call || call_id.empty?
|
|
606
|
+
_register_dial_leg(tag, event_params)
|
|
607
|
+
end
|
|
608
|
+
end
|
|
609
|
+
# Fall through to normal routing
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Normal routing by call_id
|
|
613
|
+
unless call_id.empty?
|
|
614
|
+
call = @calls_mutex.synchronize { @calls[call_id] }
|
|
615
|
+
if call
|
|
616
|
+
call._dispatch_event(outer_params)
|
|
617
|
+
if call.state == CALL_STATE_ENDED
|
|
618
|
+
@calls_mutex.synchronize { @calls.delete(call_id) }
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def _handle_disconnect(msg)
|
|
625
|
+
id = msg['id']
|
|
626
|
+
params = msg['params'] || {}
|
|
627
|
+
|
|
628
|
+
# Respond with empty result
|
|
629
|
+
_send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
|
|
630
|
+
|
|
631
|
+
# Check restart flag
|
|
632
|
+
@should_restart = params['restart'] == true
|
|
633
|
+
|
|
634
|
+
# Let the connection close, reconnect will happen automatically
|
|
635
|
+
@ws_mutex.synchronize { @connected = false }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def _handle_inbound_call(payload)
|
|
639
|
+
event_params = payload['params'] || {}
|
|
640
|
+
call = Call.new(
|
|
641
|
+
self,
|
|
642
|
+
call_id: event_params['call_id'] || '',
|
|
643
|
+
node_id: event_params['node_id'] || '',
|
|
644
|
+
project_id: event_params['project_id'] || '',
|
|
645
|
+
context: event_params['context'] || event_params['protocol'] || '',
|
|
646
|
+
tag: event_params['tag'] || '',
|
|
647
|
+
direction: event_params['direction'] || 'inbound',
|
|
648
|
+
device: event_params['device'] || {},
|
|
649
|
+
state: event_params['call_state'] || '',
|
|
650
|
+
segment_id: event_params['segment_id'] || ''
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
@calls_mutex.synchronize { @calls[call.call_id] = call }
|
|
654
|
+
|
|
655
|
+
if @on_call_handler
|
|
656
|
+
Thread.new do
|
|
657
|
+
begin
|
|
658
|
+
@on_call_handler.call(call)
|
|
659
|
+
rescue => e
|
|
660
|
+
$stderr.puts "[RELAY] Error in on_call handler: #{e.message}"
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def _handle_dial_event(payload)
|
|
667
|
+
event_params = payload['params'] || {}
|
|
668
|
+
tag = event_params['tag'] || ''
|
|
669
|
+
dial_state = event_params['dial_state'] || ''
|
|
670
|
+
call_info = event_params['call'] || {}
|
|
671
|
+
|
|
672
|
+
entry = @dials_mutex.synchronize { @pending_dials[tag] }
|
|
673
|
+
return unless entry
|
|
674
|
+
|
|
675
|
+
if dial_state == 'answered'
|
|
676
|
+
call_id = call_info['call_id'] || ''
|
|
677
|
+
node_id = call_info['node_id'] || ''
|
|
678
|
+
|
|
679
|
+
# Find or create the call
|
|
680
|
+
call = @calls_mutex.synchronize { @calls[call_id] }
|
|
681
|
+
unless call
|
|
682
|
+
call = Call.new(
|
|
683
|
+
self,
|
|
684
|
+
call_id: call_id,
|
|
685
|
+
node_id: node_id,
|
|
686
|
+
project_id: @project_id,
|
|
687
|
+
tag: call_info['tag'] || tag,
|
|
688
|
+
direction: 'outbound',
|
|
689
|
+
device: call_info['device'] || {},
|
|
690
|
+
state: CALL_STATE_ANSWERED
|
|
691
|
+
)
|
|
692
|
+
@calls_mutex.synchronize { @calls[call_id] = call }
|
|
693
|
+
end
|
|
694
|
+
call.state = CALL_STATE_ANSWERED
|
|
695
|
+
|
|
696
|
+
entry[:mutex].synchronize do
|
|
697
|
+
entry[:call] = call
|
|
698
|
+
entry[:cv].signal
|
|
699
|
+
end
|
|
700
|
+
elsif dial_state == 'failed'
|
|
701
|
+
entry[:mutex].synchronize do
|
|
702
|
+
entry[:error] = 'Dial failed'
|
|
703
|
+
entry[:cv].signal
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def _register_dial_leg(tag, event_params)
|
|
709
|
+
call_id = event_params['call_id'] || ''
|
|
710
|
+
return if call_id.empty?
|
|
711
|
+
|
|
712
|
+
call = Call.new(
|
|
713
|
+
self,
|
|
714
|
+
call_id: call_id,
|
|
715
|
+
node_id: event_params['node_id'] || '',
|
|
716
|
+
project_id: @project_id,
|
|
717
|
+
tag: tag,
|
|
718
|
+
direction: 'outbound',
|
|
719
|
+
device: event_params['device'] || {},
|
|
720
|
+
state: event_params['call_state'] || ''
|
|
721
|
+
)
|
|
722
|
+
@calls_mutex.synchronize { @calls[call_id] = call }
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def _handle_inbound_message(payload)
|
|
726
|
+
event_params = payload['params'] || {}
|
|
727
|
+
msg = Message.new(
|
|
728
|
+
message_id: event_params['message_id'] || '',
|
|
729
|
+
context: event_params['context'] || '',
|
|
730
|
+
direction: 'inbound',
|
|
731
|
+
from_number: event_params['from_number'] || '',
|
|
732
|
+
to_number: event_params['to_number'] || '',
|
|
733
|
+
body: event_params['body'] || '',
|
|
734
|
+
media: event_params['media'] || [],
|
|
735
|
+
segments: event_params['segments'] || 0,
|
|
736
|
+
state: event_params['message_state'] || 'received',
|
|
737
|
+
tags: event_params['tags'] || []
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
if @on_message_handler
|
|
741
|
+
Thread.new do
|
|
742
|
+
begin
|
|
743
|
+
@on_message_handler.call(msg)
|
|
744
|
+
rescue => e
|
|
745
|
+
$stderr.puts "[RELAY] Error in on_message handler: #{e.message}"
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def _handle_message_state(payload)
|
|
752
|
+
event_params = payload['params'] || {}
|
|
753
|
+
message_id = event_params['message_id'] || ''
|
|
754
|
+
|
|
755
|
+
msg = @messages_mutex.synchronize { @messages[message_id] }
|
|
756
|
+
return unless msg
|
|
757
|
+
|
|
758
|
+
msg._dispatch_event(payload)
|
|
759
|
+
|
|
760
|
+
# Clean up terminal messages
|
|
761
|
+
if msg.done?
|
|
762
|
+
@messages_mutex.synchronize { @messages.delete(message_id) }
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def _reject_all_pending(reason)
|
|
767
|
+
@pending_mutex.synchronize do
|
|
768
|
+
@pending.each_value do |entry|
|
|
769
|
+
entry[:mutex].synchronize do
|
|
770
|
+
entry[:error] ||= RelayError.new(-1, reason)
|
|
771
|
+
entry[:cv].signal
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
@pending.clear
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
@dials_mutex.synchronize do
|
|
778
|
+
@pending_dials.each_value do |entry|
|
|
779
|
+
entry[:mutex].synchronize do
|
|
780
|
+
entry[:error] ||= reason
|
|
781
|
+
entry[:cv].signal
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
@pending_dials.clear
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
end
|