UNPKG

drachtio-modesl

Version:

FreeSWITCH ESL Node.js Implementation

809 lines (661 loc) 26.4 kB
const { EventEmitter2 } = require('eventemitter2'); const net = require('net'); const crypto = require('crypto'); const xml2js = require('xml2js'); const esl = require('./esl'); const Event = require('./Event'); const Parser = require('./Parser'); //- function(host, port, password[, localAddress][, callback]) //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. // //- function(socket[, callback]) //Initializes a new instance of ESLconnection, using an existing net.Socket. // //Intended only for Event Socket Outbound connections. // //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 class Connection extends EventEmitter2 { constructor(...args) { super({ wildcard: true, delimiter: '::', ignoreErrors: true, maxListeners: 25 }); const len = args.length; //check if they passed a ready callback this.once('esl::ready', ((typeof args[len - 1] === 'function') ? args[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) this._inbound = true; this.reqEvents = []; this.password = args[2]; this.connectOpts = { port: args[1], host: args[0] }; if (len > 3 && typeof args[3] === 'string') { this.connectOpts.localAddress = args[3]; } //connect to ESL Socket this.socket = net.createConnection(this.connectOpts, () => this._onConnect()); this.socket.setKeepAlive(true); this.socket.on('error', (err) => this._onError(err)); } //"Outbound" connection (coming from FSW) else if (len >= 1) { //1 (net.Socket); 2 (net.Socket, callback) this._inbound = false; this.socket = args[0]; this.connecting = false; this._onConnect(); this.send('connect'); this.once('esl::event::CHANNEL_DATA::**', () => { this.emit('esl::ready'); }); this.socket.on('error', (err) => this._onError(err)); } //Invalid arguments passed else { this.emit('error', new Error('Bad arguments passed to esl.Connection')); } //emit end when stream closes this.socket.on('close', () => { this.emit('esl::end'); this.socket = null; if (this._inbound) { this._onConnectionGone(); } }); //handle logdata events this.on('esl::event::logdata', (log) => { esl._doLog(log); }); //handle command reply callbacks this.on('esl::event::command::reply', (...cbArgs) => { if (this.cmdCallbackQueue.length === 0) return; const fn = this.cmdCallbackQueue.shift(); if (fn && typeof fn === 'function') fn.apply(this, cbArgs); }); //handle api response callbacks this.on('esl::event::api::response', (...cbArgs) => { if (this.apiCallbackQueue.length === 0) return; const fn = this.apiCallbackQueue.shift(); if (fn && typeof fn === 'function') fn.apply(this, cbArgs); }); } /********************* ** 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. socketDescriptor() { if (this._inbound) return null; return this.socket; } //Test if the connection object is connected. Returns `true` if connected, `false` otherwise. connected() { 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. getInfo() { 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 send(command, args) { try { const parts = [command]; if (args) { Object.keys(args).forEach((key) => { parts.push(`${key}: ${args[key]}`); }); } parts.push('', ''); this.socket.write(parts.join('\n')); } catch (e) { this.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 context of Node. sendRecv(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"). api(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 // its own thread. This will be executed in its own thread, and is non-blocking. // //bgapi($command, $args) is identical to sendRecv("bgapi $command $args") bgapi(command, args, jobid, cb) { if (typeof args === 'function') { cb = args; args = ''; jobid = null; } if (typeof jobid === 'function') { cb = jobid; jobid = null; } args = args || ''; //in case they pass null/false if (args instanceof Array) args = args.join(' '); args = ' ' + args; jobid = jobid || crypto.randomUUID(); const params = {}; let addToFilter = (cb) => { if (cb) cb(); }; let removeFromFilter = addToFilter; const sendApiCommand = (cb) => { params['Job-UUID'] = jobid; addToFilter(() => { if (cb) { this.once('esl::event::BACKGROUND_JOB::' + jobid, (evt) => { removeFromFilter(() => { cb(evt); }); }); } else { removeFromFilter(); } this.sendRecv('bgapi ' + command + args, params); }); }; if (this.usingFilters) { addToFilter = (cb) => { this.filter('Job-UUID', jobid, cb); }; removeFromFilter = (cb) => { this.filterDelete('Job-UUID', jobid, cb); }; sendApiCommand(cb); } else { sendApiCommand(cb); } } //NOTE: This is a wrapper around sendRecv, that uses an ESLevent for the data sendEvent(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. // //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. recvEvent(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 recvEventTimed(ms, cb) { const fn = (to, event) => { clearTimeout(to); if (cb) cb(event); }; const timeout = setTimeout(() => { this.removeListener('esl::event::**', fn); if (cb) cb(); }, ms); //proxy to ensure we pass this timeout to the callback this.once('esl::event::**', fn.bind(this, timeout)); } //See the event socket filter command (http://wiki.freeswitch.org/wiki/Event_Socket#filter). filter(header, value, cb) { this.usingFilters = true; this.sendRecv('filter ' + header + ' ' + value, cb); } filterDelete(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). events(type, events, cb) { if (['plain', 'xml', 'json'].indexOf(type) === -1) type = 'plain'; if (typeof events === 'function') { cb = events; events = 'all'; } events = events || 'all'; let 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 { this.listeningEvents = (events instanceof Array ? events : events.split(' ')); for (let 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. execute(app, arg, uuid, cb) { const opts = {}; if (typeof arg === 'function') { cb = arg; arg = ''; } if (typeof uuid === 'function') { cb = uuid; uuid = null; } cb = cb || function() {}; opts['execute-app-name'] = app; if (typeof arg !== 'undefined') { opts['execute-app-arg'] = arg; } let eventUuid; if (this._inbound) { uuid = uuid || crypto.randomUUID(); eventUuid = this._doExec(uuid, 'execute', opts, cb); } else { uuid = this.getInfo().getHeader('Unique-ID'); eventUuid = this._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. executeAsync(app, arg, uuid, cb) { const old = this.execAsync; this.execAsync = true; const eventUuid = this.execute(app, arg, uuid, cb); this.execAsync = old; return eventUuid; } //Force async mode on for a socket connection. setAsyncExecute(value) { this.execAsync = value; } //Force sync mode on for a socket connection. setEventLock(value) { this.execLock = value; } //Close the socket connection to the FreeSWITCH server. disconnect() { this.closing = true; this.send('exit'); if (this.socket) { this.socket.end(); this.socket = null; } } /********************* ** Higher-level Library-Specific Functions ** Some of these simply provide syntactic sugar **********************/ auth(cb) { this.sendRecv('auth ' + this.password, (evt) => { if (evt.getHeader('Modesl-Reply-OK') === 'accepted') { this.authed = true; if (this.reqEvents.length) this.subscribe(this.reqEvents); this.emit('esl::event::auth::success', evt); this.emit('esl::ready'); if (cb && typeof cb === 'function') cb(null, evt); } else { this.authed = false; this.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) subscribe(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 show(item, format, cb) { if (typeof format === 'function') { cb = format; format = null; } format = format || 'json'; this.bgapi('show ' + item + ' as ' + format, (e) => { const data = e.getBody(); let parsed = {}; if (data.indexOf('-ERR') !== -1) { if (cb) cb(new Error(data)); return; } switch (format) { case 'json': try { parsed = JSON.parse(data); } catch (err) { if (cb) cb(err); return; } if (!parsed.rows) parsed.rows = []; break; case 'xml': { const parser = new xml2js.Parser({ explicitArray: false, explicitRoot: false, emptyTag: '' }); parser.parseString(data, (err, doc) => { if (err) { if (cb) cb(err); return; } parsed.rowCount = parseInt(doc.$.row_count, 10); parsed.rows = []; if (parsed.rowCount === 1) { delete doc.row.$; parsed.rows.push(doc.row); } else if (parsed.rowCount > 1) { doc.row.forEach((row) => { delete row.$; parsed.rows.push(row); }); } }); break; } default: { if (format.indexOf('delim')) { const delim = format.replace('delim ', ''); const lines = data.split('\n'); const cols = lines[0].split(delim); parsed = { rowCount: lines.length - 1, rows: [] }; for (let i = 1, len = lines.length; i < len; ++i) { const vals = lines[i].split(delim); const o = {}; for (let x = 0, xlen = vals.length; x < xlen; ++x) { o[cols[x]] = vals[x]; } parsed.rows.push(o); } } break; } } if (cb) cb(null, parsed, data); }); } //make an originating call originate(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; const 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 message(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 || ''; const 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 _noop() {} //helper for execute, sends the actual message _doExec(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'] = crypto.randomUUID(); const eventName = 'esl::event::CHANNEL_EXECUTE_COMPLETE::' + uuid; const cbWrapper = (evt) => { const 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 _onError(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 _onConnect() { //initialize parser this.parser = new Parser(this.socket); //on generic event this.parser.on('esl::event', (event, headers, body) => this._onEvent(event, headers, body)); //on parser error this.parser.on('error', (err) => this._onError(err)); //emit that we connected this.emit('esl::connect'); this.connecting = false; //wait for auth request this.once('esl::event::auth::request', () => this.auth()); if (this._inbound) { this.initializeRetryVars(); } } //When we get a generic ESLevent from FSW _onEvent(event, headers, body) { let emit = 'esl::event'; const 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); } initializeRetryVars() { this.reconnectVars.retryTimer = null; this.reconnectVars.retryTotaltime = 0; this.reconnectVars.retryDelay = 150; this.reconnectVars.retryBackoff = 1.7; this.reconnectVars.attempts = 1; } _onConnectionGone() { if (!this._inbound) return; // Ensure retry vars are initialized (they won't be if we never connected successfully) if (this.reconnectVars.retryDelay === undefined) { this.initializeRetryVars(); } // 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; const elapsed = this.reconnectVars.retryTotaltime; console.error( `Connection#_onConnectionGone: Couldn't get freeswitch connection after ${elapsed} ms` ); return; } this.socket = net.createConnection(this.connectOpts, () => this._onConnect()); this.socket.setKeepAlive(true); this.socket.on('error', (err) => this._onError(err)); this.socket.on('close', () => { this.emit('esl::end'); this.socket = null; this._onConnectionGone(); }); this.reconnectVars.retryTimer = null; }, this.reconnectVars.retryDelay); } } module.exports = Connection;