signalwire_agents 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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +79 -0
  3. data/bin/swaig-test +310 -0
  4. data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
  5. data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
  6. data/lib/signalwire_agents/datamap/data_map.rb +279 -0
  7. data/lib/signalwire_agents/logging.rb +92 -0
  8. data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
  9. data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
  10. data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
  11. data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
  12. data/lib/signalwire_agents/prefabs/survey.rb +75 -0
  13. data/lib/signalwire_agents/relay/action.rb +291 -0
  14. data/lib/signalwire_agents/relay/call.rb +523 -0
  15. data/lib/signalwire_agents/relay/client.rb +671 -0
  16. data/lib/signalwire_agents/relay/constants.rb +124 -0
  17. data/lib/signalwire_agents/relay/message.rb +137 -0
  18. data/lib/signalwire_agents/relay/relay_event.rb +670 -0
  19. data/lib/signalwire_agents/rest/http_client.rb +147 -0
  20. data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
  21. data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
  22. data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
  23. data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
  24. data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
  25. data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
  26. data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
  27. data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
  28. data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
  29. data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
  30. data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
  31. data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
  32. data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
  33. data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
  34. data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
  35. data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
  36. data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
  37. data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
  38. data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
  39. data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
  40. data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
  41. data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
  42. data/lib/signalwire_agents/security/session_manager.rb +124 -0
  43. data/lib/signalwire_agents/server/agent_server.rb +260 -0
  44. data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
  45. data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
  46. data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
  47. data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
  48. data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
  49. data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
  50. data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
  51. data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
  52. data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
  53. data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
  54. data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
  55. data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
  56. data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
  57. data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
  58. data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
  59. data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
  60. data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
  61. data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
  62. data/lib/signalwire_agents/skills/skill_base.rb +58 -0
  63. data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
  64. data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
  65. data/lib/signalwire_agents/swaig/function_result.rb +777 -0
  66. data/lib/signalwire_agents/swml/document.rb +84 -0
  67. data/lib/signalwire_agents/swml/schema.json +12250 -0
  68. data/lib/signalwire_agents/swml/schema.rb +81 -0
  69. data/lib/signalwire_agents/swml/service.rb +304 -0
  70. data/lib/signalwire_agents/version.rb +5 -0
  71. data/lib/signalwire_agents.rb +19 -0
  72. metadata +212 -0
@@ -0,0 +1,671 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'websocket-client-simple'
6
+
7
+ module SignalWireAgents
8
+ module Relay
9
+ # Raised for RELAY JSON-RPC errors.
10
+ class RelayError < StandardError
11
+ attr_reader :code, :error_message
12
+
13
+ def initialize(code, message)
14
+ @code = code
15
+ @error_message = message
16
+ super("RELAY error #{code}: #{message}")
17
+ end
18
+ end
19
+
20
+ # RelayClient -- WebSocket + JSON-RPC 2.0 protocol + event dispatch.
21
+ #
22
+ # One instance = one persistent WebSocket connection to SignalWire RELAY.
23
+ #
24
+ # Implements the 4 correlation mechanisms:
25
+ # 1. JSON-RPC id -> pending hash with ConditionVariable
26
+ # 2. call_id -> Call routing
27
+ # 3. control_id -> Action tracking per Call
28
+ # 4. tag -> dial correlation
29
+ class Client
30
+ attr_reader :project_id, :protocol
31
+
32
+ def initialize(project: nil, token: nil, jwt_token: nil, space: nil,
33
+ contexts: ['default'])
34
+ @project_id = project || ENV['SIGNALWIRE_PROJECT_ID'] || ''
35
+ @token = token || ENV['SIGNALWIRE_API_TOKEN'] || ''
36
+ @jwt_token = jwt_token
37
+ @space = space || ENV['SIGNALWIRE_SPACE'] || ''
38
+ @contexts = contexts
39
+
40
+ raise ArgumentError, 'project is required' if @project_id.empty?
41
+ if @token.empty? && @jwt_token.nil?
42
+ raise ArgumentError, 'token or jwt_token is required'
43
+ end
44
+ raise ArgumentError, 'space is required' if @space.empty?
45
+
46
+ @host = @space.include?('.') ? @space : "#{@space}.signalwire.com"
47
+
48
+ # Correlation mechanisms
49
+ @pending = {} # id -> { mutex:, cv:, result:, error: }
50
+ @pending_mutex = Mutex.new
51
+ @calls = {} # call_id -> Call
52
+ @calls_mutex = Mutex.new
53
+ @pending_dials = {} # tag -> { mutex:, cv:, call:, error: }
54
+ @dials_mutex = Mutex.new
55
+ @messages = {} # message_id -> Message
56
+ @messages_mutex = Mutex.new
57
+
58
+ # Session state
59
+ @protocol = nil
60
+ @authorization_state = nil
61
+ @ws = nil
62
+ @running = false
63
+ @connected = false
64
+ @ws_mutex = Mutex.new
65
+
66
+ # Handlers
67
+ @on_call_handler = nil
68
+ @on_message_handler = nil
69
+
70
+ # Reconnection
71
+ @reconnect_delay = RECONNECT_MIN_DELAY
72
+ @should_restart = false
73
+ end
74
+
75
+ # Register inbound call handler.
76
+ def on_call(&block)
77
+ @on_call_handler = block
78
+ end
79
+
80
+ # Register inbound message handler.
81
+ def on_message(&block)
82
+ @on_message_handler = block
83
+ end
84
+
85
+ # Connect, authenticate, subscribe, and enter the read loop.
86
+ # Blocks until stop is called.
87
+ def run
88
+ @running = true
89
+ while @running
90
+ begin
91
+ _connect_and_run
92
+ rescue => e
93
+ $stderr.puts "[RELAY] Connection error: #{e.message}"
94
+ end
95
+ break unless @running
96
+
97
+ # Reject all pending requests
98
+ _reject_all_pending('Disconnected')
99
+
100
+ # Exponential backoff reconnect
101
+ $stderr.puts "[RELAY] Reconnecting in #{@reconnect_delay}s..."
102
+ sleep(@reconnect_delay)
103
+ @reconnect_delay = [
104
+ @reconnect_delay * RECONNECT_BACKOFF_FACTOR,
105
+ RECONNECT_MAX_DELAY
106
+ ].min
107
+ end
108
+ end
109
+
110
+ # Graceful shutdown.
111
+ def stop
112
+ @running = false
113
+ @ws_mutex.synchronize do
114
+ @ws&.close if @connected
115
+ end
116
+ _reject_all_pending('Client stopped')
117
+ end
118
+
119
+ # ------------------------------------------------------------------
120
+ # Outbound dial
121
+ # ------------------------------------------------------------------
122
+
123
+ # Dial outbound call(s). Returns a Call object.
124
+ def dial(devices, timeout: 120, tag: nil, **kwargs)
125
+ dial_tag = tag || SecureRandom.uuid
126
+
127
+ # Register pending dial BEFORE sending RPC
128
+ entry = { mutex: Mutex.new, cv: ConditionVariable.new, call: nil, error: nil }
129
+ @dials_mutex.synchronize { @pending_dials[dial_tag] = entry }
130
+
131
+ begin
132
+ params = { 'tag' => dial_tag, 'devices' => devices }
133
+ kwargs.each { |k, v| params[k.to_s] = v }
134
+ execute('calling.dial', params)
135
+ rescue => e
136
+ @dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
137
+ raise
138
+ end
139
+
140
+ # Wait for calling.call.dial event
141
+ entry[:mutex].synchronize do
142
+ deadline = Time.now + timeout
143
+ while entry[:call].nil? && entry[:error].nil?
144
+ remaining = deadline - Time.now
145
+ if remaining <= 0
146
+ @dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
147
+ raise ActionTimeoutError, "Dial timed out after #{timeout}s"
148
+ end
149
+ entry[:cv].wait(entry[:mutex], remaining)
150
+ end
151
+ end
152
+
153
+ @dials_mutex.synchronize { @pending_dials.delete(dial_tag) }
154
+ raise RelayError.new(-1, entry[:error]) if entry[:error]
155
+ entry[:call]
156
+ end
157
+
158
+ # ------------------------------------------------------------------
159
+ # Outbound message
160
+ # ------------------------------------------------------------------
161
+
162
+ # Send an SMS/MMS message. Returns a Message object.
163
+ def send_message(to:, from:, body: nil, media: nil, context: nil,
164
+ tags: nil, on_completed: nil, **kwargs)
165
+ raise ArgumentError, 'body or media is required' if (body.nil? || body.empty?) && (media.nil? || media.empty?)
166
+
167
+ msg_context = context || @contexts.first || 'default'
168
+ params = {
169
+ 'context' => msg_context,
170
+ 'to_number' => to,
171
+ 'from_number' => from
172
+ }
173
+ params['body'] = body if body
174
+ params['media'] = media if media
175
+ params['tags'] = tags if tags
176
+ kwargs.each { |k, v| params[k.to_s] = v }
177
+
178
+ result = execute('messaging.send', params)
179
+ message_id = result['message_id'] || ''
180
+
181
+ msg = Message.new(
182
+ message_id: message_id,
183
+ context: msg_context,
184
+ direction: 'outbound',
185
+ from_number: from,
186
+ to_number: to,
187
+ body: body || '',
188
+ media: media || [],
189
+ state: 'queued',
190
+ tags: tags || []
191
+ )
192
+ msg._set_on_completed(on_completed) if on_completed
193
+ @messages_mutex.synchronize { @messages[message_id] = msg } unless message_id.empty?
194
+ msg
195
+ end
196
+
197
+ # ------------------------------------------------------------------
198
+ # Dynamic context subscription
199
+ # ------------------------------------------------------------------
200
+
201
+ def receive(contexts)
202
+ execute('signalwire.receive', { 'contexts' => contexts })
203
+ end
204
+
205
+ def unreceive(contexts)
206
+ execute('signalwire.unreceive', { 'contexts' => contexts })
207
+ end
208
+
209
+ # ------------------------------------------------------------------
210
+ # JSON-RPC execute
211
+ # ------------------------------------------------------------------
212
+
213
+ # Send a JSON-RPC request and wait for the response.
214
+ # Returns the result hash. Raises RelayError on error.
215
+ def execute(method, params = {})
216
+ id = SecureRandom.uuid
217
+
218
+ # Add protocol to params if we have one (except for signalwire.connect)
219
+ if @protocol && method != METHOD_SIGNALWIRE_CONNECT
220
+ params = params.dup
221
+ params['protocol'] = @protocol
222
+ end
223
+
224
+ msg = {
225
+ 'jsonrpc' => '2.0',
226
+ 'id' => id,
227
+ 'method' => method,
228
+ 'params' => params
229
+ }
230
+
231
+ entry = { mutex: Mutex.new, cv: ConditionVariable.new, result: nil, error: nil }
232
+ @pending_mutex.synchronize { @pending[id] = entry }
233
+
234
+ _send_json(msg)
235
+
236
+ # Wait for response (10s timeout to detect half-open connections)
237
+ entry[:mutex].synchronize do
238
+ deadline = Time.now + 10
239
+ while entry[:result].nil? && entry[:error].nil?
240
+ remaining = deadline - Time.now
241
+ if remaining <= 0
242
+ @pending_mutex.synchronize { @pending.delete(id) }
243
+ raise RelayError.new(-1, "Request #{method} timed out")
244
+ end
245
+ entry[:cv].wait(entry[:mutex], remaining)
246
+ end
247
+ end
248
+
249
+ @pending_mutex.synchronize { @pending.delete(id) }
250
+ raise entry[:error] if entry[:error]
251
+
252
+ result = entry[:result]
253
+
254
+ # Check result code for non-connect methods
255
+ if method != METHOD_SIGNALWIRE_CONNECT
256
+ code = result['code']
257
+ if code && !code.to_s.match?(/\A2\d\d\z/)
258
+ raise RelayError.new(code, result['message'] || 'Unknown error')
259
+ end
260
+ end
261
+
262
+ result
263
+ end
264
+
265
+ private
266
+
267
+ # ------------------------------------------------------------------
268
+ # WebSocket connection lifecycle
269
+ # ------------------------------------------------------------------
270
+
271
+ def _connect_and_run
272
+ url = "wss://#{@host}"
273
+ ready_mutex = Mutex.new
274
+ ready_cv = ConditionVariable.new
275
+ ready_flag = false
276
+ ws_error = nil
277
+
278
+ client_ref = self
279
+
280
+ @ws = WebSocket::Client::Simple.connect(url) do |ws|
281
+ ws.on :open do
282
+ client_ref.send(:_on_ws_open)
283
+ ready_mutex.synchronize do
284
+ ready_flag = true
285
+ ready_cv.signal
286
+ end
287
+ end
288
+
289
+ ws.on :message do |msg|
290
+ client_ref.send(:_on_ws_message, msg.data)
291
+ end
292
+
293
+ ws.on :error do |e|
294
+ ws_error = e
295
+ ready_mutex.synchronize do
296
+ ready_flag = true
297
+ ready_cv.signal
298
+ end
299
+ end
300
+
301
+ ws.on :close do |_e|
302
+ client_ref.send(:_on_ws_close)
303
+ ready_mutex.synchronize do
304
+ ready_flag = true
305
+ ready_cv.signal
306
+ end
307
+ end
308
+ end
309
+
310
+ # Wait for connection to open
311
+ ready_mutex.synchronize do
312
+ ready_cv.wait(ready_mutex, 15) until ready_flag
313
+ end
314
+
315
+ raise ws_error if ws_error
316
+
317
+ @ws_mutex.synchronize { @connected = true }
318
+ @reconnect_delay = RECONNECT_MIN_DELAY
319
+
320
+ # Authenticate
321
+ _authenticate
322
+
323
+ # Keep reading until disconnected
324
+ while @running && @connected
325
+ sleep 1
326
+ end
327
+ end
328
+
329
+ def _on_ws_open
330
+ # Connection opened
331
+ end
332
+
333
+ def _on_ws_message(data)
334
+ return if data.nil? || data.empty?
335
+
336
+ begin
337
+ msg = JSON.parse(data)
338
+ rescue JSON::ParserError => e
339
+ $stderr.puts "[RELAY] Failed to parse message: #{e.message}"
340
+ return
341
+ end
342
+
343
+ _handle_message(msg)
344
+ end
345
+
346
+ def _on_ws_close
347
+ @ws_mutex.synchronize { @connected = false }
348
+ end
349
+
350
+ def _send_json(msg)
351
+ @ws_mutex.synchronize do
352
+ return unless @ws && @connected
353
+
354
+ @ws.send(JSON.generate(msg))
355
+ end
356
+ end
357
+
358
+ # ------------------------------------------------------------------
359
+ # Authentication
360
+ # ------------------------------------------------------------------
361
+
362
+ def _authenticate
363
+ params = {
364
+ 'version' => PROTOCOL_VERSION,
365
+ 'agent' => AGENT_STRING,
366
+ 'event_acks' => true
367
+ }
368
+
369
+ if @jwt_token
370
+ params['authentication'] = { 'jwt_token' => @jwt_token }
371
+ else
372
+ params['authentication'] = {
373
+ 'project' => @project_id,
374
+ 'token' => @token
375
+ }
376
+ end
377
+
378
+ params['contexts'] = @contexts unless @contexts.empty?
379
+ params['protocol'] = @protocol if @protocol && !@should_restart
380
+ params['authorization_state'] = @authorization_state if @authorization_state && !@should_restart
381
+
382
+ if @should_restart
383
+ @protocol = nil
384
+ @authorization_state = nil
385
+ @should_restart = false
386
+ end
387
+
388
+ result = execute(METHOD_SIGNALWIRE_CONNECT, params)
389
+ @protocol = result['protocol'] if result['protocol']
390
+ end
391
+
392
+ # ------------------------------------------------------------------
393
+ # Message dispatch
394
+ # ------------------------------------------------------------------
395
+
396
+ def _handle_message(msg)
397
+ method = msg['method']
398
+ id = msg['id']
399
+
400
+ if method.nil?
401
+ # This is a response to a pending request
402
+ _handle_response(msg)
403
+ return
404
+ end
405
+
406
+ case method
407
+ when METHOD_SIGNALWIRE_EVENT
408
+ _handle_event(msg)
409
+ when METHOD_SIGNALWIRE_PING
410
+ _send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} })
411
+ when METHOD_SIGNALWIRE_DISCONNECT
412
+ _handle_disconnect(msg)
413
+ else
414
+ # Unknown method, send empty result
415
+ _send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
416
+ end
417
+ end
418
+
419
+ def _handle_response(msg)
420
+ id = msg['id']
421
+ return unless id
422
+
423
+ entry = @pending_mutex.synchronize { @pending[id] }
424
+ return unless entry
425
+
426
+ if msg['error']
427
+ err = msg['error']
428
+ entry[:mutex].synchronize do
429
+ entry[:error] = RelayError.new(err['code'], err['message'] || 'Unknown error')
430
+ entry[:cv].signal
431
+ end
432
+ else
433
+ entry[:mutex].synchronize do
434
+ entry[:result] = msg['result'] || {}
435
+ entry[:cv].signal
436
+ end
437
+ end
438
+ end
439
+
440
+ def _handle_event(msg)
441
+ id = msg['id']
442
+ outer_params = msg['params'] || {}
443
+
444
+ # ACK the event immediately
445
+ _send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
446
+
447
+ event_type = outer_params['event_type'] || ''
448
+ event_params = outer_params['params'] || {}
449
+ call_id = event_params['call_id'] || ''
450
+
451
+ # Authorization state
452
+ if event_type == EVENT_AUTHORIZATION_STATE
453
+ @authorization_state = event_params['authorization_state']
454
+ return
455
+ end
456
+
457
+ # Inbound call
458
+ if event_type == EVENT_CALL_RECEIVE
459
+ _handle_inbound_call(outer_params)
460
+ return
461
+ end
462
+
463
+ # Dial completion -- call_id is NESTED at params.call.call_id
464
+ if event_type == EVENT_CALL_DIAL
465
+ _handle_dial_event(outer_params)
466
+ return
467
+ end
468
+
469
+ # Inbound message
470
+ if event_type == EVENT_MESSAGING_RECEIVE
471
+ _handle_inbound_message(outer_params)
472
+ return
473
+ end
474
+
475
+ # Outbound message state
476
+ if event_type == EVENT_MESSAGING_STATE
477
+ _handle_message_state(outer_params)
478
+ return
479
+ end
480
+
481
+ # State events during dial -- call not registered yet
482
+ if event_type == EVENT_CALL_STATE
483
+ tag = event_params['tag'] || ''
484
+ has_pending = @dials_mutex.synchronize { @pending_dials.key?(tag) }
485
+ if !tag.empty? && has_pending
486
+ has_call = @calls_mutex.synchronize { @calls.key?(call_id) }
487
+ unless has_call || call_id.empty?
488
+ _register_dial_leg(tag, event_params)
489
+ end
490
+ end
491
+ # Fall through to normal routing
492
+ end
493
+
494
+ # Normal routing by call_id
495
+ unless call_id.empty?
496
+ call = @calls_mutex.synchronize { @calls[call_id] }
497
+ if call
498
+ call._dispatch_event(outer_params)
499
+ if call.state == CALL_STATE_ENDED
500
+ @calls_mutex.synchronize { @calls.delete(call_id) }
501
+ end
502
+ end
503
+ end
504
+ end
505
+
506
+ def _handle_disconnect(msg)
507
+ id = msg['id']
508
+ params = msg['params'] || {}
509
+
510
+ # Respond with empty result
511
+ _send_json({ 'jsonrpc' => '2.0', 'id' => id, 'result' => {} }) if id
512
+
513
+ # Check restart flag
514
+ @should_restart = params['restart'] == true
515
+
516
+ # Let the connection close, reconnect will happen automatically
517
+ @ws_mutex.synchronize { @connected = false }
518
+ end
519
+
520
+ def _handle_inbound_call(payload)
521
+ event_params = payload['params'] || {}
522
+ call = Call.new(
523
+ self,
524
+ call_id: event_params['call_id'] || '',
525
+ node_id: event_params['node_id'] || '',
526
+ project_id: event_params['project_id'] || '',
527
+ context: event_params['context'] || event_params['protocol'] || '',
528
+ tag: event_params['tag'] || '',
529
+ direction: event_params['direction'] || 'inbound',
530
+ device: event_params['device'] || {},
531
+ state: event_params['call_state'] || '',
532
+ segment_id: event_params['segment_id'] || ''
533
+ )
534
+
535
+ @calls_mutex.synchronize { @calls[call.call_id] = call }
536
+
537
+ if @on_call_handler
538
+ Thread.new do
539
+ begin
540
+ @on_call_handler.call(call)
541
+ rescue => e
542
+ $stderr.puts "[RELAY] Error in on_call handler: #{e.message}"
543
+ end
544
+ end
545
+ end
546
+ end
547
+
548
+ def _handle_dial_event(payload)
549
+ event_params = payload['params'] || {}
550
+ tag = event_params['tag'] || ''
551
+ dial_state = event_params['dial_state'] || ''
552
+ call_info = event_params['call'] || {}
553
+
554
+ entry = @dials_mutex.synchronize { @pending_dials[tag] }
555
+ return unless entry
556
+
557
+ if dial_state == 'answered'
558
+ call_id = call_info['call_id'] || ''
559
+ node_id = call_info['node_id'] || ''
560
+
561
+ # Find or create the call
562
+ call = @calls_mutex.synchronize { @calls[call_id] }
563
+ unless call
564
+ call = Call.new(
565
+ self,
566
+ call_id: call_id,
567
+ node_id: node_id,
568
+ project_id: @project_id,
569
+ tag: call_info['tag'] || tag,
570
+ direction: 'outbound',
571
+ device: call_info['device'] || {},
572
+ state: CALL_STATE_ANSWERED
573
+ )
574
+ @calls_mutex.synchronize { @calls[call_id] = call }
575
+ end
576
+ call.state = CALL_STATE_ANSWERED
577
+
578
+ entry[:mutex].synchronize do
579
+ entry[:call] = call
580
+ entry[:cv].signal
581
+ end
582
+ elsif dial_state == 'failed'
583
+ entry[:mutex].synchronize do
584
+ entry[:error] = 'Dial failed'
585
+ entry[:cv].signal
586
+ end
587
+ end
588
+ end
589
+
590
+ def _register_dial_leg(tag, event_params)
591
+ call_id = event_params['call_id'] || ''
592
+ return if call_id.empty?
593
+
594
+ call = Call.new(
595
+ self,
596
+ call_id: call_id,
597
+ node_id: event_params['node_id'] || '',
598
+ project_id: @project_id,
599
+ tag: tag,
600
+ direction: 'outbound',
601
+ device: event_params['device'] || {},
602
+ state: event_params['call_state'] || ''
603
+ )
604
+ @calls_mutex.synchronize { @calls[call_id] = call }
605
+ end
606
+
607
+ def _handle_inbound_message(payload)
608
+ event_params = payload['params'] || {}
609
+ msg = Message.new(
610
+ message_id: event_params['message_id'] || '',
611
+ context: event_params['context'] || '',
612
+ direction: 'inbound',
613
+ from_number: event_params['from_number'] || '',
614
+ to_number: event_params['to_number'] || '',
615
+ body: event_params['body'] || '',
616
+ media: event_params['media'] || [],
617
+ segments: event_params['segments'] || 0,
618
+ state: event_params['message_state'] || 'received',
619
+ tags: event_params['tags'] || []
620
+ )
621
+
622
+ if @on_message_handler
623
+ Thread.new do
624
+ begin
625
+ @on_message_handler.call(msg)
626
+ rescue => e
627
+ $stderr.puts "[RELAY] Error in on_message handler: #{e.message}"
628
+ end
629
+ end
630
+ end
631
+ end
632
+
633
+ def _handle_message_state(payload)
634
+ event_params = payload['params'] || {}
635
+ message_id = event_params['message_id'] || ''
636
+
637
+ msg = @messages_mutex.synchronize { @messages[message_id] }
638
+ return unless msg
639
+
640
+ msg._dispatch_event(payload)
641
+
642
+ # Clean up terminal messages
643
+ if msg.done?
644
+ @messages_mutex.synchronize { @messages.delete(message_id) }
645
+ end
646
+ end
647
+
648
+ def _reject_all_pending(reason)
649
+ @pending_mutex.synchronize do
650
+ @pending.each_value do |entry|
651
+ entry[:mutex].synchronize do
652
+ entry[:error] ||= RelayError.new(-1, reason)
653
+ entry[:cv].signal
654
+ end
655
+ end
656
+ @pending.clear
657
+ end
658
+
659
+ @dials_mutex.synchronize do
660
+ @pending_dials.each_value do |entry|
661
+ entry[:mutex].synchronize do
662
+ entry[:error] ||= reason
663
+ entry[:cv].signal
664
+ end
665
+ end
666
+ @pending_dials.clear
667
+ end
668
+ end
669
+ end
670
+ end
671
+ end