esl.coffee | |
|---|---|
| esl is a client and a server library for FreeSwitch's ESL protocol (c) 2010 Stephane Alnet Released under the AGPL3 license | |
Overviewesl is modelled after Node.js' own httpServer and client. It offers two ESL handlers, createClient() and createCallServer(). For more information about ESL consult the FreeSwitch wiki Event Socket Typically a client would be used to trigger calls asynchronously (for example in a click-to-dial application); this mode of operation is called "inbound" (to FreeSwitch) in the FreeSwitch documentation. A server will handle calls sent to it using the "socket" diaplan application (called "outbound" mode in the FreeSwitch documentation). The server is available at a pre-defined port which the socket application will specify. See Event Socket Outbound | |
Usage
(The library is a plain Node.js module so you can also call it from Javascript. All examples are given using CoffeeScript for simplicity.) | net = require 'net'
querystring = require 'querystring'
util = require 'util'
assert = require 'assert' |
| If you ever need to debug esl, set | exports.debug = false |
Client ExampleThe following code does the equivalent of "fs_cli -x".
Note: Use
to start receiving event notifications. | |
CallServer ExampleFrom the dialplan, use <action application="socket" data="127.0.0.1:7000 async full"/> to hand the call over to an ESL server. If you'd like to get realtime channel variables after each command(), execute the "verbose_events" command first:
For some applications you might want to capture channel events instead of using the command() / callback pattern: | |
Headers parserESL framing contains headers and a body. The header must be decoded first to learn the presence and length of the body. | parse_header_text = (header_text) ->
if exports.debug
util.log "parse_header_text(#{header_text})"
header_lines = header_text.split("\n")
headers = {}
for line in header_lines
do (line) ->
[name,value] = line.split /: /, 2
headers[name] = value |
| Decode headers: in the case of the "connect" command, the headers are all URI-encoded. | if headers['Reply-Text']?[0] is '%'
for name of headers
headers[name] = querystring.unescape(headers[name])
return headers |
ESL stream parserThe ESL parser will parse an incoming ESL stream, whether your code is acting as a client (connected to the FreeSwitch ESL server) or as a server (called back by FreeSwitch due to the "socket" application command). | class eslParser
constructor: (@socket) ->
@body_length = 0
@buffer = "" |
| When capturing the body, buffer contains the current data (text), and body_length contains how many bytes are expected to be read in the body. | capture_body: (data) ->
@buffer += data |
| As long as the whole body hasn't been received, keep adding the new data into the buffer. | if @buffer.length < @body_length
return |
| Consume the body once it has been fully received. | body = @buffer.substring(0,@body_length)
@buffer = @buffer.substring(@body_length)
@body_length = 0 |
| Process the content | @process @headers, body
@headers = {} |
| Re-parse whatever data was left after the body was fully consumed. | @capture_headers '' |
| Capture headers, meaning up to the first blank line. | capture_headers: (data) ->
@buffer += data |
| Wait until we reach the end of the header. | header_end = @buffer.indexOf("\n\n")
if header_end < 0
return |
| Consume the headers | header_text = @buffer.substring(0,header_end)
@buffer = @buffer.substring(header_end+2) |
| Parse the header lines | @headers = parse_header_text(header_text) |
| Figure out whether a body is expected | if @headers["Content-Length"]
@body_length = @headers["Content-Length"] |
| Parse the body (and eventually process) | @capture_body ''
else |
| Process the (header-only) content | @process @headers
@headers = {} |
| Re-parse whatever data was left after these headers were fully consumed. | @capture_headers '' |
| Dispatch incoming data into the header or body parsers. | on_data: (data) ->
if exports.debug
util.log "on_data(#{data})" |
| Capture the body as needed | if @body_length > 0
return @capture_body data
else
return @capture_headers data |
| For completeness provide an on_end() method. TODO: it probably should make sure the buffer is empty? | on_end: () ->
if exports.debug
util.log "Parser: end of stream"
if @buffer.length > 0
util.log "Buffer is not empty, left over: #{@buffer}" |
ESL response and associated API | class eslResponse
constructor: (@socket,@headers,@body) ->
register_callback: (event,cb) ->
@socket.removeAllListeners event
@socket.on event, (res) =>
@socket.removeAllListeners event
cb res |
| A generic way of sending commands to FreeSwitch.
This is normally not used directly. The array and callback are both optional. | send: (command,args,cb) -> |
| The arguments parameter is optional. | if typeof args is 'function' and not cb?
[cb,args] = [args,null]
if exports.debug
util.log util.inspect command: command, args: args
if cb? then @register_callback 'esl_command_reply', cb |
| Send the command out. | try
@socket.write "#{command}\n"
if args?
for key, value of args
@socket.write "#{key}: #{value}\n"
@socket.write "\n"
catch e
@socket.emit 'esl_error', error:e
on: (event,listener) -> @socket.on(event,listener)
end: () -> @socket.end() |
Channel-level commands | |
| Send an API command, see Mod commands | api: (command,cb) ->
if cb? then @register_callback 'esl_api_response', cb
@send "api #{command}" |
| Send an API command in the background. The callback will receive the Job UUID (instead of the usual response). | bgapi: (command,cb) ->
@register_callback 'esl_command_reply', (res) ->
r = res.headers['Reply-Text']?.match /\+OK Job-UUID: (.+)$/
cb? r[1]
@send "bgapi #{command}" |
Event reception and filtering | |
| Request that the server send us events in JSON format. (For all useful purposes this is the only supported format in this module.) For example: | event_json: (events...,cb) ->
@send "event json #{events.join(' ')}", cb |
| Remove the given event types from the events ACL. | nixevent: (events...,cb) ->
@send "nixevent #{events.join(' ')}", cb |
| Remove all events types. | noevents: (cb) ->
@send "noevents", cb |
| Generic event filtering | filter: (header,value,cb) ->
@send "filter #{header} #{value}", cb
filter_delete: (header,value,cb) ->
if value?
@send "filter delete #{header} #{value}", cb
else
@send "filter delete #{header}", cb |
| Send an event into the FreeSwitch event queue. | sendevent: (event_name,args,cb) ->
@send "sendevent #{event_name}", args, cb |
| Authenticate, typically used in a client: | auth: (password,cb) -> @send "auth #{password}", cb |
| connect() and linger() are used in server mode. | connect: (cb) -> @send "connect", cb # Outbound mode
linger: (cb) -> @send "linger", cb # Outbound mode |
| Send the exit command to the FreeSwitch socket. | exit: (cb) -> @send "exit", cb |
Event logging commands | log: (level,cb) ->
[level,cb] = [null,level] if typeof(level) is 'function'
if level?
@send "log #{level}", cb
else
@send "log", cb
nolog: (cb) -> @send "nolog", cb |
Message sendingSend Message (to a UUID) | sendmsg_uuid: (uuid,command,args,cb) ->
options = args ? {}
options['call-command'] = command
execute_text = if uuid? then "sendmsg #{uuid}" else 'sendmsg'
@send execute_text, options, cb |
| Same, assuming server/outbound ESL mode: | sendmsg: (command,args,cb) -> @sendmsg_uuid null, command, args, cb |
Client-mode ("inbound") commandsThe target UUID must be specified. | |
| Execute an application for the given UUID (in client mode) | execute_uuid: (uuid,app_name,app_arg,cb) ->
options =
'execute-app-name': app_name
'execute-app-arg': app_arg
@sendmsg_uuid uuid, 'execute', options, cb |
| Execute an application synchronously. The callback is only called when the command has completed. | command_uuid: (uuid,app_name,app_arg,cb) ->
if cb?
event = "CHANNEL_EXECUTE_COMPLETE #{app_name} #{app_arg}"
@register_callback event, cb
@execute_uuid uuid,app_name,app_arg |
| Hangup a call | hangup_uuid: (uuid,hangup_cause,cb) ->
hangup_cause ?= 'NORMAL_UNSPECIFIED'
options =
'hangup-cause': hangup_cause
@sendmsg_uuid uuid, 'hangup', options, cb
unicast_uuid: (uuid,args,cb) ->
@sendmsg_uuid uuid, 'unicast', args, cb |
| nomedia_uuid: TODO | |
Server-mode commandsThe target UUID is our (own) call UUID. | |
| Execute an application for the current UUID (in server/outbound mode) | execute: (app_name,app_arg,cb) -> @execute_uuid null, app_name, app_arg, cb
command: (app_name,app_arg,cb) -> @command_uuid null, app_name, app_arg, cb
hangup: (hangup_cause,cb) -> @hangup_uuid null, hangup_cause, cb
unicast: (args,cb) -> @unicast_uuid null, args, cb |
| nomedia: TODO | |
| Clean-up at the end of the connection. | auto_cleanup: ->
@on 'esl_disconnect_notice', (call) =>
if exports.debug
util.log "Received ESL disconnection notice"
switch call.headers['Content-Disposition']
when 'linger'
if exports.debug then util.log "Sending esl_linger"
@socket.emit 'esl_linger', call
when 'disconnect'
if exports.debug then util.log "Sending esl_disconnect"
@socket.emit 'esl_disconnect', call |
LingerThe default behavior in linger mode is to disconnect the call (which is roughly equivalent to not using linger mode). | @on 'esl_linger', => @exit() |
| Use call.registercallback("esllinger",...) to capture the end of the call. In this case you are responsible for calling call.exit() If you do not do it, the calls will leak. | |
DisconnectNormal behavior on disconnect is to end the call. (However you may capture esl_disconnect as well.) | @on 'esl_disconnect', => @end() |
Connection Listener (socket events handler)This is modelled after Node.js' http.js | connectionListener= (socket) ->
socket.setEncoding('ascii')
parser = new eslParser socket
socket.on 'data', (data) -> parser.on_data(data)
socket.on 'end', () -> parser.on_end() |
| Make the command responses somewhat unique. | socket.on 'CHANNEL_EXECUTE_COMPLETE', (res) ->
application = res.body['Application']
application_data = res.body['Application-Data']
socket.emit "CHANNEL_EXECUTE_COMPLETE #{application} #{application_data}", res
parser.process = (headers,body) ->
if exports.debug
util.log util.inspect headers: headers, body: body |
| Rewrite headers as needed to work around some weirdnesses in the protocol; and assign unified event IDs to the ESL Content-Types. | switch headers['Content-Type']
when 'auth/request'
event = 'esl_auth_request'
when 'command/reply'
event = 'esl_command_reply' |
| Apparently a bug in the response to "connect" | if headers['Event-Name'] is 'CHANNEL_DATA'
body = headers
headers = {}
for n in ['Content-Type','Reply-Text','Socket-Mode','Control']
headers[n] = body[n]
delete body[n]
when 'text/event-json'
try
body = JSON.parse(body)
catch error
util.log "JSON #{error} in #{body}"
return
event = body['Event-Name']
when 'text/event-plain'
body = parse_header_text(body)
event = body['Event-Name']
when 'log/data'
event = 'esl_log_data'
when 'text/disconnect-notice'
event = 'esl_disconnect_notice'
when 'api/response'
event = 'esl_api_response'
else
event = headers['Content-Type']
res = new eslResponse socket,headers,body
if exports.debug
util.log util.inspect event:event, res:res
socket.emit event, res |
| Get things started | socket.emit 'esl_connect', new eslResponse socket |
ESL Server | class eslServer extends net.Server
constructor: (requestListener) ->
@on 'connection', (socket) ->
socket.on 'esl_connect', requestListener
connectionListener socket
super() |
| The callback will receive an eslResponse object. | exports.createCallServer = ->
server = new eslServer (call) ->
Unique_ID = 'Unique-ID'
call.connect (call) ->
unique_id = call.body[Unique_ID]
call.auto_cleanup()
call.filter Unique_ID, unique_id, ->
call.event_json 'ALL', ->
server.emit 'CONNECT', call
return server |
ESL client | class eslClient extends net.Socket
constructor: () ->
@on 'connect', ->
connectionListener @
super()
exports.createClient = -> return new eslClient()
|