ruby-asterisk 0.1.0 → 1.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 (78) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +50 -3
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/LICENCE +21 -0
  12. data/README.md +454 -70
  13. data/Rakefile +3 -1
  14. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  15. data/lib/ruby-asterisk/agi/server.rb +108 -0
  16. data/lib/ruby-asterisk/agi/session.rb +187 -0
  17. data/lib/ruby-asterisk/ami/client.rb +135 -0
  18. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  19. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  20. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  21. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  22. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  23. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  24. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  25. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  26. data/lib/ruby-asterisk/ami/event.rb +22 -0
  27. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  28. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  29. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  30. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  31. data/lib/ruby-asterisk/ari/client.rb +94 -0
  32. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  33. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  34. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  35. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  36. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  37. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  38. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  39. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  40. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  41. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  42. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  43. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  44. data/lib/ruby-asterisk/compat.rb +7 -0
  45. data/lib/ruby-asterisk/error.rb +10 -0
  46. data/lib/ruby-asterisk/parsing_constants.rb +100 -0
  47. data/lib/ruby-asterisk/request.rb +38 -20
  48. data/lib/ruby-asterisk/response.rb +50 -137
  49. data/lib/ruby-asterisk/response_builder.rb +18 -0
  50. data/lib/ruby-asterisk/response_parser.rb +43 -0
  51. data/lib/ruby-asterisk/version.rb +4 -2
  52. data/lib/ruby-asterisk.rb +5 -147
  53. data/ruby-asterisk.gemspec +26 -17
  54. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  55. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  56. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  57. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  58. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  59. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  60. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  61. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  62. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  63. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  64. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  65. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  66. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  67. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  68. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  69. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  70. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  71. data/spec/ruby-asterisk/request_spec.rb +29 -9
  72. data/spec/ruby-asterisk/response_spec.rb +147 -148
  73. data/spec/spec_helper.rb +14 -0
  74. data/spec/support/mock_ami_server.rb +50 -0
  75. data/spec/support/mock_ari_websocket_server.rb +114 -0
  76. metadata +177 -21
  77. data/CHANGELOG +0 -3
  78. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'ruby-asterisk/ari/resources/base'
6
+ require 'ruby-asterisk/ari/resources/channel'
7
+ require 'ruby-asterisk/ari/resources/bridge'
8
+ require 'ruby-asterisk/ari/resources/playback'
9
+ require 'ruby-asterisk/ari/resources/endpoint'
10
+ require 'ruby-asterisk/ari/resources/collection'
11
+
12
+ module RubyAsterisk
13
+ module ARI
14
+ # HTTP client for the Asterisk REST Interface (ARI).
15
+ # Wraps Faraday to provide authenticated GET, POST, and DELETE requests
16
+ # and maps HTTP error responses to RubyAsterisk::Error exceptions.
17
+ class Client
18
+ attr_reader :base_url, :app_name
19
+
20
+ def initialize(base_url, api_key, app_name)
21
+ @base_url = base_url
22
+ @api_key = api_key
23
+ @app_name = app_name
24
+ # ARI credentials may be supplied as "user:password" (the standard
25
+ # api_key form). Split on the first colon; a bare key means empty password.
26
+ user, pass = api_key.to_s.split(':', 2)
27
+ pass ||= ''
28
+ @connection = Faraday.new(url: base_url) do |conn|
29
+ conn.request :authorization, :basic, user, pass
30
+ conn.headers['Content-Type'] = 'application/json'
31
+ conn.headers['Accept'] = 'application/json'
32
+ end
33
+ end
34
+
35
+ def get(path, params = {})
36
+ response = @connection.get(path, params)
37
+ handle_response(response)
38
+ end
39
+
40
+ def post(path, body = {})
41
+ response = @connection.post(path, body.to_json)
42
+ handle_response(response)
43
+ end
44
+
45
+ def delete(path, params = {})
46
+ response = @connection.delete(path, params)
47
+ handle_response(response)
48
+ end
49
+
50
+ def asterisk_info
51
+ get('/ari/asterisk/info')
52
+ end
53
+
54
+ def channels
55
+ Resources::Collection.new(Resources::Channel, '/ari/channels', self)
56
+ end
57
+
58
+ def bridges
59
+ Resources::Collection.new(Resources::Bridge, '/ari/bridges', self)
60
+ end
61
+
62
+ def playbacks
63
+ Resources::Collection.new(Resources::Playback, '/ari/playbacks', self)
64
+ end
65
+
66
+ def endpoints
67
+ Resources::Collection.new(Resources::Endpoint, '/ari/endpoints', self)
68
+ end
69
+
70
+ private
71
+
72
+ def handle_response(response)
73
+ raise Error, error_message(response) if response.status >= 400
74
+
75
+ parse_body(response.body)
76
+ end
77
+
78
+ def error_message(response)
79
+ parsed = parse_body(response.body)
80
+ parsed.is_a?(Hash) ? parsed['message'] || response.reason_phrase : response.reason_phrase
81
+ rescue StandardError
82
+ response.reason_phrase
83
+ end
84
+
85
+ def parse_body(body)
86
+ return nil if body.nil? || body.empty?
87
+
88
+ JSON.parse(body)
89
+ rescue JSON::ParserError
90
+ body
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Base class providing shared data access for ARI resource objects.
7
+ # Subclasses receive raw JSON data and a client reference, then expose
8
+ # domain-specific action methods that delegate HTTP calls to the client.
9
+ class Base
10
+ attr_reader :data, :client
11
+
12
+ def initialize(data, client)
13
+ @data = data
14
+ @client = client
15
+ end
16
+
17
+ def id
18
+ data['id']
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Represents an ARI Bridge resource with channel-mixing action methods.
7
+ class Bridge < Base
8
+ def add_channel(channel_id)
9
+ client.post("/ari/bridges/#{id}/addChannel", { channel: channel_id })
10
+ end
11
+
12
+ def remove_channel(channel_id)
13
+ client.post("/ari/bridges/#{id}/removeChannel", { channel: channel_id })
14
+ end
15
+
16
+ def destroy
17
+ client.delete("/ari/bridges/#{id}")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Represents an ARI Channel resource with call-control action methods.
7
+ class Channel < Base
8
+ def ring
9
+ client.post("/ari/channels/#{id}/ring")
10
+ end
11
+
12
+ def answer
13
+ client.post("/ari/channels/#{id}/answer")
14
+ end
15
+
16
+ def hangup
17
+ client.delete("/ari/channels/#{id}")
18
+ end
19
+
20
+ def play(media, params = {})
21
+ client.post("/ari/channels/#{id}/play", { media: media }.merge(params))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Provides a typed collection interface for fetching ARI resources.
7
+ # Enables the Active Record-style pattern: client.channels.get(id).
8
+ class Collection
9
+ def initialize(resource_class, base_path, client)
10
+ @resource_class = resource_class
11
+ @base_path = base_path
12
+ @client = client
13
+ end
14
+
15
+ def get(id)
16
+ data = @client.get("#{@base_path}/#{id}")
17
+ @resource_class.new(data, @client)
18
+ end
19
+
20
+ def list(params = {})
21
+ data = @client.get(@base_path, params)
22
+ (data || []).map { |item| @resource_class.new(item, @client) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Represents an ARI Endpoint resource (a SIP/PJSIP/DAHDI endpoint).
7
+ class Endpoint < Base
8
+ def technology
9
+ data['technology']
10
+ end
11
+
12
+ def resource
13
+ data['resource']
14
+ end
15
+
16
+ def state
17
+ data['state']
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ module Resources
6
+ # Represents an ARI Playback resource with media-control action methods.
7
+ class Playback < Base
8
+ def stop
9
+ client.delete("/ari/playbacks/#{id}")
10
+ end
11
+
12
+ def control(operation)
13
+ client.post("/ari/playbacks/#{id}/control", { operation: operation })
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ class WebSocket
6
+ # Connection management for ARI WebSocket
7
+ module Connection
8
+ private
9
+
10
+ # Build the WebSocket URL
11
+ #
12
+ # @return [String]
13
+ def build_url
14
+ uri = URI.parse(@base_url)
15
+ ws_scheme = uri.scheme == 'https' ? 'wss' : 'ws'
16
+ user, pass = @api_key.to_s.split(':', 2)
17
+ auth = "#{encode_userinfo(user)}:#{encode_userinfo(pass)}@"
18
+ app = URI.encode_www_form_component(@app_name)
19
+
20
+ "#{ws_scheme}://#{auth}#{uri.host}:#{uri.port}/ari/events?app=#{app}"
21
+ end
22
+
23
+ # URL-encode a userinfo component (user or password), leaving nil as ''
24
+ def encode_userinfo(component)
25
+ URI::DEFAULT_PARSER.escape(component.to_s, /[^A-Za-z0-9\-._~]/)
26
+ end
27
+
28
+ # Run a single connection: establish it and read until it drops.
29
+ def run_connection
30
+ establish_connection
31
+ rescue StandardError => e
32
+ @logger.error "Failed to establish connection: #{e.message}"
33
+ else
34
+ read_loop
35
+ ensure
36
+ cleanup_connection
37
+ end
38
+
39
+ # Establish WebSocket connection
40
+ def establish_connection
41
+ @logger.info "Connecting to ARI WebSocket: app=#{@app_name}"
42
+
43
+ @socket = open_socket(URI.parse(@base_url))
44
+ @driver = ::WebSocket::Driver.client(SocketAdapter.new(build_url, @socket))
45
+
46
+ setup_event_handlers
47
+ @driver.start
48
+ end
49
+
50
+ # Open a TCP socket, wrapped in TLS when the base URL is https
51
+ def open_socket(uri)
52
+ tcp = TCPSocket.new(uri.host, uri.port)
53
+ tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
54
+ tcp.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
55
+ return tcp unless uri.scheme == 'https'
56
+
57
+ context = OpenSSL::SSL::SSLContext.new
58
+ context.set_params
59
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
60
+ ssl.hostname = uri.host
61
+ ssl.sync_close = true
62
+ ssl.connect
63
+ ssl
64
+ end
65
+
66
+ # Setup WebSocket event handlers
67
+ def setup_event_handlers
68
+ @driver.on(:open) { |_event| handle_open }
69
+ @driver.on(:message) { |event| handle_message(event) }
70
+ @driver.on(:close) { |event| handle_close(event) }
71
+ @driver.on(:error) { |event| handle_error(event) }
72
+ end
73
+
74
+ # Feed incoming bytes to the driver until the socket closes.
75
+ #
76
+ # driver.parse dispatches :open/:message synchronously (under
77
+ # @driver_monitor); handlers only enqueue closures via #defer so user
78
+ # callbacks run here, after the monitor is released.
79
+ def read_loop
80
+ loop do
81
+ chunk = @socket.readpartial(4096)
82
+ pending = []
83
+ @driver_monitor.synchronize do
84
+ @pending_dispatch = pending
85
+ begin
86
+ @driver.parse(chunk)
87
+ ensure
88
+ @pending_dispatch = nil
89
+ end
90
+ end
91
+ dispatch_pending(pending)
92
+ end
93
+ rescue IOError, SystemCallError
94
+ handle_connection_lost
95
+ end
96
+
97
+ # Run deferred callbacks, containing any exception so it never kills the loop.
98
+ def dispatch_pending(callbacks)
99
+ callbacks.each do |callback|
100
+ callback.call
101
+ rescue StandardError => e
102
+ @logger.error "Error dispatching WebSocket event: #{e.message}"
103
+ end
104
+ end
105
+
106
+ # Enqueue a closure to run after the driver monitor is released. When
107
+ # invoked outside an active parse (no dispatch buffer), run immediately.
108
+ def defer(&block)
109
+ if @pending_dispatch
110
+ @pending_dispatch << block
111
+ else
112
+ yield
113
+ end
114
+ end
115
+
116
+ # Release per-connection resources after the read loop exits
117
+ def cleanup_connection
118
+ stop_ping_timer
119
+ close_socket
120
+ @driver = nil
121
+ @socket = nil
122
+ @connected = false
123
+ end
124
+
125
+ # Send a close frame (if the connection is open) and close the socket
126
+ def close_connection
127
+ driver = @driver
128
+ @driver_monitor.synchronize { driver.close } if driver && driver.state == :open
129
+ rescue StandardError
130
+ nil
131
+ ensure
132
+ close_socket
133
+ end
134
+
135
+ def close_socket
136
+ @socket&.close
137
+ rescue StandardError
138
+ nil
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ class WebSocket
6
+ # Event handling for ARI WebSocket
7
+ module EventHandlers
8
+ private
9
+
10
+ # Handle WebSocket open event
11
+ def handle_open
12
+ @connected = true
13
+ @reconnect_attempts = 0
14
+ @awaiting_pong = false
15
+ @logger.info 'WebSocket connected successfully'
16
+
17
+ start_ping_timer
18
+ # Runs after the driver monitor is released (see Connection#read_loop).
19
+ defer { @on_connect_callback&.call(self) }
20
+ end
21
+
22
+ # Handle incoming WebSocket message
23
+ #
24
+ # @param event [WebSocket::Driver::MessageEvent]
25
+ def handle_message(event)
26
+ data = JSON.parse(event.data)
27
+ event_type = data['type']
28
+
29
+ @logger.debug "Received event: #{event_type}"
30
+
31
+ # Dispatch to registered callbacks after the driver monitor is released
32
+ # (see Connection#read_loop) so user handlers never run under the monitor.
33
+ defer { dispatch_event(event_type, data) }
34
+ rescue JSON::ParserError => e
35
+ @logger.error "Failed to parse message: #{e.message}"
36
+ end
37
+
38
+ # Handle WebSocket close event. Reconnection is handled by the
39
+ # connection thread once the read loop unblocks.
40
+ #
41
+ # @param event [WebSocket::Driver::CloseEvent]
42
+ def handle_close(event)
43
+ @connected = false
44
+ stop_ping_timer
45
+
46
+ @logger.warn "WebSocket closed: code=#{event.code}, reason=#{event.reason}"
47
+
48
+ close_socket
49
+ end
50
+
51
+ # Handle WebSocket error event
52
+ #
53
+ # @param event [WebSocket::Driver::ProtocolError]
54
+ def handle_error(event)
55
+ @logger.error "WebSocket error: #{event.message}"
56
+ end
57
+
58
+ # Dispatch event to registered callbacks
59
+ #
60
+ # @param event_type [String] Event type
61
+ # @param data [Hash] Event data
62
+ def dispatch_event(event_type, data)
63
+ # Call specific event handlers
64
+ @callbacks[event_type]&.each do |callback|
65
+ callback.call(data)
66
+ rescue StandardError => e
67
+ @logger.error "Error in event handler for #{event_type}: #{e.message}"
68
+ end
69
+
70
+ # Call wildcard handlers (if registered with '*')
71
+ @callbacks['*']&.each do |callback|
72
+ callback.call(data)
73
+ rescue StandardError => e
74
+ @logger.error "Error in wildcard event handler: #{e.message}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ class WebSocket
6
+ # Ping keep-alive timer and interruptible waits for ARI WebSocket
7
+ module Heartbeat
8
+ private
9
+
10
+ # Start the ping timer to keep connection alive
11
+ def start_ping_timer
12
+ stop_ping_timer
13
+
14
+ token = @ping_token = Object.new
15
+ @ping_thread = Thread.new { ping_loop(token) }
16
+ end
17
+
18
+ # Send a ping every @ping_interval seconds until the token is revoked.
19
+ # If the previous ping was never answered by a pong, treat the connection
20
+ # as dead (half-open) and close the socket so the read loop unblocks and
21
+ # auto-reconnect can take over.
22
+ def ping_loop(token)
23
+ loop do
24
+ wait_or_wake(@ping_interval)
25
+ break unless token.equal?(@ping_token)
26
+ next unless connected?
27
+
28
+ if @awaiting_pong
29
+ @logger.warn 'No pong received within ping interval; closing dead connection'
30
+ close_socket
31
+ break
32
+ end
33
+
34
+ @awaiting_pong = true
35
+ @logger.debug 'Sending ping'
36
+ send_ping
37
+ end
38
+ end
39
+
40
+ def send_ping
41
+ driver = @driver
42
+ @driver_monitor.synchronize { driver&.ping('') { @awaiting_pong = false } }
43
+ rescue IOError, SystemCallError
44
+ nil
45
+ end
46
+
47
+ # Stop the ping timer
48
+ def stop_ping_timer
49
+ @ping_token = nil
50
+ @ping_thread = nil
51
+ wake_sleepers
52
+ end
53
+
54
+ # Interruptible sleep: returns early when wake_sleepers is called
55
+ def wait_or_wake(seconds)
56
+ @wake_mutex.synchronize { @wake_cv.wait(@wake_mutex, seconds) }
57
+ end
58
+
59
+ def wake_sleepers
60
+ @wake_mutex.synchronize { @wake_cv.broadcast }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ class WebSocket
6
+ # Reconnection lifecycle for ARI WebSocket: the connection-thread loop and
7
+ # the exponential backoff between attempts.
8
+ module Reconnect
9
+ private
10
+
11
+ # Main loop of the connection thread: connect, read until the
12
+ # connection drops, then reconnect while auto-reconnect is enabled.
13
+ def connection_loop
14
+ loop do
15
+ run_connection
16
+ break unless @should_reconnect
17
+ break unless wait_before_reconnect
18
+ end
19
+ end
20
+
21
+ # The socket dropped without a WebSocket close frame
22
+ def handle_connection_lost
23
+ return unless @connected
24
+
25
+ @connected = false
26
+ stop_ping_timer
27
+ @logger.warn 'WebSocket connection lost'
28
+ end
29
+
30
+ # Wait before the next reconnection attempt.
31
+ #
32
+ # @return [Boolean] false when reconnection must stop
33
+ def wait_before_reconnect
34
+ if MAX_RECONNECT_ATTEMPTS && @reconnect_attempts >= MAX_RECONNECT_ATTEMPTS
35
+ @logger.error 'Max reconnection attempts reached, giving up'
36
+ return false
37
+ end
38
+
39
+ @reconnect_attempts += 1
40
+ delay = reconnect_backoff_delay
41
+ @logger.info "Scheduling reconnection attempt #{@reconnect_attempts} in #{delay} seconds"
42
+
43
+ wait_or_wake(delay)
44
+ @should_reconnect
45
+ end
46
+
47
+ # Exponential backoff capped at MAX_RECONNECT_DELAY
48
+ def reconnect_backoff_delay
49
+ [@reconnect_delay * (2**(@reconnect_attempts - 1)), MAX_RECONNECT_DELAY].min
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module ARI
5
+ class WebSocket
6
+ # Minimal socket wrapper handed to WebSocket::Driver.client: the driver
7
+ # reads #url to build the handshake request (including Basic auth from
8
+ # the URL userinfo) and calls #write to emit outgoing bytes.
9
+ class SocketAdapter
10
+ attr_reader :url
11
+
12
+ def initialize(url, io)
13
+ @url = url
14
+ @io = io
15
+ end
16
+
17
+ def write(data)
18
+ @io.write(data)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end