ruby-asterisk 0.2.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.
- checksums.yaml +5 -5
- data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
- data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
- data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
- data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
- data/.github/workflows/ci.yml +79 -0
- data/.gitignore +48 -4
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/README.md +410 -108
- data/Rakefile +3 -5
- data/lib/ruby-asterisk/agi/protocol.rb +160 -0
- data/lib/ruby-asterisk/agi/server.rb +108 -0
- data/lib/ruby-asterisk/agi/session.rb +187 -0
- data/lib/ruby-asterisk/ami/client.rb +135 -0
- data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
- data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
- data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
- data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
- data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
- data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
- data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
- data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
- data/lib/ruby-asterisk/ami/event.rb +22 -0
- data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
- data/lib/ruby-asterisk/ami/parser.rb +60 -0
- data/lib/ruby-asterisk/ami/promise.rb +108 -0
- data/lib/ruby-asterisk/ami/reactor.rb +162 -0
- data/lib/ruby-asterisk/ari/client.rb +94 -0
- data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
- data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
- data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
- data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
- data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
- data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
- data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
- data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
- data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
- data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
- data/lib/ruby-asterisk/ari/websocket.rb +155 -0
- data/lib/ruby-asterisk/compat.rb +7 -0
- data/lib/ruby-asterisk/error.rb +10 -0
- data/lib/ruby-asterisk/parsing_constants.rb +71 -69
- data/lib/ruby-asterisk/request.rb +33 -14
- data/lib/ruby-asterisk/response.rb +43 -16
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +10 -9
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -222
- data/ruby-asterisk.gemspec +23 -17
- data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
- data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
- data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
- data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
- data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
- data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
- data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
- data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
- data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
- data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
- data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
- data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
- data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
- data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
- data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
- data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
- data/spec/ruby-asterisk/immutability_spec.rb +148 -0
- data/spec/ruby-asterisk/request_spec.rb +29 -9
- data/spec/ruby-asterisk/response_spec.rb +46 -47
- data/spec/spec_helper.rb +12 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +138 -18
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
|
@@ -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
|