switest 0.5.1 → 0.6.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 +4 -4
- data/README.md +29 -25
- data/lib/switest/agent.rb +34 -9
- data/lib/switest/assertions.rb +51 -0
- data/lib/switest/call.rb +196 -0
- data/lib/switest/client.rb +137 -0
- data/lib/switest/{esl/escaper.rb → escaper.rb} +1 -3
- data/lib/switest/events.rb +16 -65
- data/lib/switest/{esl/from_parser.rb → from_parser.rb} +1 -3
- data/lib/switest/scenario.rb +11 -52
- data/lib/switest/session.rb +49 -0
- data/lib/switest/version.rb +1 -1
- data/lib/switest.rb +6 -6
- metadata +10 -11
- data/lib/switest/case_insensitive_hash.rb +0 -17
- data/lib/switest/esl/call.rb +0 -209
- data/lib/switest/esl/client.rb +0 -213
- data/lib/switest/esl/connection.rb +0 -226
- data/lib/switest/esl/event.rb +0 -69
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 694639e976172b9f71b539b6bfc9d1decb8c2ae1735647c394a732213a419db7
|
|
4
|
+
data.tar.gz: bf525eb26fb1fbe8ed28cf7dbf760e08b8a3c9d4831b01e01355e69dceda42df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: edb3db5bd51e39e16707578d5a07c298edb10bac0188c5eebcbb34829677507203499837b3a58a7b1924003c3c925c9b61368ae45e5d373b21e6ea14f11c4a41
|
|
7
|
+
data.tar.gz: a2115e1b1429a1a7c7558dcf75fd4d54b53c64f9992723b5af742343d44c5fe2ad99ba5844f4712417d6f32711fa0d42a84d72bad67183bd0c0f94c3b7336c40
|
data/README.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/banner.svg" alt="switest" width="800"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# Switest
|
|
2
6
|
|
|
3
|
-
Functional testing for voice applications via FreeSWITCH
|
|
7
|
+
Functional testing for voice applications via FreeSWITCH.
|
|
4
8
|
|
|
5
|
-
Switest lets you write tests for your voice applications using
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
FreeSWITCH.
|
|
9
|
+
Switest lets you write tests for your voice applications using
|
|
10
|
+
[librevox](https://github.com/relatel/librevox) to communicate with
|
|
11
|
+
FreeSWITCH over Event Socket. Tests run as plain Minitest cases — no
|
|
12
|
+
Adhearsion, no Rayo, just a TCP socket to FreeSWITCH. Each test runs
|
|
13
|
+
inside an [async](https://github.com/socketry/async) reactor for
|
|
14
|
+
fiber-based concurrency.
|
|
9
15
|
|
|
10
16
|
## Table of Contents
|
|
11
17
|
|
|
@@ -74,8 +80,9 @@ bundle exec rake test
|
|
|
74
80
|
### Scenario
|
|
75
81
|
|
|
76
82
|
`Switest::Scenario` is a Minitest::Test subclass that handles FreeSWITCH
|
|
77
|
-
connection lifecycle for you. Each test method gets a fresh
|
|
78
|
-
|
|
83
|
+
connection lifecycle for you. Each test method gets a fresh connection that
|
|
84
|
+
is established on setup and torn down after the test. The test body runs
|
|
85
|
+
inside an `Async` reactor, so all waits are fiber-based and non-blocking.
|
|
79
86
|
|
|
80
87
|
```ruby
|
|
81
88
|
class MyTest < Switest::Scenario
|
|
@@ -137,6 +144,7 @@ Agent.listen_for_call(guards) # e.g. to: /pattern/, from: /pattern/
|
|
|
137
144
|
agent.answer(wait: 5) # Answer an inbound call
|
|
138
145
|
agent.hangup(wait: 5) # Hang up
|
|
139
146
|
agent.reject(reason = :decline) # Reject inbound call (:decline or :busy)
|
|
147
|
+
agent.play_audio(url) # Play an audio file or tone stream
|
|
140
148
|
agent.send_dtmf(digits) # Send DTMF tones
|
|
141
149
|
agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
|
|
142
150
|
```
|
|
@@ -145,8 +153,7 @@ agent.receive_dtmf(count:, timeout:) # Receive DTMF digits
|
|
|
145
153
|
|
|
146
154
|
```ruby
|
|
147
155
|
agent.wait_for_call(timeout: 5) # Wait for inbound call to arrive
|
|
148
|
-
agent.wait_for_answer(timeout: 5) # Wait for call to be answered
|
|
149
|
-
agent.wait_for_bridge(timeout: 5) # Wait for call to be bridged
|
|
156
|
+
agent.wait_for_answer(timeout: 5) # Wait for call to be fully answered (ACTIVE)
|
|
150
157
|
agent.wait_for_end(timeout: 5) # Wait for call to end
|
|
151
158
|
```
|
|
152
159
|
|
|
@@ -158,34 +165,30 @@ agent.alive? # Call exists and not ended?
|
|
|
158
165
|
agent.active? # Answered and not ended?
|
|
159
166
|
agent.answered? # Has been answered?
|
|
160
167
|
agent.ended? # Has ended?
|
|
168
|
+
agent.inbound? # Is an inbound call?
|
|
169
|
+
agent.outbound? # Is an outbound call?
|
|
170
|
+
agent.id # Call UUID
|
|
171
|
+
agent.headers # Call headers hash
|
|
161
172
|
agent.start_time # When call started
|
|
162
173
|
agent.answer_time # When answered
|
|
174
|
+
agent.end_time # When ended
|
|
163
175
|
agent.end_reason # e.g. "NORMAL_CLEARING"
|
|
164
176
|
```
|
|
165
177
|
|
|
166
178
|
### Scenario Assertions
|
|
167
179
|
|
|
168
|
-
`Switest::
|
|
180
|
+
Available via `Switest::Assertions` (included in `Switest::Scenario`):
|
|
169
181
|
|
|
170
182
|
```ruby
|
|
171
183
|
assert_call(agent, timeout: 5) # Agent receives a call
|
|
172
184
|
assert_no_call(agent, timeout: 2) # Agent does NOT receive a call
|
|
173
|
-
assert_answered(agent, timeout: 5) # Call
|
|
174
|
-
assert_bridged(agent, timeout: 5) # Call has been bridged (see note below)
|
|
185
|
+
assert_answered(agent, timeout: 5) # Call is fully established (ACTIVE)
|
|
175
186
|
assert_hungup(agent, timeout: 5) # Call has ended
|
|
176
187
|
assert_not_hungup(agent, timeout: 2) # Call is still active
|
|
177
188
|
assert_dtmf(agent, "123", timeout: 5) # Agent receives expected DTMF digits
|
|
178
189
|
assert_dtmf(agent, "123") { other.send_dtmf("123") } # With block: flushes stale DTMF first
|
|
179
190
|
```
|
|
180
191
|
|
|
181
|
-
**Note on `assert_bridged`:** This assertion only works when a CHANNEL_BRIDGE
|
|
182
|
-
event fires on a channel Switest tracks. It works when the FreeSWITCH dialplan
|
|
183
|
-
runs the `bridge` application on an inbound channel picked up via
|
|
184
|
-
`listen_for_call`. It does **not** work for agent-to-agent calls routed through
|
|
185
|
-
SIP gateways — the bridge happens on internal gateway legs whose UUIDs Switest
|
|
186
|
-
doesn't track. For gateway scenarios, use `assert_answered` on both agents
|
|
187
|
-
instead to confirm the call is connected.
|
|
188
|
-
|
|
189
192
|
The `hangup_all` helper ends all active calls (useful before CDR assertions):
|
|
190
193
|
|
|
191
194
|
```ruby
|
|
@@ -272,12 +275,13 @@ docker compose up -d freeswitch # start FreeSWITCH
|
|
|
272
275
|
docker compose run --rm test # run integration tests
|
|
273
276
|
```
|
|
274
277
|
|
|
275
|
-
The compose file mounts
|
|
278
|
+
The compose file mounts config files into FreeSWITCH:
|
|
276
279
|
|
|
277
280
|
| Local file | Container path |
|
|
278
281
|
|-----------------------------------------------|-----------------------------------------------------------------|
|
|
279
282
|
| `docker/freeswitch/event_socket.conf.xml` | `/etc/freeswitch/autoload_configs/event_socket.conf.xml` |
|
|
280
283
|
| `docker/freeswitch/acl.conf.xml` | `/etc/freeswitch/autoload_configs/acl.conf.xml` |
|
|
284
|
+
| `docker/freeswitch/switch.conf.xml` | `/etc/freeswitch/autoload_configs/switch.conf.xml` |
|
|
281
285
|
| `docker/freeswitch/dialplan.xml` | `/etc/freeswitch/dialplan/public/00_switest.xml` |
|
|
282
286
|
|
|
283
287
|
### FreeSWITCH Requirements
|
|
@@ -312,13 +316,13 @@ The compose file mounts three config files into FreeSWITCH:
|
|
|
312
316
|
```ruby
|
|
313
317
|
Switest.configure do |config|
|
|
314
318
|
config.host = "127.0.0.1" # FreeSWITCH host
|
|
315
|
-
config.port = 8021 #
|
|
316
|
-
config.password = "ClueCon" #
|
|
319
|
+
config.port = 8021 # Event Socket port
|
|
320
|
+
config.password = "ClueCon" # Event Socket password
|
|
317
321
|
config.default_timeout = 5 # Default timeout for waits
|
|
318
322
|
end
|
|
319
323
|
```
|
|
320
324
|
|
|
321
|
-
Or via environment variables (used by the
|
|
325
|
+
Or via environment variables (used by the scenario helper):
|
|
322
326
|
|
|
323
327
|
```bash
|
|
324
328
|
FREESWITCH_HOST=127.0.0.1
|
|
@@ -329,7 +333,7 @@ FREESWITCH_PASSWORD=ClueCon
|
|
|
329
333
|
## Dependencies
|
|
330
334
|
|
|
331
335
|
- Ruby >= 3.0
|
|
332
|
-
-
|
|
336
|
+
- librevox ~> 1.0
|
|
333
337
|
- minitest >= 5.5, < 7.0
|
|
334
338
|
|
|
335
339
|
## License
|
data/lib/switest/agent.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/promise"
|
|
5
|
+
|
|
3
6
|
module Switest
|
|
4
7
|
class Agent
|
|
5
8
|
class << self
|
|
@@ -30,8 +33,7 @@ module Switest
|
|
|
30
33
|
|
|
31
34
|
# Register a one-time handler for matching inbound calls
|
|
32
35
|
@events.once(:offer, guards) do |data|
|
|
33
|
-
agent.
|
|
34
|
-
agent.instance_variable_get(:@call_event).set
|
|
36
|
+
agent.receive_call(data[:call])
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
agent
|
|
@@ -42,7 +44,7 @@ module Switest
|
|
|
42
44
|
|
|
43
45
|
def initialize(call)
|
|
44
46
|
@call = call
|
|
45
|
-
@
|
|
47
|
+
@call_promise = Async::Promise.new
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def call?
|
|
@@ -81,7 +83,9 @@ module Switest
|
|
|
81
83
|
|
|
82
84
|
def wait_for_call(timeout: 5)
|
|
83
85
|
return true if @call
|
|
84
|
-
|
|
86
|
+
Async::Task.current.with_timeout(timeout) { @call_promise.wait }
|
|
87
|
+
!@call.nil?
|
|
88
|
+
rescue Async::TimeoutError
|
|
85
89
|
!@call.nil?
|
|
86
90
|
end
|
|
87
91
|
|
|
@@ -90,11 +94,6 @@ module Switest
|
|
|
90
94
|
@call.wait_for_answer(timeout: timeout)
|
|
91
95
|
end
|
|
92
96
|
|
|
93
|
-
def wait_for_bridge(timeout: 5)
|
|
94
|
-
raise "No call to wait for" unless @call
|
|
95
|
-
@call.wait_for_bridge(timeout: timeout)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
97
|
def wait_for_end(timeout: 5)
|
|
99
98
|
raise "No call to wait for" unless @call
|
|
100
99
|
@call.wait_for_end(timeout: timeout)
|
|
@@ -117,6 +116,18 @@ module Switest
|
|
|
117
116
|
@call&.ended? || false
|
|
118
117
|
end
|
|
119
118
|
|
|
119
|
+
def outbound?
|
|
120
|
+
@call&.outbound? || false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def inbound?
|
|
124
|
+
@call&.inbound? || false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def id
|
|
128
|
+
@call&.id
|
|
129
|
+
end
|
|
130
|
+
|
|
120
131
|
def start_time
|
|
121
132
|
@call&.start_time
|
|
122
133
|
end
|
|
@@ -125,8 +136,22 @@ module Switest
|
|
|
125
136
|
@call&.answer_time
|
|
126
137
|
end
|
|
127
138
|
|
|
139
|
+
def end_time
|
|
140
|
+
@call&.end_time
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
def end_reason
|
|
129
144
|
@call&.end_reason
|
|
130
145
|
end
|
|
146
|
+
|
|
147
|
+
def headers
|
|
148
|
+
@call&.headers
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @api private — called by listen_for_call handler
|
|
152
|
+
def receive_call(call)
|
|
153
|
+
@call = call
|
|
154
|
+
@call_promise.resolve(true)
|
|
155
|
+
end
|
|
131
156
|
end
|
|
132
157
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Switest
|
|
4
|
+
module Assertions
|
|
5
|
+
def assert_call(agent, timeout: 5)
|
|
6
|
+
success = agent.wait_for_call(timeout: timeout)
|
|
7
|
+
assert success, "Expected agent to receive a call within #{timeout} seconds"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def assert_no_call(agent, timeout: 2)
|
|
11
|
+
sleep timeout
|
|
12
|
+
refute agent.call?, "Expected agent to not have received a call"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assert_answered(agent, timeout: 5)
|
|
16
|
+
assert agent.call?, "Agent has no call"
|
|
17
|
+
success = agent.wait_for_answer(timeout: timeout)
|
|
18
|
+
assert success, "Expected call to be answered within #{timeout} seconds"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def assert_hungup(agent, timeout: 5)
|
|
22
|
+
assert agent.call?, "Agent has no call"
|
|
23
|
+
success = agent.wait_for_end(timeout: timeout)
|
|
24
|
+
assert success, "Expected call to be hung up within #{timeout} seconds"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def assert_not_hungup(agent, timeout: 2)
|
|
28
|
+
assert agent.call?, "Agent has no call"
|
|
29
|
+
sleep timeout
|
|
30
|
+
refute agent.ended?, "Expected call to still be active"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def assert_dtmf(agent, expected_dtmf, timeout: 5, after: 1, &block)
|
|
34
|
+
assert agent.call?, "Agent has no call"
|
|
35
|
+
|
|
36
|
+
if block
|
|
37
|
+
agent.flush_dtmf
|
|
38
|
+
Async do
|
|
39
|
+
sleep after
|
|
40
|
+
block.call
|
|
41
|
+
end
|
|
42
|
+
received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout + after)
|
|
43
|
+
else
|
|
44
|
+
received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
assert_equal expected_dtmf, received, "Expected DTMF '#{expected_dtmf}' but received '#{received}'"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/switest/call.rb
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/promise"
|
|
5
|
+
require "async/queue"
|
|
6
|
+
|
|
7
|
+
module Switest
|
|
8
|
+
class Call
|
|
9
|
+
CALLSTATE_MAP = { "RINGING" => :ringing, "ACTIVE" => :answered }.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :id, :to, :from, :headers, :direction
|
|
12
|
+
attr_reader :start_time, :answer_time, :end_time, :end_reason
|
|
13
|
+
attr_reader :state
|
|
14
|
+
|
|
15
|
+
def initialize(id:, direction:, to: nil, from: nil, headers: {}, session: nil)
|
|
16
|
+
@id = id
|
|
17
|
+
@session = session
|
|
18
|
+
@direction = direction # :inbound or :outbound
|
|
19
|
+
@to = to
|
|
20
|
+
@from = from
|
|
21
|
+
@headers = headers.is_a?(Hash) ? headers.dup : {}
|
|
22
|
+
|
|
23
|
+
@state = :new
|
|
24
|
+
@start_time = Time.now
|
|
25
|
+
@answer_time = nil
|
|
26
|
+
@end_time = nil
|
|
27
|
+
@end_reason = nil
|
|
28
|
+
|
|
29
|
+
@dtmf_queue = Async::Queue.new
|
|
30
|
+
|
|
31
|
+
@answered_promise = Async::Promise.new
|
|
32
|
+
@ended_promise = Async::Promise.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# State queries
|
|
36
|
+
def alive?
|
|
37
|
+
@state != :ended
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def active?
|
|
41
|
+
@state == :answered
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def answered?
|
|
45
|
+
@state == :answered
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ended?
|
|
49
|
+
@state == :ended
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inbound?
|
|
53
|
+
@direction == :inbound
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def outbound?
|
|
57
|
+
@direction == :outbound
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Actions
|
|
61
|
+
def answer(wait: 5)
|
|
62
|
+
return unless @state == :ringing && inbound?
|
|
63
|
+
sendmsg("execute", "answer")
|
|
64
|
+
return unless wait
|
|
65
|
+
wait_for_answer(timeout: wait)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def hangup(cause = "NORMAL_CLEARING", wait: 5)
|
|
69
|
+
return if ended?
|
|
70
|
+
sendmsg("hangup", hangup_cause: cause)
|
|
71
|
+
return unless wait
|
|
72
|
+
wait_for_end(timeout: wait)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reject(reason = :decline, wait: 5)
|
|
76
|
+
return unless @state == :ringing && inbound?
|
|
77
|
+
cause = case reason
|
|
78
|
+
when :busy then "USER_BUSY"
|
|
79
|
+
when :decline then "CALL_REJECTED"
|
|
80
|
+
else "CALL_REJECTED"
|
|
81
|
+
end
|
|
82
|
+
hangup(cause, wait: wait)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def play_audio(url, wait: true)
|
|
86
|
+
sendmsg("execute", "playback", url, event_lock: wait)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def send_dtmf(digits, wait: true)
|
|
90
|
+
play_audio("tone_stream://d=200;w=250;#{digits}", wait: wait)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def flush_dtmf
|
|
94
|
+
@dtmf_queue.dequeue until @dtmf_queue.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def receive_dtmf(count: 1, timeout: 5)
|
|
98
|
+
digits = String.new
|
|
99
|
+
deadline = Time.now + timeout
|
|
100
|
+
|
|
101
|
+
count.times do
|
|
102
|
+
remaining = deadline - Time.now
|
|
103
|
+
break if remaining <= 0
|
|
104
|
+
|
|
105
|
+
Async::Task.current.with_timeout(remaining) do
|
|
106
|
+
digits << @dtmf_queue.dequeue
|
|
107
|
+
end
|
|
108
|
+
rescue Async::TimeoutError
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
digits
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Blocking waits
|
|
116
|
+
def wait_for_answer(timeout: 5)
|
|
117
|
+
return true if answered?
|
|
118
|
+
Async::Task.current.with_timeout(timeout) { @answered_promise.wait }
|
|
119
|
+
answered?
|
|
120
|
+
rescue Async::TimeoutError
|
|
121
|
+
answered?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def wait_for_end(timeout: 5)
|
|
125
|
+
return true if ended?
|
|
126
|
+
Async::Task.current.with_timeout(timeout) { @ended_promise.wait }
|
|
127
|
+
ended?
|
|
128
|
+
rescue Async::TimeoutError
|
|
129
|
+
ended?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Internal: dispatch a librevox Response event to the appropriate handler.
|
|
133
|
+
def handle_event(response)
|
|
134
|
+
return unless response.event?
|
|
135
|
+
|
|
136
|
+
case response.event
|
|
137
|
+
when "CHANNEL_CALLSTATE"
|
|
138
|
+
handle_callstate(response.content[:channel_call_state])
|
|
139
|
+
when "CHANNEL_HANGUP_COMPLETE"
|
|
140
|
+
cause = response.content[:hangup_cause]
|
|
141
|
+
handle_hangup(cause, response.content)
|
|
142
|
+
when "DTMF"
|
|
143
|
+
digit = response.content[:dtmf_digit]
|
|
144
|
+
handle_dtmf(digit) if digit
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Internal state updates
|
|
149
|
+
def handle_callstate(call_state)
|
|
150
|
+
return if @state == :ended
|
|
151
|
+
new_state = CALLSTATE_MAP[call_state]
|
|
152
|
+
return if new_state.nil? || @state == new_state
|
|
153
|
+
|
|
154
|
+
@state = new_state
|
|
155
|
+
if new_state == :answered
|
|
156
|
+
@answer_time = Time.now
|
|
157
|
+
@answered_promise.resolve(true)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_hangup(cause, event_content = {})
|
|
162
|
+
return if @state == :ended
|
|
163
|
+
@state = :ended
|
|
164
|
+
@end_time = Time.now
|
|
165
|
+
@end_reason = cause
|
|
166
|
+
|
|
167
|
+
# Merge event data into headers
|
|
168
|
+
if event_content.is_a?(Hash)
|
|
169
|
+
event_content.each do |k, v|
|
|
170
|
+
next if k == :body
|
|
171
|
+
@headers[k] = v.to_s
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
@answered_promise.resolve(true)
|
|
176
|
+
@ended_promise.resolve(true)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def handle_dtmf(digit)
|
|
180
|
+
@dtmf_queue.enqueue(digit)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def sendmsg(command, app = nil, arg = nil, event_lock: false, hangup_cause: nil)
|
|
186
|
+
msg = +"sendmsg #{@id}\n"
|
|
187
|
+
msg << "call-command: #{command}\n"
|
|
188
|
+
msg << "execute-app-name: #{app}\n" if app
|
|
189
|
+
msg << "execute-app-arg: #{arg}\n" if arg
|
|
190
|
+
msg << "event-lock: true\n" if event_lock
|
|
191
|
+
msg << "hangup-cause: #{hangup_cause}" if hangup_cause
|
|
192
|
+
@session.command(msg.chomp)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "async/promise"
|
|
5
|
+
require "io/endpoint/host_endpoint"
|
|
6
|
+
require_relative "from_parser"
|
|
7
|
+
|
|
8
|
+
module Switest
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :calls
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@calls = {}
|
|
14
|
+
@offer_handlers = []
|
|
15
|
+
@session = nil
|
|
16
|
+
@client_task = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
config = Switest.configuration
|
|
21
|
+
|
|
22
|
+
# Configure Session class with client state
|
|
23
|
+
Session.call_registry = @calls
|
|
24
|
+
Session.offer_handler = method(:handle_inbound_offer)
|
|
25
|
+
Session.connection_promise = Async::Promise.new
|
|
26
|
+
|
|
27
|
+
endpoint = IO::Endpoint.tcp(config.host, config.port)
|
|
28
|
+
client = Librevox::Client.new(Session, endpoint, auth: config.password)
|
|
29
|
+
@client_task = Async { client.run }
|
|
30
|
+
|
|
31
|
+
# Wait for the session to be established
|
|
32
|
+
@session = Async::Task.current.with_timeout(config.default_timeout) do
|
|
33
|
+
Session.connection_promise.wait
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
self
|
|
37
|
+
rescue Async::TimeoutError
|
|
38
|
+
@client_task&.stop
|
|
39
|
+
@client_task = nil
|
|
40
|
+
raise Switest::ConnectionError, "Timed out connecting to FreeSWITCH"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
hangup_all
|
|
45
|
+
@client_task&.stop
|
|
46
|
+
@client_task = nil
|
|
47
|
+
@session = nil
|
|
48
|
+
@calls.clear
|
|
49
|
+
Session.call_registry = nil
|
|
50
|
+
Session.offer_handler = nil
|
|
51
|
+
Session.connection_promise = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def connected?
|
|
55
|
+
!@session.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
|
|
59
|
+
active = @calls.values.reject(&:ended?)
|
|
60
|
+
|
|
61
|
+
active.each do |call|
|
|
62
|
+
call.hangup(cause, wait: false) rescue nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
deadline = Time.now + timeout
|
|
66
|
+
active.each do |call|
|
|
67
|
+
remaining = deadline - Time.now
|
|
68
|
+
break if remaining <= 0
|
|
69
|
+
call.wait_for_end(timeout: remaining) unless call.ended?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dial(to:, from: nil, timeout: nil, headers: {})
|
|
74
|
+
uuid = SecureRandom.uuid
|
|
75
|
+
|
|
76
|
+
vars = {
|
|
77
|
+
origination_uuid: uuid,
|
|
78
|
+
return_ring_ready: true
|
|
79
|
+
}
|
|
80
|
+
vars.merge!(FromParser.parse(from)) if from
|
|
81
|
+
vars[:originate_timeout] = timeout if timeout
|
|
82
|
+
|
|
83
|
+
var_string = Escaper.build_var_string(vars, headers)
|
|
84
|
+
|
|
85
|
+
call = Call.new(
|
|
86
|
+
id: uuid,
|
|
87
|
+
direction: :outbound,
|
|
88
|
+
to: to,
|
|
89
|
+
from: from,
|
|
90
|
+
headers: headers,
|
|
91
|
+
session: @session
|
|
92
|
+
)
|
|
93
|
+
@calls[uuid] = call
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
@session.bgapi("originate #{var_string}#{to} &park()")
|
|
97
|
+
rescue
|
|
98
|
+
@calls.delete(uuid)
|
|
99
|
+
raise
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
call
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def on_offer(&block)
|
|
106
|
+
@offer_handlers << block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def active_calls
|
|
110
|
+
@calls.select { |_k, v| v.alive? }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def handle_inbound_offer(event)
|
|
116
|
+
uuid = event.content[:unique_id]
|
|
117
|
+
data = event.content
|
|
118
|
+
|
|
119
|
+
call = Call.new(
|
|
120
|
+
id: uuid,
|
|
121
|
+
direction: :inbound,
|
|
122
|
+
to: data[:caller_destination_number],
|
|
123
|
+
from: data[:caller_caller_id_number],
|
|
124
|
+
headers: data,
|
|
125
|
+
session: @session
|
|
126
|
+
)
|
|
127
|
+
call.handle_callstate("RINGING")
|
|
128
|
+
@calls[uuid] = call
|
|
129
|
+
|
|
130
|
+
fire_offer(call)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def fire_offer(call)
|
|
134
|
+
@offer_handlers.each { |h| h.call(call) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Switest
|
|
4
|
-
|
|
5
|
-
# Escapes values for use in FreeSWITCH channel variable strings.
|
|
4
|
+
# Escapes values for use in FreeSWITCH channel variable strings.
|
|
6
5
|
#
|
|
7
6
|
# FreeSWITCH originate syntax: {var1=value1,var2=value2}endpoint
|
|
8
7
|
#
|
|
@@ -139,6 +138,5 @@ module Switest
|
|
|
139
138
|
# Fallback - this should rarely happen
|
|
140
139
|
":"
|
|
141
140
|
end
|
|
142
|
-
end
|
|
143
141
|
end
|
|
144
142
|
end
|