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.
@@ -0,0 +1,382 @@
1
+ require 'eventmachine'
2
+ require 'securerandom'
3
+ require 'logger'
4
+ require_relative 'connection'
5
+ require_relative 'parser'
6
+ require_relative 'response'
7
+ require_relative 'errors'
8
+
9
+ module FreeswitchESL
10
+ # Inbound ESL client: connects to mod_event_socket (default :8021),
11
+ # authenticates, subscribes to events and exposes api/bgapi.
12
+ #
13
+ # EM.run do
14
+ # client = FreeswitchESL::Client.new(host: '127.0.0.1', password: 'ClueCon')
15
+ # client.on('CHANNEL_ANSWER') { |event| puts event['Unique-ID'] }
16
+ # client.connect!
17
+ # client.api('status') { |res| puts res.body }
18
+ # end
19
+ #
20
+ # The client can also own its reactor thread (no surrounding EM.run needed);
21
+ # in that case #connect! spawns one and #close! shuts it down.
22
+ class Client
23
+ DEFAULT_EVENTS = %w[
24
+ CHANNEL_CREATE CHANNEL_ANSWER CHANNEL_BRIDGE CHANNEL_HANGUP_COMPLETE
25
+ CHANNEL_DESTROY CHANNEL_CALLSTATE CHANNEL_EXECUTE BACKGROUND_JOB
26
+ ].freeze
27
+
28
+ attr_accessor :connected, :reconnect_interval, :closing
29
+ attr_reader :host, :port, :password, :logger
30
+
31
+ def initialize(host: '127.0.0.1', port: 8021, password: 'ClueCon', logger: nil,
32
+ reconnect_interval: 2, events: DEFAULT_EVENTS, auto_reconnect: true)
33
+ @host = host
34
+ @port = port
35
+ @password = password
36
+ @logger = logger || Logger.new($stdout)
37
+ @reconnect_interval = reconnect_interval
38
+ @events = events
39
+ @auto_reconnect = auto_reconnect
40
+
41
+ @connected = false
42
+ @authenticated = false
43
+ @closing = false
44
+ @connecting = false
45
+ @owns_reactor = false
46
+
47
+ @conn = nil
48
+ @queue = []
49
+ @api_callbacks = []
50
+ @job_callbacks = {}
51
+ @bgapi_callbacks = []
52
+ @event_handlers = {}
53
+ end
54
+
55
+ # ---------------------------------------------------------------- lifecycle
56
+
57
+ def connect!
58
+ connect
59
+ end
60
+
61
+ def connect
62
+ return if @connecting || @connected
63
+
64
+ @connecting = true
65
+ @closing = false
66
+ @logger.info "[FS-ESL] Connecting to #{@host}:#{@port}..."
67
+
68
+ if EM.reactor_thread?
69
+ open_socket
70
+ else
71
+ @owns_reactor = true
72
+ Thread.new { EM.run { open_socket } }
73
+ end
74
+ self
75
+ end
76
+
77
+ def open_socket
78
+ EM.connect(@host, @port, Connection) do |conn|
79
+ conn.password = @password
80
+ conn.client = self
81
+ attach(conn)
82
+ end
83
+ end
84
+
85
+ def attach(conn)
86
+ @conn = conn
87
+ @connecting = false
88
+ @logger.debug '[FS-ESL] Connection established'
89
+ end
90
+
91
+ def connection_closed(_conn)
92
+ @authenticated = false
93
+ @connected = false
94
+ @conn = nil
95
+
96
+ if @closing
97
+ @logger.debug '[FS-ESL] Connection closed (intentional)'
98
+ return
99
+ end
100
+
101
+ unless @auto_reconnect
102
+ @logger.warn '[FS-ESL] Connection lost (auto_reconnect disabled)'
103
+ return
104
+ end
105
+
106
+ @logger.warn "[FS-ESL] Connection lost, reconnecting in #{@reconnect_interval}s..."
107
+ EM.add_timer(@reconnect_interval) { open_socket unless @closing }
108
+ end
109
+
110
+ def close!
111
+ @closing = true
112
+ @connected = false
113
+ @authenticated = false
114
+ conn = @conn
115
+ @conn = nil
116
+ conn&.close_connection
117
+
118
+ return unless @owns_reactor && EM.reactor_running?
119
+
120
+ EM.next_tick do
121
+ EM.stop
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ end
126
+ alias close close!
127
+
128
+ def authenticated?
129
+ @authenticated
130
+ end
131
+ alias ready? authenticated?
132
+
133
+ # ------------------------------------------------------------------- frames
134
+
135
+ def handle_frame(conn, frame)
136
+ headers = frame[:headers]
137
+ body = frame[:body].to_s
138
+ content_type = frame[:content_type]
139
+
140
+ case content_type
141
+ when 'auth/request'
142
+ @logger.debug '[FS-ESL] Auth requested'
143
+ conn.send_command("auth #{@password}")
144
+ when 'command/reply'
145
+ handle_command_reply(headers)
146
+ when 'api/response'
147
+ response = Response.new(headers, body)
148
+ callback = @api_callbacks.shift
149
+ callback&.call(response)
150
+ when 'text/event-plain'
151
+ event_headers, event_body = Parser.parse_event(body)
152
+ dispatch_event(event_headers, event_body)
153
+ when 'text/event-json'
154
+ event_headers, event_body = Parser.parse_event(body)
155
+ dispatch_event(event_headers, event_body)
156
+ when 'text/disconnect-notice'
157
+ @logger.warn '[FS-ESL] Disconnected by server'
158
+ @connected = false
159
+ @authenticated = false
160
+ else
161
+ # Some FreeSWITCH builds send the first event without Content-Type.
162
+ if body.include?('Event-Name:')
163
+ event_headers, event_body = Parser.parse_event(body)
164
+ dispatch_event(event_headers, event_body)
165
+ end
166
+ end
167
+ end
168
+
169
+ def handle_command_reply(headers)
170
+ reply = headers['Reply-Text'].to_s
171
+
172
+ if !@authenticated && reply.start_with?('+OK')
173
+ @authenticated = true
174
+ @connected = true
175
+ @logger.info '[FS-ESL] Authenticated successfully'
176
+ subscribe_default_events
177
+ flush_queue
178
+ elsif reply.start_with?('-ERR')
179
+ @logger.error "[FS-ESL] Command failed: #{reply}"
180
+ if !@authenticated
181
+ @logger.error '[FS-ESL] Authentication failed, check password'
182
+ close!
183
+ end
184
+ else
185
+ @logger.debug "[FS-ESL] Reply: #{reply}"
186
+ # FreeSWITCH generates the Job-UUID itself; the callback order is FIFO.
187
+ if (job_uuid = reply[/Job-UUID:\s*(\S+)/, 1])
188
+ callback = @bgapi_callbacks.shift
189
+ @job_callbacks[job_uuid] = callback if callback
190
+ end
191
+ end
192
+ end
193
+
194
+ def dispatch_event(event, raw_body)
195
+ event_name = event['Event-Name'].to_s
196
+
197
+ if event_name == 'BACKGROUND_JOB'
198
+ job_uuid = event['Job-UUID']
199
+ callback = @job_callbacks.delete(job_uuid)
200
+ callback&.call(Response.new(event, raw_body))
201
+ end
202
+
203
+ handlers = Array(@event_handlers[event_name.upcase]) + Array(@event_handlers['*'])
204
+ handlers.each do |handler|
205
+ begin
206
+ handler.call(event)
207
+ rescue StandardError => e
208
+ @logger.error "[FS-ESL] Event handler error (#{event_name}): #{e.class}: #{e.message}"
209
+ end
210
+ end
211
+ end
212
+
213
+ # -------------------------------------------------------------------- events
214
+
215
+ def subscribe_default_events
216
+ @events = DEFAULT_EVENTS if @events.nil? || @events.empty?
217
+ send_raw("event plain #{@events.join(' ')}")
218
+ end
219
+
220
+ # client.on('CHANNEL_ANSWER') { |event| ... }
221
+ # client.on('*') { |event| ... } — every subscribed event
222
+ def on(event_name, &block)
223
+ key = event_name.to_s.upcase
224
+ @event_handlers[key] ||= []
225
+ @event_handlers[key] << block
226
+ self
227
+ end
228
+
229
+ # ------------------------------------------------------------------ commands
230
+
231
+ def api(command, &block)
232
+ @api_callbacks << block if block
233
+ transmit("api #{command}")
234
+ end
235
+
236
+ def bgapi(command, &block)
237
+ @bgapi_callbacks << block if block
238
+ transmit("bgapi #{command}")
239
+ end
240
+
241
+ def send_command(command, job_uuid: nil)
242
+ # Kept for backwards compatibility; Job-UUIDs are generated by FreeSWITCH.
243
+ transmit(command)
244
+ end
245
+
246
+ def transmit(command)
247
+ if @authenticated && @conn
248
+ @conn.send_command(command)
249
+ else
250
+ @queue << command
251
+ end
252
+ end
253
+
254
+ def send_raw(command)
255
+ return unless @conn
256
+
257
+ @conn.send_command(command)
258
+ end
259
+
260
+ def flush_queue
261
+ return unless @conn
262
+
263
+ queued = @queue.dup
264
+ @queue.clear
265
+ queued.each { |command| @conn.send_command(command) }
266
+ end
267
+
268
+ # --------------------------------------------------------------- call helpers
269
+
270
+ # Places an outbound call. FreeSWITCH syntax:
271
+ #
272
+ # originate <ultra-global><global>[per-leg]<call url> <exten>|&<app>(<args>)
273
+ # [<dialplan>] [<context>] [<cid_name>] [<cid_num>] [<timeout_sec>]
274
+ #
275
+ # Variable scopes (see src/switch_ivr_originate.c):
276
+ # ultra_vars: `<>` applied to every leg of the originate
277
+ # vars: `{}` applied to the originated channel; also carries control
278
+ # variables (origination_caller_id_number, ignore_early_media,
279
+ # originate_timeout, ...)
280
+ # leg_vars: `[]` session specific variables for this destination.
281
+ # NOTE: when a name exists in both, FreeSWITCH 1.11 keeps the
282
+ # `{}` value (verified live) — use distinct names per scope.
283
+ #
284
+ # client.originate('user/1000', vars: { ignore_early_media: false })
285
+ # client.originate('sofia/gateway/agent/555', application: '&park()',
286
+ # vars: { origination_caller_id_number: '1001' },
287
+ # leg_vars: { effective_caller_id_name: 'Agent' })
288
+ # client.originate('sofia/gateway/agent/555', exten: '1000',
289
+ # dialplan: 'XML', context: 'default',
290
+ # cid_name: 'Agent', cid_num: '1001', timeout: 30)
291
+ #
292
+ # The callback receives a Response; +res.uuid+ is the originated channel.
293
+ def originate(dialstring, application: nil, exten: nil, dialplan: nil, context: nil,
294
+ cid_name: nil, cid_num: nil, timeout: nil,
295
+ vars: {}, leg_vars: {}, ultra_vars: {}, &block)
296
+ target =
297
+ if application
298
+ application.to_s.start_with?('&') ? application.to_s : "&#{application}"
299
+ elsif exten
300
+ [exten, dialplan || 'XML', context || 'default'].join(' ')
301
+ else
302
+ '&park()'
303
+ end
304
+
305
+ args = [target]
306
+ if cid_name || cid_num || timeout
307
+ # Positional args; FreeSWITCH collapses empty tokens, so skipped slots
308
+ # must be the literal `undef` (see originate_function in mod_commands.c).
309
+ args << (dialplan || 'XML')
310
+ args << (context || 'default')
311
+ args << (cid_name || 'undef')
312
+ args << (cid_num || 'undef')
313
+ args << timeout if timeout
314
+ end
315
+
316
+ bgapi("originate #{build_originate_args(dialstring, vars, leg_vars, ultra_vars)} " \
317
+ "#{args.join(' ')}", &block)
318
+ end
319
+
320
+ # <ultra>{global}[leg]dialstring, in the order FreeSWITCH parses it.
321
+ def build_originate_args(dialstring, vars = {}, leg_vars = {}, ultra_vars = {})
322
+ "#{format_vars(ultra_vars, '<', '>')}#{format_vars(vars)}" \
323
+ "#{format_vars(leg_vars, '[', ']')}#{dialstring}"
324
+ end
325
+
326
+ # `{key=value,key=value}` prefix for dial strings, '' when empty.
327
+ def format_vars(vars, open = '{', close = '}')
328
+ pairs = (vars || {}).reject { |_k, v| v.nil? }.map { |k, v| "#{k}=#{escape_var(v)}" }
329
+ pairs.empty? ? '' : "#{open}#{pairs.join(',')}#{close}"
330
+ end
331
+
332
+ # Commas separate variables inside {}/[]/<>.
333
+ def escape_var(value)
334
+ value.to_s.gsub(',', '\\,')
335
+ end
336
+
337
+ def answer_call(uuid, &block)
338
+ bgapi("uuid_answer #{uuid}", &block)
339
+ end
340
+
341
+ def kill(uuid, cause = 'NORMAL_CLEARING', &block)
342
+ bgapi("uuid_kill #{uuid} #{cause}", &block)
343
+ end
344
+ alias hangup kill
345
+
346
+ def bridge_calls(uuid1, uuid2, &block)
347
+ bgapi("uuid_bridge #{uuid1} #{uuid2}", &block)
348
+ end
349
+
350
+ def play_file(uuid, file, leg = 'aleg', &block)
351
+ bgapi("uuid_broadcast #{uuid} #{file} #{leg}", &block)
352
+ end
353
+
354
+ def stop_playback(uuid, &block)
355
+ bgapi("uuid_break #{uuid} all", &block)
356
+ end
357
+
358
+ def set_var(uuid, name, value, &block)
359
+ bgapi("uuid_setvar #{uuid} #{name} #{value}", &block)
360
+ end
361
+
362
+ def get_var(uuid, name, &block)
363
+ api("uuid_getvar #{uuid} #{name}", &block)
364
+ end
365
+
366
+ def transfer(uuid, destination, dialplan = 'XML', context = 'default', &block)
367
+ bgapi("uuid_transfer #{uuid} #{destination} #{dialplan} #{context}", &block)
368
+ end
369
+
370
+ def park(uuid, &block)
371
+ bgapi("uuid_park #{uuid}", &block)
372
+ end
373
+
374
+ def status(&block)
375
+ api('status', &block)
376
+ end
377
+
378
+ def channels(&block)
379
+ api('show channels as json', &block)
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,35 @@
1
+ require 'eventmachine'
2
+
3
+ module FreeswitchESL
4
+ class Connection < EM::Connection
5
+ attr_accessor :password, :client
6
+
7
+ def post_init
8
+ @framer = Framer.new
9
+ client&.logger&.debug '[FS-ESL] Connection established'
10
+ end
11
+
12
+ def receive_data(data)
13
+ @framer << data
14
+ @framer.each_frame do |frame|
15
+ begin
16
+ client&.handle_frame(self, frame)
17
+ rescue StandardError => e
18
+ client&.logger&.error "[FS-ESL] Error handling frame: #{e.class}: #{e.message}"
19
+ client&.logger&.error e.backtrace.first(5).join("\n")
20
+ end
21
+ end
22
+ end
23
+
24
+ def send_command(cmd)
25
+ return unless client
26
+
27
+ client.logger&.debug "[FS-ESL] -> #{cmd.strip}"
28
+ send_data(cmd.end_with?("\n\n") ? cmd : "#{cmd}\n\n")
29
+ end
30
+
31
+ def unbind
32
+ client&.connection_closed(self)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ module FreeswitchESL
2
+ class Error < StandardError; end
3
+ class ConnectionError < Error; end
4
+ class AuthError < Error; end
5
+ class CommandError < Error; end
6
+ class TimeoutError < Error; end
7
+ end
@@ -0,0 +1,155 @@
1
+ require 'eventmachine'
2
+
3
+ module FreeswitchESL
4
+ module Outbound
5
+ # One TCP connection from FreeSWITCH (`socket host:port async full`).
6
+ #
7
+ # Flow (see mod_event_socket.c, socket_application_fn):
8
+ # 1. FreeSWITCH connects and waits for the `connect` command.
9
+ # 2. We send "connect"; FreeSWITCH answers with a command/reply frame
10
+ # that also carries the whole channel data (Unique-ID, caller, ...).
11
+ # 3. The channel is parked; we drive it with sendmsg/execute and receive
12
+ # events as text/event-plain while the socket stays open.
13
+ class Connection < EM::Connection
14
+ attr_accessor :server, :session
15
+ attr_reader :logger
16
+
17
+ def post_init
18
+ @framer = Framer.new
19
+ @api_callbacks = []
20
+ @job_callbacks = {}
21
+ @bgapi_pending = []
22
+ @logger = server&.logger
23
+ @logger&.debug "[FS-ESL OUTBOUND] New connection from #{peer_address}"
24
+ server&.track(self)
25
+ # Async outbound mode: FreeSWITCH will not send channel data until we ask.
26
+ send_command('connect')
27
+ end
28
+
29
+ def receive_data(data)
30
+ @framer << data
31
+ @framer.each_frame do |frame|
32
+ handle_frame(frame)
33
+ rescue StandardError => e
34
+ @logger&.error "[FS-ESL OUTBOUND] Frame error: #{e.class}: #{e.message}"
35
+ @logger&.error e.backtrace.first(5).join("\n")
36
+ end
37
+ end
38
+
39
+ def handle_frame(frame)
40
+ headers = frame[:headers]
41
+ body = frame[:body].to_s
42
+
43
+ case frame[:content_type]
44
+ when 'command/reply'
45
+ reply = headers['Reply-Text'].to_s
46
+ if session.nil? && headers['Unique-ID']
47
+ # The very first reply also contains the whole channel data.
48
+ start_session(headers)
49
+ elsif reply.include?('Job-UUID:')
50
+ job_uuid = reply[/Job-UUID:\s*(\S+)/, 1]
51
+ callback = @bgapi_pending.shift
52
+ @job_callbacks[job_uuid] = callback if job_uuid && callback
53
+ else
54
+ @logger&.debug "[FS-ESL OUTBOUND] reply: #{reply}"
55
+ end
56
+ when 'api/response'
57
+ callback = @api_callbacks.shift
58
+ callback&.call(Response.new(headers, body))
59
+ when 'text/event-plain'
60
+ event, event_body = Parser.parse_event(body)
61
+ dispatch_event(event, event_body)
62
+ when 'text/event-json'
63
+ event, event_body = Parser.parse_event(body)
64
+ dispatch_event(event, event_body)
65
+ when 'text/disconnect-notice'
66
+ @logger&.debug '[FS-ESL OUTBOUND] Disconnect notice'
67
+ close_connection
68
+ else
69
+ if session.nil? && headers['Unique-ID']
70
+ start_session(headers)
71
+ elsif body.include?('Event-Name:')
72
+ event, event_body = Parser.parse_event(body)
73
+ dispatch_event(event, event_body)
74
+ end
75
+ end
76
+ end
77
+
78
+ def start_session(headers)
79
+ @session = Session.new(self, headers)
80
+ @logger&.info "[FS-ESL OUTBOUND] Session #{@session.uuid} from #{@session.caller_id_number} to #{@session.destination_number}"
81
+ # Outbound sockets receive no events until they subscribe (mod_event_socket.c
82
+ # only delivers when LFLAG_EVENTS is set by the `event` command).
83
+ send_command("event plain #{Session::DEFAULT_EVENTS.join(' ')}")
84
+ server&.handler&.call(@session)
85
+ end
86
+
87
+ def dispatch_event(event, raw_body)
88
+ name = event['Event-Name'].to_s
89
+
90
+ if name == 'BACKGROUND_JOB'
91
+ callback = @job_callbacks.delete(event['Job-UUID'])
92
+ callback&.call(Response.new(event, raw_body))
93
+ return
94
+ end
95
+
96
+ return unless session
97
+ return unless belongs_to_session?(event)
98
+
99
+ session.handle_event(event, raw_body)
100
+ end
101
+
102
+ # An outbound socket gets events for every channel in the system, so only
103
+ # forward the ones that belong to our dialog.
104
+ def belongs_to_session?(event)
105
+ uuid = session&.uuid
106
+ return false unless uuid
107
+ return true if event['Unique-ID'] == uuid
108
+
109
+ %w[Bridge-A-Unique-ID Bridge-B-Unique-ID Other-Leg-Unique-ID Originating-Leg-Unique-ID]
110
+ .any? { |key| event[key] == uuid }
111
+ end
112
+
113
+ # -------------------------------------------------------------- commands
114
+
115
+ def send_command(command)
116
+ data = command.end_with?("\n\n") ? command : "#{command}\n\n"
117
+ @logger&.debug "[FS-ESL OUTBOUND] -> #{command.strip}"
118
+ send_data(data)
119
+ end
120
+
121
+ def execute(app, args = nil, job_uuid: nil)
122
+ lines = ['sendmsg']
123
+ lines << "call-command: execute"
124
+ lines << "execute-app-name: #{app}"
125
+ lines << "execute-app-arg: #{args}" if args && !args.to_s.empty?
126
+ lines << "event-lock: true"
127
+ lines << "Job-UUID: #{job_uuid}" if job_uuid
128
+ send_command(lines.join("\n"))
129
+ end
130
+
131
+ def api(command, &block)
132
+ @api_callbacks << block if block
133
+ send_command("api #{command}")
134
+ end
135
+
136
+ def bgapi(command, &block)
137
+ @bgapi_pending << block if block
138
+ send_command("bgapi #{command}")
139
+ nil
140
+ end
141
+
142
+ def unbind
143
+ @logger&.debug '[FS-ESL OUTBOUND] Connection closed'
144
+ server&.untrack(self)
145
+ session&.close
146
+ end
147
+
148
+ def peer_address
149
+ get_peername ? Socket.unpack_sockaddr_in(get_peername).reverse.join(':') : 'unknown'
150
+ rescue StandardError
151
+ 'unknown'
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,65 @@
1
+ require 'eventmachine'
2
+ require 'logger'
3
+
4
+ module FreeswitchESL
5
+ module Outbound
6
+ # Outbound ESL server.
7
+ #
8
+ # EM.run do
9
+ # server = FreeswitchESL.outbound_server(port: 8084) do |session|
10
+ # session.answer
11
+ # session.play('ivr/ivr-welcome_to_freeswitch.wav')
12
+ # session.hangup
13
+ # end
14
+ # server.start
15
+ # end
16
+ class Server
17
+ attr_reader :host, :port, :handler, :logger
18
+ attr_accessor :signature
19
+
20
+ def initialize(host: '0.0.0.0', port: 8084, logger: nil, &block)
21
+ @host = host
22
+ @port = port
23
+ @handler = block
24
+ @logger = logger || Logger.new($stdout)
25
+ @running = false
26
+ @connections = []
27
+ end
28
+
29
+ def start
30
+ return self if @running
31
+
32
+ @logger.info "[FS-ESL OUTBOUND] Starting server on #{@host}:#{@port}"
33
+ @signature = EM.start_server(@host, @port, Connection) do |conn|
34
+ conn.server = self
35
+ end
36
+ @running = true
37
+ self
38
+ end
39
+
40
+ def stop
41
+ EM.stop_server(@signature) if @signature
42
+ @signature = nil
43
+ @running = false
44
+ self
45
+ end
46
+
47
+ def running?
48
+ @running
49
+ end
50
+
51
+ # Called by Connection#post_init / #unbind
52
+ def track(conn)
53
+ @connections << conn
54
+ end
55
+
56
+ def untrack(conn)
57
+ @connections.delete(conn)
58
+ end
59
+
60
+ def connections
61
+ @connections
62
+ end
63
+ end
64
+ end
65
+ end