freeswitch-esl-client 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: a50fbec915b234efd22756ad6b06bfbebf8df7598e5a5db71f66a3af37713bf0
4
+ data.tar.gz: cf7f08880ca0e01f42278f03c24ee6f55bbd45fbe4de8749e09ac60a4ffc7eb6
5
+ SHA512:
6
+ metadata.gz: c07ab1867f846fa12bdf11f8fe7319ab520711735cb91cc98bab480836a98559f927f2bd8680dec6c000c41074f2304649c8f4e001f1904c38f29b9dba9f2d7f
7
+ data.tar.gz: 70bff8ac50d2ce9d3486dd4262c727c82c35e32c5deacfc261f5ceada8999fd12400d988cf56ef5fb29edc03f853bd0c6c4084447ca9438f40c3ec03a6eea869
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-29
10
+
11
+ First public release. Verified live against FreeSWITCH 1.11 and Asterisk 20 in Docker,
12
+ on Ruby 3.2+ and 4.0.
13
+
14
+ The gem is published as `freeswitch-esl-client` because `freeswitch-esl` was already
15
+ taken on RubyGems. The library name is unchanged: `require 'freeswitch-esl'` and the
16
+ `FreeswitchESL` namespace. `require 'freeswitch-esl-client'` is available as a shim.
17
+
18
+ ### Added
19
+
20
+ - **Inbound mode** — `FreeswitchESL::Client` connects to `mod_event_socket` (default
21
+ `127.0.0.1:8021`), authenticates, subscribes to events, auto-reconnects, and exposes
22
+ `api` / `bgapi` with callbacks.
23
+ - **Outbound mode** — `FreeswitchESL.outbound_server` starts a TCP server that
24
+ FreeSWITCH dials into (`socket host:port async full`). Every call becomes a
25
+ `FreeswitchESL::Outbound::Session` with `answer`, `play`, `bridge`, `transfer`,
26
+ `set`, `export`, `speak`, `record`, `read`, `send_dtmf`, `execute`, `api`, `bgapi`.
27
+ - Output-side `connect` handshake and automatic event subscription for outbound
28
+ sockets, with per-call event filtering by `Unique-ID`.
29
+ - `Client#originate` with `application:`, `exten:`/`dialplan:`/`context:`,
30
+ positional `cid_name`/`cid_num`/`timeout`, and the three FreeSWITCH variable
31
+ scopes: `ultra_vars:` (`<>`), `vars:` (`{}`), `leg_vars:` (`[]`).
32
+ - Binary-safe `Framer` that honours `Content-Length` and never splits frames on
33
+ blank lines, plus a parser for nested BACKGROUND_JOB bodies.
34
+ - `Response` helpers: `success?`, `error?`, `reply_text`, `uuid`, `job_uuid`,
35
+ `event_name`, `body` / `output`.
36
+ - Call helpers on the inbound client: `answer_call`, `kill`/`hangup`,
37
+ `bridge_calls`, `play_file`, `stop_playback`, `set_var`, `get_var`, `transfer`,
38
+ `park`, `status`, `channels`.
39
+ - RSpec suite (26 examples) with a fake ESL server covering inbound, outbound and
40
+ originate command building.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # freeswitch-esl-client
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/freeswitch-esl-client.svg)](https://rubygems.org/gems/freeswitch-esl-client)
4
+
5
+ A small, dependency-light Ruby client for the [FreeSWITCH](https://freeswitch.org/)
6
+ Event Socket Layer (`mod_event_socket`), with **inbound** and **outbound** modes.
7
+
8
+ > The gem is published as **`freeswitch-esl-client`** because `freeswitch-esl` was
9
+ > already taken on RubyGems. The library keeps its original name: `require 'freeswitch-esl'`
10
+ > (as does the module, `FreeswitchESL`). `require 'freeswitch-esl-client'` works too.
11
+
12
+ - **Inbound** — your Ruby process connects to FreeSWITCH on `:8021`, subscribes to
13
+ events and drives calls with `api` / `bgapi`.
14
+ - **Outbound** — FreeSWITCH dials into an embedded TCP server of yours
15
+ (`<action application="socket" data="host:port async full"/>`), so call handling
16
+ lives in Ruby instead of static dialplan XML. Ideal for building a B2BUA, IVR or
17
+ dynamic routing service.
18
+
19
+ Verified live against FreeSWITCH 1.11 and Asterisk 20 in Docker, on Ruby 3.2+ and 4.0.
20
+
21
+ ## Contents
22
+
23
+ - [Requirements](#requirements)
24
+ - [Installation](#installation)
25
+ - [Inbound mode](#inbound-mode)
26
+ - [Originating calls](#originating-calls)
27
+ - [Outbound mode](#outbound-mode)
28
+ - [Threading and callbacks](#threading-and-callbacks)
29
+ - [Gotchas](#gotchas)
30
+ - [Development](#development)
31
+ - [License](#license)
32
+
33
+ ## Requirements
34
+
35
+ - Ruby >= 3.2
36
+ - FreeSWITCH with `mod_event_socket` loaded
37
+ (`conf/autoload_configs/event_socket.conf.xml`, default password `ClueCon`)
38
+ - Nothing else at runtime except [`eventmachine`](https://rubygems.org/gems/eventmachine)
39
+
40
+ ## Installation
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ gem 'freeswitch-esl-client'
45
+ ```
46
+
47
+ Or straight from Git while a release is not published yet:
48
+
49
+ ```ruby
50
+ gem 'freeswitch-esl-client', git: 'https://github.com/miclast/freeswitch-esl-client.git', tag: 'v0.1.0'
51
+ ```
52
+
53
+ Or from a local checkout:
54
+
55
+ ```ruby
56
+ gem 'freeswitch-esl-client', path: '../freeswitch-esl'
57
+ ```
58
+
59
+ Then:
60
+
61
+ ```bash
62
+ bundle install
63
+ ```
64
+
65
+ ## Inbound mode
66
+
67
+ ```ruby
68
+ require 'freeswitch-esl'
69
+ require 'eventmachine'
70
+
71
+ EM.run do
72
+ client = FreeswitchESL::Client.new(
73
+ host: '127.0.0.1',
74
+ port: 8021,
75
+ password: 'ClueCon'
76
+ )
77
+
78
+ client.on('CHANNEL_ANSWER') { |event| puts "answered #{event['Unique-ID']}" }
79
+ client.on('CHANNEL_HANGUP_COMPLETE') { |event| puts "hung up: #{event['Hangup-Cause']}" }
80
+ client.on('*') { |event| puts event['Event-Name'] } # catch-all
81
+
82
+ client.connect # inside a running reactor
83
+ # client.connect! # without a reactor: spawns its own thread
84
+
85
+ EM.add_timer(1) do
86
+ client.api('status') { |res| puts res.body }
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### Constructor options
92
+
93
+ | option | default | description |
94
+ | --- | --- | --- |
95
+ | `host` | `'127.0.0.1'` | ESL host |
96
+ | `port` | `8021` | ESL port |
97
+ | `password` | `'ClueCon'` | ESL password |
98
+ | `logger` | `Logger.new($stdout)` | any `Logger`-compatible object |
99
+ | `reconnect_interval` | `2` | seconds between reconnect attempts |
100
+ | `events` | `Client::DEFAULT_EVENTS` | events to subscribe to |
101
+ | `auto_reconnect` | `true` | reconnect automatically after a dropped connection |
102
+
103
+ Commands issued before authentication are queued and flushed once the socket is
104
+ ready; `client.ready?` tells whether that has happened.
105
+
106
+ ### Commands
107
+
108
+ `api(command) { |res| … }` runs synchronously on the FreeSWITCH side and returns an
109
+ `api/response`; `bgapi(command) { |res| … }` returns immediately and resolves via a
110
+ `BACKGROUND_JOB` event. The gem maps the server-generated `Job-UUID` back to your
111
+ block, so callbacks stay in order even with concurrent jobs.
112
+
113
+ ```ruby
114
+ client.bgapi('status') { |res| puts res.reply_text } # +OK Job-UUID: …
115
+ client.api('show channels count') { |res| puts res.body } # "2 total."
116
+ ```
117
+
118
+ ### Call helpers
119
+
120
+ Convenience wrappers over the API (all callback-based):
121
+
122
+ | helper | FreeSWITCH command |
123
+ | --- | --- |
124
+ | `answer_call(uuid)` | `uuid_answer` |
125
+ | `kill(uuid, cause = 'NORMAL_CLEARING')` / `hangup` | `uuid_kill` |
126
+ | `bridge_calls(uuid1, uuid2)` | `uuid_bridge` |
127
+ | `play_file(uuid, file, leg = 'aleg')` | `uuid_broadcast` |
128
+ | `stop_playback(uuid)` | `uuid_break … all` |
129
+ | `set_var(uuid, name, value)` | `uuid_setvar` |
130
+ | `get_var(uuid, name)` | `uuid_getvar` |
131
+ | `transfer(uuid, destination, dialplan = 'XML', context = 'default')` | `uuid_transfer` |
132
+ | `park(uuid)` | `uuid_park` |
133
+ | `status` | `status` |
134
+ | `channels` | `show channels as json` |
135
+
136
+ ### Response object
137
+
138
+ | method | meaning |
139
+ | --- | --- |
140
+ | `success?` / `error?` | `+OK …` / `-ERR …` (works for bgapi too, where the text is in the body) |
141
+ | `reply_text` | `Reply-Text` header or the first body line |
142
+ | `uuid` | uuid from `+OK <uuid>` (handy for originate) |
143
+ | `job_uuid`, `event_name` | job/event identifiers |
144
+ | `body` / `output` | raw payload (API output, BACKGROUND_JOB output) |
145
+ | `[key]` | any header of the reply/event |
146
+
147
+ ## Originating calls
148
+
149
+ `Client#originate` builds the FreeSWITCH `originate` command for you:
150
+
151
+ ```ruby
152
+ client.originate('sofia/gateway/agent/555',
153
+ application: '&park()', # or exten: '1000'
154
+ vars: {
155
+ origination_caller_id_number: '1001',
156
+ origination_caller_id_name: 'Agent',
157
+ ignore_early_media: 'false'
158
+ },
159
+ leg_vars: { effective_caller_id_name: 'Agent' }) do |res|
160
+ if res.success?
161
+ uuid = res.uuid # +OK <uuid>
162
+ else
163
+ warn res.reply_text # -ERR INVALID_GATEWAY
164
+ end
165
+ end
166
+ ```
167
+
168
+ A positional form maps to FreeSWITCH's `[dialplan] [context] [cid_name] [cid_num] [timeout_sec]`:
169
+
170
+ ```ruby
171
+ client.originate('user/1000', cid_name: 'Support', cid_num: '1001', timeout: 30)
172
+ # => bgapi originate user/1000 &park() XML default Support 1001 30
173
+ ```
174
+
175
+ Missing intermediate slots are sent as the literal `undef`, which FreeSWITCH turns
176
+ into `NULL`. Empty strings would be collapsed by its argv parser and shift the
177
+ remaining arguments (the timeout would silently become the caller ID).
178
+
179
+ ### Variable scopes
180
+
181
+ FreeSWITCH accepts three prefixes on a dial string and they are not equivalent:
182
+
183
+ | scope | option | applies to |
184
+ | --- | --- | --- |
185
+ | `<...>` | `ultra_vars` | every leg of the originate |
186
+ | `{...}` | `vars` | the originated channel, plus originate control variables (`originate_timeout`, `ignore_early_media`, `origination_caller_id_*`, retries, `group_confirm_*`) |
187
+ | `[...]` | `leg_vars` | per-destination session variables |
188
+
189
+ They are emitted in that order: `<ultra>{global}[leg]dialstring`. Note that when the
190
+ same name appears in `{}` and `[]`, FreeSWITCH 1.11 keeps the `{}` value — use
191
+ distinct names per scope instead of relying on an override.
192
+
193
+ ## Outbound mode
194
+
195
+ FreeSWITCH makes the call, then hands the channel to your Ruby process:
196
+
197
+ ```xml
198
+ <!-- dialplan -->
199
+ <extension name="app">
200
+ <condition field="destination_number" expression="^9999$">
201
+ <action application="socket" data="10.0.0.5:8084 async full"/>
202
+ </condition>
203
+ </extension>
204
+ ```
205
+
206
+ ```ruby
207
+ require 'freeswitch-esl'
208
+ require 'eventmachine'
209
+
210
+ EM.run do
211
+ server = FreeswitchESL.outbound_server(host: '0.0.0.0', port: 8084) do |session|
212
+ puts "call from #{session.caller_id_number} to #{session.destination_number}"
213
+
214
+ session.on('DTMF') { |e| puts "digit #{e['DTMF-Digit']}" }
215
+ session.on('CHANNEL_HANGUP_COMPLETE') { |e| puts "cause #{e['Hangup-Cause']}" }
216
+
217
+ session.answer
218
+ session.play('ivr/ivr-welcome_to_freeswitch.wav')
219
+ session.bridge('sofia/gateway/trunk/555')
220
+ session.hangup('NORMAL_CLEARING')
221
+ end
222
+
223
+ server.start
224
+ # server.stop when shutting the application down
225
+ end
226
+ ```
227
+
228
+ ### How it works
229
+
230
+ 1. FreeSWITCH connects and waits for the `connect` command — the server sends it
231
+ automatically in `post_init`.
232
+ 2. FreeSWITCH answers with a `command/reply` frame that also carries the channel
233
+ data (`Unique-ID`, caller, destination, …); that becomes the `Session`.
234
+ 3. The channel is parked. The session subscribes to its events and is driven with
235
+ `sendmsg` (`execute`, `answer`, `bridge`, …).
236
+ 4. Events are filtered by the session UUID (`Unique-ID`, `Bridge-A/B-Unique-ID`,
237
+ `Other-Leg-Unique-ID`, `Originating-Leg-Unique-ID`), so concurrent calls on the
238
+ same socket stay isolated.
239
+
240
+ ### Session API
241
+
242
+ | group | methods |
243
+ | --- | --- |
244
+ | state | `uuid`, `caller_id_number`, `caller_id_name`, `destination_number`, `state`, `hangup_cause`, `answered?`, `ended?` |
245
+ | call control | `answer`, `pre_answer`, `ring_ready`, `hangup(cause)`, `bridge(dialstring)`, `transfer(extension, dialplan:, context:)`, `park` |
246
+ | media | `play(file)`, `play_and_get_digits(...)`, `read(...)`, `record(file, limit, ...)`, `speak(text, engine:, voice:)`, `sleep(ms)` |
247
+ | variables | `set(name, value)`, `export(name, value)`, `unset(name)`, `variable(name)`, `[](name)`, `headers` |
248
+ | signals | `send_dtmf(dtmf)`, `flush_dtmf` |
249
+ | escape hatch | `execute(app, args)`, `api(command)`, `bgapi(command)`, `send_command(raw)` |
250
+ | events | `on('DTMF') { … }`, `on('*') { … }` |
251
+
252
+ `Session::DEFAULT_EVENTS` can be tuned in the constant if you need fewer events per call.
253
+
254
+ ## Threading and callbacks
255
+
256
+ EventMachine runs everything on a single reactor thread. Event handlers and
257
+ `api`/`bgapi` callbacks are invoked there, so keep them short: push work into a queue
258
+ (Sidekiq, a Ruby `Queue`, etc.) instead of doing blocking I/O inside a callback.
259
+
260
+ Calling `connect!` outside a reactor starts one owned by the client; `close!` stops
261
+ it. Inside an existing reactor use `connect` and manage the reactor yourself.
262
+
263
+ ## Gotchas
264
+
265
+ - **Outbound sockets receive no events until they subscribe.** `listener_run` in
266
+ `mod_event_socket.c` only delivers events after `event plain …`; the gem does this
267
+ for you in `start_session`.
268
+ - **`bgapi` ignores a client-supplied `job-uuid=`.** FreeSWITCH generates its own and
269
+ reports it in the reply; the gem parses and matches it.
270
+ - **Codecs must be in the bridge dial string** when a dialplan `set` is not honoured
271
+ on the outbound leg:
272
+ `session.bridge('{absolute_codec_string=PCMU,PCMA}sofia/gateway/trunk/555')`.
273
+ - **`Caller-Caller-ID-*` may be rewritten** by a gateway/profile; check `Caller-ANI`
274
+ and `variable_origination_caller_id_*` when testing caller ID.
275
+ - **Frames are content-length aware.** The parser never splits on blank lines, because
276
+ `BACKGROUND_JOB` bodies contain their own header block.
277
+
278
+ ## Development
279
+
280
+ ```bash
281
+ bundle install
282
+ bundle exec rspec # 26 examples, no external services required
283
+ rake build # builds pkg/freeswitch-esl-client-0.1.0.gem
284
+ ```
285
+
286
+ The spec suite spins up fake ESL servers in-process, so FreeSWITCH does not need to
287
+ be running for the tests.
288
+
289
+ ### Releasing
290
+
291
+ ```bash
292
+ rake release # tags, pushes and uploads to rubygems.org
293
+ # or manually:
294
+ gem build freeswitch-esl-client.gemspec
295
+ gem push freeswitch-esl-client-0.1.0.gem
296
+ ```
297
+
298
+ ## Contributing
299
+
300
+ Issues and pull requests are welcome. Please run `bundle exec rspec` before opening a
301
+ PR and keep the public API backwards compatible within a minor version.
302
+
303
+ ## License
304
+
305
+ Released under the [MIT License](LICENSE).
@@ -0,0 +1,40 @@
1
+ require_relative 'lib/freeswitch-esl/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'freeswitch-esl-client'
5
+ spec.version = FreeswitchESL::VERSION
6
+ spec.authors = ['Michael']
7
+ spec.email = ['michael@example.com']
8
+
9
+ spec.summary = 'Ruby client for the FreeSWITCH Event Socket Layer (inbound and outbound).'
10
+ spec.description = <<~DESC
11
+ EventMachine-based client for the FreeSWITCH Event Socket Layer (mod_event_socket).
12
+ Supports inbound mode (connect to port 8021, subscribe to events, run api/bgapi,
13
+ auto-reconnect) and outbound mode (an embedded TCP server that FreeSWITCH dials into
14
+ with the socket application, so call control lives in Ruby instead of dialplan XML).
15
+ Includes a binary-safe ESL frame parser, an originate helper covering FreeSWITCH
16
+ variable scopes and positional arguments, and per-channel outbound sessions.
17
+ DESC
18
+ spec.homepage = 'https://github.com/miclast/freeswitch-esl-client'
19
+ spec.license = 'MIT'
20
+ spec.required_ruby_version = '>= 3.2'
21
+
22
+ spec.metadata = {
23
+ 'homepage_uri' => spec.homepage,
24
+ 'changelog_uri' => "#{spec.homepage}/blob/main/CHANGELOG.md",
25
+ 'bug_tracker_uri' => "#{spec.homepage}/issues",
26
+ 'documentation_uri' => 'https://www.rubydoc.info/gems/freeswitch-esl-client',
27
+ 'rubygems_mfa_required' => 'true'
28
+ }
29
+
30
+ spec.files = Dir.glob('{lib,exe}/**/*') + %w[README.md CHANGELOG.md LICENSE freeswitch-esl-client.gemspec]
31
+ spec.bindir = 'exe'
32
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ['lib']
34
+
35
+ spec.add_dependency 'eventmachine', '~> 1.2', '>= 1.2.7'
36
+ spec.add_dependency 'logger', '>= 1.6'
37
+
38
+ spec.add_development_dependency 'rake', '~> 13.0'
39
+ spec.add_development_dependency 'rspec', '~> 3.0'
40
+ end