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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. 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