ruby-asterisk 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +48 -4
- data/.rubocop.yml +84 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +3 -1
- data/README.md +410 -108
- data/Rakefile +3 -5
- 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 +71 -69
- data/lib/ruby-asterisk/request.rb +33 -14
- data/lib/ruby-asterisk/response.rb +43 -16
- data/lib/ruby-asterisk/response_builder.rb +18 -0
- data/lib/ruby-asterisk/response_parser.rb +10 -9
- data/lib/ruby-asterisk/version.rb +4 -2
- data/lib/ruby-asterisk.rb +5 -222
- data/ruby-asterisk.gemspec +23 -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 +46 -47
- data/spec/spec_helper.rb +12 -0
- data/spec/support/mock_ami_server.rb +50 -0
- data/spec/support/mock_ari_websocket_server.rb +114 -0
- metadata +138 -18
- data/CHANGELOG +0 -3
- data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -192
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/agi/server'
|
|
5
|
+
require 'socket'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
# Starts an AGI server on an ephemeral port and returns [server, server_thread, port].
|
|
9
|
+
# Automatically registers an after hook that calls stop and joins the thread.
|
|
10
|
+
def start_test_server(server, &)
|
|
11
|
+
server.handle(&)
|
|
12
|
+
thread = Thread.new { server.run }
|
|
13
|
+
Timeout.timeout(2) { sleep 0.01 until server.running? || !thread.alive? }
|
|
14
|
+
raise 'Server did not start' unless server.running?
|
|
15
|
+
|
|
16
|
+
[thread, server.port]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Connect as a fake Asterisk client, send env + responses, collect written commands.
|
|
20
|
+
def fake_asterisk_session(port, env_lines: [], responses: [])
|
|
21
|
+
socket = TCPSocket.new('127.0.0.1', port)
|
|
22
|
+
# Send env block
|
|
23
|
+
env_lines.each { |l| socket.puts(l) }
|
|
24
|
+
socket.puts('') # blank line terminates env
|
|
25
|
+
|
|
26
|
+
received_commands = []
|
|
27
|
+
responses.each do |resp|
|
|
28
|
+
cmd = socket.gets
|
|
29
|
+
received_commands << cmd.chomp if cmd
|
|
30
|
+
socket.puts(resp)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
socket.close
|
|
34
|
+
received_commands
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
socket&.close
|
|
37
|
+
raise e
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RSpec.describe RubyAsterisk::AGI::Server do
|
|
41
|
+
let(:logger) { instance_double(Logger, info: nil, warn: nil, error: nil, debug: nil) }
|
|
42
|
+
subject(:server) { described_class.new('127.0.0.1', 0, logger: logger) }
|
|
43
|
+
|
|
44
|
+
after do
|
|
45
|
+
server.stop if server.running?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#initialize' do
|
|
49
|
+
it 'stores the host' do
|
|
50
|
+
expect(server.host).to eq('127.0.0.1')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'starts with running? false' do
|
|
54
|
+
expect(server.running?).to be(false)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#handle' do
|
|
59
|
+
it 'stores the handler and returns self' do
|
|
60
|
+
result = server.handle { |s| s }
|
|
61
|
+
expect(result).to be(server)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#run' do
|
|
66
|
+
it 'raises RubyAsterisk::Error when no handler is registered' do
|
|
67
|
+
expect { server.run }.to raise_error(RubyAsterisk::Error, /No handler registered/)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'blocks and sets running? to true while accepting' do
|
|
71
|
+
_thread, _port = start_test_server(server) { |_s| nil }
|
|
72
|
+
expect(server.running?).to be(true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'sets running? back to false after stop' do
|
|
76
|
+
thread, = start_test_server(server) { |_s| nil }
|
|
77
|
+
server.stop
|
|
78
|
+
thread.join(2)
|
|
79
|
+
expect(server.running?).to be(false)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'updates port to the actual bound port when given 0' do
|
|
83
|
+
start_test_server(server) { |_s| nil }
|
|
84
|
+
expect(server.port).to be > 0
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe 'connection handling' do
|
|
89
|
+
it 'invokes the handler with a Session for each connection' do
|
|
90
|
+
sessions_seen = []
|
|
91
|
+
_thread, port = start_test_server(server) do |session|
|
|
92
|
+
sessions_seen << session
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
fake_asterisk_session(port, env_lines: ['agi_channel: SIP/test'])
|
|
96
|
+
sleep 0.1
|
|
97
|
+
server.stop
|
|
98
|
+
|
|
99
|
+
expect(sessions_seen.length).to eq(1)
|
|
100
|
+
expect(sessions_seen.first).to be_a(RubyAsterisk::AGI::Session)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'populates session.env from the AGI environment block' do
|
|
104
|
+
captured_env = nil
|
|
105
|
+
_thread, port = start_test_server(server) do |session|
|
|
106
|
+
captured_env = session.env.dup
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
fake_asterisk_session(port, env_lines: ['agi_channel: SIP/alice', 'agi_callerid: 5551234'])
|
|
110
|
+
sleep 0.1
|
|
111
|
+
server.stop
|
|
112
|
+
|
|
113
|
+
expect(captured_env['agi_channel']).to eq('SIP/alice')
|
|
114
|
+
expect(captured_env['agi_callerid']).to eq('5551234')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'exchanges AGI commands and responses correctly' do
|
|
118
|
+
_thread, port = start_test_server(server) do |session|
|
|
119
|
+
session.answer
|
|
120
|
+
session.hangup
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
cmds = fake_asterisk_session(port,
|
|
124
|
+
env_lines: ['agi_channel: SIP/test'],
|
|
125
|
+
responses: ['200 result=1', '200 result=1'])
|
|
126
|
+
server.stop
|
|
127
|
+
|
|
128
|
+
expect(cmds).to eq(%w[ANSWER HANGUP])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'does not crash the accept loop when a handler raises' do
|
|
132
|
+
call_count = 0
|
|
133
|
+
_thread, port = start_test_server(server) do |_session|
|
|
134
|
+
call_count += 1
|
|
135
|
+
raise 'deliberate error' if call_count == 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
fake_asterisk_session(port, env_lines: [])
|
|
139
|
+
sleep 0.05
|
|
140
|
+
fake_asterisk_session(port, env_lines: [])
|
|
141
|
+
sleep 0.05
|
|
142
|
+
server.stop
|
|
143
|
+
|
|
144
|
+
expect(call_count).to eq(2)
|
|
145
|
+
expect(logger).to have_received(:error).at_least(:once)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe 'concurrency' do
|
|
150
|
+
it 'handles multiple simultaneous connections using Fiber scheduling' do
|
|
151
|
+
guard = Mutex.new
|
|
152
|
+
started = 0
|
|
153
|
+
finished = 0
|
|
154
|
+
captured_scheduler = nil
|
|
155
|
+
|
|
156
|
+
_thread, port = start_test_server(server) do |_session|
|
|
157
|
+
guard.synchronize do
|
|
158
|
+
started += 1
|
|
159
|
+
captured_scheduler ||= Fiber.scheduler
|
|
160
|
+
end
|
|
161
|
+
sleep 0.2
|
|
162
|
+
guard.synchronize { finished += 1 }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
start = Time.now
|
|
166
|
+
|
|
167
|
+
Array.new(5) do
|
|
168
|
+
Thread.new do
|
|
169
|
+
fake_asterisk_session(port, env_lines: [])
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end.each(&:join)
|
|
174
|
+
|
|
175
|
+
# Wait until all 5 handlers have started.
|
|
176
|
+
Timeout.timeout(2) { sleep 0.01 until started == 5 }
|
|
177
|
+
|
|
178
|
+
server.stop
|
|
179
|
+
|
|
180
|
+
# With Fiber scheduling all 5 handlers run concurrently, so finishing
|
|
181
|
+
# takes ~0.2s regardless of connection count.
|
|
182
|
+
Timeout.timeout(2) { sleep 0.01 until finished == 5 }
|
|
183
|
+
elapsed = Time.now - start
|
|
184
|
+
|
|
185
|
+
expect(finished).to eq(5)
|
|
186
|
+
expect(elapsed).to be < 0.8
|
|
187
|
+
# Proves handlers ran inside an Async Fiber scheduler, not threads.
|
|
188
|
+
expect(captured_scheduler).not_to be_nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe '#stop' do
|
|
193
|
+
it 'is idempotent — calling twice does not raise' do
|
|
194
|
+
start_test_server(server) { |_s| nil }
|
|
195
|
+
server.stop
|
|
196
|
+
expect { server.stop }.not_to raise_error
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -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
|