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.
Files changed (77) 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 +48 -4
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/README.md +410 -108
  12. data/Rakefile +3 -5
  13. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  14. data/lib/ruby-asterisk/agi/server.rb +108 -0
  15. data/lib/ruby-asterisk/agi/session.rb +187 -0
  16. data/lib/ruby-asterisk/ami/client.rb +135 -0
  17. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  18. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  19. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  20. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  21. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  22. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  23. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  24. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  25. data/lib/ruby-asterisk/ami/event.rb +22 -0
  26. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  27. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  28. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  29. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  30. data/lib/ruby-asterisk/ari/client.rb +94 -0
  31. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  32. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  33. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  34. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  35. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  36. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  37. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  38. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  39. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  40. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  41. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  42. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  43. data/lib/ruby-asterisk/compat.rb +7 -0
  44. data/lib/ruby-asterisk/error.rb +10 -0
  45. data/lib/ruby-asterisk/parsing_constants.rb +71 -69
  46. data/lib/ruby-asterisk/request.rb +33 -14
  47. data/lib/ruby-asterisk/response.rb +43 -16
  48. data/lib/ruby-asterisk/response_builder.rb +18 -0
  49. data/lib/ruby-asterisk/response_parser.rb +10 -9
  50. data/lib/ruby-asterisk/version.rb +4 -2
  51. data/lib/ruby-asterisk.rb +5 -222
  52. data/ruby-asterisk.gemspec +23 -17
  53. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  54. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  55. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  56. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  57. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  58. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  59. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  60. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  61. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  62. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  63. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  64. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  65. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  66. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  67. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  68. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  69. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  70. data/spec/ruby-asterisk/request_spec.rb +29 -9
  71. data/spec/ruby-asterisk/response_spec.rb +46 -47
  72. data/spec/spec_helper.rb +12 -0
  73. data/spec/support/mock_ami_server.rb +50 -0
  74. data/spec/support/mock_ari_websocket_server.rb +114 -0
  75. metadata +138 -18
  76. data/CHANGELOG +0 -3
  77. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ##
2
4
  #
3
5
  # File containing parsing constants
@@ -11,88 +13,88 @@ module RubyAsterisk
11
13
  '3' => 'Unavailable',
12
14
  '4' => 'Ringing',
13
15
  '5' => 'On Hold'
14
- }
16
+ }.freeze
15
17
 
16
18
  PARSE_DATA = {
17
19
  'CoreShowChannels' => {
18
- :symbol => :channels,
19
- :search_for => 'Event: CoreShowChannel',
20
- :stop_with => 'Event: CoreShowChannelsComplete'
20
+ symbol: :channels,
21
+ search_for: 'Event: CoreShowChannel',
22
+ stop_with: 'Event: CoreShowChannelsComplete'
21
23
  },
22
24
  'ParkedCalls' => {
23
- :symbol => :calls,
24
- :search_for => 'Event: ParkedCall',
25
- :stop_with => nil
26
- },
27
- 'Originate' => {
28
- :symbol => :dial,
29
- :search_for => 'Event: Dial',
30
- :stop_with => nil
31
- },
32
- 'MeetMeList' => {
33
- :symbol => :rooms,
34
- :search_for => 'Event: MeetmeList',
35
- :stop_with => nil
36
- },
37
- 'ConfbridgeListRooms' => {
38
- :symbol => :rooms,
39
- :search_for => 'Event: ConfbridgeListRooms',
40
- :stop_with => nil
41
- },
42
- 'ConfbridgeList' => {
43
- :symbol => :channels,
44
- :search_for => 'Event: ConfbridgeList',
45
- :stop_with => nil
46
- },
47
- 'Status' => {
48
- :symbol => :status,
49
- :search_for => 'Event: Status',
50
- :stop_with => nil
51
- },
52
- 'ExtensionState' => {
53
- :symbol => :hints,
54
- :search_for => 'Response: Success',
55
- :stop_with => nil
56
- },
57
- 'DeviceStateList' => {
58
- :symbol => :hints,
59
- :search_for => 'Response: Success',
60
- :stop_with => nil
61
- },
62
- 'SKINNYdevices' => {
63
- :symbol => :skinnydevs,
64
- :search_for => 'Event: DeviceEntry',
65
- :stop_with => nil
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
66
68
  },
67
69
  'SKINNYlines' => {
68
- :symbol => :skinnylines,
69
- :search_for => 'Event: LineEntry',
70
- :stop_with => nil
70
+ symbol: :skinnylines,
71
+ search_for: 'Event: LineEntry',
72
+ stop_with: nil
71
73
  },
72
74
  'QueuePause' => {
73
- :symbol => :queue_pause,
74
- :search_for => 'Response:',
75
- :stop_with => nil
75
+ symbol: :queue_pause,
76
+ search_for: 'Response:',
77
+ stop_with: nil
76
78
  },
77
79
  'Pong' => {
78
- :symbol => :pong,
79
- :search_for => 'Response:',
80
- :stop_with => nil
80
+ symbol: :pong,
81
+ search_for: 'Response:',
82
+ stop_with: nil
81
83
  },
82
84
  'Events' => {
83
- :symbol => :event_mask,
84
- :search_for => 'Ping:',
85
- :stop_with => nil
85
+ symbol: :event_mask,
86
+ search_for: 'Ping:',
87
+ stop_with: nil
86
88
  },
87
89
  'SIPpeers' => {
88
- :symbol => :peers,
89
- :search_for => 'Event: PeerEntry',
90
- :stop_with => nil
91
- },
92
- 'SIPshowpeer' => {
93
- :symbol => :hints,
94
- :search_for => 'Response: Success',
95
- :stop_with => nil
96
- },
97
- }
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
98
100
  end
@@ -1,31 +1,50 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RubyAsterisk
2
4
  ##
3
5
  #
4
6
  # Class responsible of building commands structure
5
7
  #
6
8
  class Request
7
- attr_accessor :action, :action_id, :parameters, :response_data
9
+ attr_reader :action, :action_id, :parameters
10
+
11
+ @id_mutex = Mutex.new
12
+ @id_counter = 0
8
13
 
9
- def initialize(action, parameters = {})
10
- self.action = action
11
- self.action_id = Request.generate_action_id
12
- self.parameters = parameters
13
- self.response_data = ''
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
14
24
  end
15
25
 
16
26
  def commands
17
- _commands = ["Action: #{self.action}\r\n", "ActionID: #{self.action_id}\r\n"]
18
- self.parameters.each do |key, value|
19
- _commands<<"#{key.to_s}: #{value.to_s}\r\n" unless value.nil?
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?
20
30
  end
21
- _commands[_commands.length - 1] << "\r\n"
22
- _commands
31
+ command_list[-1] << "\r\n"
32
+ command_list
23
33
  end
24
34
 
25
- protected
26
-
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.
27
37
  def self.generate_action_id
28
- Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s(36)
38
+ count = @id_mutex.synchronize { @id_counter += 1 }
39
+ "#{Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond).to_s(36)}-#{count.to_s(36)}"
40
+ end
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
29
48
  end
30
49
  end
31
50
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'ruby-asterisk/response_parser'
2
4
 
3
5
  module RubyAsterisk
@@ -6,43 +8,68 @@ module RubyAsterisk
6
8
  # Class for response coming from Asterisk
7
9
  #
8
10
  class Response
9
- attr_accessor :type, :action_id, :message, :data, :raw_response
11
+ attr_reader :type, :action_id, :message, :data, :raw_response
10
12
 
11
- def initialize(type,response)
12
- self.raw_response = response
13
- self.type = type
14
- self.action_id = self._parse_action_id
15
- self.message = self._parse_message
16
- self.data = self._parse_response
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
17
21
  end
18
22
 
19
23
  def success
20
- self.raw_response.include?("Response: Success")
24
+ raw_response.join.include?('Response: Success')
21
25
  end
22
26
 
23
27
  protected
24
28
 
25
29
  def _parse_action_id
26
- self._parse("ActionID:")
30
+ _parse('ActionID:')
27
31
  end
28
32
 
29
33
  def _parse_message
30
- self._parse("Message:")
34
+ _parse('Message:')
31
35
  end
32
36
 
33
37
  def _parse(field)
34
- _value = nil
35
- self.raw_response.each_line do |line|
36
- if line.start_with?(field)
37
- _value = line[line.rindex(":")+1..line.size].strip
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)
38
43
  end
39
44
  end
40
- _value
45
+ nil
41
46
  end
42
47
 
43
48
  def _parse_response
44
- ResponseParser.parse(self.raw_response, self.type)
49
+ ResponseParser.parse(raw_response, type)
50
+ end
51
+
52
+ private
53
+
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)
45
60
  end
46
61
 
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)
68
+ end
69
+ when Array
70
+ obj.each { |element| deep_freeze_value(element) }
71
+ end
72
+ obj.freeze
73
+ end
47
74
  end
48
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'ruby-asterisk/parsing_constants'
2
4
 
3
5
  module RubyAsterisk
@@ -6,32 +8,31 @@ module RubyAsterisk
6
8
  # Class for parsing response coming from Asterisk
7
9
  #
8
10
  class ResponseParser
9
-
10
11
  def self.parse(raw_response, type)
11
12
  if PARSE_DATA.include?(type)
12
- self._parse_objects(raw_response, PARSE_DATA[type])
13
+ _parse_objects(raw_response, PARSE_DATA[type])
13
14
  else
14
15
  raw_response
15
16
  end
16
17
  end
17
18
 
18
- protected
19
-
20
19
  def self._add_status(exten_array)
21
20
  exten_array.each do |hint|
22
- hint["DescriptiveStatus"] = DESCRIPTIVE_STATUS[hint["Status"]]
21
+ hint['DescriptiveStatus'] = DESCRIPTIVE_STATUS[hint['Status']]
23
22
  end
24
23
  exten_array
25
24
  end
26
25
 
27
26
  def self._parse_objects(response, parse_params)
28
27
  object_array = []
29
- object_regex = Regexp.new(/#{parse_params[:search_for]}\n(.*?)\n\n/m)
30
- response.scan(object_regex) do |match|
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|
31
32
  object = {}
32
- match[0].split(/\n/).each do |line|
33
+ match[0].split(/\r?\n/).each do |line|
33
34
  tokens = line.split(':', 2)
34
- object[tokens[0].strip]=tokens[1].strip unless tokens[1].nil?
35
+ object[tokens[0].strip] = tokens[1].strip unless tokens[1].nil?
35
36
  end
36
37
  object_array << object unless object.empty?
37
38
  end
@@ -1,3 +1,5 @@
1
- module Rami
2
- VERSION = "0.2.0"
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ VERSION = '1.0.0'
3
5
  end