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.
- 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 +50 -3
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/LICENCE +21 -0
- data/README.md +454 -70
- data/Rakefile +3 -1
- 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 +100 -0
- data/lib/ruby-asterisk/request.rb +38 -20
- data/lib/ruby-asterisk/response.rb +50 -137
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +43 -0
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -147
- data/ruby-asterisk.gemspec +26 -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 +147 -148
- data/spec/spec_helper.rb +14 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +177 -21
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'websocket/driver'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'uri'
|
|
8
|
+
require 'logger'
|
|
9
|
+
require 'monitor'
|
|
10
|
+
require_relative 'websocket/socket_adapter'
|
|
11
|
+
require_relative 'websocket/connection'
|
|
12
|
+
require_relative 'websocket/reconnect'
|
|
13
|
+
require_relative 'websocket/heartbeat'
|
|
14
|
+
require_relative 'websocket/event_handlers'
|
|
15
|
+
|
|
16
|
+
module RubyAsterisk
|
|
17
|
+
module ARI
|
|
18
|
+
# WebSocket client for ARI events
|
|
19
|
+
# Connects to the Asterisk ARI WebSocket endpoint to receive real-time events
|
|
20
|
+
#
|
|
21
|
+
# I/O model (no EventMachine): the WebSocket protocol is handled by the pure
|
|
22
|
+
# Ruby websocket-driver gem over a plain TCPSocket (SSLSocket for wss).
|
|
23
|
+
#
|
|
24
|
+
# connection_thread — owns the socket lifecycle: connects, runs the read
|
|
25
|
+
# loop (readpartial -> driver.parse -> callbacks), and
|
|
26
|
+
# re-connects after a drop while auto-reconnect is on.
|
|
27
|
+
#
|
|
28
|
+
# ping_thread — started when the connection opens; wakes every
|
|
29
|
+
# ping_interval seconds and sends a WebSocket ping.
|
|
30
|
+
#
|
|
31
|
+
# Event callbacks execute in the connection thread. All driver calls are
|
|
32
|
+
# serialized through a reentrant Monitor so send_message? is safe from any
|
|
33
|
+
# thread.
|
|
34
|
+
class WebSocket
|
|
35
|
+
include Connection
|
|
36
|
+
include Reconnect
|
|
37
|
+
include Heartbeat
|
|
38
|
+
include EventHandlers
|
|
39
|
+
|
|
40
|
+
attr_reader :url, :app_name, :callbacks, :connected
|
|
41
|
+
|
|
42
|
+
# Ping interval in seconds to keep connection alive
|
|
43
|
+
PING_INTERVAL = 30
|
|
44
|
+
|
|
45
|
+
# Reconnect delay in seconds (base for exponential backoff)
|
|
46
|
+
RECONNECT_DELAY = 5
|
|
47
|
+
|
|
48
|
+
# Upper bound for the exponential reconnect backoff, in seconds
|
|
49
|
+
MAX_RECONNECT_DELAY = 60
|
|
50
|
+
|
|
51
|
+
# Maximum reconnection attempts (nil for infinite)
|
|
52
|
+
MAX_RECONNECT_ATTEMPTS = nil
|
|
53
|
+
|
|
54
|
+
# Initialize a new WebSocket client
|
|
55
|
+
#
|
|
56
|
+
# @param base_url [String] Base URL of the Asterisk server (e.g., 'http://localhost:8088')
|
|
57
|
+
# @param api_key [String] API key for authentication
|
|
58
|
+
# @param app_name [String] Stasis application name
|
|
59
|
+
# @param options [Hash] Additional options
|
|
60
|
+
# @option options [Logger] :logger Logger instance for debugging
|
|
61
|
+
# @option options [Integer] :ping_interval Ping interval in seconds (default: 30)
|
|
62
|
+
# @option options [Boolean] :auto_reconnect Enable auto-reconnect (default: true)
|
|
63
|
+
# @option options [Integer] :reconnect_delay Delay between reconnection attempts (default: 5)
|
|
64
|
+
def initialize(base_url, api_key, app_name, options = {})
|
|
65
|
+
@base_url = base_url
|
|
66
|
+
@api_key = api_key
|
|
67
|
+
@app_name = app_name
|
|
68
|
+
@callbacks = {}
|
|
69
|
+
@connected = false
|
|
70
|
+
@should_reconnect = options.fetch(:auto_reconnect, true)
|
|
71
|
+
@reconnect_attempts = 0
|
|
72
|
+
@logger = options[:logger] || Logger.new($stdout)
|
|
73
|
+
@ping_interval = options.fetch(:ping_interval, PING_INTERVAL)
|
|
74
|
+
@reconnect_delay = options.fetch(:reconnect_delay, RECONNECT_DELAY)
|
|
75
|
+
initialize_io_state
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Connect to the WebSocket endpoint
|
|
79
|
+
#
|
|
80
|
+
# @yield [self] Block called when connection is established
|
|
81
|
+
# @return [self]
|
|
82
|
+
def connect(&block)
|
|
83
|
+
@on_connect_callback = block
|
|
84
|
+
@connection_thread = Thread.new { connection_loop }
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Register a callback for a specific event type
|
|
89
|
+
#
|
|
90
|
+
# @param event_type [String, Symbol] Event type to listen for (e.g., 'StasisStart')
|
|
91
|
+
# @param block [Proc] Block to execute when event is received
|
|
92
|
+
# @return [self]
|
|
93
|
+
def on(event_type, &block)
|
|
94
|
+
@callbacks[event_type.to_s] = [] unless @callbacks.key?(event_type.to_s)
|
|
95
|
+
@callbacks[event_type.to_s] << block
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Disconnect from the WebSocket
|
|
100
|
+
#
|
|
101
|
+
# @return [self]
|
|
102
|
+
def disconnect
|
|
103
|
+
@should_reconnect = false
|
|
104
|
+
stop_ping_timer
|
|
105
|
+
close_connection
|
|
106
|
+
wake_sleepers
|
|
107
|
+
|
|
108
|
+
thread = @connection_thread
|
|
109
|
+
@connection_thread = nil
|
|
110
|
+
thread&.join(2) unless thread == Thread.current
|
|
111
|
+
|
|
112
|
+
@connected = false
|
|
113
|
+
@logger.info 'WebSocket disconnected'
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if WebSocket is connected
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def connected?
|
|
121
|
+
!!(@connected && @driver && @driver.state == :open)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Send a message through the WebSocket
|
|
125
|
+
#
|
|
126
|
+
# @param message [Hash, String] Message to send (will be converted to JSON if Hash)
|
|
127
|
+
# @return [Boolean] true if sent successfully
|
|
128
|
+
def send_message?(message)
|
|
129
|
+
driver = @driver
|
|
130
|
+
return false unless @connected && driver && driver.state == :open
|
|
131
|
+
|
|
132
|
+
data = message.is_a?(Hash) ? JSON.generate(message) : message
|
|
133
|
+
@driver_monitor.synchronize { driver.text(data) }
|
|
134
|
+
true
|
|
135
|
+
rescue IOError, SystemCallError
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def initialize_io_state
|
|
142
|
+
@driver = nil
|
|
143
|
+
@socket = nil
|
|
144
|
+
@connection_thread = nil
|
|
145
|
+
@ping_thread = nil
|
|
146
|
+
@ping_token = nil
|
|
147
|
+
@awaiting_pong = false
|
|
148
|
+
@pending_dispatch = nil
|
|
149
|
+
@driver_monitor = Monitor.new
|
|
150
|
+
@wake_mutex = Mutex.new
|
|
151
|
+
@wake_cv = ConditionVariable.new
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# IO#timeout / IO#timeout= were added in Ruby 3.2.
|
|
4
|
+
# The async gem's Fiber Scheduler calls io.timeout inside io_wait to honour
|
|
5
|
+
# per-IO timeouts; returning nil disables that path on Ruby 3.1.
|
|
6
|
+
IO.define_method(:timeout) { nil } unless IO.method_defined?(:timeout)
|
|
7
|
+
IO.define_method(:timeout=) { |_| nil } unless IO.method_defined?(:timeout=)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when Asterisk sends an out-of-band `HANGUP` line mid-command
|
|
7
|
+
# (the channel was hung up). Callers may rescue this to abort cleanly
|
|
8
|
+
# instead of receiving a bogus response and desynchronizing the stream.
|
|
9
|
+
class HangupError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
#
|
|
5
|
+
# File containing parsing constants
|
|
6
|
+
#
|
|
7
|
+
module RubyAsterisk
|
|
8
|
+
DESCRIPTIVE_STATUS = {
|
|
9
|
+
'-1' => 'Extension not found',
|
|
10
|
+
'0' => 'Idle',
|
|
11
|
+
'1' => 'In Use',
|
|
12
|
+
'2' => 'Busy',
|
|
13
|
+
'3' => 'Unavailable',
|
|
14
|
+
'4' => 'Ringing',
|
|
15
|
+
'5' => 'On Hold'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
PARSE_DATA = {
|
|
19
|
+
'CoreShowChannels' => {
|
|
20
|
+
symbol: :channels,
|
|
21
|
+
search_for: 'Event: CoreShowChannel',
|
|
22
|
+
stop_with: 'Event: CoreShowChannelsComplete'
|
|
23
|
+
},
|
|
24
|
+
'ParkedCalls' => {
|
|
25
|
+
symbol: :calls,
|
|
26
|
+
search_for: 'Event: ParkedCall',
|
|
27
|
+
stop_with: nil
|
|
28
|
+
},
|
|
29
|
+
'Originate' => {
|
|
30
|
+
symbol: :dial,
|
|
31
|
+
search_for: 'Event: Dial',
|
|
32
|
+
stop_with: nil
|
|
33
|
+
},
|
|
34
|
+
'MeetMeList' => {
|
|
35
|
+
symbol: :rooms,
|
|
36
|
+
search_for: 'Event: MeetmeList',
|
|
37
|
+
stop_with: nil
|
|
38
|
+
},
|
|
39
|
+
'ConfbridgeListRooms' => {
|
|
40
|
+
symbol: :rooms,
|
|
41
|
+
search_for: 'Event: ConfbridgeListRooms',
|
|
42
|
+
stop_with: nil
|
|
43
|
+
},
|
|
44
|
+
'ConfbridgeList' => {
|
|
45
|
+
symbol: :channels,
|
|
46
|
+
search_for: 'Event: ConfbridgeList',
|
|
47
|
+
stop_with: nil
|
|
48
|
+
},
|
|
49
|
+
'Status' => {
|
|
50
|
+
symbol: :status,
|
|
51
|
+
search_for: 'Event: Status',
|
|
52
|
+
stop_with: nil
|
|
53
|
+
},
|
|
54
|
+
'ExtensionState' => {
|
|
55
|
+
symbol: :hints,
|
|
56
|
+
search_for: 'Response: Success',
|
|
57
|
+
stop_with: nil
|
|
58
|
+
},
|
|
59
|
+
'DeviceStateList' => {
|
|
60
|
+
symbol: :hints,
|
|
61
|
+
search_for: 'Response: Success',
|
|
62
|
+
stop_with: nil
|
|
63
|
+
},
|
|
64
|
+
'SKINNYdevices' => {
|
|
65
|
+
symbol: :skinnydevs,
|
|
66
|
+
search_for: 'Event: DeviceEntry',
|
|
67
|
+
stop_with: nil
|
|
68
|
+
},
|
|
69
|
+
'SKINNYlines' => {
|
|
70
|
+
symbol: :skinnylines,
|
|
71
|
+
search_for: 'Event: LineEntry',
|
|
72
|
+
stop_with: nil
|
|
73
|
+
},
|
|
74
|
+
'QueuePause' => {
|
|
75
|
+
symbol: :queue_pause,
|
|
76
|
+
search_for: 'Response:',
|
|
77
|
+
stop_with: nil
|
|
78
|
+
},
|
|
79
|
+
'Pong' => {
|
|
80
|
+
symbol: :pong,
|
|
81
|
+
search_for: 'Response:',
|
|
82
|
+
stop_with: nil
|
|
83
|
+
},
|
|
84
|
+
'Events' => {
|
|
85
|
+
symbol: :event_mask,
|
|
86
|
+
search_for: 'Ping:',
|
|
87
|
+
stop_with: nil
|
|
88
|
+
},
|
|
89
|
+
'SIPpeers' => {
|
|
90
|
+
symbol: :peers,
|
|
91
|
+
search_for: 'Event: PeerEntry',
|
|
92
|
+
stop_with: nil
|
|
93
|
+
},
|
|
94
|
+
'SIPshowpeer' => {
|
|
95
|
+
symbol: :hints,
|
|
96
|
+
search_for: 'Response: Success',
|
|
97
|
+
stop_with: nil
|
|
98
|
+
}
|
|
99
|
+
}.each_value(&:freeze).freeze
|
|
100
|
+
end
|
|
@@ -1,32 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module RubyAsterisk
|
|
4
|
+
##
|
|
5
|
+
#
|
|
6
|
+
# Class responsible of building commands structure
|
|
7
|
+
#
|
|
2
8
|
class Request
|
|
3
|
-
|
|
9
|
+
attr_reader :action, :action_id, :parameters
|
|
10
|
+
|
|
11
|
+
@id_mutex = Mutex.new
|
|
12
|
+
@id_counter = 0
|
|
4
13
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
# @param action [String] AMI action name
|
|
15
|
+
# @param parameters [Hash] additional AMI headers
|
|
16
|
+
# @param action_id [String, nil] ActionID to use; a generated one when nil.
|
|
17
|
+
# Passing it here (rather than as a parameter) keeps a single ActionID
|
|
18
|
+
# header on the wire, so responses still correlate with their Promise.
|
|
19
|
+
def initialize(action, parameters = {}, action_id: nil)
|
|
20
|
+
@action = action.freeze
|
|
21
|
+
@action_id = (action_id || Request.generate_action_id).to_s.freeze
|
|
22
|
+
@parameters = deep_freeze_hash(parameters)
|
|
23
|
+
freeze
|
|
10
24
|
end
|
|
11
25
|
|
|
12
26
|
def commands
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
command_list = ["Action: #{action}\r\n", "ActionID: #{action_id}\r\n"]
|
|
28
|
+
parameters.each do |key, value|
|
|
29
|
+
command_list << "#{key}: #{value}\r\n" unless value.nil?
|
|
16
30
|
end
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
protected
|
|
31
|
+
command_list[-1] << "\r\n"
|
|
32
|
+
command_list
|
|
33
|
+
end
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
35
|
+
# Monotonic timestamp plus a process-wide atomic counter, so two calls made
|
|
36
|
+
# within the same nanosecond (or across an NTP clock step-back) never collide.
|
|
37
|
+
def self.generate_action_id
|
|
38
|
+
count = @id_mutex.synchronize { @id_counter += 1 }
|
|
39
|
+
"#{Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).to_s(36)}-#{count.to_s(36)}"
|
|
29
40
|
end
|
|
30
41
|
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def deep_freeze_hash(hash)
|
|
45
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
46
|
+
result[key.freeze] = value.freeze
|
|
47
|
+
end.freeze
|
|
48
|
+
end
|
|
31
49
|
end
|
|
32
50
|
end
|
|
@@ -1,162 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/response_parser'
|
|
4
|
+
|
|
1
5
|
module RubyAsterisk
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Class for response coming from Asterisk
|
|
9
|
+
#
|
|
2
10
|
class Response
|
|
3
|
-
|
|
11
|
+
attr_reader :type, :action_id, :message, :data, :raw_response
|
|
4
12
|
|
|
5
|
-
def initialize(type,response)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
def initialize(type, response)
|
|
14
|
+
@raw_response = [response].flatten
|
|
15
|
+
@type = type
|
|
16
|
+
@action_id = _parse_action_id
|
|
17
|
+
@message = _parse_message
|
|
18
|
+
@data = _parse_response
|
|
19
|
+
deep_freeze
|
|
20
|
+
freeze
|
|
12
21
|
end
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _parse_successfull(response)
|
|
17
|
-
response.include?("Response: Success")
|
|
23
|
+
def success
|
|
24
|
+
raw_response.join.include?('Response: Success')
|
|
18
25
|
end
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
self._parse(response,"ActionID:")
|
|
22
|
-
end
|
|
27
|
+
protected
|
|
23
28
|
|
|
24
|
-
def
|
|
25
|
-
|
|
29
|
+
def _parse_action_id
|
|
30
|
+
_parse('ActionID:')
|
|
26
31
|
end
|
|
27
32
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
response.each_line do |line|
|
|
31
|
-
if line.start_with?(field)
|
|
32
|
-
_value = line[line.rindex(":")+1..line.size].strip
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
_value
|
|
33
|
+
def _parse_message
|
|
34
|
+
_parse('Message:')
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
self._parse_originate(response)
|
|
46
|
-
when "MeetMeList"
|
|
47
|
-
self._parse_meet_me_list(response)
|
|
48
|
-
when "Status"
|
|
49
|
-
self._parse_status(response)
|
|
50
|
-
when "ExtensionState"
|
|
51
|
-
self._parse_extension_state(response)
|
|
52
|
-
when "SKINNYdevices"
|
|
53
|
-
self._parse_skinny_devices(response)
|
|
54
|
-
when "SKINNYlines"
|
|
55
|
-
self._parse_skinny_lines(response)
|
|
56
|
-
when "Command"
|
|
57
|
-
response
|
|
58
|
-
when "QueuePause"
|
|
59
|
-
self._parse_queue_pause(response)
|
|
60
|
-
when "Pong"
|
|
61
|
-
self._parse_pong(response)
|
|
62
|
-
when "Events"
|
|
63
|
-
self._parse_event_mask(response)
|
|
64
|
-
when "SIPpeers"
|
|
65
|
-
self._parse_sip_peers(response)
|
|
37
|
+
def _parse(field)
|
|
38
|
+
raw_response.each do |data|
|
|
39
|
+
data.each_line do |line|
|
|
40
|
+
# Split only on the field's own colon so values that themselves contain
|
|
41
|
+
# ':' (e.g. "Message: Call failed: no route") are not truncated.
|
|
42
|
+
return line[field.length..].strip if line.start_with?(field)
|
|
43
|
+
end
|
|
66
44
|
end
|
|
67
|
-
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def _parse_sip_peers(response)
|
|
71
|
-
self._parse_objects(response, :peers, "Event: PeerEntry")
|
|
45
|
+
nil
|
|
72
46
|
end
|
|
73
47
|
|
|
74
|
-
def
|
|
75
|
-
|
|
48
|
+
def _parse_response
|
|
49
|
+
ResponseParser.parse(raw_response, type)
|
|
76
50
|
end
|
|
77
51
|
|
|
78
|
-
|
|
79
|
-
self._parse_objects(response,:dial,"Event: Dial")
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def _parse_data_parked_calls(response)
|
|
83
|
-
self._parse_objects(response,:calls,"Event: ParkedCall")
|
|
84
|
-
end
|
|
52
|
+
private
|
|
85
53
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
self._convert_status(_data)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def _parse_skinny_devices(response)
|
|
96
|
-
self._parse_objects(response,:skinnydevs,"Event: DeviceEntry")
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def _parse_skinny_lines(response)
|
|
100
|
-
self._parse_objects(response,:skinnylines,"Event: LineEntry")
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def _parse_queue_pause(response)
|
|
104
|
-
_data = self._parse_objects(response,:queue_pause,"Response:")
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def _parse_pong(response)
|
|
108
|
-
_data = self._parse_objects(response,:pong, "Response:")
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def _parse_event_mask(response)
|
|
112
|
-
_data = self._parse_objects(response, :event_mask, "Ping:")
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def _parse_status(response)
|
|
116
|
-
self._parse_objects(response, :status, "Event: Status")
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def _convert_status(_data)
|
|
120
|
-
_data[:hints].each do |hint|
|
|
121
|
-
case hint["Status"]
|
|
122
|
-
when "-1"
|
|
123
|
-
hint["DescriptiveStatus"] = "Extension not found"
|
|
124
|
-
when "0"
|
|
125
|
-
hint["DescriptiveStatus"] = "Idle"
|
|
126
|
-
when "1"
|
|
127
|
-
hint["DescriptiveStatus"] = "In Use"
|
|
128
|
-
when "2"
|
|
129
|
-
hint["DescriptiveStatus"] = "Busy"
|
|
130
|
-
when "4"
|
|
131
|
-
hint["DescriptiveStatus"] = "Unavailable"
|
|
132
|
-
when "8"
|
|
133
|
-
hint["DescriptiveStatus"] = "Ringing"
|
|
134
|
-
when "16"
|
|
135
|
-
hint["DescriptiveStatus"] = "On Hold"
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
_data
|
|
54
|
+
def deep_freeze
|
|
55
|
+
@type.freeze
|
|
56
|
+
@action_id.freeze
|
|
57
|
+
@message.freeze
|
|
58
|
+
deep_freeze_value(@raw_response)
|
|
59
|
+
deep_freeze_value(@data)
|
|
139
60
|
end
|
|
140
61
|
|
|
141
|
-
def
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if line.strip.empty? or (!stop_with.nil? and line.start_with?(stop_with))
|
|
148
|
-
parsing = false
|
|
149
|
-
elsif line.start_with?(search_for)
|
|
150
|
-
_data[symbol_name] << object unless object.nil?
|
|
151
|
-
object = {}
|
|
152
|
-
parsing = true
|
|
153
|
-
elsif parsing
|
|
154
|
-
tokens = line.split(':', 2)
|
|
155
|
-
object[tokens[0].strip]=tokens[1].strip unless tokens[1].nil?
|
|
62
|
+
def deep_freeze_value(obj)
|
|
63
|
+
case obj
|
|
64
|
+
when Hash
|
|
65
|
+
obj.each do |key, value|
|
|
66
|
+
key.freeze
|
|
67
|
+
deep_freeze_value(value)
|
|
156
68
|
end
|
|
69
|
+
when Array
|
|
70
|
+
obj.each { |element| deep_freeze_value(element) }
|
|
157
71
|
end
|
|
158
|
-
|
|
159
|
-
_data
|
|
72
|
+
obj.freeze
|
|
160
73
|
end
|
|
161
74
|
end
|
|
162
75
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyAsterisk
|
|
4
|
+
##
|
|
5
|
+
#
|
|
6
|
+
# Mutable builder that produces an immutable Response
|
|
7
|
+
#
|
|
8
|
+
class ResponseBuilder
|
|
9
|
+
attr_accessor :type, :raw_response
|
|
10
|
+
|
|
11
|
+
def build
|
|
12
|
+
raise ArgumentError, 'type is required' if @type.nil?
|
|
13
|
+
raise ArgumentError, 'raw_response is required' if @raw_response.nil?
|
|
14
|
+
|
|
15
|
+
Response.new(@type, @raw_response)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby-asterisk/parsing_constants'
|
|
4
|
+
|
|
5
|
+
module RubyAsterisk
|
|
6
|
+
##
|
|
7
|
+
#
|
|
8
|
+
# Class for parsing response coming from Asterisk
|
|
9
|
+
#
|
|
10
|
+
class ResponseParser
|
|
11
|
+
def self.parse(raw_response, type)
|
|
12
|
+
if PARSE_DATA.include?(type)
|
|
13
|
+
_parse_objects(raw_response, PARSE_DATA[type])
|
|
14
|
+
else
|
|
15
|
+
raw_response
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self._add_status(exten_array)
|
|
20
|
+
exten_array.each do |hint|
|
|
21
|
+
hint['DescriptiveStatus'] = DESCRIPTIVE_STATUS[hint['Status']]
|
|
22
|
+
end
|
|
23
|
+
exten_array
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self._parse_objects(response, parse_params)
|
|
27
|
+
object_array = []
|
|
28
|
+
# AMI frames are CRLF-delimited on the wire; tolerate both \r\n and \n so
|
|
29
|
+
# aggregated multi-event responses parse regardless of line endings.
|
|
30
|
+
object_regex = /#{parse_params[:search_for]}\r?\n(.*?)\r?\n\r?\n/m
|
|
31
|
+
response.join.scan(object_regex) do |match|
|
|
32
|
+
object = {}
|
|
33
|
+
match[0].split(/\r?\n/).each do |line|
|
|
34
|
+
tokens = line.split(':', 2)
|
|
35
|
+
object[tokens[0].strip] = tokens[1].strip unless tokens[1].nil?
|
|
36
|
+
end
|
|
37
|
+
object_array << object unless object.empty?
|
|
38
|
+
end
|
|
39
|
+
object_array = _add_status(object_array) if parse_params[:symbol].eql?(:hints)
|
|
40
|
+
{ parse_params[:symbol] => object_array }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|