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,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
- 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
4
13
 
5
- def initialize(action,parameters={})
6
- self.action = action
7
- self.action_id = self.generate_action_id
8
- self.parameters = parameters
9
- 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
10
24
  end
11
25
 
12
26
  def commands
13
- _commands=["Action: "+self.action+"\r\n","ActionID: "+self.action_id+"\r\n"]
14
- self.parameters.each do |key,value|
15
- _commands<<key+": "+value+"\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?
16
30
  end
17
- _commands[_commands.length-1]<<"\r\n"
18
- _commands
19
- end
20
-
21
- protected
31
+ command_list[-1] << "\r\n"
32
+ command_list
33
+ end
22
34
 
23
- def generate_action_id
24
- if RUBY_VERSION.start_with?("1.9")
25
- Random.rand(999).to_s
26
- else
27
- rand(999).to_s
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
- attr_accessor :type, :success, :action_id, :message, :data, :raw_response
11
+ attr_reader :type, :action_id, :message, :data, :raw_response
4
12
 
5
- def initialize(type,response)
6
- self.raw_response = response
7
- self.type = type
8
- self.success = self._parse_successfull(response)
9
- self.action_id = self._parse_action_id(response)
10
- self.message = self._parse_message(response)
11
- self.data = self._parse_data(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
12
21
  end
13
22
 
14
- protected
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
- def _parse_action_id(response)
21
- self._parse(response,"ActionID:")
22
- end
27
+ protected
23
28
 
24
- def _parse_message(response)
25
- self._parse(response,"Message:")
29
+ def _parse_action_id
30
+ _parse('ActionID:')
26
31
  end
27
32
 
28
- def _parse(response,field)
29
- _value = nil
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 _parse_data(response)
39
- case self.type
40
- when "CoreShowChannels"
41
- self._parse_data_core_show_channels(response)
42
- when "ParkedCalls"
43
- self._parse_data_parked_calls(response)
44
- when "Originate"
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 _parse_meet_me_list(response)
75
- self._parse_objects(response,:rooms,"Event: MeetmeList")
48
+ def _parse_response
49
+ ResponseParser.parse(raw_response, type)
76
50
  end
77
51
 
78
- def _parse_originate(response)
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 _parse_data_core_show_channels(response)
87
- self._parse_objects(response,:channels,"Event: CoreShowChannel","Event: CoreShowChannelsComplete")
88
- end
89
-
90
- def _parse_extension_state(response)
91
- _data = self._parse_objects(response,:hints,"Response:")
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 _parse_objects(response,symbol_name,search_for,stop_with=nil)
142
- _data = { symbol_name => [] }
143
- parsing = false
144
- object = nil
145
- response.each_line do |line|
146
- line.strip!
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
- _data[symbol_name] << object unless object.nil?
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
@@ -1,3 +1,5 @@
1
- module Rami
2
- VERSION = "0.1.0"
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ VERSION = '1.0.0'
3
5
  end