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
data/lib/ruby-asterisk.rb
CHANGED
|
@@ -1,148 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
require "ruby-asterisk/request"
|
|
3
|
-
require "ruby-asterisk/response"
|
|
1
|
+
# frozen_string_literal: true
|
|
4
2
|
|
|
5
|
-
require '
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
attr_accessor :host, :port, :connected
|
|
10
|
-
|
|
11
|
-
def initialize(host,port)
|
|
12
|
-
self.host = host.to_s
|
|
13
|
-
self.port = port.to_i
|
|
14
|
-
self.connected = false
|
|
15
|
-
@session = nil
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def connect
|
|
19
|
-
begin
|
|
20
|
-
@session = Net::Telnet::new("Host" => self.host,"Port" => self.port)
|
|
21
|
-
self.connected = true
|
|
22
|
-
rescue Exception => ex
|
|
23
|
-
false
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def disconnect
|
|
28
|
-
begin
|
|
29
|
-
@session.close if self.connected
|
|
30
|
-
@session = nil
|
|
31
|
-
self.connected = false
|
|
32
|
-
true
|
|
33
|
-
rescue Exception => ex
|
|
34
|
-
puts ex
|
|
35
|
-
false
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def login(username,password)
|
|
40
|
-
self.connect unless self.connected
|
|
41
|
-
execute "Login", {"Username" => username, "Secret" => password, "Event" => "On"}
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def command(command)
|
|
45
|
-
execute "Command", {"Command" => command}
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def core_show_channels
|
|
49
|
-
execute "CoreShowChannels"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def meet_me_list
|
|
53
|
-
execute "MeetMeList"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def parked_calls
|
|
57
|
-
execute "ParkedCalls"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def extension_state(exten, context, action_id=nil)
|
|
61
|
-
execute "ExtensionState", {"Exten" => exten, "Context" => context, "ActionID" => action_id}
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def skinny_devices
|
|
65
|
-
execute "SKINNYdevices"
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def skinny_lines
|
|
69
|
-
execute "SKINNYlines"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def status(channel=nil,action_id=nil)
|
|
73
|
-
execute "Status", {"Channel" => channel, "ActionID" => action_id}
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def originate(caller,context,callee,priority,variable=nil)
|
|
77
|
-
execute "Originate", {"Channel" => caller, "Context" => context, "Exten" => callee, "Priority" => priority, "Callerid" => caller, "Timeout" => "30000", "Variable" => variable }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def channels
|
|
81
|
-
execute "Command", { "Command" => "show channels" }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def redirect(caller,context,callee,priority,variable=nil)
|
|
85
|
-
execute "Redirect", {"Channel" => caller, "Context" => context, "Exten" => callee, "Priority" => priority, "Callerid" => caller, "Timeout" => "30000", "Variable" => variable}
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def queues
|
|
89
|
-
execute "Queues", {}
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def queue_add(queue, exten, penalty=2, paused=false, member_name)
|
|
93
|
-
execute "QueueAdd", {"Queue" => queue, "Interface" => exten, "Penalty" => penalty, "Paused" => paused, "MemberName" => member_name}
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def queue_pause(queue, exten)
|
|
97
|
-
execute "QueuePause", {"Interface" => exten, "Paused" => paused}
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def queue_remove(queue, exten)
|
|
101
|
-
execute "QueueRemove", {"Queue" => queue, "Interface" => exten}
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def queue_status
|
|
105
|
-
execute "QueueStatus"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def queue_summary(queue)
|
|
109
|
-
execute "QueueSummary", {"Queue" => queue}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def mailbox_status(exten, context="default")
|
|
113
|
-
execute "MailboxStatus", {"Mailbox" => "#{exten}@#{context}"}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def mailbox_count(exten, context="default")
|
|
117
|
-
execute "MailboxCount", {"Mailbox" => "#{exten}@#{context}"}
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def queue_pause(interface,paused,queue,reason='none')
|
|
121
|
-
execute "QueuePause", {"Interface" => interface, "Paused" => paused, "Queue" => queue, "Reason" => reason}
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def ping
|
|
125
|
-
execute "Ping"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def event_mask(event_mask="off")
|
|
129
|
-
execute "Events", {"EventMask" => event_mask}
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def sip_peers
|
|
133
|
-
execute "SIPpeers"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
private
|
|
137
|
-
def execute(command, options={})
|
|
138
|
-
request = Request.new(command, options)
|
|
139
|
-
request.commands.each do |command|
|
|
140
|
-
@session.write(command)
|
|
141
|
-
end
|
|
142
|
-
@session.waitfor("Match" => /ActionID: #{request.action_id}.*?\n\n/m, "Timeout" => 10) do |data|
|
|
143
|
-
request.response_data << data
|
|
144
|
-
end
|
|
145
|
-
Response.new(command,request.response_data)
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
3
|
+
require 'ruby-asterisk/ami/client'
|
|
4
|
+
require 'ruby-asterisk/ari/client'
|
|
5
|
+
require 'ruby-asterisk/ari/websocket'
|
|
6
|
+
require 'ruby-asterisk/agi/server'
|
data/ruby-asterisk.gemspec
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
4
|
+
require 'ruby-asterisk/version'
|
|
4
5
|
|
|
5
6
|
Gem::Specification.new do |s|
|
|
6
|
-
s.name =
|
|
7
|
-
s.version =
|
|
7
|
+
s.name = 'ruby-asterisk'
|
|
8
|
+
s.version = RubyAsterisk::VERSION
|
|
8
9
|
s.platform = Gem::Platform::RUBY
|
|
9
|
-
s.authors = [
|
|
10
|
-
s.email = [
|
|
11
|
-
s.homepage =
|
|
12
|
-
s.summary =
|
|
13
|
-
s.description =
|
|
14
|
-
|
|
15
|
-
s.
|
|
10
|
+
s.authors = ['Emiliano Della Casa']
|
|
11
|
+
s.email = ['emiliano.dellacasa@gmail.com']
|
|
12
|
+
s.homepage = 'http://github.com/emilianodellacasa/ruby-asterisk'
|
|
13
|
+
s.summary = 'Asterisk Manager Interface in Ruby'
|
|
14
|
+
s.description = 'Add support to your ruby or rails projects to Asterisk Manager Interface (AMI)'
|
|
15
|
+
s.licenses = ['MIT']
|
|
16
|
+
s.required_ruby_version = '>= 3.1'
|
|
16
17
|
|
|
17
18
|
s.files = `git ls-files`.split("\n")
|
|
18
|
-
s.
|
|
19
|
-
s.
|
|
20
|
-
|
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
20
|
+
s.require_paths = ['lib']
|
|
21
|
+
|
|
22
|
+
s.add_runtime_dependency 'async', '~> 2.0'
|
|
23
|
+
s.add_runtime_dependency 'faraday', '>= 1.0'
|
|
24
|
+
s.add_runtime_dependency 'logger'
|
|
25
|
+
s.add_runtime_dependency 'websocket-driver', '~> 0.8'
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
s.add_development_dependency 'rake'
|
|
28
|
+
s.add_development_dependency 'rspec'
|
|
29
|
+
s.add_development_dependency 'rubocop'
|
|
30
|
+
s.add_development_dependency 'rubocop-performance'
|
|
31
|
+
s.add_development_dependency 'simplecov'
|
|
32
|
+
s.metadata['rubygems_mfa_required'] = 'true'
|
|
24
33
|
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ruby-asterisk/agi/protocol'
|
|
5
|
+
|
|
6
|
+
RSpec.describe RubyAsterisk::AGI::Protocol do
|
|
7
|
+
describe '.parse_response' do
|
|
8
|
+
subject(:parse) { described_class.parse_response(line) }
|
|
9
|
+
|
|
10
|
+
context 'with a plain success result' do
|
|
11
|
+
let(:line) { '200 result=1' }
|
|
12
|
+
|
|
13
|
+
it { expect(parse).to eq(code: 200, result: '1', data: nil) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
context 'with result=0' do
|
|
17
|
+
let(:line) { '200 result=0' }
|
|
18
|
+
|
|
19
|
+
it { expect(parse).to eq(code: 200, result: '0', data: nil) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
context 'with endpos extra data' do
|
|
23
|
+
let(:line) { '200 result=0 endpos=12345' }
|
|
24
|
+
|
|
25
|
+
it { expect(parse).to eq(code: 200, result: '0', data: 'endpos=12345') }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'with parenthetical extra data' do
|
|
29
|
+
let(:line) { '200 result=1 (some text)' }
|
|
30
|
+
|
|
31
|
+
it { expect(parse).to eq(code: 200, result: '1', data: 'some text') }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
context 'with trailing newline' do
|
|
35
|
+
let(:line) { "200 result=1\n" }
|
|
36
|
+
|
|
37
|
+
it { expect(parse[:code]).to eq(200) }
|
|
38
|
+
it { expect(parse[:result]).to eq('1') }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context 'with 510 error' do
|
|
42
|
+
let(:line) { '510 Invalid or unknown command' }
|
|
43
|
+
|
|
44
|
+
it { expect(parse).to eq(code: 510, result: nil, data: 'Invalid or unknown command') }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
context 'with 511 error' do
|
|
48
|
+
let(:line) { '511 Command Not Permitted on a dead channel' }
|
|
49
|
+
|
|
50
|
+
it { expect(parse).to eq(code: 511, result: nil, data: 'Command Not Permitted on a dead channel') }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context 'with 520 single-line syntax error' do
|
|
54
|
+
let(:line) { '520 Invalid command syntax' }
|
|
55
|
+
|
|
56
|
+
it { expect(parse).to eq(code: 520, result: nil, data: 'Invalid command syntax') }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'with nil (socket EOF)' do
|
|
60
|
+
let(:line) { nil }
|
|
61
|
+
|
|
62
|
+
it { expect(parse).to eq(code: 0, result: nil, data: 'Connection closed') }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
context 'with an unrecognised line' do
|
|
66
|
+
let(:line) { 'INVALID' }
|
|
67
|
+
|
|
68
|
+
it { expect(parse[:code]).to eq(0) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '.format_command' do
|
|
73
|
+
it 'formats a bare command with no args' do
|
|
74
|
+
expect(described_class.format_command('ANSWER')).to eq("ANSWER\n")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'appends plain args without quoting' do
|
|
78
|
+
expect(described_class.format_command('WAIT FOR DIGIT', '5000')).to eq("WAIT FOR DIGIT 5000\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'wraps args that contain spaces in double quotes' do
|
|
82
|
+
expect(described_class.format_command('VERBOSE', 'hello world', '1')).to eq(%(VERBOSE "hello world" 1\n))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'escapes backslashes inside quoted args' do
|
|
86
|
+
expect(described_class.format_command('EXEC', 'foo', 'a\\b')).to eq(%(EXEC foo "a\\\\b"\n))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'escapes double quotes inside quoted args' do
|
|
90
|
+
expect(described_class.format_command('EXEC', 'foo', 'say "hi"')).to eq(%(EXEC foo "say \\"hi\\""\n))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '.escape_argument' do
|
|
95
|
+
it 'returns "" for an empty string' do
|
|
96
|
+
expect(described_class.escape_argument('')).to eq('""')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'returns the arg unchanged when no special chars' do
|
|
100
|
+
expect(described_class.escape_argument('hello-world')).to eq('hello-world')
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'wraps in quotes when arg contains a space' do
|
|
104
|
+
expect(described_class.escape_argument('hello world')).to eq('"hello world"')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'escapes an internal backslash' do
|
|
108
|
+
expect(described_class.escape_argument('a\\b')).to eq('"a\\\\b"')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'escapes an internal double-quote' do
|
|
112
|
+
expect(described_class.escape_argument('say "hi"')).to eq('"say \\"hi\\""')
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '.quote' do
|
|
117
|
+
it 'always wraps in double quotes even for plain strings' do
|
|
118
|
+
expect(described_class.quote('hello-world')).to eq('"hello-world"')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'wraps an empty string' do
|
|
122
|
+
expect(described_class.quote('')).to eq('""')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'escapes internal double quotes' do
|
|
126
|
+
expect(described_class.quote('say "hi"')).to eq('"say \\"hi\\""')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'escapes internal backslashes' do
|
|
130
|
+
expect(described_class.quote('a\\b')).to eq('"a\\\\b"')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '.parse_env_line' do
|
|
135
|
+
it 'returns [key, value] for a valid agi_ line' do
|
|
136
|
+
expect(described_class.parse_env_line('agi_channel: SIP/test-1')).to eq(['agi_channel', 'SIP/test-1'])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'strips leading/trailing whitespace from the value' do
|
|
140
|
+
expect(described_class.parse_env_line('agi_request: agi://localhost ')).to eq(['agi_request', 'agi://localhost'])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'returns nil for a non-agi line' do
|
|
144
|
+
expect(described_class.parse_env_line('unexpected garbage')).to be_nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'returns nil for a blank line' do
|
|
148
|
+
expect(described_class.parse_env_line('')).to be_nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe '.parse_env_block' do
|
|
153
|
+
require 'stringio'
|
|
154
|
+
|
|
155
|
+
it 'parses multiple agi_* lines into a hash' do
|
|
156
|
+
io = StringIO.new("agi_channel: SIP/1001\nagi_callerid: 555\n\n")
|
|
157
|
+
env = described_class.parse_env_block(io)
|
|
158
|
+
|
|
159
|
+
expect(env).to eq('agi_channel' => 'SIP/1001', 'agi_callerid' => '555')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'stops at the blank line and ignores content after it' do
|
|
163
|
+
io = StringIO.new("agi_channel: SIP/1001\n\nagi_after_blank: ignored\n")
|
|
164
|
+
env = described_class.parse_env_block(io)
|
|
165
|
+
|
|
166
|
+
expect(env).not_to have_key('agi_after_blank')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'yields unrecognised lines to the block' do
|
|
170
|
+
io = StringIO.new("agi_channel: SIP/1001\nunknown line\n\n")
|
|
171
|
+
unrecognised = []
|
|
172
|
+
described_class.parse_env_block(io) { |l| unrecognised << l }
|
|
173
|
+
|
|
174
|
+
expect(unrecognised).to eq(['unknown line'])
|
|
175
|
+
expect(unrecognised.length).to eq(1)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'handles EOF gracefully (no blank line terminator)' do
|
|
179
|
+
io = StringIO.new("agi_channel: SIP/1001\n")
|
|
180
|
+
env = described_class.parse_env_block(io)
|
|
181
|
+
|
|
182
|
+
expect(env).to eq('agi_channel' => 'SIP/1001')
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe '.collect_multiline_error' do
|
|
187
|
+
require 'stringio'
|
|
188
|
+
|
|
189
|
+
let(:base_response) { { code: 520, result: nil, data: nil } }
|
|
190
|
+
|
|
191
|
+
it 'returns first_response unchanged when first_line has no continuation marker' do
|
|
192
|
+
io = StringIO.new('')
|
|
193
|
+
result = described_class.collect_multiline_error('520 Invalid command syntax', base_response, io)
|
|
194
|
+
|
|
195
|
+
expect(result).to equal(base_response)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'reads continuation lines until the closing NNN line and joins them' do
|
|
199
|
+
continuation = "520-Usage: WAIT FOR DIGIT <timeout>\n520 End of proper usage.\n"
|
|
200
|
+
io = StringIO.new(continuation)
|
|
201
|
+
result = described_class.collect_multiline_error('520-Invalid command syntax.', base_response, io)
|
|
202
|
+
|
|
203
|
+
expect(result[:code]).to eq(520)
|
|
204
|
+
expect(result[:data]).to include('Invalid command syntax')
|
|
205
|
+
expect(result[:data]).to include('WAIT FOR DIGIT')
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'preserves the code from first_response' do
|
|
209
|
+
continuation = "520-Details\n520 End of proper usage.\n"
|
|
210
|
+
io = StringIO.new(continuation)
|
|
211
|
+
result = described_class.collect_multiline_error('520-Intro', { code: 520, result: nil, data: nil }, io)
|
|
212
|
+
|
|
213
|
+
expect(result[:code]).to eq(520)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'returns a frozen hash' do
|
|
217
|
+
continuation = "520-Usage line\n520 End of proper usage.\n"
|
|
218
|
+
io = StringIO.new(continuation)
|
|
219
|
+
result = described_class.collect_multiline_error('520-Intro', base_response, io)
|
|
220
|
+
|
|
221
|
+
expect(result).to be_frozen
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe '.error?' do
|
|
226
|
+
it 'returns true for 5xx codes' do
|
|
227
|
+
expect(described_class.error?(code: 510, result: nil, data: 'x')).to be(true)
|
|
228
|
+
expect(described_class.error?(code: 520, result: nil, data: 'x')).to be(true)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it 'returns false for 200' do
|
|
232
|
+
expect(described_class.error?(code: 200, result: '1', data: nil)).to be(false)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'returns false for code 0' do
|
|
236
|
+
expect(described_class.error?(code: 0, result: nil, data: 'x')).to be(false)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -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
|