freeswitch-esl 0.1.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 +7 -0
- data/README.md +265 -0
- data/lib/freeswitch/esl/client.rb +118 -0
- data/lib/freeswitch/esl/configuration.rb +30 -0
- data/lib/freeswitch/esl/connection/event_dispatcher.rb +91 -0
- data/lib/freeswitch/esl/connection/message_reader.rb +109 -0
- data/lib/freeswitch/esl/connection.rb +169 -0
- data/lib/freeswitch/esl/errors.rb +17 -0
- data/lib/freeswitch/esl/logger.rb +20 -0
- data/lib/freeswitch/esl/protocol/event.rb +46 -0
- data/lib/freeswitch/esl/protocol/message.rb +42 -0
- data/lib/freeswitch/esl/version.rb +7 -0
- data/lib/freeswitch/esl.rb +33 -0
- data/lib/freeswitch-esl.rb +3 -0
- metadata +144 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f837d66f54cb1b978273c193f63ae772a2c967943b330130e77bd56335d6dd5
|
|
4
|
+
data.tar.gz: 3e71ae84d554456aa359483b3d8a2923cba563904e03ecb1780cf94c440745bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 742e626b3428f49a9a3e0bc5bb10e85ce284baa6a57ce7cd13157351ad3475201548028c0e124d37d56f428d3a6b3e03dd5eaedd908a24e7795ec17d4cb2b3c2
|
|
7
|
+
data.tar.gz: d4a46398d82a476311a0508780d6a15275b73b664657d22bd1236d488faaea62973992223a81d20b3fed29064a1f5e9d860040c0643307ee129f576e471d7dbf
|
data/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# freeswitch-esl
|
|
2
|
+
|
|
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 uses a single shared client, `Freeswitch::ESL.client`.
|
|
5
|
+
|
|
6
|
+
The implementation keeps the public API small and groups protocol concerns separately:
|
|
7
|
+
|
|
8
|
+
- `Freeswitch::ESL::Client`
|
|
9
|
+
- `Freeswitch::ESL::Connection`
|
|
10
|
+
- `Freeswitch::ESL::Protocol::Message`
|
|
11
|
+
- `Freeswitch::ESL::Protocol::Event`
|
|
12
|
+
|
|
13
|
+
## Why ESL
|
|
14
|
+
|
|
15
|
+
For FreeSWITCH, ESL is still the most complete and stable integration surface for external control.
|
|
16
|
+
Other interfaces exist, but they are generally less complete:
|
|
17
|
+
|
|
18
|
+
- `mod_xml_rpc`: useful for some management operations, but not a replacement for event streaming and call control
|
|
19
|
+
- custom REST/gRPC wrappers: possible, but usually built on top of ESL rather than replacing it
|
|
20
|
+
|
|
21
|
+
If you need event subscriptions, command execution and background jobs, ESL remains the right interface.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Choose one installation mode based on your environment.
|
|
26
|
+
|
|
27
|
+
### Local path (development)
|
|
28
|
+
|
|
29
|
+
Use this while developing the gem and application together:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem 'freeswitch-esl', path: '/path/to/freeswitch-esl'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Git source (production)
|
|
36
|
+
|
|
37
|
+
Use this when you want to pin a tag or commit from your repository:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem 'freeswitch-esl', git: 'https://gitlab.example.com/your-group/freeswitch-esl.git', tag: 'v0.1.0'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### RubyGems style (for future releases)
|
|
44
|
+
|
|
45
|
+
When the gem is published on RubyGems, the classic entry is:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
gem 'freeswitch-esl'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then require it:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require 'freeswitch-esl'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Configure the library once and then use the shared client:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
require 'logger'
|
|
63
|
+
require 'freeswitch-esl'
|
|
64
|
+
|
|
65
|
+
Freeswitch::ESL.configure do |config|
|
|
66
|
+
config.freeswitch.host = 'freeswitch.example.com'
|
|
67
|
+
config.freeswitch.port = 8021
|
|
68
|
+
config.freeswitch.password = 'ClueCon'
|
|
69
|
+
config.freeswitch.timeout = 5
|
|
70
|
+
config.freeswitch.retry_delay = 1.0
|
|
71
|
+
config.freeswitch.max_retries = 5
|
|
72
|
+
config.logger = Logger.new($stdout)
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Defaults:
|
|
77
|
+
|
|
78
|
+
- `config.freeswitch.host = '127.0.0.1'`
|
|
79
|
+
- `config.freeswitch.port = 8021`
|
|
80
|
+
- `config.freeswitch.password = 'ClueCon'`
|
|
81
|
+
- `config.freeswitch.timeout = 5`
|
|
82
|
+
- `config.freeswitch.retry_delay = 1.0`
|
|
83
|
+
- `config.freeswitch.max_retries = 5`
|
|
84
|
+
- `config.logger = nil`
|
|
85
|
+
|
|
86
|
+
## FreeSWITCH Configuration
|
|
87
|
+
|
|
88
|
+
### Inbound ESL (`mod_event_socket`)
|
|
89
|
+
|
|
90
|
+
FreeSWITCH must expose `mod_event_socket`.
|
|
91
|
+
For local development, a minimal configuration looks like this:
|
|
92
|
+
|
|
93
|
+
```xml
|
|
94
|
+
<configuration name="event_socket.conf" description="Socket Client">
|
|
95
|
+
<settings>
|
|
96
|
+
<param name="listen-ip" value="0.0.0.0"/>
|
|
97
|
+
<param name="listen-port" value="8021"/>
|
|
98
|
+
<param name="password" value="ClueCon"/>
|
|
99
|
+
<param name="apply-inbound-acl" value="lan"/>
|
|
100
|
+
</settings>
|
|
101
|
+
</configuration>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For production, tighten the ACL further and do not expose port `8021` publicly.
|
|
105
|
+
In the included Docker setup, `lan` is intentional because it allows both loopback and Docker private-network traffic while still avoiding a fully open event socket.
|
|
106
|
+
|
|
107
|
+
## Usage
|
|
108
|
+
|
|
109
|
+
### Shared client
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
require 'freeswitch-esl'
|
|
113
|
+
|
|
114
|
+
Freeswitch::ESL.configure do |config|
|
|
115
|
+
config.freeswitch.host = '127.0.0.1'
|
|
116
|
+
config.freeswitch.port = 8021
|
|
117
|
+
config.freeswitch.password = 'ClueCon'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
client = Freeswitch::ESL.client
|
|
121
|
+
|
|
122
|
+
client.subscribe('CHANNEL_CREATE', 'CHANNEL_HANGUP', 'DTMF')
|
|
123
|
+
client.on('CHANNEL_CREATE') do |event|
|
|
124
|
+
puts "new channel: #{event.uuid}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
client.on('DTMF') do |event|
|
|
128
|
+
puts "digit=#{event['DTMF-Digit']} duration=#{event['DTMF-Duration']}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
response = client.api('status')
|
|
132
|
+
puts response.body
|
|
133
|
+
|
|
134
|
+
Freeswitch::ESL.reset_client!
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Background API
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
require 'freeswitch-esl'
|
|
141
|
+
|
|
142
|
+
client = Freeswitch::ESL.client
|
|
143
|
+
|
|
144
|
+
job_uuid = client.bgapi('originate', 'sofia/default/1000 &park') do |event|
|
|
145
|
+
puts "job #{event.job_uuid} finished"
|
|
146
|
+
puts event.body
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
puts "submitted job=#{job_uuid}"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Auto reconnect
|
|
153
|
+
|
|
154
|
+
Event handlers remain registered across reconnects. After reconnect, re-subscribe to events:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
require 'freeswitch-esl'
|
|
158
|
+
|
|
159
|
+
client = Freeswitch::ESL.client
|
|
160
|
+
|
|
161
|
+
client.on('CHANNEL_HANGUP') do |event|
|
|
162
|
+
puts "hangup cause=#{event['Hangup-Cause']}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
client.on_reconnect do
|
|
166
|
+
client.subscribe('CHANNEL_HANGUP')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
client.subscribe('CHANNEL_HANGUP')
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Docker Compose Test Lab
|
|
173
|
+
|
|
174
|
+
This repository includes a local FreeSWITCH environment for ESL experimentation.
|
|
175
|
+
It is intended for development and manual testing, and it can later serve as a base for realistic integration tests.
|
|
176
|
+
|
|
177
|
+
Files included:
|
|
178
|
+
|
|
179
|
+
- `docker-compose.yml`
|
|
180
|
+
- `docker/freeswitch/autoload_configs/event_socket.conf.xml`
|
|
181
|
+
- `examples/inbound_status.rb`
|
|
182
|
+
|
|
183
|
+
The compose file uses the public image `safarov/freeswitch:latest` and overlays only the ESL-specific configuration needed for local development.
|
|
184
|
+
|
|
185
|
+
### Start FreeSWITCH
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
docker compose up -d
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
If you change image or base distro later, verify that the configuration directory is still `/etc/freeswitch`.
|
|
192
|
+
|
|
193
|
+
### Check that FreeSWITCH is up
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
docker compose exec freeswitch fs_cli -x 'status'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Test inbound ESL connectivity
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
require 'freeswitch-esl'
|
|
203
|
+
|
|
204
|
+
Freeswitch::ESL.configure do |config|
|
|
205
|
+
config.freeswitch.host = '127.0.0.1'
|
|
206
|
+
config.freeswitch.port = 8021
|
|
207
|
+
config.freeswitch.password = 'ClueCon'
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
puts Freeswitch::ESL.client.api('status').body
|
|
211
|
+
Freeswitch::ESL.reset_client!
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Or run the ready-made example:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
ruby examples/inbound_status.rb
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Production Notes
|
|
221
|
+
|
|
222
|
+
- keep ESL bound to a private interface whenever possible
|
|
223
|
+
- protect the event socket with ACLs and a non-default password
|
|
224
|
+
- do not use the included Docker configuration as-is in production
|
|
225
|
+
- prefer explicit event subscriptions instead of `ALL` unless you really need global event traffic
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
Run style checks:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
bundle exec rubocop
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Run tests:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
bundle exec rspec
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### GitLab CI local
|
|
242
|
+
|
|
243
|
+
Run the full local pipeline:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
gitlab-ci-local
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Run only the test job:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
gitlab-ci-local rspec
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Run only lint:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
gitlab-ci-local rubocop
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Test stability note
|
|
262
|
+
|
|
263
|
+
Some client specs use reconnect threads. To avoid order-dependent failures in random runs,
|
|
264
|
+
the test teardown always closes created clients. This makes sure reconnect-related activity
|
|
265
|
+
is stopped before the next example starts.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
# Inbound ESL client: connects to FreeSWITCH mod_event_socket.
|
|
8
|
+
class Client < Connection
|
|
9
|
+
include Freeswitch::ESL::Logger
|
|
10
|
+
|
|
11
|
+
def config
|
|
12
|
+
@config ||= Freeswitch::ESL.configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configure(**)
|
|
16
|
+
close if instance_variable_defined?(:@socket) && @socket && !closed?
|
|
17
|
+
@config = Freeswitch::ESL::Configuration.build(**)
|
|
18
|
+
@intentionally_closed = false
|
|
19
|
+
establish_connection
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(freeswitch: {}, logger: nil) # rubocop:disable Lint/MissingSuper
|
|
24
|
+
@reconnect_handlers = []
|
|
25
|
+
@intentionally_closed = false
|
|
26
|
+
|
|
27
|
+
configure(freeswitch:, logger:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close
|
|
31
|
+
logger.info "Closing FreeSWITCH ESL client connection"
|
|
32
|
+
@intentionally_closed = true
|
|
33
|
+
if @reconnect_thread
|
|
34
|
+
# Stop reconnect thread now so it cannot keep running after close.
|
|
35
|
+
@reconnect_thread.kill
|
|
36
|
+
@reconnect_thread.join(0.1)
|
|
37
|
+
end
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on_reconnect(&block)
|
|
42
|
+
logger.debug "Registering FreeSWITCH ESL client reconnect handler"
|
|
43
|
+
@reconnect_handlers << block
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def establish_connection
|
|
50
|
+
logger.info "Connecting to FreeSWITCH ESL at #{config.freeswitch.host}:#{config.freeswitch.port}"
|
|
51
|
+
socket = build_socket
|
|
52
|
+
initialize_socket(socket)
|
|
53
|
+
authenticate_with_config!
|
|
54
|
+
logger.info "Authenticated with FreeSWITCH ESL"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_socket
|
|
58
|
+
socket = TCPSocket.new(config.freeswitch.host, config.freeswitch.port)
|
|
59
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
60
|
+
socket
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def authenticate_with_config!
|
|
64
|
+
authenticate!(config.freeswitch.password, timeout: config.freeswitch.timeout)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def on_disconnect(error)
|
|
68
|
+
return if @intentionally_closed
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
logger.warn "Disconnected from FreeSWITCH ESL: #{error.message}"
|
|
72
|
+
# Check the flag again in case user code called close during
|
|
73
|
+
# disconnect handling.
|
|
74
|
+
attempt_reconnect unless @intentionally_closed
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def attempt_reconnect
|
|
78
|
+
@reconnect_thread = Thread.new do
|
|
79
|
+
reconnect_with_backoff
|
|
80
|
+
end
|
|
81
|
+
@reconnect_thread.name = "esl-reconnect"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reconnect_with_backoff
|
|
85
|
+
retries = 0
|
|
86
|
+
delay = config.freeswitch.retry_delay
|
|
87
|
+
|
|
88
|
+
loop do
|
|
89
|
+
# Wait before each retry to avoid a tight retry loop.
|
|
90
|
+
sleep(delay)
|
|
91
|
+
break if @intentionally_closed
|
|
92
|
+
|
|
93
|
+
break if reconnect_once
|
|
94
|
+
|
|
95
|
+
retries += 1
|
|
96
|
+
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
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reconnect_once
|
|
104
|
+
establish_connection
|
|
105
|
+
@reconnect_handlers.each(&:call)
|
|
106
|
+
true
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
# Return false so the outer loop can try again.
|
|
109
|
+
logger.warn "Reconnect attempt failed: #{e.message}"
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stop_reconnect?(retries)
|
|
114
|
+
retries >= config.freeswitch.max_retries || @intentionally_closed
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "configatron"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
class Configuration
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
freeswitch: {
|
|
10
|
+
host: ENV.fetch("FREESWITCH_ESL_HOST", "127.0.0.1"),
|
|
11
|
+
port: ENV.fetch("FREESWITCH_ESL_PORT", 8021).to_i,
|
|
12
|
+
password: ENV.fetch("FREESWITCH_ESL_PASSWORD", "ClueCon"),
|
|
13
|
+
timeout: ENV.fetch("FREESWITCH_ESL_TIMEOUT", 5).to_i,
|
|
14
|
+
retry_delay: ENV.fetch("FREESWITCH_ESL_RETRY_DELAY", 1.0).to_f,
|
|
15
|
+
max_retries: Float::INFINITY
|
|
16
|
+
},
|
|
17
|
+
logger: Freeswitch::ESL::Logger.default_logger
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def build(**options)
|
|
22
|
+
config = Configatron::RootStore.new
|
|
23
|
+
config.freeswitch.configure_from_hash(DEFAULTS[:freeswitch].dup.merge(options[:freeswitch] || {}))
|
|
24
|
+
config.logger = options[:logger] || DEFAULTS[:logger]
|
|
25
|
+
config
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Freeswitch
|
|
4
|
+
module ESL
|
|
5
|
+
class Connection
|
|
6
|
+
# Manages event dispatching from a queue to registered handlers.
|
|
7
|
+
# Runs in a dedicated thread and handles both event handlers and bgapi callbacks.
|
|
8
|
+
class EventDispatcher
|
|
9
|
+
def initialize
|
|
10
|
+
@event_queue = Queue.new
|
|
11
|
+
@event_handlers = Hash.new { |h, k| h[k] = [] }
|
|
12
|
+
@bgapi_handlers = {}
|
|
13
|
+
@bgapi_mutex = Mutex.new
|
|
14
|
+
@handlers_mutex = Mutex.new
|
|
15
|
+
@dispatcher_thread = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Enqueue an event for async dispatch.
|
|
19
|
+
def enqueue_event(event)
|
|
20
|
+
@event_queue << event
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Register a handler block for an event name.
|
|
24
|
+
# Use "ALL" to handle every event. Multiple handlers per event are supported.
|
|
25
|
+
# Returns self for chaining.
|
|
26
|
+
def on(event_name, &block)
|
|
27
|
+
@handlers_mutex.synchronize do
|
|
28
|
+
@event_handlers[event_name.to_s.upcase] << block
|
|
29
|
+
end
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register a handler for a background job result (by job UUID).
|
|
34
|
+
def register_bgapi_handler(job_uuid, block)
|
|
35
|
+
@bgapi_mutex.synchronize { @bgapi_handlers[job_uuid] = block }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Start the dispatcher thread.
|
|
39
|
+
def start
|
|
40
|
+
return if @dispatcher_thread
|
|
41
|
+
|
|
42
|
+
@dispatcher_thread = Thread.new do
|
|
43
|
+
loop do
|
|
44
|
+
event = @event_queue.pop
|
|
45
|
+
break if event.nil? # Queue closed (Ruby 3.x returns nil on closed empty queue)
|
|
46
|
+
|
|
47
|
+
dispatch(event)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
@dispatcher_thread.name = "esl-dispatcher"
|
|
51
|
+
@dispatcher_thread.abort_on_exception = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Stop the dispatcher thread (close queue and join).
|
|
55
|
+
def stop
|
|
56
|
+
@event_queue&.close
|
|
57
|
+
@dispatcher_thread&.join
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def dispatch(event)
|
|
63
|
+
handle_bgapi_result(event) if event.name == "BACKGROUND_JOB"
|
|
64
|
+
|
|
65
|
+
handlers = @handlers_mutex.synchronize do
|
|
66
|
+
# Copy handlers before looping, so handlers can update registration
|
|
67
|
+
# without changing the list we are reading now.
|
|
68
|
+
(@event_handlers[event.name.to_s] + @event_handlers["ALL"]).dup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
handlers.each do |handler|
|
|
72
|
+
handler.call(event)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil # never crash the dispatcher thread
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_bgapi_result(event)
|
|
79
|
+
job_uuid = event.job_uuid
|
|
80
|
+
return unless job_uuid
|
|
81
|
+
|
|
82
|
+
# A bgapi handler should run once, so remove it on first match.
|
|
83
|
+
handler = @bgapi_mutex.synchronize { @bgapi_handlers.delete(job_uuid) }
|
|
84
|
+
handler&.call(event)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
class Connection
|
|
8
|
+
# Reads ESL protocol messages from the socket and routes them appropriately.
|
|
9
|
+
# Runs in a dedicated thread and handles socket parsing and message dispatch.
|
|
10
|
+
class MessageReader
|
|
11
|
+
def initialize(socket, event_dispatcher, &on_disconnect_callback)
|
|
12
|
+
@socket = socket
|
|
13
|
+
@event_dispatcher = event_dispatcher
|
|
14
|
+
@on_disconnect_callback = on_disconnect_callback
|
|
15
|
+
@response_queue = Queue.new
|
|
16
|
+
@reader_thread = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get the response queue (where command replies are enqueued).
|
|
20
|
+
attr_reader :response_queue
|
|
21
|
+
|
|
22
|
+
# Start the reader thread.
|
|
23
|
+
def start
|
|
24
|
+
return if @reader_thread
|
|
25
|
+
|
|
26
|
+
@reader_thread = Thread.new do
|
|
27
|
+
loop do
|
|
28
|
+
msg = read_message
|
|
29
|
+
unless msg
|
|
30
|
+
on_disconnect(DisconnectedError.new("Connection closed by remote host"))
|
|
31
|
+
break
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
route_message(msg)
|
|
35
|
+
rescue IOError, Errno::ECONNRESET, Errno::ENOTCONN => e
|
|
36
|
+
on_disconnect(DisconnectedError.new(e.message))
|
|
37
|
+
break
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
@reader_thread.name = "esl-reader"
|
|
41
|
+
@reader_thread.abort_on_exception = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Stop the reader thread and join it.
|
|
45
|
+
def stop
|
|
46
|
+
# The thread exits when socket reads hit EOF/error. `stop` is only a
|
|
47
|
+
# synchronisation point to wait for clean shutdown.
|
|
48
|
+
@reader_thread&.join
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Wait for and return a response from the response queue with timeout.
|
|
52
|
+
def receive_response(timeout:)
|
|
53
|
+
result = nil
|
|
54
|
+
Timeout.timeout(timeout) { result = @response_queue.pop }
|
|
55
|
+
raise result if result.is_a?(Exception)
|
|
56
|
+
|
|
57
|
+
result
|
|
58
|
+
rescue Timeout::Error
|
|
59
|
+
raise TimeoutError, "Command timed out after #{timeout}s — connection closed"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Enqueue an error to unblock waiting threads (e.g., on disconnect).
|
|
63
|
+
def enqueue_error(error)
|
|
64
|
+
@response_queue << error
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def read_message
|
|
70
|
+
headers = {}
|
|
71
|
+
|
|
72
|
+
loop do
|
|
73
|
+
line = @socket.gets("\n")
|
|
74
|
+
return nil unless line
|
|
75
|
+
|
|
76
|
+
line = line.chomp
|
|
77
|
+
break if line.empty?
|
|
78
|
+
|
|
79
|
+
key, value = line.split(": ", 2)
|
|
80
|
+
headers[key] = value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return nil if headers.empty?
|
|
84
|
+
|
|
85
|
+
body = @socket.read(headers["Content-Length"].to_i) if headers.key?("Content-Length")
|
|
86
|
+
Protocol::Message.new(headers, body)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def route_message(message)
|
|
90
|
+
# Unknown content types are ignored on purpose.
|
|
91
|
+
case message.content_type
|
|
92
|
+
when "auth/request", "command/reply", "api/response"
|
|
93
|
+
@response_queue << message
|
|
94
|
+
when "text/event-json"
|
|
95
|
+
@event_dispatcher.enqueue_event(Protocol::Event.new(message.body))
|
|
96
|
+
when "text/disconnect-notice"
|
|
97
|
+
on_disconnect(DisconnectedError.new("FreeSWITCH sent disconnect notice"))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def on_disconnect(error)
|
|
102
|
+
# First wake up waiting command calls, then notify upper layers.
|
|
103
|
+
@response_queue << error # unblock any waiting receive_response
|
|
104
|
+
@on_disconnect_callback&.call(error)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
require "freeswitch/esl/protocol/event"
|
|
6
|
+
require "freeswitch/esl/protocol/message"
|
|
7
|
+
require "freeswitch/esl/connection/message_reader"
|
|
8
|
+
require "freeswitch/esl/connection/event_dispatcher"
|
|
9
|
+
|
|
10
|
+
module Freeswitch
|
|
11
|
+
module ESL
|
|
12
|
+
# Base ESL connection that handles the wire protocol.
|
|
13
|
+
#
|
|
14
|
+
# Subclasses are responsible for providing an open +socket+ and calling
|
|
15
|
+
# {#initialize_socket} to start the reader and event-dispatcher threads.
|
|
16
|
+
#
|
|
17
|
+
# Threading model:
|
|
18
|
+
# * A *reader thread* reads messages from the socket and routes them:
|
|
19
|
+
# - command/api replies → @response_queue (consumed by the calling thread)
|
|
20
|
+
# - events → @event_queue (consumed by the dispatcher thread)
|
|
21
|
+
# * A *dispatcher thread* processes @event_queue and calls registered handlers.
|
|
22
|
+
# * Command execution is serialised by @send_mutex so that each send+receive
|
|
23
|
+
# pair is atomic and responses are never mixed up.
|
|
24
|
+
# * On timeout the connection is closed because the protocol state is unknown.
|
|
25
|
+
class Connection
|
|
26
|
+
MSG_TERMINATOR = "\n\n"
|
|
27
|
+
DEFAULT_TIMEOUT = 5
|
|
28
|
+
|
|
29
|
+
def initialize(socket)
|
|
30
|
+
initialize_socket(socket)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Send a raw ESL command and return the {Message} reply.
|
|
34
|
+
def send_command(command, timeout: DEFAULT_TIMEOUT)
|
|
35
|
+
raise DisconnectedError, "Connection is closed" if closed?
|
|
36
|
+
|
|
37
|
+
# Keep write+read together so one thread cannot read another thread's
|
|
38
|
+
# reply by mistake.
|
|
39
|
+
@send_mutex.synchronize do
|
|
40
|
+
@socket.write("#{command}#{MSG_TERMINATOR}")
|
|
41
|
+
receive_response(timeout: timeout)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Execute a synchronous +api+ command. Returns the {Message} with the
|
|
46
|
+
# result in {Message#body}.
|
|
47
|
+
def api(command, args = nil, timeout: DEFAULT_TIMEOUT)
|
|
48
|
+
parts = ["api", command, args].compact
|
|
49
|
+
@send_mutex.synchronize do
|
|
50
|
+
@socket.write("#{parts.join(' ')}#{MSG_TERMINATOR}")
|
|
51
|
+
receive_response(timeout: timeout)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Execute a background +bgapi+ command. Returns the Job-UUID string.
|
|
56
|
+
# The optional block is called with the BACKGROUND_JOB {Event} when the
|
|
57
|
+
# result arrives.
|
|
58
|
+
def bgapi(command, args = nil, timeout: DEFAULT_TIMEOUT, &block)
|
|
59
|
+
parts = ["bgapi", command, args].compact
|
|
60
|
+
job_uuid = @send_mutex.synchronize do
|
|
61
|
+
@socket.write("#{parts.join(' ')}#{MSG_TERMINATOR}")
|
|
62
|
+
reply = receive_response(timeout: timeout)
|
|
63
|
+
reply["Job-UUID"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@event_dispatcher.register_bgapi_handler(job_uuid, block) if block && job_uuid
|
|
67
|
+
job_uuid
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Subscribe to one or more event names (JSON format). Pass no arguments
|
|
71
|
+
# or +"ALL"+ to receive every event.
|
|
72
|
+
def subscribe(*event_names)
|
|
73
|
+
events = event_names.empty? ? "ALL" : event_names.join(" ")
|
|
74
|
+
send_command("event json #{events}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Cancel subscriptions. Without arguments cancels all events.
|
|
78
|
+
def unsubscribe(*event_names)
|
|
79
|
+
if event_names.empty?
|
|
80
|
+
send_command("noevents")
|
|
81
|
+
else
|
|
82
|
+
event_names.each { |e| send_command("nixevent #{e}") }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Add an event-header filter so FreeSWITCH only sends matching events.
|
|
87
|
+
def filter(header, value)
|
|
88
|
+
send_command("filter #{header} #{value}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Register a handler block for an event name. Use +"ALL"+ to handle
|
|
92
|
+
# every event. Multiple handlers per event name are supported.
|
|
93
|
+
# Returns +self+ for chaining.
|
|
94
|
+
def on(event_name, &)
|
|
95
|
+
@event_dispatcher.on(event_name, &)
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def closed?
|
|
100
|
+
@closed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def close
|
|
104
|
+
return if @closed
|
|
105
|
+
|
|
106
|
+
@closed = true
|
|
107
|
+
begin
|
|
108
|
+
@socket.close
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
@message_reader&.stop
|
|
113
|
+
@event_dispatcher&.stop
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
protected
|
|
117
|
+
|
|
118
|
+
# (Re-)initialise all per-connection state for the given socket.
|
|
119
|
+
# Called by subclasses on initial connect and on reconnect.
|
|
120
|
+
def initialize_socket(socket)
|
|
121
|
+
@socket = socket
|
|
122
|
+
@send_mutex = Mutex.new
|
|
123
|
+
@closed = false
|
|
124
|
+
|
|
125
|
+
# Keep dispatcher data across reconnects so existing handlers still work
|
|
126
|
+
# after a new socket is created.
|
|
127
|
+
unless instance_variable_defined?(:@event_dispatcher)
|
|
128
|
+
@event_dispatcher = EventDispatcher.new
|
|
129
|
+
@event_dispatcher.start
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Always create new MessageReader for each socket
|
|
133
|
+
@message_reader = MessageReader.new(@socket, @event_dispatcher) { |error| on_disconnect(error) }
|
|
134
|
+
@message_reader.start
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Authenticate against FreeSWITCH after the initial auth/request message.
|
|
138
|
+
def authenticate!(password, timeout: DEFAULT_TIMEOUT)
|
|
139
|
+
auth_request = receive_response(timeout: timeout)
|
|
140
|
+
unless auth_request.content_type == "auth/request"
|
|
141
|
+
raise AuthenticationError, "Expected auth/request, got: #{auth_request.content_type}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `auth` is a basic ESL command with a command/reply response, so we
|
|
145
|
+
# use send_command (not api/bgapi).
|
|
146
|
+
reply = send_command("auth #{password}", timeout: timeout)
|
|
147
|
+
return if reply.successful?
|
|
148
|
+
|
|
149
|
+
raise AuthenticationError, "Authentication failed: #{reply.reply_text}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def receive_response(timeout:)
|
|
153
|
+
@message_reader.receive_response(timeout: timeout)
|
|
154
|
+
rescue TimeoutError
|
|
155
|
+
# If a request times out, the next reply might belong to the old
|
|
156
|
+
# command, so we close and start from a clean connection.
|
|
157
|
+
close
|
|
158
|
+
raise
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Called by MessageReader when socket is disconnected or closed.
|
|
164
|
+
def on_disconnect(_error)
|
|
165
|
+
@closed = true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Freeswitch
|
|
4
|
+
module ESL
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class AuthenticationError < Error; end
|
|
8
|
+
|
|
9
|
+
class CommandError < Error; end
|
|
10
|
+
|
|
11
|
+
class ConnectionError < Error; end
|
|
12
|
+
|
|
13
|
+
class TimeoutError < Error; end
|
|
14
|
+
|
|
15
|
+
class DisconnectedError < Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
# Shared logger helpers for ESL classes.
|
|
8
|
+
module Logger
|
|
9
|
+
def self.default_logger
|
|
10
|
+
::Logger.new(IO::NULL)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def logger
|
|
14
|
+
return @logger if defined?(@logger) && @logger
|
|
15
|
+
|
|
16
|
+
@logger = Freeswitch::ESL.configuration.logger
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Freeswitch
|
|
6
|
+
module ESL
|
|
7
|
+
module Protocol
|
|
8
|
+
# Represents a FreeSWITCH event received in JSON format.
|
|
9
|
+
class Event
|
|
10
|
+
attr_reader :data
|
|
11
|
+
|
|
12
|
+
def initialize(raw_json)
|
|
13
|
+
@data = JSON.parse(raw_json).freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](key)
|
|
17
|
+
data[key]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
data["Event-Name"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def subclass
|
|
25
|
+
data["Event-Subclass"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def uuid
|
|
29
|
+
data["Unique-ID"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def job_uuid
|
|
33
|
+
data["Job-UUID"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def body
|
|
37
|
+
data["_body"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def channel_variable(name)
|
|
41
|
+
data["variable_#{name}"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Freeswitch
|
|
4
|
+
module ESL
|
|
5
|
+
module Protocol
|
|
6
|
+
# Represents a raw ESL protocol message (headers + optional body).
|
|
7
|
+
class Message
|
|
8
|
+
attr_reader :headers, :body
|
|
9
|
+
|
|
10
|
+
def initialize(headers, body = nil)
|
|
11
|
+
@headers = headers.freeze
|
|
12
|
+
@body = body&.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](key)
|
|
16
|
+
headers[key]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def content_type
|
|
20
|
+
headers["Content-Type"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reply_text
|
|
24
|
+
headers["Reply-Text"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def successful?
|
|
28
|
+
if content_type == "api/response"
|
|
29
|
+
body&.start_with?("+OK")
|
|
30
|
+
else
|
|
31
|
+
reply_text&.start_with?("+OK")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def error_message
|
|
36
|
+
text = content_type == "api/response" ? body : reply_text
|
|
37
|
+
text&.start_with?("-ERR") ? text.sub(/\A-ERR\s*/, "").strip : nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
require "freeswitch/esl/version"
|
|
6
|
+
require "freeswitch/esl/errors"
|
|
7
|
+
require "freeswitch/esl/logger"
|
|
8
|
+
require "freeswitch/esl/configuration"
|
|
9
|
+
require "freeswitch/esl/protocol/message"
|
|
10
|
+
require "freeswitch/esl/protocol/event"
|
|
11
|
+
require "freeswitch/esl/connection"
|
|
12
|
+
require "freeswitch/esl/client"
|
|
13
|
+
|
|
14
|
+
module Freeswitch
|
|
15
|
+
module ESL
|
|
16
|
+
Message = Protocol::Message
|
|
17
|
+
Event = Protocol::Event
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def configure
|
|
21
|
+
yield(configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configuration
|
|
25
|
+
@configuration ||= Configuration.build
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset!
|
|
29
|
+
@configuration = nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: freeswitch-esl
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Demetra Opinioni.net Srl
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-05 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: configatron
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '4.5'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '4.5'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: logger
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.6'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.6'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.65'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.65'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop-rspec
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: simplecov
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.22'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.22'
|
|
97
|
+
description: |
|
|
98
|
+
A thread-safe Ruby client for the FreeSWITCH Event Socket Library.
|
|
99
|
+
Supports inbound (client→FreeSWITCH) and outbound (FreeSWITCH→client) socket modes,
|
|
100
|
+
API commands, background API, event subscriptions, call control and DTMF handling.
|
|
101
|
+
email:
|
|
102
|
+
- developers@opinioni.net
|
|
103
|
+
executables: []
|
|
104
|
+
extensions: []
|
|
105
|
+
extra_rdoc_files: []
|
|
106
|
+
files:
|
|
107
|
+
- README.md
|
|
108
|
+
- lib/freeswitch-esl.rb
|
|
109
|
+
- lib/freeswitch/esl.rb
|
|
110
|
+
- lib/freeswitch/esl/client.rb
|
|
111
|
+
- lib/freeswitch/esl/configuration.rb
|
|
112
|
+
- lib/freeswitch/esl/connection.rb
|
|
113
|
+
- lib/freeswitch/esl/connection/event_dispatcher.rb
|
|
114
|
+
- lib/freeswitch/esl/connection/message_reader.rb
|
|
115
|
+
- lib/freeswitch/esl/errors.rb
|
|
116
|
+
- lib/freeswitch/esl/logger.rb
|
|
117
|
+
- lib/freeswitch/esl/protocol/event.rb
|
|
118
|
+
- lib/freeswitch/esl/protocol/message.rb
|
|
119
|
+
- lib/freeswitch/esl/version.rb
|
|
120
|
+
homepage: https://gitlab.opinioni.net/demetra-opinioni/rubygems/freeswitch-esl
|
|
121
|
+
licenses:
|
|
122
|
+
- MIT
|
|
123
|
+
metadata:
|
|
124
|
+
rubygems_mfa_required: 'true'
|
|
125
|
+
post_install_message:
|
|
126
|
+
rdoc_options: []
|
|
127
|
+
require_paths:
|
|
128
|
+
- lib
|
|
129
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
130
|
+
requirements:
|
|
131
|
+
- - ">="
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: 3.3.0
|
|
134
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - ">="
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '0'
|
|
139
|
+
requirements: []
|
|
140
|
+
rubygems_version: 3.5.16
|
|
141
|
+
signing_key:
|
|
142
|
+
specification_version: 4
|
|
143
|
+
summary: Ruby client for FreeSWITCH Event Socket Library (ESL)
|
|
144
|
+
test_files: []
|