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 +7 -0
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/freeswitch-esl-client.gemspec +40 -0
- data/lib/freeswitch-esl/client.rb +382 -0
- data/lib/freeswitch-esl/connection.rb +35 -0
- data/lib/freeswitch-esl/errors.rb +7 -0
- data/lib/freeswitch-esl/outbound/connection.rb +155 -0
- data/lib/freeswitch-esl/outbound/server.rb +65 -0
- data/lib/freeswitch-esl/outbound/session.rb +218 -0
- data/lib/freeswitch-esl/parser.rb +106 -0
- data/lib/freeswitch-esl/response.rb +61 -0
- data/lib/freeswitch-esl/version.rb +3 -0
- data/lib/freeswitch-esl-client.rb +4 -0
- data/lib/freeswitch-esl.rb +25 -0
- metadata +128 -0
|
@@ -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,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
|