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
@@ -1,185 +1,184 @@
1
- # encoding: utf-8
1
+ # frozen_string_literal: true
2
+
2
3
  require 'spec_helper'
3
4
  describe RubyAsterisk::Response do
4
-
5
5
  def sip_peers_response
6
6
  "Response: Success
7
7
  Message: Peer status list will follow
8
-
8
+
9
9
  Event: PeerEntry
10
10
  Channeltype: SIP
11
11
  ObjectName: 9915057
12
- IPport: 5060"
12
+ IPport: 5060\n\n"
13
+ end
14
+
15
+ def empty_core_show_channels_response
16
+ "Event: CoreShowChannelsComplete
17
+ EventList: Complete
18
+ ListItems: 1
19
+ ActionID: 839\n\n"
13
20
  end
14
21
 
15
- def empty_core_show_channels_response
16
- "Event: CoreShowChannelsComplete
17
- EventList: Complete
22
+ def core_show_channels_response
23
+ "Event: CoreShowChannel
24
+ ActionID: 839
25
+ Channel: SIP/195.62.226.4-00000025
26
+ UniqueID: 1335448133.61
27
+ Context: incoming
28
+ Extension: s
29
+ Priority: 1
30
+ ChannelState: 6
31
+ ChannelStateDesc: Up
32
+ Application: Parked Call
33
+ ApplicationData:
34
+ CallerIDnum: 123456
35
+ CallerIDname:
36
+ ConnectedLineNum:
37
+ ConnectedLineName:
38
+ Duration: 00:00:05
39
+ AccountCode:
40
+ BridgedChannel:
41
+ BridgedUniqueID:
42
+
43
+ Event: CoreShowChannelsComplete
44
+ EventList: Complete
18
45
  ListItems: 1
19
- ActionID: 839"
20
- end
21
-
22
- def core_show_channels_response
23
- "Event: CoreShowChannel
24
- ActionID: 839
25
- Channel: SIP/195.62.226.4-00000025
26
- UniqueID: 1335448133.61
27
- Context: incoming
28
- Extension: s
29
- Priority: 1
30
- ChannelState: 6
31
- ChannelStateDesc: Up
32
- Application: Parked Call
33
- ApplicationData:
34
- CallerIDnum: 123456
35
- CallerIDname:
36
- ConnectedLineNum:
37
- ConnectedLineName:
38
- Duration: 00:00:05
39
- AccountCode:
40
- BridgedChannel:
41
- BridgedUniqueID:
42
-
43
- Event: CoreShowChannelsComplete
44
- EventList: Complete
45
- ListItems: 1
46
- ActionID: 839"
47
- end
48
-
49
- def parked_calls_response
50
- "Event: ParkedCall
51
- Parkinglot: default
52
- Exten: 701
53
- Channel: SIP/195.62.226.2-00000026
54
- From: SIP/195.62.226.2-00000026
55
- Timeout: 3
56
- CallerIDNum: 123456
57
- CallerIDName:
58
- ConnectedLineNum:
59
- ConnectedLineName:
60
- ActionID: 899"
61
- end
62
-
63
- def originate_response
64
- "Event: Dial
65
- Privilege: call,all
66
- SubEvent: End
67
- Channel: SIP/9100-0000002b
68
- UniqueID: 1335457364.68
69
- DialStatus: CHANUNAVAIL"
70
- end
71
-
72
- def meet_me_list_response
73
- "Event: MeetmeList
74
- ActionID: 921
75
- Conference: 1234
76
- UserNumber: 1
77
- CallerIDNum: 123456
78
- CallerIDName: <no name>
79
- ConnectedLineNum: <unknown>
80
- ConnectedLineName: <no name>
81
- Channel: SIP/195.62.226.18-0000000e
82
- Admin: No
83
- Role: Talk and listen
84
- MarkedUser: No
85
- Muted: No
86
- Talking: Not monitored"
87
- end
88
-
89
- def extension_state_response
90
- "Response: Success
91
- ActionID: 180
92
- Message: Extension Status
93
- Exten: 9100
94
- Context: HINT
95
- Hint: SIP/9100
96
- Status: 0"
97
- end
98
-
99
- describe ".new" do
100
-
101
- describe "receiving a Core Show Channels request" do
102
- it "should parse correctly data coming from Asterisk about channels" do
103
- @response = RubyAsterisk::Response.new("CoreShowChannels",core_show_channels_response)
104
- @response.data[:channels].should_not be_empty
105
- end
106
-
107
- it "should correctly fill the fields" do
108
- @response = RubyAsterisk::Response.new("CoreShowChannels",core_show_channels_response)
109
- @response.data[:channels][0]["CallerIDnum"].should eq("123456")
46
+ ActionID: 839\n\n"
47
+ end
48
+
49
+ def parked_calls_response
50
+ "Event: ParkedCall
51
+ Parkinglot: default
52
+ Exten: 701
53
+ Channel: SIP/195.62.226.2-00000026
54
+ From: SIP/195.62.226.2-00000026
55
+ Timeout: 3
56
+ CallerIDNum: 123456
57
+ CallerIDName:
58
+ ConnectedLineNum:
59
+ ConnectedLineName:
60
+ ActionID: 899\n\n"
61
+ end
62
+
63
+ def originate_response
64
+ "Event: Dial
65
+ Privilege: call,all
66
+ SubEvent: End
67
+ Channel: SIP/9100-0000002b
68
+ UniqueID: 1335457364.68
69
+ DialStatus: CHANUNAVAIL\n\n"
70
+ end
71
+
72
+ def meet_me_list_response
73
+ "Event: MeetmeList
74
+ ActionID: 921
75
+ Conference: 1234
76
+ UserNumber: 1
77
+ CallerIDNum: 123456
78
+ CallerIDName: <no name>
79
+ ConnectedLineNum: <unknown>
80
+ ConnectedLineName: <no name>
81
+ Channel: SIP/195.62.226.18-0000000e
82
+ Admin: No
83
+ Role: Talk and listen
84
+ MarkedUser: No
85
+ Muted: No
86
+ Talking: Not monitored\n\n"
87
+ end
88
+
89
+ def extension_state_response
90
+ "Response: Success
91
+ ActionID: 180
92
+ Message: Extension Status
93
+ Exten: 9100
94
+ Context: HINT
95
+ Hint: SIP/9100
96
+ Status: 0\n\n"
97
+ end
98
+
99
+ describe '.new' do
100
+ describe 'receiving a Core Show Channels request' do
101
+ it 'should parse correctly data coming from Asterisk about channels' do
102
+ @response = RubyAsterisk::Response.new('CoreShowChannels', [core_show_channels_response])
103
+ @response.data[:channels].should_not be_empty
104
+ end
105
+
106
+ it 'should correctly fill the fields' do
107
+ @response = RubyAsterisk::Response.new('CoreShowChannels', [core_show_channels_response])
108
+ @response.data[:channels][0]['CallerIDnum'].should eq('123456')
110
109
  end
111
110
 
112
- it "should have no channels if answer is empty" do
113
- @response = RubyAsterisk::Response.new("CoreShowChannels",empty_core_show_channels_response)
114
- @response.data[:channels].count.should eq(0)
115
- end
116
- end
117
-
118
- describe "receiving a Parked Calls request" do
119
- it "should parse correctly data coming from Asterisk about calls" do
120
- @response = RubyAsterisk::Response.new("ParkedCalls",parked_calls_response)
121
- @response.data[:calls].should_not be_empty
122
- end
123
-
124
- it "should correctly fill the fields" do
125
- @response = RubyAsterisk::Response.new("ParkedCalls",parked_calls_response)
126
- @response.data[:calls][0]["Exten"].should eq("701")
127
- end
128
- end
129
-
130
- describe "receiving a Originate request" do
131
- it "should parse correctly data coming from Asterisk about the call" do
132
- @response = RubyAsterisk::Response.new("Originate",originate_response)
111
+ it 'should have no channels if answer is empty' do
112
+ @response = RubyAsterisk::Response.new('CoreShowChannels', [empty_core_show_channels_response])
113
+ @response.data[:channels].count.should eq(0)
114
+ end
115
+ end
116
+
117
+ describe 'receiving a Parked Calls request' do
118
+ it 'should parse correctly data coming from Asterisk about calls' do
119
+ @response = RubyAsterisk::Response.new('ParkedCalls', [parked_calls_response])
120
+ @response.data[:calls].should_not be_empty
121
+ end
122
+
123
+ it 'should correctly fill the fields' do
124
+ @response = RubyAsterisk::Response.new('ParkedCalls', [parked_calls_response])
125
+ @response.data[:calls][0]['Exten'].should eq('701')
126
+ end
127
+ end
128
+
129
+ describe 'receiving a Originate request' do
130
+ it 'should parse correctly data coming from Asterisk about the call' do
131
+ @response = RubyAsterisk::Response.new('Originate', [originate_response])
133
132
  @response.data[:dial].should_not be_empty
134
133
  end
135
134
 
136
- it "should correctly fill the fields" do
137
- @response = RubyAsterisk::Response.new("Originate",originate_response)
138
- @response.data[:dial][0]["UniqueID"].should eq("1335457364.68")
135
+ it 'should correctly fill the fields' do
136
+ @response = RubyAsterisk::Response.new('Originate', [originate_response])
137
+ @response.data[:dial][0]['UniqueID'].should eq('1335457364.68')
139
138
  end
140
139
 
141
- it "should have a field with original raw response" do
142
- @response = RubyAsterisk::Response.new("Originate",originate_response)
143
- @response.raw_response.should eq(originate_response)
140
+ it 'should have a field with original raw response' do
141
+ @response = RubyAsterisk::Response.new('Originate', [originate_response])
142
+ @response.raw_response.join.should eq(originate_response)
144
143
  end
145
144
  end
146
-
147
- describe "receiving a MeetMeList request" do
148
- it "should parse correctly data coming from Asterisk about the conference room" do
149
- @response = RubyAsterisk::Response.new("MeetMeList",meet_me_list_response)
145
+
146
+ describe 'receiving a MeetMeList request' do
147
+ it 'should parse correctly data coming from Asterisk about the conference room' do
148
+ @response = RubyAsterisk::Response.new('MeetMeList', [meet_me_list_response])
150
149
  @response.data[:rooms].should_not be_empty
151
150
  end
152
151
 
153
- it "should correctly fill the fields" do
154
- @response = RubyAsterisk::Response.new("MeetMeList",meet_me_list_response)
155
- @response.data[:rooms][0]["Conference"].should eq("1234")
152
+ it 'should correctly fill the fields' do
153
+ @response = RubyAsterisk::Response.new('MeetMeList', [meet_me_list_response])
154
+ @response.data[:rooms][0]['Conference'].should eq('1234')
156
155
  end
157
156
  end
158
157
 
159
- describe "receiving a ExtensionState request" do
160
- it "should parse correctly data coming from Asterisk about the state of the extension" do
161
- @response = RubyAsterisk::Response.new("ExtensionState",extension_state_response)
158
+ describe 'receiving a ExtensionState request' do
159
+ it 'should parse correctly data coming from Asterisk about the state of the extension' do
160
+ @response = RubyAsterisk::Response.new('ExtensionState', [extension_state_response])
162
161
  @response.data[:hints].should_not be_empty
163
162
  end
164
163
 
165
- it "should correctly fill the fields" do
166
- @response = RubyAsterisk::Response.new("ExtensionState",extension_state_response)
167
- @response.data[:hints][0]["Status"].should eq("0")
168
- @response.data[:hints][0]["DescriptiveStatus"].should eq("Idle")
164
+ it 'should correctly fill the fields' do
165
+ @response = RubyAsterisk::Response.new('ExtensionState', [extension_state_response])
166
+ @response.data[:hints][0]['Status'].should eq('0')
167
+ @response.data[:hints][0]['DescriptiveStatus'].should eq('Idle')
169
168
  end
170
169
  end
171
- end
170
+ end
172
171
 
173
- describe "receiving a SIPPeers request" do
174
- it "should parse correctly data coming from Asterisk abous sip peers" do
175
- @response = RubyAsterisk::Response.new("SIPpeers",sip_peers_response)
172
+ describe 'receiving a SIPPeers request' do
173
+ it 'should parse correctly data coming from Asterisk abous sip peers' do
174
+ @response = RubyAsterisk::Response.new('SIPpeers', [sip_peers_response])
176
175
  @response.data[:peers].should_not be_empty
177
176
  end
178
-
179
- it "should correctly fill the fields" do
180
- @response = RubyAsterisk::Response.new("SIPpeers",sip_peers_response)
181
- @response.data[:peers][0]["ObjectName"].should eq("9915057")
182
- @response.data[:peers][0]["IPport"].should eq("5060")
177
+
178
+ it 'should correctly fill the fields' do
179
+ @response = RubyAsterisk::Response.new('SIPpeers', [sip_peers_response])
180
+ @response.data[:peers][0]['ObjectName'].should eq('9915057')
181
+ @response.data[:peers][0]['IPport'].should eq('5060')
183
182
  end
184
183
  end
185
184
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ SimpleCov.start
1
5
  require 'rubygems'
2
6
  require 'bundler'
3
7
 
4
8
  require 'ruby-asterisk'
9
+
10
+ RSpec.configure do |config|
11
+ config.expect_with :rspec do |c|
12
+ c.syntax = %i[should expect]
13
+ end
14
+
15
+ config.after(:suite) do
16
+ puts '[CI] Done.'
17
+ end
18
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+
5
+ module MockAMIServer
6
+ def self.handle_client(client, &block)
7
+ client.puts 'Asterisk Call Manager/1.1'
8
+
9
+ if block
10
+ yield(client)
11
+ else
12
+ while (line = client.gets)
13
+ client.puts "Echo: #{line}"
14
+ end
15
+ end
16
+ rescue StandardError => e
17
+ puts "Client error: #{e.message}"
18
+ ensure
19
+ begin
20
+ client.close
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ puts 'Client disconnected'
25
+ end
26
+
27
+ def self.start(port = 0, &block)
28
+ server = TCPServer.new('localhost', port)
29
+ assigned_port = server.addr[1]
30
+
31
+ client_threads = []
32
+ server_thread = Thread.new do
33
+ loop do
34
+ client = server.accept
35
+ puts "Client connected: #{client.peeraddr.inspect}"
36
+ client_threads << Thread.new(client) { |c| handle_client(c, &block) }
37
+ end
38
+ rescue IOError, Errno::EBADF # Server closed
39
+ ensure
40
+ begin
41
+ server.close
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ client_threads.each(&:kill)
46
+ end
47
+
48
+ [server_thread, assigned_port]
49
+ end
50
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'json'
5
+ require 'websocket/driver'
6
+
7
+ # Minimal in-process WebSocket server used by ARI::WebSocket integration specs.
8
+ # Accepts connections on an ephemeral port, completes the WebSocket handshake
9
+ # with websocket-driver in server mode, and records what clients send.
10
+ class MockAriWebSocketServer
11
+ # websocket-driver server adapter: it only needs #write
12
+ class ServerAdapter
13
+ def initialize(io)
14
+ @io = io
15
+ end
16
+
17
+ def write(data)
18
+ @io.write(data)
19
+ end
20
+ end
21
+
22
+ ClientConnection = Struct.new(:socket, :driver)
23
+
24
+ attr_reader :port, :messages, :pings, :connections
25
+
26
+ def initialize
27
+ @server = TCPServer.new('127.0.0.1', 0)
28
+ @port = @server.addr[1]
29
+ @messages = Queue.new # text frames received from clients
30
+ @pings = Queue.new # ping frames received from clients
31
+ @connections = Queue.new # one entry per completed handshake
32
+ @clients = []
33
+ @clients_mutex = Mutex.new
34
+ @accept_thread = Thread.new { accept_loop }
35
+ end
36
+
37
+ # Send an event (Hash) to every connected client
38
+ def broadcast(event)
39
+ data = JSON.generate(event)
40
+ each_client { |client| client.driver.text(data) }
41
+ end
42
+
43
+ # Close every client socket abruptly (no close frame), simulating a drop
44
+ def drop_clients
45
+ each_client { |client| client.socket.close }
46
+ @clients_mutex.synchronize { @clients.clear }
47
+ end
48
+
49
+ def stop
50
+ @server.close
51
+ @accept_thread.join(1)
52
+ drop_clients
53
+ rescue IOError
54
+ nil
55
+ end
56
+
57
+ # Pop from one of the queues, failing the spec on timeout
58
+ def self.pop(queue, timeout = 5)
59
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
60
+ loop do
61
+ return queue.pop(true)
62
+ rescue ThreadError
63
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
64
+ raise "timed out waiting for queue after #{timeout}s"
65
+ end
66
+
67
+ sleep 0.01
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def accept_loop
74
+ loop do
75
+ socket = @server.accept
76
+ Thread.new { handle_client(socket) }
77
+ end
78
+ rescue IOError, SystemCallError
79
+ nil
80
+ end
81
+
82
+ def handle_client(socket)
83
+ driver = ::WebSocket::Driver.server(ServerAdapter.new(socket))
84
+ client = ClientConnection.new(socket, driver)
85
+ @clients_mutex.synchronize { @clients << client }
86
+
87
+ setup_driver(driver, client)
88
+ driver.parse(socket.readpartial(4096)) until socket.closed?
89
+ rescue IOError, SystemCallError
90
+ nil
91
+ ensure
92
+ begin
93
+ socket.close
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+
99
+ def setup_driver(driver, client)
100
+ driver.on(:connect) { driver.start if ::WebSocket::Driver.websocket?(driver.env) }
101
+ driver.on(:open) { @connections << client }
102
+ driver.on(:message) { |event| @messages << event.data }
103
+ driver.on(:ping) { |event| @pings << event }
104
+ end
105
+
106
+ def each_client
107
+ clients = @clients_mutex.synchronize { @clients.dup }
108
+ clients.each do |client|
109
+ yield client
110
+ rescue IOError, SystemCallError
111
+ nil
112
+ end
113
+ end
114
+ end