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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/error'
4
+ require 'ruby-asterisk/ari/client'
5
+
6
+ RSpec.describe RubyAsterisk::ARI::Client do
7
+ let(:base_url) { 'http://localhost:8088' }
8
+ let(:api_key) { 'asterisk_api_key' }
9
+ let(:app_name) { 'stasis_app' }
10
+
11
+ let(:stubs) { Faraday::Adapter::Test::Stubs.new }
12
+ let(:test_conn) do
13
+ Faraday.new do |conn|
14
+ conn.adapter :test, stubs
15
+ end
16
+ end
17
+
18
+ subject(:client) { described_class.new(base_url, api_key, app_name) }
19
+
20
+ before do
21
+ client.instance_variable_set(:@connection, test_conn)
22
+ end
23
+
24
+ describe '#initialize' do
25
+ it 'stores base_url' do
26
+ expect(client.base_url).to eq(base_url)
27
+ end
28
+
29
+ it 'stores app_name' do
30
+ expect(client.app_name).to eq(app_name)
31
+ end
32
+ end
33
+
34
+ describe '#get' do
35
+ context 'when the request succeeds' do
36
+ before do
37
+ stubs.get('/ari/channels') do
38
+ [200, { 'Content-Type' => 'application/json' }, '[{"id":"12345"}]']
39
+ end
40
+ end
41
+
42
+ it 'returns parsed JSON' do
43
+ result = client.get('/ari/channels')
44
+ expect(result).to eq([{ 'id' => '12345' }])
45
+ end
46
+ end
47
+
48
+ context 'when the server returns 404' do
49
+ before do
50
+ stubs.get('/ari/channels/missing') do
51
+ [404, { 'Content-Type' => 'application/json' }, '{"message":"Channel not found"}']
52
+ end
53
+ end
54
+
55
+ it 'raises RubyAsterisk::Error' do
56
+ expect { client.get('/ari/channels/missing') }.to raise_error(RubyAsterisk::Error)
57
+ end
58
+ end
59
+
60
+ context 'when the server returns 500' do
61
+ before do
62
+ stubs.get('/ari/channels') do
63
+ [500, { 'Content-Type' => 'application/json' }, '{"message":"Internal Server Error"}']
64
+ end
65
+ end
66
+
67
+ it 'raises RubyAsterisk::Error' do
68
+ expect { client.get('/ari/channels') }.to raise_error(RubyAsterisk::Error)
69
+ end
70
+ end
71
+ end
72
+
73
+ describe '#post' do
74
+ context 'when the request succeeds' do
75
+ before do
76
+ stubs.post('/ari/channels') do
77
+ [200, { 'Content-Type' => 'application/json' }, '{"id":"new_channel"}']
78
+ end
79
+ end
80
+
81
+ it 'returns parsed JSON' do
82
+ result = client.post('/ari/channels', { endpoint: 'SIP/alice' })
83
+ expect(result).to eq({ 'id' => 'new_channel' })
84
+ end
85
+ end
86
+
87
+ context 'when the server returns 400' do
88
+ before do
89
+ stubs.post('/ari/channels') do
90
+ [400, { 'Content-Type' => 'application/json' }, '{"message":"Missing parameter"}']
91
+ end
92
+ end
93
+
94
+ it 'raises RubyAsterisk::Error with the server message' do
95
+ expect { client.post('/ari/channels', {}) }.to raise_error(RubyAsterisk::Error, 'Missing parameter')
96
+ end
97
+ end
98
+ end
99
+
100
+ describe '#delete' do
101
+ context 'when the request succeeds' do
102
+ before do
103
+ stubs.delete('/ari/channels/12345') do
104
+ [204, {}, '']
105
+ end
106
+ end
107
+
108
+ it 'returns nil for an empty body' do
109
+ result = client.delete('/ari/channels/12345')
110
+ expect(result).to be_nil
111
+ end
112
+ end
113
+
114
+ context 'when the server returns 404' do
115
+ before do
116
+ stubs.delete('/ari/channels/missing') do
117
+ [404, { 'Content-Type' => 'application/json' }, '{"message":"Channel not found"}']
118
+ end
119
+ end
120
+
121
+ it 'raises RubyAsterisk::Error' do
122
+ expect { client.delete('/ari/channels/missing') }.to raise_error(RubyAsterisk::Error)
123
+ end
124
+ end
125
+ end
126
+
127
+ describe '#channels' do
128
+ it 'returns a Collection for Channel resources' do
129
+ expect(client.channels).to be_a(RubyAsterisk::ARI::Resources::Collection)
130
+ end
131
+ end
132
+
133
+ describe '#bridges' do
134
+ it 'returns a Collection for Bridge resources' do
135
+ expect(client.bridges).to be_a(RubyAsterisk::ARI::Resources::Collection)
136
+ end
137
+ end
138
+
139
+ describe '#playbacks' do
140
+ it 'returns a Collection for Playback resources' do
141
+ expect(client.playbacks).to be_a(RubyAsterisk::ARI::Resources::Collection)
142
+ end
143
+ end
144
+
145
+ describe '#endpoints' do
146
+ it 'returns a Collection for Endpoint resources' do
147
+ expect(client.endpoints).to be_a(RubyAsterisk::ARI::Resources::Collection)
148
+ end
149
+ end
150
+
151
+ describe '#asterisk_info' do
152
+ let(:info_payload) do
153
+ { 'build' => { 'os' => 'Linux' }, 'system' => { 'entity_id' => 'asterisk' } }.to_json
154
+ end
155
+
156
+ before do
157
+ stubs.get('/ari/asterisk/info') do
158
+ [200, { 'Content-Type' => 'application/json' }, info_payload]
159
+ end
160
+ end
161
+
162
+ it 'performs GET /ari/asterisk/info and returns parsed data' do
163
+ result = client.asterisk_info
164
+ expect(result).to be_a(Hash)
165
+ expect(result).to have_key('build')
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Bridge do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) { { 'id' => 'bridge-456' } }
8
+
9
+ subject(:bridge) { described_class.new(data, client) }
10
+
11
+ describe '#id' do
12
+ it 'returns the bridge id from data' do
13
+ expect(bridge.id).to eq('bridge-456')
14
+ end
15
+ end
16
+
17
+ describe '#add_channel' do
18
+ it 'posts to the addChannel endpoint with the channel id' do
19
+ body = { channel: 'channel-123' }
20
+ allow(client).to receive(:post).with('/ari/bridges/bridge-456/addChannel', body).and_return(nil)
21
+ bridge.add_channel('channel-123')
22
+ expect(client).to have_received(:post).with('/ari/bridges/bridge-456/addChannel', body)
23
+ end
24
+ end
25
+
26
+ describe '#remove_channel' do
27
+ it 'posts to the removeChannel endpoint with the channel id' do
28
+ body = { channel: 'channel-123' }
29
+ allow(client).to receive(:post).with('/ari/bridges/bridge-456/removeChannel', body).and_return(nil)
30
+ bridge.remove_channel('channel-123')
31
+ expect(client).to have_received(:post).with('/ari/bridges/bridge-456/removeChannel', body)
32
+ end
33
+ end
34
+
35
+ describe '#destroy' do
36
+ it 'deletes the bridge' do
37
+ allow(client).to receive(:delete).with('/ari/bridges/bridge-456').and_return(nil)
38
+ bridge.destroy
39
+ expect(client).to have_received(:delete).with('/ari/bridges/bridge-456')
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Channel do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) { { 'id' => 'channel-123' } }
8
+
9
+ subject(:channel) { described_class.new(data, client) }
10
+
11
+ describe '#id' do
12
+ it 'returns the channel id from data' do
13
+ expect(channel.id).to eq('channel-123')
14
+ end
15
+ end
16
+
17
+ describe '#ring' do
18
+ it 'posts to the ring endpoint' do
19
+ allow(client).to receive(:post).with('/ari/channels/channel-123/ring').and_return(nil)
20
+ channel.ring
21
+ expect(client).to have_received(:post).with('/ari/channels/channel-123/ring')
22
+ end
23
+ end
24
+
25
+ describe '#answer' do
26
+ it 'posts to the answer endpoint' do
27
+ allow(client).to receive(:post).with('/ari/channels/channel-123/answer').and_return(nil)
28
+ channel.answer
29
+ expect(client).to have_received(:post).with('/ari/channels/channel-123/answer')
30
+ end
31
+ end
32
+
33
+ describe '#hangup' do
34
+ it 'deletes the channel' do
35
+ allow(client).to receive(:delete).with('/ari/channels/channel-123').and_return(nil)
36
+ channel.hangup
37
+ expect(client).to have_received(:delete).with('/ari/channels/channel-123')
38
+ end
39
+ end
40
+
41
+ describe '#play' do
42
+ let(:expected_body) { { media: 'sound:hello-world' } }
43
+
44
+ it 'posts to the play endpoint with the media parameter' do
45
+ allow(client).to receive(:post).with('/ari/channels/channel-123/play', expected_body).and_return({})
46
+ channel.play('sound:hello-world')
47
+ expect(client).to have_received(:post).with('/ari/channels/channel-123/play', expected_body)
48
+ end
49
+
50
+ it 'merges additional params into the request body' do
51
+ body = { media: 'sound:hello-world', lang: 'en' }
52
+ allow(client).to receive(:post).with('/ari/channels/channel-123/play', body).and_return({})
53
+ channel.play('sound:hello-world', { lang: 'en' })
54
+ expect(client).to have_received(:post).with('/ari/channels/channel-123/play', body)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Collection do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:resource_class) { RubyAsterisk::ARI::Resources::Channel }
8
+
9
+ subject(:collection) { described_class.new(resource_class, '/ari/channels', client) }
10
+
11
+ describe '#get' do
12
+ let(:channel_data) { { 'id' => 'channel-123' } }
13
+
14
+ before do
15
+ allow(client).to receive(:get).with('/ari/channels/channel-123').and_return(channel_data)
16
+ end
17
+
18
+ it 'fetches the resource by id' do
19
+ collection.get('channel-123')
20
+ expect(client).to have_received(:get).with('/ari/channels/channel-123')
21
+ end
22
+
23
+ it 'returns the resource wrapped in the correct class' do
24
+ result = collection.get('channel-123')
25
+ expect(result).to be_a(RubyAsterisk::ARI::Resources::Channel)
26
+ end
27
+
28
+ it 'passes the client to the resource' do
29
+ result = collection.get('channel-123')
30
+ expect(result.client).to eq(client)
31
+ end
32
+
33
+ it 'passes the data to the resource' do
34
+ result = collection.get('channel-123')
35
+ expect(result.data).to eq(channel_data)
36
+ end
37
+ end
38
+
39
+ describe '#list' do
40
+ let(:channels_data) { [{ 'id' => 'ch-1' }, { 'id' => 'ch-2' }] }
41
+
42
+ before do
43
+ allow(client).to receive(:get).with('/ari/channels', {}).and_return(channels_data)
44
+ end
45
+
46
+ it 'fetches the full list of resources' do
47
+ collection.list
48
+ expect(client).to have_received(:get).with('/ari/channels', {})
49
+ end
50
+
51
+ it 'returns an array of wrapped resources' do
52
+ result = collection.list
53
+ expect(result).to all(be_a(RubyAsterisk::ARI::Resources::Channel))
54
+ end
55
+
56
+ it 'returns the correct number of resources' do
57
+ expect(collection.list.size).to eq(2)
58
+ end
59
+
60
+ it 'accepts query params' do
61
+ allow(client).to receive(:get).with('/ari/channels', { state: 'Up' }).and_return(channels_data)
62
+ collection.list({ state: 'Up' })
63
+ expect(client).to have_received(:get).with('/ari/channels', { state: 'Up' })
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Endpoint do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) do
8
+ { 'technology' => 'SIP', 'resource' => 'alice', 'state' => 'online' }
9
+ end
10
+
11
+ subject(:endpoint) { described_class.new(data, client) }
12
+
13
+ describe '#technology' do
14
+ it 'returns the technology from data' do
15
+ expect(endpoint.technology).to eq('SIP')
16
+ end
17
+ end
18
+
19
+ describe '#resource' do
20
+ it 'returns the resource from data' do
21
+ expect(endpoint.resource).to eq('alice')
22
+ end
23
+ end
24
+
25
+ describe '#state' do
26
+ it 'returns the state from data' do
27
+ expect(endpoint.state).to eq('online')
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ari/client'
4
+
5
+ RSpec.describe RubyAsterisk::ARI::Resources::Playback do
6
+ let(:client) { instance_double(RubyAsterisk::ARI::Client) }
7
+ let(:data) { { 'id' => 'playback-789' } }
8
+
9
+ subject(:playback) { described_class.new(data, client) }
10
+
11
+ describe '#id' do
12
+ it 'returns the playback id from data' do
13
+ expect(playback.id).to eq('playback-789')
14
+ end
15
+ end
16
+
17
+ describe '#stop' do
18
+ it 'deletes the playback' do
19
+ allow(client).to receive(:delete).with('/ari/playbacks/playback-789').and_return(nil)
20
+ playback.stop
21
+ expect(client).to have_received(:delete).with('/ari/playbacks/playback-789')
22
+ end
23
+ end
24
+
25
+ describe '#control' do
26
+ it 'posts the operation to the control endpoint' do
27
+ body = { operation: 'pause' }
28
+ allow(client).to receive(:post).with('/ari/playbacks/playback-789/control', body).and_return(nil)
29
+ playback.control('pause')
30
+ expect(client).to have_received(:post).with('/ari/playbacks/playback-789/control', body)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'support/mock_ari_websocket_server'
5
+
6
+ RSpec.describe RubyAsterisk::ARI::WebSocket, 'integration' do
7
+ let(:server) { MockAriWebSocketServer.new }
8
+ let(:base_url) { "http://127.0.0.1:#{server.port}" }
9
+ let(:logger) { Logger.new(File::NULL) }
10
+
11
+ after do
12
+ @websocket&.disconnect
13
+ server.stop
14
+ end
15
+
16
+ def connect(options = {})
17
+ opened = Queue.new
18
+ defaults = { logger: logger, reconnect_delay: 0.1 }
19
+ @websocket = described_class.new(base_url, 'api_key', 'test_app', defaults.merge(options))
20
+ yield @websocket if block_given?
21
+ @websocket.connect { opened << true }
22
+ MockAriWebSocketServer.pop(opened)
23
+ @websocket
24
+ end
25
+
26
+ it 'completes the handshake and fires the on_connect callback' do
27
+ websocket = connect
28
+
29
+ expect(websocket.connected?).to be(true)
30
+ end
31
+
32
+ it 'dispatches server events to registered callbacks' do
33
+ received = Queue.new
34
+ connect { |ws| ws.on('StasisStart') { |data| received << data } }
35
+
36
+ server.broadcast('type' => 'StasisStart', 'channel' => { 'id' => '42' })
37
+
38
+ event = MockAriWebSocketServer.pop(received)
39
+ expect(event).to eq('type' => 'StasisStart', 'channel' => { 'id' => '42' })
40
+ end
41
+
42
+ it 'sends messages to the server' do
43
+ websocket = connect
44
+
45
+ expect(websocket.send_message?({ 'type' => 'ping' })).to be(true)
46
+ expect(MockAriWebSocketServer.pop(server.messages)).to eq('{"type":"ping"}')
47
+ end
48
+
49
+ it 'sends pings at the configured interval' do
50
+ connect(ping_interval: 0.1)
51
+
52
+ expect(MockAriWebSocketServer.pop(server.pings)).not_to be_nil
53
+ end
54
+
55
+ it 'reconnects automatically after a connection drop' do
56
+ received = Queue.new
57
+ connect { |ws| ws.on('StasisStart') { |data| received << data } }
58
+ MockAriWebSocketServer.pop(server.connections) # first handshake
59
+
60
+ server.drop_clients
61
+ MockAriWebSocketServer.pop(server.connections) # second handshake
62
+
63
+ server.broadcast('type' => 'StasisStart')
64
+ expect(MockAriWebSocketServer.pop(received)).to eq('type' => 'StasisStart')
65
+ end
66
+
67
+ it 'does not reconnect when auto_reconnect is disabled' do
68
+ websocket = connect(auto_reconnect: false)
69
+ thread = websocket.instance_variable_get(:@connection_thread)
70
+
71
+ server.drop_clients
72
+
73
+ expect(thread.join(2)).to eq(thread)
74
+ expect(websocket.connected?).to be(false)
75
+ end
76
+
77
+ it 'stops cleanly on disconnect' do
78
+ websocket = connect
79
+ thread = websocket.instance_variable_get(:@connection_thread)
80
+
81
+ websocket.disconnect
82
+
83
+ expect(websocket.connected?).to be(false)
84
+ expect(thread.alive?).to be(false)
85
+ end
86
+ end