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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Freeswitch
4
+ module ESL
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "freeswitch/esl"
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: []