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,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/agi/session'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
RSpec.describe RubyAsterisk::AGI::Session do
|
|
8
|
+
# A fake socket backed by StringIO for reads and a StringIO capture for writes.
|
|
9
|
+
def fake_socket(input_string, responses: [])
|
|
10
|
+
reader = StringIO.new(input_string)
|
|
11
|
+
writer = StringIO.new
|
|
12
|
+
socket = double('socket')
|
|
13
|
+
|
|
14
|
+
allow(socket).to receive(:gets) do
|
|
15
|
+
line = reader.gets
|
|
16
|
+
line || responses.shift
|
|
17
|
+
end
|
|
18
|
+
allow(socket).to receive(:write) { |data| writer.write(data) }
|
|
19
|
+
|
|
20
|
+
[socket, writer]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Builds a socket whose reads cycle through env_block then response_lines
|
|
24
|
+
def session_socket(env_block, response_lines)
|
|
25
|
+
all_input = env_block + response_lines.map { |l| "#{l}\n" }.join
|
|
26
|
+
socket, writer = fake_socket(all_input)
|
|
27
|
+
[socket, writer]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
let(:logger) { instance_double(Logger, debug: nil, info: nil, warn: nil, error: nil) }
|
|
31
|
+
|
|
32
|
+
describe '#read_env' do
|
|
33
|
+
it 'parses agi_* key/value pairs from the env block' do
|
|
34
|
+
env_block = "agi_channel: SIP/test-1\nagi_callerid: 1000\n\n"
|
|
35
|
+
socket, = fake_socket(env_block)
|
|
36
|
+
session = described_class.new(socket, logger: logger)
|
|
37
|
+
session.read_env
|
|
38
|
+
|
|
39
|
+
expect(session.env['agi_channel']).to eq('SIP/test-1')
|
|
40
|
+
expect(session.env['agi_callerid']).to eq('1000')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'stops at the blank line' do
|
|
44
|
+
env_block = "agi_channel: SIP/test-1\n\nagi_after_blank: ignored\n"
|
|
45
|
+
socket, = fake_socket(env_block)
|
|
46
|
+
session = described_class.new(socket, logger: logger)
|
|
47
|
+
session.read_env
|
|
48
|
+
|
|
49
|
+
expect(session.env).not_to have_key('agi_after_blank')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'ignores non-agi lines with a debug log' do
|
|
53
|
+
env_block = "agi_channel: SIP/x\nunknown line\n\n"
|
|
54
|
+
socket, = fake_socket(env_block)
|
|
55
|
+
session = described_class.new(socket, logger: logger)
|
|
56
|
+
session.read_env
|
|
57
|
+
|
|
58
|
+
expect(logger).to have_received(:debug).with(/unexpected line/)
|
|
59
|
+
expect(session.env.keys).to eq(['agi_channel'])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#execute' do
|
|
64
|
+
subject(:session) { described_class.new(socket, logger: logger) }
|
|
65
|
+
|
|
66
|
+
let(:socket) do
|
|
67
|
+
s, = session_socket('', ['200 result=1'])
|
|
68
|
+
s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'returns a parsed response hash' do
|
|
72
|
+
result = session.execute('ANSWER')
|
|
73
|
+
expect(result).to eq(code: 200, result: '1', data: nil)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'raises RubyAsterisk::Error on 5xx response' do
|
|
77
|
+
s = double('socket')
|
|
78
|
+
allow(s).to receive(:write)
|
|
79
|
+
allow(s).to receive(:gets).and_return("510 Invalid or unknown command\n")
|
|
80
|
+
session = described_class.new(s, logger: logger)
|
|
81
|
+
|
|
82
|
+
expect { session.execute('BADCMD') }.to raise_error(RubyAsterisk::Error, /510/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'raises RubyAsterisk::Error on EOF (nil)' do
|
|
86
|
+
s = double('socket')
|
|
87
|
+
allow(s).to receive(:write)
|
|
88
|
+
allow(s).to receive(:gets).and_return(nil)
|
|
89
|
+
session = described_class.new(s, logger: logger)
|
|
90
|
+
|
|
91
|
+
expect { session.execute('ANSWER') }.to raise_error(RubyAsterisk::Error)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe 'command wrappers' do
|
|
96
|
+
let(:written) { StringIO.new }
|
|
97
|
+
let(:socket) do
|
|
98
|
+
s = double('socket')
|
|
99
|
+
allow(s).to receive(:write) { |data| written.write(data) }
|
|
100
|
+
allow(s).to receive(:gets).and_return("200 result=1\n")
|
|
101
|
+
s
|
|
102
|
+
end
|
|
103
|
+
subject(:session) { described_class.new(socket, logger: logger) }
|
|
104
|
+
|
|
105
|
+
it '#answer writes ANSWER command' do
|
|
106
|
+
session.answer
|
|
107
|
+
expect(written.string).to eq("ANSWER\n")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it '#hangup writes HANGUP without channel' do
|
|
111
|
+
session.hangup
|
|
112
|
+
expect(written.string).to eq("HANGUP\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it '#hangup writes HANGUP with channel' do
|
|
116
|
+
session.hangup('SIP/1001')
|
|
117
|
+
expect(written.string).to eq("HANGUP SIP/1001\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it '#stream_file quotes filename and escape_digits' do
|
|
121
|
+
session.stream_file('hello-world', '#*')
|
|
122
|
+
expect(written.string).to eq(%(STREAM FILE "hello-world" "#*"\n))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it '#stream_file uses empty escape_digits by default' do
|
|
126
|
+
session.stream_file('hello-world')
|
|
127
|
+
expect(written.string).to eq(%(STREAM FILE "hello-world" ""\n))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it '#say_digits writes SAY DIGITS command' do
|
|
131
|
+
session.say_digits('1234')
|
|
132
|
+
expect(written.string).to eq(%(SAY DIGITS 1234 ""\n))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it '#say_number writes SAY NUMBER command' do
|
|
136
|
+
session.say_number(42)
|
|
137
|
+
expect(written.string).to eq(%(SAY NUMBER 42 ""\n))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it '#exec writes EXEC command with joined args' do
|
|
141
|
+
session.exec('AGICommand', 'arg1', 'arg2')
|
|
142
|
+
expect(written.string).to eq(%(EXEC AGICommand "arg1,arg2"\n))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it '#set_variable writes SET VARIABLE command' do
|
|
146
|
+
session.set_variable('MYVAR', 'hello')
|
|
147
|
+
expect(written.string).to eq(%(SET VARIABLE MYVAR "hello"\n))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it '#get_variable writes GET VARIABLE command' do
|
|
151
|
+
session.get_variable('MYVAR')
|
|
152
|
+
expect(written.string).to eq("GET VARIABLE MYVAR\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it '#get_data writes GET DATA command with defaults' do
|
|
156
|
+
session.get_data('silence')
|
|
157
|
+
expect(written.string).to eq(%(GET DATA "silence" 5000 1024\n))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it '#get_data accepts timeout and max_digits overrides' do
|
|
161
|
+
session.get_data('beep', timeout: 3000, max_digits: 4)
|
|
162
|
+
expect(written.string).to eq(%(GET DATA "beep" 3000 4\n))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it '#verbose writes VERBOSE command' do
|
|
166
|
+
session.verbose('test message', level: 2)
|
|
167
|
+
expect(written.string).to eq(%(VERBOSE "test message" 2\n))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it '#exec escapes args containing commas correctly' do
|
|
171
|
+
session.exec('AGI', 'arg,with,commas', 'say "hi"')
|
|
172
|
+
expect(written.string).to eq(%(EXEC AGI "arg,with,commas,say \\"hi\\""\n))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# -- Telephony commands ---------------------------------------------------
|
|
176
|
+
|
|
177
|
+
it '#dial writes EXEC Dial with target and timeout' do
|
|
178
|
+
session.dial('SIP/1001', timeout: 30)
|
|
179
|
+
expect(written.string).to eq(%(EXEC Dial "SIP/1001,30"\n))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it '#dial includes options when provided' do
|
|
183
|
+
session.dial('SIP/1001', timeout: 15, options: 'r')
|
|
184
|
+
expect(written.string).to eq(%(EXEC Dial "SIP/1001,15,r"\n))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it '#wait_for_digit writes WAIT FOR DIGIT with timeout' do
|
|
188
|
+
session.wait_for_digit(3000)
|
|
189
|
+
expect(written.string).to eq("WAIT FOR DIGIT 3000\n")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it '#wait_for_digit uses 5000ms default timeout' do
|
|
193
|
+
session.wait_for_digit
|
|
194
|
+
expect(written.string).to eq("WAIT FOR DIGIT 5000\n")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it '#record_file writes RECORD FILE with beep by default' do
|
|
198
|
+
session.record_file('recording', format: 'wav', escape_digits: '#', timeout_ms: 10_000, offset: 0)
|
|
199
|
+
expect(written.string).to eq(%(RECORD FILE "recording" wav "#" 10000 0 BEEP\n))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it '#record_file includes silence param when provided' do
|
|
203
|
+
session.record_file('msg', silence: 5)
|
|
204
|
+
expect(written.string).to eq(%(RECORD FILE "msg" wav "#" -1 0 BEEP s=5\n))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it '#record_file omits BEEP when beep: false' do
|
|
208
|
+
session.record_file('msg', beep: false)
|
|
209
|
+
expect(written.string).to eq(%(RECORD FILE "msg" wav "#" -1 0\n))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it '#send_text writes SEND TEXT with quoted message' do
|
|
213
|
+
session.send_text('hello world')
|
|
214
|
+
expect(written.string).to eq(%(SEND TEXT "hello world"\n))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it '#send_image writes SEND IMAGE with filename' do
|
|
218
|
+
session.send_image('logo.png')
|
|
219
|
+
expect(written.string).to eq("SEND IMAGE logo.png\n")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it '#channel_status writes CHANNEL STATUS without arg by default' do
|
|
223
|
+
session.channel_status
|
|
224
|
+
expect(written.string).to eq("CHANNEL STATUS\n")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it '#channel_status writes CHANNEL STATUS with a channel name' do
|
|
228
|
+
session.channel_status('SIP/1001')
|
|
229
|
+
expect(written.string).to eq("CHANNEL STATUS SIP/1001\n")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# -- AstDB commands -------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
it '#database_get writes DATABASE GET command' do
|
|
235
|
+
session.database_get('family', 'key')
|
|
236
|
+
expect(written.string).to eq("DATABASE GET family key\n")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it '#database_put writes DATABASE PUT command with quoted value' do
|
|
240
|
+
session.database_put('family', 'key', 'hello world')
|
|
241
|
+
expect(written.string).to eq(%(DATABASE PUT family key "hello world"\n))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it '#database_del writes DATABASE DEL command' do
|
|
245
|
+
session.database_del('family', 'key')
|
|
246
|
+
expect(written.string).to eq("DATABASE DEL family key\n")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it '#database_deltree writes DATABASE DELTREE without subtree' do
|
|
250
|
+
session.database_deltree('family')
|
|
251
|
+
expect(written.string).to eq("DATABASE DELTREE family\n")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it '#database_deltree writes DATABASE DELTREE with subtree' do
|
|
255
|
+
session.database_deltree('family', 'sub/tree')
|
|
256
|
+
expect(written.string).to eq("DATABASE DELTREE family sub/tree\n")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
describe 'multi-line 520 error handling' do
|
|
261
|
+
let(:logger) { instance_double(Logger, debug: nil, info: nil, warn: nil, error: nil) }
|
|
262
|
+
|
|
263
|
+
it 'raises with the accumulated body for a 520- intro followed by 520 closer' do
|
|
264
|
+
lines = [
|
|
265
|
+
"520-Invalid command syntax. Proper usage follows:\n",
|
|
266
|
+
"520-Usage: WAIT FOR DIGIT <timeout>\n",
|
|
267
|
+
"520 End of proper usage.\n"
|
|
268
|
+
]
|
|
269
|
+
socket = double('socket')
|
|
270
|
+
allow(socket).to receive(:write)
|
|
271
|
+
allow(socket).to receive(:gets).and_return(*lines)
|
|
272
|
+
session = described_class.new(socket, logger: logger)
|
|
273
|
+
|
|
274
|
+
expect { session.execute('BADCMD') }
|
|
275
|
+
.to raise_error(RubyAsterisk::Error, /Invalid command syntax/)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'accumulates the usage lines into the error message' do
|
|
279
|
+
lines = [
|
|
280
|
+
"520-Invalid command syntax. Proper usage follows:\n",
|
|
281
|
+
"520-Usage: WAIT FOR DIGIT <timeout>\n",
|
|
282
|
+
"520 End of proper usage.\n"
|
|
283
|
+
]
|
|
284
|
+
socket = double('socket')
|
|
285
|
+
allow(socket).to receive(:write)
|
|
286
|
+
allow(socket).to receive(:gets).and_return(*lines)
|
|
287
|
+
session = described_class.new(socket, logger: logger)
|
|
288
|
+
|
|
289
|
+
expect { session.execute('BADCMD') }
|
|
290
|
+
.to raise_error(RubyAsterisk::Error, /WAIT FOR DIGIT/)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/client'
|
|
5
|
+
require 'ruby-asterisk/ami/promise'
|
|
6
|
+
require 'support/mock_ami_server'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
# Helpers shared across examples in this file
|
|
10
|
+
ASYNC_NEWLINES = ["\r\n", "\n"].freeze
|
|
11
|
+
|
|
12
|
+
# Builds a MockAMIServer block that responds to Login and Ping commands.
|
|
13
|
+
def ami_responder
|
|
14
|
+
lambda do |sock|
|
|
15
|
+
buf = +''
|
|
16
|
+
while (line = sock.gets)
|
|
17
|
+
buf << line
|
|
18
|
+
next unless ASYNC_NEWLINES.include?(line)
|
|
19
|
+
|
|
20
|
+
if buf =~ /ActionID: (\S+)/
|
|
21
|
+
action_id = Regexp.last_match(1)
|
|
22
|
+
if buf.include?('Action: Login')
|
|
23
|
+
sock.print "Response: Success\r\nActionID: #{action_id}\r\nMessage: Authentication accepted\r\n\r\n"
|
|
24
|
+
elsif buf.include?('Action: Ping')
|
|
25
|
+
sock.print "Response: Success\r\nActionID: #{action_id}\r\nPing: Pong\r\n\r\n"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
buf.clear
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RSpec.describe 'RubyAsterisk::AMI::Client — async behaviour' do
|
|
34
|
+
# -------------------------------------------------------------------------
|
|
35
|
+
# Shared setup: a standard mock Asterisk server
|
|
36
|
+
# -------------------------------------------------------------------------
|
|
37
|
+
before do
|
|
38
|
+
@server_thread, @port = MockAMIServer.start(&ami_responder)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
after { @server_thread&.kill }
|
|
42
|
+
|
|
43
|
+
let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
|
|
44
|
+
|
|
45
|
+
# -------------------------------------------------------------------------
|
|
46
|
+
# execute is non-blocking
|
|
47
|
+
# -------------------------------------------------------------------------
|
|
48
|
+
describe '#execute non-blocking behaviour' do
|
|
49
|
+
it 'returns a Promise without waiting for Asterisk' do
|
|
50
|
+
client.connect
|
|
51
|
+
|
|
52
|
+
start_time = Time.now
|
|
53
|
+
promise = client.ping # internally calls execute
|
|
54
|
+
elapsed = Time.now - start_time
|
|
55
|
+
|
|
56
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
57
|
+
expect(elapsed).to be < 0.5 # near-instant — no blocking wait
|
|
58
|
+
|
|
59
|
+
promise.value(2) # clean up: wait for response
|
|
60
|
+
client.disconnect
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'promise is not yet resolved right after execute' do
|
|
64
|
+
client.connect
|
|
65
|
+
promise = client.ping
|
|
66
|
+
# Promise may or may not be resolved immediately — just check it is a Promise
|
|
67
|
+
expect(promise).to be_a(RubyAsterisk::AMI::Promise)
|
|
68
|
+
promise.value(2)
|
|
69
|
+
client.disconnect
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# -------------------------------------------------------------------------
|
|
74
|
+
# Promise resolves to a proper Response
|
|
75
|
+
# -------------------------------------------------------------------------
|
|
76
|
+
describe 'Promise#value' do
|
|
77
|
+
it 'returns a RubyAsterisk::Response on success' do
|
|
78
|
+
client.connect
|
|
79
|
+
response = client.ping.value(2)
|
|
80
|
+
expect(response).to be_a(RubyAsterisk::Response)
|
|
81
|
+
expect(response.success).to be true
|
|
82
|
+
client.disconnect
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'raises Timeout::Error when no response arrives in time' do
|
|
86
|
+
# Server accepts but never responds to commands
|
|
87
|
+
silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
|
|
91
|
+
slow_client.connect
|
|
92
|
+
promise = slow_client.execute('Ping')
|
|
93
|
+
expect { promise.value(0.2) }.to raise_error(Timeout::Error)
|
|
94
|
+
slow_client.disconnect
|
|
95
|
+
ensure
|
|
96
|
+
silent_thread&.kill
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# -------------------------------------------------------------------------
|
|
102
|
+
# Multiple concurrent commands
|
|
103
|
+
# -------------------------------------------------------------------------
|
|
104
|
+
describe 'concurrent commands' do
|
|
105
|
+
it 'resolves multiple in-flight promises independently' do
|
|
106
|
+
client.connect
|
|
107
|
+
|
|
108
|
+
# Fire three pings without waiting for any of them
|
|
109
|
+
promises = Array.new(3) { client.ping }
|
|
110
|
+
|
|
111
|
+
# Now collect all responses
|
|
112
|
+
responses = promises.map { |p| p.value(2) }
|
|
113
|
+
|
|
114
|
+
expect(responses).to all(be_a(RubyAsterisk::Response))
|
|
115
|
+
expect(responses.map(&:success)).to all(be true)
|
|
116
|
+
|
|
117
|
+
client.disconnect
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# -------------------------------------------------------------------------
|
|
122
|
+
# Disconnect rejects pending promises
|
|
123
|
+
# -------------------------------------------------------------------------
|
|
124
|
+
describe '#disconnect' do
|
|
125
|
+
it 'rejects promises that are still pending' do
|
|
126
|
+
# Use a server that accepts the connection but never responds to commands
|
|
127
|
+
slow_thread, slow_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
slow_client = RubyAsterisk::AMI::Client.new(host: 'localhost', port: slow_port)
|
|
131
|
+
slow_client.connect
|
|
132
|
+
|
|
133
|
+
promise = slow_client.execute('Ping')
|
|
134
|
+
slow_client.disconnect # tears down before response arrives
|
|
135
|
+
|
|
136
|
+
expect { promise.value(0.5) }.to raise_error(RuntimeError, /disconnected/i)
|
|
137
|
+
ensure
|
|
138
|
+
slow_thread&.kill
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'sets connected to false' do
|
|
143
|
+
client.connect
|
|
144
|
+
expect(client.connected).to be true
|
|
145
|
+
client.disconnect
|
|
146
|
+
expect(client.connected).to be false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# -------------------------------------------------------------------------
|
|
151
|
+
# A timed-out promise must not leak in the reactor's pending map
|
|
152
|
+
# -------------------------------------------------------------------------
|
|
153
|
+
describe 'pending-promise cleanup on timeout' do
|
|
154
|
+
it 'unregisters the promise after Promise#value times out' do
|
|
155
|
+
silent_thread, silent_port = MockAMIServer.start { |_sock| sleep 30 }
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: silent_port)
|
|
159
|
+
c.connect
|
|
160
|
+
promise = c.execute('Ping')
|
|
161
|
+
expect { promise.value(0.2) }.to raise_error(Timeout::Error)
|
|
162
|
+
|
|
163
|
+
pending = c.instance_variable_get(:@reactor).instance_variable_get(:@promises)
|
|
164
|
+
expect(pending).to be_empty
|
|
165
|
+
|
|
166
|
+
c.disconnect
|
|
167
|
+
ensure
|
|
168
|
+
silent_thread&.kill
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# -------------------------------------------------------------------------
|
|
174
|
+
# Per-command timeout must not mutate the client-wide default
|
|
175
|
+
# -------------------------------------------------------------------------
|
|
176
|
+
describe 'per-command timeout isolation' do
|
|
177
|
+
it 'does not raise the shared default timeout' do
|
|
178
|
+
client.connect
|
|
179
|
+
expect(client.instance_variable_get(:@timeout)).to eq(5)
|
|
180
|
+
|
|
181
|
+
client.originate('SIP/100', 'default', '200', '1', timeout: 300_000)
|
|
182
|
+
|
|
183
|
+
expect(client.instance_variable_get(:@timeout)).to eq(5)
|
|
184
|
+
client.disconnect
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# -------------------------------------------------------------------------
|
|
189
|
+
# A caller-supplied ActionID replaces the generated one instead of being
|
|
190
|
+
# appended as a second ActionID header (which would break correlation)
|
|
191
|
+
# -------------------------------------------------------------------------
|
|
192
|
+
describe 'caller-supplied ActionID' do
|
|
193
|
+
it 'sends exactly one ActionID header and still resolves the promise' do
|
|
194
|
+
frames = Thread::Queue.new
|
|
195
|
+
echo_thread, echo_port = MockAMIServer.start do |sock|
|
|
196
|
+
buf = +''
|
|
197
|
+
while (line = sock.gets)
|
|
198
|
+
buf << line
|
|
199
|
+
next unless ASYNC_NEWLINES.include?(line)
|
|
200
|
+
|
|
201
|
+
frames << buf.dup
|
|
202
|
+
sock.print "Response: Success\r\nActionID: #{buf[/ActionID: (\S+)/, 1]}\r\n\r\n"
|
|
203
|
+
buf.clear
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
begin
|
|
208
|
+
c = RubyAsterisk::AMI::Client.new(host: 'localhost', port: echo_port)
|
|
209
|
+
c.connect
|
|
210
|
+
response = c.status(channel: 'SIP/alice-001', action_id: 'custom-id').value(2)
|
|
211
|
+
|
|
212
|
+
frame = frames.pop
|
|
213
|
+
expect(frame.scan(/^ActionID:/).size).to eq(1)
|
|
214
|
+
expect(frame).to include("ActionID: custom-id\r\n")
|
|
215
|
+
expect(response.success).to be true
|
|
216
|
+
|
|
217
|
+
c.disconnect
|
|
218
|
+
ensure
|
|
219
|
+
echo_thread&.kill
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# -------------------------------------------------------------------------
|
|
225
|
+
# WaitEvent with a negative Timeout must not impose a Promise deadline
|
|
226
|
+
# -------------------------------------------------------------------------
|
|
227
|
+
describe '#wait_event' do
|
|
228
|
+
it 'leaves the promise without a deadline when Timeout is negative' do
|
|
229
|
+
client.connect
|
|
230
|
+
|
|
231
|
+
promise = client.wait_event(timeout: -1)
|
|
232
|
+
|
|
233
|
+
expect(promise.instance_variable_get(:@timeout)).to be_nil
|
|
234
|
+
client.disconnect
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'keeps the client default as the floor for a positive Timeout' do
|
|
238
|
+
client.connect
|
|
239
|
+
|
|
240
|
+
promise = client.wait_event(timeout: 30)
|
|
241
|
+
|
|
242
|
+
expect(promise.instance_variable_get(:@timeout)).to eq(30)
|
|
243
|
+
client.disconnect
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# -------------------------------------------------------------------------
|
|
248
|
+
# Commands issued before connect fail loudly rather than hanging
|
|
249
|
+
# -------------------------------------------------------------------------
|
|
250
|
+
describe 'guard when not connected' do
|
|
251
|
+
it 'raises instead of NoMethodError' do
|
|
252
|
+
fresh = RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port)
|
|
253
|
+
expect { fresh.execute('Ping') }.to raise_error(RubyAsterisk::Error, /not connected/i)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/ami/client'
|
|
5
|
+
require 'support/mock_ami_server'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
# Regression coverage for AMI EventList (multi-frame) responses. A list action
|
|
9
|
+
# replies with an ack frame (EventList: start), then one Event frame per item,
|
|
10
|
+
# then a terminating *Complete event — each a separate \r\n\r\n frame carrying
|
|
11
|
+
# the same ActionID. The Reactor must aggregate them before resolving so that
|
|
12
|
+
# ResponseParser can extract the structured data.
|
|
13
|
+
FRAME_NEWLINES = ["\r\n", "\n"].freeze
|
|
14
|
+
|
|
15
|
+
def list_responder
|
|
16
|
+
lambda do |sock|
|
|
17
|
+
buf = +''
|
|
18
|
+
while (line = sock.gets)
|
|
19
|
+
buf << line
|
|
20
|
+
next unless FRAME_NEWLINES.include?(line)
|
|
21
|
+
|
|
22
|
+
if buf.include?('Action: CoreShowChannels') && buf =~ /ActionID: (\S+)/
|
|
23
|
+
id = Regexp.last_match(1)
|
|
24
|
+
sock.print "Response: Success\r\nActionID: #{id}\r\nEventList: start\r\n" \
|
|
25
|
+
"Message: Channels will follow\r\n\r\n"
|
|
26
|
+
sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
|
|
27
|
+
"Channel: SIP/100-00000001\r\nChannelState: 6\r\n\r\n"
|
|
28
|
+
sock.print "Event: CoreShowChannel\r\nActionID: #{id}\r\n" \
|
|
29
|
+
"Channel: SIP/200-00000002\r\nChannelState: 6\r\n\r\n"
|
|
30
|
+
sock.print "Event: CoreShowChannelsComplete\r\nActionID: #{id}\r\n" \
|
|
31
|
+
"EventList: Complete\r\nListItems: 2\r\n\r\n"
|
|
32
|
+
end
|
|
33
|
+
buf.clear
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
RSpec.describe 'RubyAsterisk::AMI::Client — EventList aggregation' do
|
|
39
|
+
before { @server_thread, @port = MockAMIServer.start(&list_responder) }
|
|
40
|
+
after { @server_thread&.kill }
|
|
41
|
+
|
|
42
|
+
let(:client) { RubyAsterisk::AMI::Client.new(host: 'localhost', port: @port) }
|
|
43
|
+
|
|
44
|
+
it 'aggregates the ack, event, and Complete frames into one Response' do
|
|
45
|
+
client.connect
|
|
46
|
+
response = client.core_show_channels.value(2)
|
|
47
|
+
|
|
48
|
+
expect(response).to be_a(RubyAsterisk::Response)
|
|
49
|
+
expect(response.data).to be_a(Hash)
|
|
50
|
+
channels = response.data[:channels]
|
|
51
|
+
expect(channels.size).to eq(2)
|
|
52
|
+
expect(channels.map { |c| c['Channel'] })
|
|
53
|
+
.to contain_exactly('SIP/100-00000001', 'SIP/200-00000002')
|
|
54
|
+
|
|
55
|
+
client.disconnect
|
|
56
|
+
end
|
|
57
|
+
end
|