freeswitch-esl 0.1.0 → 0.2.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 +83 -22
- data/lib/freeswitch/esl/client.rb +120 -19
- data/lib/freeswitch/esl/command.rb +221 -0
- data/lib/freeswitch/esl/configuration.rb +39 -13
- data/lib/freeswitch/esl/connection/command_dispatcher.rb +258 -0
- data/lib/freeswitch/esl/connection/command_request.rb +154 -0
- data/lib/freeswitch/esl/connection/event_dispatcher.rb +10 -2
- data/lib/freeswitch/esl/connection.rb +53 -75
- data/lib/freeswitch/esl/logger.rb +3 -1
- data/lib/freeswitch/esl/protocol/event.rb +119 -0
- data/lib/freeswitch/esl/protocol/message.rb +7 -2
- data/lib/freeswitch/esl/version.rb +1 -1
- data/lib/freeswitch/esl.rb +3 -1
- metadata +61 -3
- data/lib/freeswitch/esl/connection/message_reader.rb +0 -109
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c0d4b60476e72dd268724e8d4d2e560505295de1e5d91cb78c53e065b539d8e
|
|
4
|
+
data.tar.gz: d79c813eb54beaa5074f7b7e05659fb1fe272bb3ef914b2d50c0a1b35540de62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c815f9380e5de15a2e31d3fa9940b761ccdd4ac10b856cf82bb986c209a9f7d1b4b6236f626fd7ebf828a7379caaabc1469e0eb8eb3a62e3e9ed54b341dafde
|
|
7
|
+
data.tar.gz: eeca1cbb4a3ca90cfa48fbb5e4589f1efac8b55c29529162bc82f4f6bbe06749cb6d190aba2a2b4e0d005b222a30c7e0cd3d4537fd208d274f243e7cc6c41a18
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# freeswitch-esl
|
|
2
2
|
|
|
3
3
|
`freeswitch-esl` is a Ruby gem for interacting with FreeSWITCH through ESL (Event Socket Library).
|
|
4
|
-
This version is intentionally focused on one direction only: your Ruby process connects to `mod_event_socket` and
|
|
4
|
+
This version is intentionally focused on one direction only: your Ruby process connects inbound to `mod_event_socket` and manages one or more `Freeswitch::ESL::Client` instances.
|
|
5
5
|
|
|
6
6
|
The implementation keeps the public API small and groups protocol concerns separately:
|
|
7
7
|
|
|
@@ -56,7 +56,7 @@ require 'freeswitch-esl'
|
|
|
56
56
|
|
|
57
57
|
## Configuration
|
|
58
58
|
|
|
59
|
-
Configure the library once and then
|
|
59
|
+
Configure the library once and then connect a client:
|
|
60
60
|
|
|
61
61
|
```ruby
|
|
62
62
|
require 'logger'
|
|
@@ -66,10 +66,12 @@ Freeswitch::ESL.configure do |config|
|
|
|
66
66
|
config.freeswitch.host = 'freeswitch.example.com'
|
|
67
67
|
config.freeswitch.port = 8021
|
|
68
68
|
config.freeswitch.password = 'ClueCon'
|
|
69
|
-
config.freeswitch.
|
|
69
|
+
config.freeswitch.reconnect = true
|
|
70
70
|
config.freeswitch.retry_delay = 1.0
|
|
71
71
|
config.freeswitch.max_retries = 5
|
|
72
72
|
config.logger = Logger.new($stdout)
|
|
73
|
+
config.logger.level = Logger::INFO
|
|
74
|
+
config.debug = false
|
|
73
75
|
end
|
|
74
76
|
```
|
|
75
77
|
|
|
@@ -77,11 +79,12 @@ Defaults:
|
|
|
77
79
|
|
|
78
80
|
- `config.freeswitch.host = '127.0.0.1'`
|
|
79
81
|
- `config.freeswitch.port = 8021`
|
|
80
|
-
- `config.freeswitch.password =
|
|
81
|
-
- `config.freeswitch.
|
|
82
|
+
- `config.freeswitch.password = nil`
|
|
83
|
+
- `config.freeswitch.reconnect = true`
|
|
82
84
|
- `config.freeswitch.retry_delay = 1.0`
|
|
83
|
-
- `config.freeswitch.max_retries =
|
|
84
|
-
- `config.logger =
|
|
85
|
+
- `config.freeswitch.max_retries = Float::INFINITY`
|
|
86
|
+
- `config.logger = Logger.new(IO::NULL)`
|
|
87
|
+
- `config.debug = false`
|
|
85
88
|
|
|
86
89
|
## FreeSWITCH Configuration
|
|
87
90
|
|
|
@@ -106,7 +109,7 @@ In the included Docker setup, `lan` is intentional because it allows both loopba
|
|
|
106
109
|
|
|
107
110
|
## Usage
|
|
108
111
|
|
|
109
|
-
###
|
|
112
|
+
### Connect a client
|
|
110
113
|
|
|
111
114
|
```ruby
|
|
112
115
|
require 'freeswitch-esl'
|
|
@@ -117,7 +120,7 @@ Freeswitch::ESL.configure do |config|
|
|
|
117
120
|
config.freeswitch.password = 'ClueCon'
|
|
118
121
|
end
|
|
119
122
|
|
|
120
|
-
client = Freeswitch::ESL.
|
|
123
|
+
client = Freeswitch::ESL::Client.connect
|
|
121
124
|
|
|
122
125
|
client.subscribe('CHANNEL_CREATE', 'CHANNEL_HANGUP', 'DTMF')
|
|
123
126
|
client.on('CHANNEL_CREATE') do |event|
|
|
@@ -128,10 +131,24 @@ client.on('DTMF') do |event|
|
|
|
128
131
|
puts "digit=#{event['DTMF-Digit']} duration=#{event['DTMF-Duration']}"
|
|
129
132
|
end
|
|
130
133
|
|
|
131
|
-
response = client.
|
|
134
|
+
response = client.exec('status').response
|
|
132
135
|
puts response.body
|
|
133
136
|
|
|
134
|
-
|
|
137
|
+
client.close
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`Freeswitch::ESL::Client.connect` builds a client with the global configuration,
|
|
141
|
+
opens the TCP socket, waits for FreeSWITCH `auth/request`, authenticates, subscribes
|
|
142
|
+
to the default event set, and returns a ready client instance.
|
|
143
|
+
|
|
144
|
+
### Reconfigure an existing client
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
client = Freeswitch::ESL::Client.connect
|
|
148
|
+
|
|
149
|
+
client.close
|
|
150
|
+
client.configure(freeswitch: { host: 'fs-backup.example.com', reconnect: true })
|
|
151
|
+
client.connect
|
|
135
152
|
```
|
|
136
153
|
|
|
137
154
|
### Background API
|
|
@@ -139,24 +156,51 @@ Freeswitch::ESL.reset_client!
|
|
|
139
156
|
```ruby
|
|
140
157
|
require 'freeswitch-esl'
|
|
141
158
|
|
|
142
|
-
client = Freeswitch::ESL.
|
|
159
|
+
client = Freeswitch::ESL::Client.connect
|
|
160
|
+
|
|
161
|
+
command = client.exec('originate', 'sofia/default/1000 &park')
|
|
143
162
|
|
|
144
|
-
|
|
145
|
-
puts "job #{
|
|
146
|
-
puts
|
|
163
|
+
command.on_complete do |completed|
|
|
164
|
+
puts "job #{completed.job_uuid} finished with status=#{completed.status}"
|
|
165
|
+
puts completed.response.body if completed.response
|
|
147
166
|
end
|
|
148
167
|
|
|
149
|
-
puts "submitted job=#{job_uuid}"
|
|
168
|
+
puts "submitted job=#{command.job_uuid}"
|
|
169
|
+
|
|
170
|
+
command.wait
|
|
171
|
+
client.close
|
|
150
172
|
```
|
|
151
173
|
|
|
174
|
+
`Client#exec` wraps `bgapi` and returns a `Freeswitch::ESL::Command` object.
|
|
175
|
+
You can:
|
|
176
|
+
|
|
177
|
+
- call `command.wait` for blocking flow
|
|
178
|
+
- call `command.response` to block until completion and get the final `BACKGROUND_JOB` event
|
|
179
|
+
- inspect `command.status`, `command.ok?`, `command.failed?`, `command.timeout?`
|
|
180
|
+
- attach `command.on_complete { |cmd| ... }` for async completion handling
|
|
181
|
+
|
|
182
|
+
If you prefer lower-level primitives, `Freeswitch::ESL::Client` also exposes the inherited connection API:
|
|
183
|
+
|
|
184
|
+
- `send_command`
|
|
185
|
+
- `bgapi`
|
|
186
|
+
- `subscribe`
|
|
187
|
+
- `unsubscribe`
|
|
188
|
+
- `filter`
|
|
189
|
+
- `on`
|
|
190
|
+
|
|
152
191
|
### Auto reconnect
|
|
153
192
|
|
|
154
|
-
|
|
193
|
+
When `config.freeswitch.reconnect` is enabled, `Client#connect` retries on initial
|
|
194
|
+
connection/authentication failure and the client reconnects automatically after later
|
|
195
|
+
disconnects.
|
|
196
|
+
|
|
197
|
+
Event handlers remain registered across reconnects because the event dispatcher is
|
|
198
|
+
preserved. Subscriptions do not: after reconnect, re-subscribe to the events you need.
|
|
155
199
|
|
|
156
200
|
```ruby
|
|
157
201
|
require 'freeswitch-esl'
|
|
158
202
|
|
|
159
|
-
client = Freeswitch::ESL.
|
|
203
|
+
client = Freeswitch::ESL::Client.connect
|
|
160
204
|
|
|
161
205
|
client.on('CHANNEL_HANGUP') do |event|
|
|
162
206
|
puts "hangup cause=#{event['Hangup-Cause']}"
|
|
@@ -169,6 +213,9 @@ end
|
|
|
169
213
|
client.subscribe('CHANNEL_HANGUP')
|
|
170
214
|
```
|
|
171
215
|
|
|
216
|
+
`client.close` stops reconnect attempts immediately. The same client instance can be
|
|
217
|
+
connected again later with `client.connect`.
|
|
218
|
+
|
|
172
219
|
## Docker Compose Test Lab
|
|
173
220
|
|
|
174
221
|
This repository includes a local FreeSWITCH environment for ESL experimentation.
|
|
@@ -180,7 +227,20 @@ Files included:
|
|
|
180
227
|
- `docker/freeswitch/autoload_configs/event_socket.conf.xml`
|
|
181
228
|
- `examples/inbound_status.rb`
|
|
182
229
|
|
|
183
|
-
The compose file
|
|
230
|
+
The compose file builds FreeSWITCH locally from the official source repository release.
|
|
231
|
+
The Dockerfile is aligned with the upstream reference at
|
|
232
|
+
`signalwire/freeswitch/docker/examples/Debian11/Dockerfile`, adapted for Debian Bookworm and this project's runtime needs.
|
|
233
|
+
By default it uses `FS_VERSION=latest`.
|
|
234
|
+
|
|
235
|
+
To pin a specific release tag (recommended for reproducible environments), set the build arg in `docker-compose.yml`:
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
services:
|
|
239
|
+
freeswitch:
|
|
240
|
+
build:
|
|
241
|
+
args:
|
|
242
|
+
FS_VERSION: v1.10.12
|
|
243
|
+
```
|
|
184
244
|
|
|
185
245
|
### Start FreeSWITCH
|
|
186
246
|
|
|
@@ -188,7 +248,7 @@ The compose file uses the public image `safarov/freeswitch:latest` and overlays
|
|
|
188
248
|
docker compose up -d
|
|
189
249
|
```
|
|
190
250
|
|
|
191
|
-
If you change image or base distro later, verify that the configuration directory is still `/etc/freeswitch`.
|
|
251
|
+
If you change image or base distro later, verify that the configuration directory is still `/usr/local/freeswitch/etc/freeswitch`.
|
|
192
252
|
|
|
193
253
|
### Check that FreeSWITCH is up
|
|
194
254
|
|
|
@@ -207,8 +267,9 @@ Freeswitch::ESL.configure do |config|
|
|
|
207
267
|
config.freeswitch.password = 'ClueCon'
|
|
208
268
|
end
|
|
209
269
|
|
|
210
|
-
|
|
211
|
-
|
|
270
|
+
client = Freeswitch::ESL::Client.connect
|
|
271
|
+
puts client.exec('status').response.body
|
|
272
|
+
client.close
|
|
212
273
|
```
|
|
213
274
|
|
|
214
275
|
Or run the ready-made example:
|
|
@@ -8,26 +8,91 @@ module Freeswitch
|
|
|
8
8
|
class Client < Connection
|
|
9
9
|
include Freeswitch::ESL::Logger
|
|
10
10
|
|
|
11
|
+
# For full events list see https://github.com/signalwire/freeswitch/blob/master/src/switch_event.c
|
|
12
|
+
DEFAULT_EVENTS = [
|
|
13
|
+
# Events useful for channel state tracking
|
|
14
|
+
"CHANNEL_CREATE",
|
|
15
|
+
"CHANNEL_STATE",
|
|
16
|
+
"CHANNEL_CALLSTATE",
|
|
17
|
+
"CHANNEL_ANSWER",
|
|
18
|
+
"CHANNEL_BRIDGE",
|
|
19
|
+
"CHANNEL_UNBRIDGE",
|
|
20
|
+
"CHANNEL_HANGUP",
|
|
21
|
+
"CHANNEL_HANGUP_COMPLETE",
|
|
22
|
+
"CHANNEL_DESTROY",
|
|
23
|
+
|
|
24
|
+
# Extra CHANNEL events
|
|
25
|
+
"CHANNEL_EXECUTE",
|
|
26
|
+
"CHANNEL_EXECUTE_COMPLETE",
|
|
27
|
+
"CHANNEL_PROGRESS",
|
|
28
|
+
"CHANNEL_PROGRESS_MEDIA",
|
|
29
|
+
|
|
30
|
+
# Receive BACKGROUND_JOB events for bgapi commands.
|
|
31
|
+
"BACKGROUND_JOB",
|
|
32
|
+
|
|
33
|
+
# Other events that might be useful for various applications
|
|
34
|
+
"DTMF",
|
|
35
|
+
"PLAYBACK_START",
|
|
36
|
+
"PLAYBACK_STOP",
|
|
37
|
+
"RECORD_START",
|
|
38
|
+
"RECORD_STOP",
|
|
39
|
+
"HEARTBEAT",
|
|
40
|
+
"CUSTOM"
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def connect(**)
|
|
45
|
+
new(**).connect
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
11
49
|
def config
|
|
12
50
|
@config ||= Freeswitch::ESL.configuration
|
|
13
51
|
end
|
|
14
52
|
|
|
15
53
|
def configure(**)
|
|
16
54
|
close if instance_variable_defined?(:@socket) && @socket && !closed?
|
|
17
|
-
|
|
55
|
+
config.update(**)
|
|
18
56
|
@intentionally_closed = false
|
|
19
|
-
establish_connection
|
|
20
57
|
self
|
|
21
58
|
end
|
|
22
59
|
|
|
23
|
-
def initialize(freeswitch: {}, logger: nil) # rubocop:disable Lint/MissingSuper
|
|
60
|
+
def initialize(freeswitch: {}, logger: nil, debug: false) # rubocop:disable Lint/MissingSuper
|
|
24
61
|
@reconnect_handlers = []
|
|
25
62
|
@intentionally_closed = false
|
|
63
|
+
@ready = false
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
@ready_cv = ConditionVariable.new
|
|
66
|
+
|
|
67
|
+
configure(freeswitch:, logger:, debug:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def connect
|
|
71
|
+
@intentionally_closed = false
|
|
72
|
+
config.freeswitch.reconnect ? attempt_reconnect(sync: true) : establish_connection
|
|
73
|
+
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait_until_ready(timeout: nil, raise_error: true)
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
return self if ready?
|
|
80
|
+
|
|
81
|
+
@ready_cv.wait(@mutex, timeout)
|
|
82
|
+
|
|
83
|
+
raise TimeoutError, "Timed out waiting for FreeSWITCH ESL client to be ready" if raise_error && !ready?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
self
|
|
87
|
+
end
|
|
26
88
|
|
|
27
|
-
|
|
89
|
+
def ready?
|
|
90
|
+
@ready
|
|
28
91
|
end
|
|
29
92
|
|
|
30
93
|
def close
|
|
94
|
+
@mutex.synchronize { @ready = false }
|
|
95
|
+
|
|
31
96
|
logger.info "Closing FreeSWITCH ESL client connection"
|
|
32
97
|
@intentionally_closed = true
|
|
33
98
|
if @reconnect_thread
|
|
@@ -44,14 +109,27 @@ module Freeswitch
|
|
|
44
109
|
self
|
|
45
110
|
end
|
|
46
111
|
|
|
47
|
-
|
|
112
|
+
# Execute a command via bgapi and return a {Command} object.
|
|
113
|
+
#
|
|
114
|
+
# @param command [String] the FreeSWITCH API command (e.g. "originate")
|
|
115
|
+
# @param args [String, nil] command arguments
|
|
116
|
+
# @param timeout [Integer] seconds to wait for the bgapi result event
|
|
117
|
+
# @yieldparam cmd [Command] called on completion (success or failure)
|
|
118
|
+
# @return [Command]
|
|
119
|
+
def exec(command, *, timeout: Command::DEFAULT_TIMEOUT, raise_error: true, &)
|
|
120
|
+
cmd = Command.new(self, command, *, timeout:, raise_error:, &)
|
|
121
|
+
cmd.execute!
|
|
122
|
+
|
|
123
|
+
cmd
|
|
124
|
+
end
|
|
48
125
|
|
|
49
126
|
def establish_connection
|
|
50
127
|
logger.info "Connecting to FreeSWITCH ESL at #{config.freeswitch.host}:#{config.freeswitch.port}"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
128
|
+
initialize_socket(build_socket, debug: config.debug)
|
|
129
|
+
authenticate!
|
|
130
|
+
subscribe(*DEFAULT_EVENTS)
|
|
131
|
+
ready!
|
|
132
|
+
logger.info "FreeSWITCH ESL client authenticated and ready!"
|
|
55
133
|
end
|
|
56
134
|
|
|
57
135
|
def build_socket
|
|
@@ -60,21 +138,47 @@ module Freeswitch
|
|
|
60
138
|
socket
|
|
61
139
|
end
|
|
62
140
|
|
|
63
|
-
|
|
64
|
-
|
|
141
|
+
# Authenticate against FreeSWITCH after the initial auth/request message.
|
|
142
|
+
def authenticate!
|
|
143
|
+
# Wait for the initial auth/request message initiated by FreeSWITCH before sending the auth command.
|
|
144
|
+
# This ensures we don't send auth before FreeSWITCH is ready to receive it,
|
|
145
|
+
# which would cause an immediate disconnect.
|
|
146
|
+
logger.debug "Waiting for auth/request from FreeSWITCH..."
|
|
147
|
+
wait_for_auth_request(timeout: 10)
|
|
148
|
+
|
|
149
|
+
# Send the auth command and wait for the response to confirm authentication succeeded.
|
|
150
|
+
cmd = send_command("auth #{config.freeswitch.password}", timeout: 10).wait(raise_error: false)
|
|
151
|
+
return if cmd.ok?
|
|
152
|
+
|
|
153
|
+
raise AuthenticationError,
|
|
154
|
+
"Authentication failed: #{cmd.error.class} - #{cmd.error.message.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ready!
|
|
158
|
+
@mutex.synchronize { @ready = true }
|
|
159
|
+
@ready_cv.broadcast
|
|
65
160
|
end
|
|
66
161
|
|
|
67
162
|
def on_disconnect(error)
|
|
163
|
+
super
|
|
164
|
+
|
|
165
|
+
@mutex.synchronize { @ready = false }
|
|
68
166
|
return if @intentionally_closed
|
|
69
167
|
|
|
70
|
-
super
|
|
71
168
|
logger.warn "Disconnected from FreeSWITCH ESL: #{error.message}"
|
|
72
169
|
# Check the flag again in case user code called close during
|
|
73
170
|
# disconnect handling.
|
|
74
171
|
attempt_reconnect unless @intentionally_closed
|
|
75
172
|
end
|
|
76
173
|
|
|
77
|
-
def attempt_reconnect
|
|
174
|
+
def attempt_reconnect(sync: false)
|
|
175
|
+
# If sync is true, the reconnect attempt is made in the current thread and blocks until it succeeds or fails.
|
|
176
|
+
return reconnect_with_backoff if sync
|
|
177
|
+
|
|
178
|
+
# If a reconnect thread is already running, do not start another one.
|
|
179
|
+
return if @reconnect_thread&.alive?
|
|
180
|
+
|
|
181
|
+
# Start a new thread to handle reconnect attempts in the background.
|
|
78
182
|
@reconnect_thread = Thread.new do
|
|
79
183
|
reconnect_with_backoff
|
|
80
184
|
end
|
|
@@ -86,17 +190,14 @@ module Freeswitch
|
|
|
86
190
|
delay = config.freeswitch.retry_delay
|
|
87
191
|
|
|
88
192
|
loop do
|
|
89
|
-
# Wait before each retry to avoid a tight retry loop.
|
|
90
|
-
sleep(delay)
|
|
91
193
|
break if @intentionally_closed
|
|
92
|
-
|
|
93
194
|
break if reconnect_once
|
|
94
195
|
|
|
196
|
+
# Wait before each retry to avoid a tight retry loop.
|
|
197
|
+
sleep(delay)
|
|
198
|
+
|
|
95
199
|
retries += 1
|
|
96
200
|
break if stop_reconnect?(retries)
|
|
97
|
-
|
|
98
|
-
# Increase delay after each failure, but keep it under max_retry_delay.
|
|
99
|
-
delay = [delay * 2, config.freeswitch.max_retry_delay].min
|
|
100
201
|
end
|
|
101
202
|
end
|
|
102
203
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Freeswitch
|
|
4
|
+
module ESL
|
|
5
|
+
# Represents the asynchronous execution of a bgapi command.
|
|
6
|
+
#
|
|
7
|
+
# A Command is created by {Client#exec} and tracks the full lifecycle of the
|
|
8
|
+
# request: pending → completed (success or failure).
|
|
9
|
+
#
|
|
10
|
+
# === Async usage (non-blocking)
|
|
11
|
+
#
|
|
12
|
+
# cmd = client.exec("originate", "sofia/default/1234@example.com")
|
|
13
|
+
# cmd.on_complete { |c| puts c.response.body }
|
|
14
|
+
# # ... do other work ...
|
|
15
|
+
# cmd.response # blocks until completed (or timeout)
|
|
16
|
+
#
|
|
17
|
+
# === Sync usage (blocking)
|
|
18
|
+
#
|
|
19
|
+
# cmd = client.exec("originate", "sofia/default/1234@example.com")
|
|
20
|
+
# cmd.wait
|
|
21
|
+
# puts cmd.response.body
|
|
22
|
+
#
|
|
23
|
+
# === Timeout
|
|
24
|
+
#
|
|
25
|
+
# The default timeout is {DEFAULT_TIMEOUT} seconds. If the bgapi result event
|
|
26
|
+
# does not arrive in time the command transitions to :timeout and any
|
|
27
|
+
# subsequent result is silently ignored.
|
|
28
|
+
class Command
|
|
29
|
+
DEFAULT_TIMEOUT = 60
|
|
30
|
+
|
|
31
|
+
# @return [:pending, :ok, :failed, :timeout] current status of the command
|
|
32
|
+
attr_reader :status
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] Job-UUID assigned by FreeSWITCH (nil if invalid)
|
|
35
|
+
attr_reader :job_uuid
|
|
36
|
+
|
|
37
|
+
# @return [CommandError, TimeoutError, nil] the error that caused the command to fail (nil if successful)
|
|
38
|
+
attr_reader :error
|
|
39
|
+
|
|
40
|
+
attr_reader :command, :args, :sent_at, :finished_at
|
|
41
|
+
|
|
42
|
+
def initialize(client, command, *args, timeout: DEFAULT_TIMEOUT, raise_error: true, &block)
|
|
43
|
+
@client = client
|
|
44
|
+
@command = command
|
|
45
|
+
@args = args
|
|
46
|
+
@timeout = timeout
|
|
47
|
+
@raise_error = raise_error
|
|
48
|
+
@status = :pending
|
|
49
|
+
@job_uuid = nil
|
|
50
|
+
@response = nil
|
|
51
|
+
@error = nil
|
|
52
|
+
@callbacks = block ? [block] : []
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@cond = ConditionVariable.new
|
|
55
|
+
@sent_at = nil
|
|
56
|
+
@finished_at = nil
|
|
57
|
+
|
|
58
|
+
validate_command!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute!
|
|
62
|
+
configure_timeout!
|
|
63
|
+
return unless wait_until_client_ready
|
|
64
|
+
|
|
65
|
+
@sent_at = now
|
|
66
|
+
@status = :sent
|
|
67
|
+
@job_uuid = @client.bgapi(@command, *@args) do |event|
|
|
68
|
+
event.error? ? fail!(event) : complete!(event)
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
@status = :failed
|
|
72
|
+
@error = e
|
|
73
|
+
@finished_at = now
|
|
74
|
+
raise e if @raise_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Register a callback to be called when the command completes (success or
|
|
78
|
+
# failure). Multiple callbacks are supported and are called in order.
|
|
79
|
+
# If the command is already completed the block is called immediately.
|
|
80
|
+
# Returns +self+ for chaining.
|
|
81
|
+
def on_complete(&block)
|
|
82
|
+
raise ArgumentError, "Block is required" unless block_given?
|
|
83
|
+
|
|
84
|
+
@callbacks << block
|
|
85
|
+
block.call(self) if completed?
|
|
86
|
+
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Block the calling thread until the command completes or the timeout
|
|
91
|
+
# expires.
|
|
92
|
+
# @return [Protocol::Event] the event returned by FreeSWITCH when the command completed successfully
|
|
93
|
+
# @return [nil] if the command failed or timed out but did not raise an exception (raise_error: false)
|
|
94
|
+
# @raise [CommandError, TimeoutError] when the command failed or timed out
|
|
95
|
+
def response
|
|
96
|
+
wait unless completed?
|
|
97
|
+
|
|
98
|
+
@response
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Wait for the command to complete, fail or timeout.
|
|
102
|
+
# @return [Command] self after completion
|
|
103
|
+
def wait
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
return self if completed?
|
|
106
|
+
|
|
107
|
+
wait_time = @timeout ? (@timeout - execution_time.to_f) : nil
|
|
108
|
+
@cond.wait(@mutex, wait_time)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise @error if @error && @raise_error
|
|
112
|
+
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Boolean] true when the command has finished (success or failure)
|
|
117
|
+
def completed?
|
|
118
|
+
!pending?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Boolean] true when the command is still waiting for a result
|
|
122
|
+
def pending?
|
|
123
|
+
%i[pending sent].include?(@status)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Boolean] true when the command completed successfully
|
|
127
|
+
def ok?
|
|
128
|
+
@status == :ok
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Boolean] true when the command terminated with a timeout (a special kind of failure)
|
|
132
|
+
def timeout?
|
|
133
|
+
@status == :timeout
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Boolean] true when the command failed or timed out
|
|
137
|
+
def failed?
|
|
138
|
+
%i[failed timeout].include?(@status)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def execution_time
|
|
142
|
+
return nil unless @sent_at
|
|
143
|
+
|
|
144
|
+
(@finished_at || now) - @sent_at
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def validate_command!
|
|
150
|
+
return unless @command.nil? || @command.strip.empty?
|
|
151
|
+
|
|
152
|
+
@status = :failed
|
|
153
|
+
@error = "Command cannot be blank"
|
|
154
|
+
raise ArgumentError, @error
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def configure_timeout!
|
|
158
|
+
return unless @timeout
|
|
159
|
+
|
|
160
|
+
Ztimer.after(@timeout * 1000) do
|
|
161
|
+
timeout!("Command timed out after #{@timeout}s waiting for BACKGROUND_JOB result")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Wait until the client is ready to send commands. If readiness does not
|
|
166
|
+
# happen within the configured timeout, the command fails before any bgapi
|
|
167
|
+
# request is sent to FreeSWITCH.
|
|
168
|
+
def wait_until_client_ready
|
|
169
|
+
is_ready = @client.wait_until_ready(timeout: @timeout, raise_error: false).ready?
|
|
170
|
+
return is_ready if is_ready
|
|
171
|
+
|
|
172
|
+
settle(:failed) do
|
|
173
|
+
@error = ConnectionError.new("FreeSWITCH ESL Client is not ready to send commands")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Called when the BACKGROUND_JOB event reports an ESL-level failure body.
|
|
180
|
+
# @param event [Protocol::Event] the terminal event containing the error details.
|
|
181
|
+
def fail!(event)
|
|
182
|
+
settle(:failed) do
|
|
183
|
+
@response = event
|
|
184
|
+
@error = CommandError.new("Command `#{@command}#{@args.map { |arg| " #{arg}" }.join}` failed: #{event.body}")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Called internally by {EventDispatcher} when the BACKGROUND_JOB event
|
|
189
|
+
# arrives. Ignored if the command is no longer pending (e.g. already
|
|
190
|
+
# timed out).
|
|
191
|
+
# @param event [Protocol::Event]
|
|
192
|
+
def complete!(event)
|
|
193
|
+
settle(:ok) { @response = event }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def timeout!(message)
|
|
197
|
+
settle(:timeout) { @error = TimeoutError.new(message) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Apply a terminal state transition under the mutex, wake waiters and run
|
|
201
|
+
# completion callbacks after the internal state is visible to observers.
|
|
202
|
+
def settle(status)
|
|
203
|
+
@mutex.synchronize do
|
|
204
|
+
return unless pending? # do nothing if already completed
|
|
205
|
+
|
|
206
|
+
yield
|
|
207
|
+
@status = status.to_sym
|
|
208
|
+
@finished_at = now
|
|
209
|
+
@sent_at ||= @finished_at # if the command never sent, set sent_at to finished_at
|
|
210
|
+
@cond.broadcast
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@callbacks.each { |cb| cb.call(self) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def now
|
|
217
|
+
Time.now
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|