UNPKG

drachtio-modesl

Version:

FreeSWITCH ESL Node.js Implementation

892 lines (731 loc) 28.5 kB
var EventEmitter2 = require('eventemitter2').EventEmitter2, util = require('util'), net = require('net'), xml2js = require('xml2js'), generateUuid = require('uuid-random'), esl = require('./esl'), Event = require('./Event'), assert = require('assert'), Parser = require('./Parser'); //- function(host, port, localAddress, password) //Initializes a new instance of ESLconnection, and connects to the // host $host on the port $port, and supplies $password to freeswitch. // //Intended only for an event socket in "Inbound" mode. In other words, // this is only intended for the purpose of creating a connection to // FreeSWITCH that is not initially bound to any particular call or channel. // //Does not initialize channel information (since inbound connections are // not bound to a particular channel). In plain language, this means that // calls to getInfo() will always return NULL. // //- function(fd) //Initializes a new instance of ESLconnection, using the existing file // number contained in $fd. // //Intended only for Event Socket Outbound connections. It will fail on // Inbound connections, even if passed a valid inbound socket. // //The standard method for using this function is to listen for an incoming // connection on a socket, accept the incoming connection from FreeSWITCH, // fork a new copy of your process if you want to listen for more connections, // and then pass the file number of the socket to new($fd). // //NOTE: The Connection class only supports 1 connection from FSW, the second // ctor option will take in a net.Socket instance (gained from net.connect or // on a server's connection event). For multiple connections use esl.Server var Connection = module.exports = function() { EventEmitter2.call(this, { wildcard: true, delimiter: '::', ignoreErrors: true, maxListeners: 25 }); var len = arguments.length, self = this; //check if they passed a ready callback this.once('esl::ready', ((typeof arguments[len - 1] === 'function') ? arguments[len - 1] : this._noop)); //reasonable defaults for values this.execAsync = false; this.execLock = false; this.connecting = true; this.authed = false; this.channelData = null; this.cmdCallbackQueue = []; this.apiCallbackQueue = []; //reconnect variables for inbound connections this.reconnectOpts = {} ; this.reconnectVars = {} ; //events required for the module to operate properly this.reqEvents = ['BACKGROUND_JOB', 'CHANNEL_EXECUTE_COMPLETE']; this.listeningEvents = []; //"Inbound" connection (going into FSW) if(len > 2 && len < 6) { //3 (host, port, password ); //4 (host, port, password, localAddress | callback); //5 (host, port, password, localAddress, callback) //set inbound to true this._inbound = true; this.reqEvents = []; //save password this.password = arguments[2]; this.connectOpts = { port: arguments[1], host: arguments[0] } ; if( len > 3 && 'string' === typeof arguments[3] ) { this.connectOpts.localAddress = arguments[3] ; } //connect to ESL Socket this.socket = net.createConnection(this.connectOpts, this._onConnect.bind(this)); this.socket.setKeepAlive(true); //this.installListeners() ; this.socket.on('error', this._onError.bind(this)); } //"Outbound" connection (coming from FSW) else if(len >= 1) { //1 (net.Socket); 2 (net.Socket, callback) //set inbound to false this._inbound = false; this.socket = arguments[0]; this.connecting = false; this._onConnect(); this.send('connect'); this.once('esl::event::CHANNEL_DATA::**', function() { //Note: its up to the aplication to subscribe //self.subscribe(self.reqEvents, function() { self.emit('esl::ready'); //}); }); this.socket.on('error', this._onError.bind(this)); } //Invalid arguments passed else { //0 args, or more than 4 this.emit('error', new Error('Bad arguments passed to esl.Connection')); } //emit end when stream closes this.socket.on('close', () => { self.emit('esl::end'); self.socket = null; if (this._inbound) { this._onConnectionGone(); } }); //handle logdata events this.on('esl::event::logdata', function(log) { esl._doLog(log); }); //handle command reply callbacks this.on('esl::event::command::reply', function() { if(self.cmdCallbackQueue.length === 0) return; var fn = self.cmdCallbackQueue.shift(); if(fn && typeof fn === 'function') fn.apply(self, arguments); }); //handle api response callbacks this.on('esl::event::api::response', function() { if(self.apiCallbackQueue.length === 0) return; var fn = self.apiCallbackQueue.shift(); if(fn && typeof fn === 'function') fn.apply(self, arguments); }); }; util.inherits(Connection, EventEmitter2); /********************* ** Lower-level ESL Specification ** http://wiki.freeswitch.org/wiki/Event_Socket_Library **********************/ //Returns the UNIX file descriptor for the connection object, // if the connection object is connected. This is the same file // descriptor that was passed to new($fd) when used in outbound mode. Connection.prototype.socketDescriptor = function() { if(this._inbound) return null; return this.socket; }; //Test if the connection object is connected. Returns `true` if connected, `false` otherwise. Connection.prototype.connected = function() { return (!this.connecting && !!this.socket); }; //When FS connects to an "Event Socket Outbound" handler, it sends // a "CHANNEL_DATA" event as the first event after the initial connection. // getInfo() returns an ESLevent that contains this Channel Data. // //getInfo() returns NULL when used on an "Event Socket Inbound" connection. Connection.prototype.getInfo = function() { return this.channelData; //remains null on Inbound socket }; //Sends a command to FreeSWITCH. // //Does not wait for a reply. You should immediately call recvEvent // or recvEventTimed in a loop until you get the reply. The reply // event will have a header named "content-type" that has a value // of "api/response" or "command/reply". // //To automatically wait for the reply event, use sendRecv() instead of send(). // //NOTE: This is a FAF method of sending a command Connection.prototype.send = function(command, args) { var self = this; //write raw command to socket try { self.socket.write(command + '\n'); if(args) { Object.keys(args).forEach(function(key) { self.socket.write(key + ': ' + args[key] + '\n'); }); } self.socket.write('\n'); } catch(e) { self.emit('error', e); } }; //Internally sendRecv($command) calls send($command) then recvEvent(), // and returns an instance of ESLevent. // //recvEvent() is called in a loop until it receives an event with a header // named "content-type" that has a value of "api/response" or "command/reply", // and then returns it as an instance of ESLevent. // //Any events that are received by recvEvent() prior to the reply event are queued // up, and will get returned on subsequent calls to recvEvent() in your program. // //NOTE: This listens for a response when calling `.send()` doing recvEvent() in a loop // doesn't make sense in the contet of Node. Connection.prototype.sendRecv = function(command, args, cb) { if(typeof args === 'function') { cb = args; args = null; } //queue callback for command reply this.cmdCallbackQueue.push(cb); this.send(command, args); }; //Send an API command (http://wiki.freeswitch.org/wiki/Mod_commands#Core_Commands) // to the FreeSWITCH server. This method blocks further execution until // the command has been executed. // //api($command, $args) is identical to sendRecv("api $command $args"). Connection.prototype.api = function(command, args, cb) { if(typeof args === 'function') { cb = args; args = ''; } if(args instanceof Array) args = args.join(' '); args = (args ? ' ' + args : ''); //queue callback for api response this.apiCallbackQueue.push(cb); this.send('api ' + command + args); }; //Send a background API command to the FreeSWITCH server to be executed in // it's own thread. This will be executed in it's own thread, and is non-blocking. // //bgapi($command, $args) is identical to sendRecv("bgapi $command $args") Connection.prototype.bgapi = function(command, args, jobid, cb) { if(typeof args === 'function') { cb = args; args = ''; jobid = null; } if(typeof jobid === 'function') { cb = jobid; jobid = null; } args = args || ''; //incase they pass null/false if(args instanceof Array) args = args.join(' '); args = ' ' + args; jobid = jobid || generateUuid(); var self = this, params = {}, addToFilter = function(cb) { if(cb) cb(); }, removeFromFilter = addToFilter, sendApiCommand = function(cb) { params['Job-UUID'] = jobid; addToFilter(function() { if(cb) { self.once('esl::event::BACKGROUND_JOB::' + jobid, function(evt) { removeFromFilter(function() { cb(evt); }); }); } else { removeFromFilter(); } self.sendRecv('bgapi ' + command + args, params); }); }; if(self.usingFilters) { addToFilter = function(cb) { self.filter('Job-UUID', jobid, cb); }; removeFromFilter = function(cb) { self.filterDelete('Job-UUID', jobid, cb); }; sendApiCommand(cb); } else { sendApiCommand(cb); } }; //NOTE: This is a wrapper around sendRecv, that uses an ESLevent for the data Connection.prototype.sendEvent = function(event, cb) { this.sendRecv('sendevent ' + event.getHeader('Event-Name') + '\n' + event.serialize(), cb); }; //Returns the next event from FreeSWITCH. If no events are waiting, this // call will block until an event arrives. // //If any events were queued during a call to sendRecv(), then the first // one will be returned, and removed from the queue. Otherwise, then next // event will be read from the connection. // //NOTE: This is the same as `connection.once('esl::event::**', ...)` and in fact // that is all it does. It does not block as the description says, nor does // it queue events. Node has a better Event system than this, use it. Connection.prototype.recvEvent = function(cb) { cb = cb || this._noop; this.once('esl::event::**', cb); }; //Similar to recvEvent(), except that it will block for at most $milliseconds. // //A call to recvEventTimed(0) will return immediately. This is useful for polling for events. // //NOTE: This does the same as recvEvent, except will timeout if an event isn't received in // the specified timeframe Connection.prototype.recvEventTimed = function(ms, cb) { var self = this, timeout, fn; fn = function(to, event) { clearTimeout(to); if(cb) cb(event); }; timeout = setTimeout(function() { self.removeListener('esl::event::**', fn); if(cb) cb(); }, ms); //proxy to ensure we pass this timeout to the callback self.once('esl::event::**', fn.bind(self, timeout)); }; //See the event socket filter command (http://wiki.freeswitch.org/wiki/Event_Socket#filter). Connection.prototype.filter = function(header, value, cb) { this.usingFilters = true; this.sendRecv('filter ' + header + ' ' + value, cb); }; Connection.prototype.filterDelete = function(header, value, cb) { if(typeof value === 'function') { cb = value; value = null; } this.sendRecv('filter delete ' + header + (!!value ? ' ' + value : ''), cb); }; //$event_type can have the value "plain" or "xml" or "json". Any other value specified // for $event_type gets replaced with "plain". // //See the event socket event command for more info (http://wiki.freeswitch.org/wiki/Event_Socket#event). Connection.prototype.events = function(type, events, cb) { if(['plain','xml','json'].indexOf(type) === -1) type = 'plain'; if(typeof events === 'function') { cb = events; events = 'all'; } events = events || 'all'; var all = false; if(events instanceof Array) all = (events.length === 1 && events[0].toLowerCase() === 'all'); else all = (events.toLowerCase() === 'all'); //if we specify all that includes required events if(all) { this.listeningEvents = ['all']; } //otherwise we need to concat the events to the required events else { //set listeningEvents to the new events this.listeningEvents = (events instanceof Array ? events : events.split(' ')); //if the required events are not in there, add them for(var i = 0, len = this.reqEvents.length; i < len; ++i) { if(this.listeningEvents.indexOf(this.reqEvents[i]) !== -1) continue; this.listeningEvents.push(this.reqEvents[i]); } } this.sendRecv('event ' + type + ' ' + this.listeningEvents.join(' '), cb); }; //Execute a dialplan application (http://wiki.freeswitch.org/wiki/Mod_dptools#Applications), // and wait for a response from the server. // On socket connections not anchored to a channel (most of the time inbound), // all three arguments are required -- $uuid specifies the channel to execute // the application on. // //Returns an ESLevent object containing the response from the server. The // getHeader("Reply-Text") method of this ESLevent object returns the server's // response. The server's response will contain "+OK [Success Message]" on success // or "-ERR [Error Message]" on failure. Connection.prototype.execute = function(app, arg, uuid, cb) { var self = this, opts = {}; if(typeof arg === 'function') { cb = arg; arg = ''; } if(typeof uuid === 'function') { cb = uuid; uuid = null; } cb = cb || function (){}; //setup options opts['execute-app-name'] = app; if( typeof arg !== 'undefined') { opts['execute-app-arg'] = arg; } var eventUuid ; //if inbound if(self._inbound) { //if no uuid passed, create one uuid = uuid || generateUuid(); //execute with the new uuid eventUuid = self._doExec(uuid, 'execute', opts, cb); } //if outbound else { //grab our unique-id from channel_data uuid = self.getInfo().getHeader('Unique-ID'); eventUuid = self._doExec(uuid, 'execute', opts, cb); } return eventUuid ; }; //Same as execute, but doesn't wait for a response from the server. // //This works by causing the underlying call to execute() to append // "async: true" header in the message sent to the channel. Connection.prototype.executeAsync = function(app, arg, uuid, cb) { //temporarily set async to true var old = this.execAsync; this.execAsync = true; //run execute var eventUuid = this.execute(app, arg, uuid, cb); //reset async this.execAsync = old; return eventUuid ; }; //Force async mode on for a socket connection. This command has // no effect on outbound socket connections that are set to "async" // in the dialplan and inbound socket connections, since these // connections are already set to async mode on. // //$value should be `true` to force async mode, and `false` to not force it. // //Specifically, calling setAsyncExecute(true) operates by causing future calls // to execute() to include the "async: true" header in the message sent to // the channel. Other event socket library routines are not affected by this call. // Connection.prototype.setAsyncExecute = function(value) { this.execAsync = value; }; //Force sync mode on for a socket connection. This command has no effect on // outbound socket connections that are not set to "async" in the dialplan, // since these connections are already set to sync mode. // //$value should be `true` to force sync mode, and `false` to not force it. // //Specifically, calling setEventLock(1) operates by causing future calls to // execute() to include the "event-lock: true" header in the message sent // to the channel. Other event socket library routines are not affected by this call. // //See Also: // Q: Ordering and async keyword // (http://wiki.freeswitch.org/wiki/Event_socket_outbound#Q:_Ordering_and_async_keyword) // Q: Can I bridge a call with an Outbound Socket? // (http://wiki.freeswitch.org/wiki/Event_socket_outbound#Q:_Can_I_bridge_a_call_with_an_Outbound_socket_.3F) Connection.prototype.setEventLock = function(value) { this.execLock = value; }; //Close the socket connection to the FreeSWITCH server. Connection.prototype.disconnect = function() { this.send('exit'); this.closing = true; if (this.socket) { this.socket.end(); this.socket = null; } }; /********************* ** Higher-level Library-Specific Functions ** Some of these simply provide syntatic sugar **********************/ Connection.prototype.auth = function(cb) { var self = this; //send auth command self.sendRecv('auth ' + self.password, function(evt) { if(evt.getHeader('Modesl-Reply-OK') === 'accepted') { self.authed = true; if (self.reqEvents.length) self.subscribe(self.reqEvents); self.emit('esl::event::auth::success', evt); self.emit('esl::ready'); if(cb && typeof cb === 'function') cb(null, evt); } else { self.authed = false; self.emit('esl::event::auth::fail', evt); if(cb && typeof cb === 'function') cb(new Error('Authentication Failed'), evt); } }); }; //subscribe to events using json format (native support) Connection.prototype.subscribe = function(events, cb) { events = events || 'all'; this.events('json', events, cb); }; //wraps the show mod_commands function and parses the return //value into a javascript array Connection.prototype.show = function(item, format, cb) { if(typeof format === 'function') { cb = format; format = null; } format = format || 'json'; this.bgapi('show ' + item + ' as ' + format, function(e) { var data = e.getBody(), parsed = {}; //if error send them that if(data.indexOf('-ERR') !== -1) { if(cb) cb(new Error(data)); return; } //otherwise parse the event switch(format) { case 'json': //json format, easy and efficient try { parsed = JSON.parse(data); } catch(e) { if(cb) cb(e); return; } if(!parsed.rows) parsed.rows = []; break; case 'xml': //xml format, need to massage a bit after parsing var parser = new xml2js.Parser({ explicitArray: false, explicitRoot: false, emptyTag: '' }); parser.parseString(data, function(err, doc) { if(err) { if(cb) cb(err); return; } // jshint -W106 parsed.rowCount = parseInt(doc.$.row_count, 10); // jshint +W106 parsed.rows = []; //case where only one row, means "row" is not an array if(parsed.rowCount === 1) { delete doc.row.$; parsed.rows.push(doc.row); } else if(parsed.rowCount > 1) { doc.row.forEach(function(row) { delete row.$; parsed.rows.push(row); }); } }); break; default: //delim seperated values, custom parsing if(format.indexOf('delim')) { var delim = format.replace('delim ', ''), lines = data.split('\n'), cols = lines[0].split(delim); parsed = { rowCount: lines.length - 1, rows: [] }; for(var i = 1, len = lines.length; i < len; ++i) { var vals = lines[i].split(delim), o = {}; for(var x = 0, xlen = vals.length; x < xlen; ++x) { o[cols[x]] = vals[x]; } parsed.rows.push(o); } } break; } //return the parsed version of the data if(cb) cb(null, parsed, data); return; }); }; //make an originating call Connection.prototype.originate = function(options, cb) { if(typeof options === 'function') { cb = options; options = null; } options.profile = options.profile || ''; options.gateway = options.gateway || ''; options.number = options.number || ''; options.app = options.app || ''; options.sync = options.sync || false; var arg = 'sofia/' + options.profile + '/' + options.number + '@' + options.gateway + (options.app ? ' &' + options.app : ''); if(options.sync) { this.api('originate', arg, cb); } else { this.bgapi('originate', arg, cb); } }; //send a SIP MESSAGE Connection.prototype.message = function(options, cb) { if(typeof options === 'function') { cb = options; options = null; } options = options || {}; options.to = options.to || ''; options.from = options.from || ''; options.profile = options.profile || ''; options.body = options.body || ''; options.subject = options.subject || ''; options.deliveryConfirmation = options.deliveryConfirmation || ''; var event = new Event('custom', 'SMS::SEND_MESSAGE'); event.addHeader('proto', 'sip'); event.addHeader('dest_proto', 'sip'); event.addHeader('from', 'sip:' + options.from); event.addHeader('from_full', 'sip:' + options.from); event.addHeader('to', options.to); event.addHeader('sip_profile', options.profile); event.addHeader('subject', options.subject); if(options.deliveryConfirmation) { event.addHeader('blocking', 'true'); } event.addHeader('type', 'text/plain'); event.addHeader('Content-Type', 'text/plain'); event.addBody(options.body); this.sendEvent(event, cb); }; /********************* ** Private helpers **********************/ //noop because EventEmitter2 makes me pass a function Connection.prototype._noop = function() {}; //helper for execute, sends the actual message Connection.prototype._doExec = function(uuid, cmd, args, cb) { args['call-command'] = cmd; if(this.execAsync) args.async = true; if(this.execLock) args['event-lock'] = true; //this method of event tracking is based on: //http://lists.freeswitch.org/pipermail/freeswitch-users/2013-May/095329.html args['Event-UUID'] = generateUuid(); var eventName = 'esl::event::CHANNEL_EXECUTE_COMPLETE::' + uuid; var cbWrapper = (evt) => { var evtUuid = evt.getHeader('Application-UUID') || evt.getHeader('Event-UUID'); if (args['Event-UUID'] === evtUuid) { this.removeListener(eventName, cbWrapper); cb(evt); } }; this.on(eventName, cbWrapper); this.send('sendmsg ' + uuid, args); return args['Event-UUID'] ; }; //called on socket/generic error, simply echo the error //to the user Connection.prototype._onError = function(err) { this.emit('error', err); if (this._inbound && !this.closing) { this._onConnectionGone(); } }; //called when socket connects to FSW ESL Server //or when we successfully listen to the fd Connection.prototype._onConnect = function() { //initialize parser this.parser = new Parser(this.socket); //on generic event this.parser.on('esl::event', this._onEvent.bind(this)); //on parser error this.parser.on('error', this._onError.bind(this)); //emit that we connected this.emit('esl::connect'); this.connecting = false; //wait for auth request this.once('esl::event::auth::request', this.auth.bind(this)); if (this._inbound) { // set up reconnection stuff this.initializeRetryVars() ; } }; //When we get a generic ESLevent from FSW Connection.prototype._onEvent = function(event, headers, body) { var emit = 'esl::event', uuid = event.getHeader('Job-UUID') || event.getHeader('Unique-ID') || event.getHeader('Core-UUID'); //massage Content-Types into event names, //since not all events actually have an Event-Name //header; we have to make our own switch(headers['Content-Type']) { case 'auth/request': emit += '::auth::request'; break; case 'command/reply': emit += '::command::reply'; if(headers['Event-Name'] === 'CHANNEL_DATA') { if(!this._inbound) { this.channelData = event; this.emit('esl::event::CHANNEL_DATA' + (!!uuid ? '::' + uuid : ''), event); } } break; case 'log/data': emit += '::logdata'; break; case 'text/disconnect-notice': emit += '::disconnect::notice'; break; case 'api/response': emit += '::api::response'; break; case 'text/event-json': case 'text/event-plain': case 'text/event-xml': emit += '::' + event.getHeader('Event-Name') + (!!uuid ? '::' + uuid : ''); break; default: emit += '::raw::' + headers['Content-Type']; } this.emit(emit, event, headers, body); }; Connection.prototype.initializeRetryVars = function() { this.reconnectVars.retryTimer = null; this.reconnectVars.retryTotaltime = 0; this.reconnectVars.retryDelay = 150; this.reconnectVars.retryBackoff = 1.7; this.reconnectVars.attempts = 1; }; Connection.prototype._onConnectionGone = function() { assert(this._inbound); // If a retry is already in progress, just let that happen if (this.reconnectVars.retryTimer) { return; } // If this is a requested shutdown, then don't retry if (this.closing) { this.reconnectVars.retryTimer = null; return; } const nextDelay = Math.floor(this.reconnectVars.retryDelay * this.reconnectVars.retryBackoff); if (this.reconnectOpts.retryMaxDelay !== null && nextDelay > this.reconnectOpts.retryMaxDelay) { this.reconnectVars.retryDelay = this.reconnectOpts.retryMaxDelay; } else { this.reconnectVars.retryDelay = nextDelay; } if (this.reconnectOpts.maxAttempts && this.reconnectVars.attempts >= this.reconnectOpts.maxAttempts) { this.reconnectVars.retryTimer = null; return; } this.reconnectVars.attempts += 1; this.emit('esl::reconnecting', { delay: this.reconnectVars.retryDelay, attempt: this.reconnectVars.attempts }); this.reconnectVars.retryTimer = setTimeout(() => { this.reconnectVars.retryTotaltime += this.reconnectVars.retryDelay; if (this.reconnectOpts.connectTimeout && this.reconnectVars.retryTotaltime >= this.reconnectOpts.connectTimeout) { this.reconnectVars.retryTimer = null; console.error('Connection#_onConnectionGone: ' + `Couldn't get freeswitch connection after ${this.reconnectVars.retryTotaltime} ms`); return; } this.socket = net.createConnection(this.connectOpts, this._onConnect.bind(this)); this.socket.setKeepAlive(true); this.socket.on('error', this._onError.bind(this)); //emit end when stream closes this.socket.on('end', () => { this.emit('esl::end'); this.socket = null; this._onConnectionGone(); }); this.reconnectVars.retryTimer = null; }, this.reconnectVars.retryDelay); };