diff --git a/app.js b/app.js index 5baad823..4751a7cf 100644 --- a/app.js +++ b/app.js @@ -1,5 +1,7 @@ const Srf = require('drachtio-srf'); const srf = new Srf(); +const Mrf = require('drachtio-fsmrf'); +srf.locals.mrf = new Mrf(srf); const config = require('config'); const logger = require('pino')(config.get('logging')); const {lookupAppByPhoneNumber} = require('jambonz-db-helpers')(config.get('mysql'), logger); @@ -10,6 +12,7 @@ const { retrieveApplication, invokeWebCallback } = require('./lib/middleware')(srf, logger); + const CallSession = require('./lib/call-session'); // disable logging in test mode diff --git a/config/default.json.example b/config/default.json.example index dfd6b93f..a4ef6929 100644 --- a/config/default.json.example +++ b/config/default.json.example @@ -3,6 +3,13 @@ "port": 3010, "secret": "cymru" }, + "freeswitch: [ + { + "address": "127.0.0.1", + "port": 8021, + "secret": "ClueCon" + } + ], "logging": { "level": "info" }, diff --git a/lib/call-session.js b/lib/call-session.js index f01ae7a4..f24999d5 100644 --- a/lib/call-session.js +++ b/lib/call-session.js @@ -1,11 +1,6 @@ const Emitter = require('events'); -/* const config = require('config'); -const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar'); -const {parseUri, SipError} = require('drachtio-srf'); -const debug = require('debug')('jambonz:sbc-inbound'); -const assert = require('assert'); -*/ + class CallSession extends Emitter { constructor(req, res) { super(); @@ -14,6 +9,11 @@ class CallSession extends Emitter { this.srf = req.srf; this.logger = req.locals.logger; this.application = req.locals.application; + this.resources = new Map(); + + req.on('cancel', this._onCallerHangup.bind(this)); + + this.on('callStatusChange', this._onCallStatusChange.bind(this)); } async exec() { @@ -26,13 +26,69 @@ class CallSession extends Emitter { } catch (err) { this.logger.error({err, task}, 'Error executing task'); } - this.logger.info('finished all tasks'); + this.logger.info('CallSession: finished all tasks'); if (!this.res.finalResponseSent) { - this.logger.info('auto-generating non-success response to invite'); + this.logger.info('CallSession: auto-generating non-success response to invite'); this.res.send(603); } + this._clearResources(); } } + + addResource(name, resource) { + this.logger.debug(`CallSession:addResource: adding ${name}`); + this.resources.set(name, resource); + } + + getResource(name) { + return this.resources.get(name); + } + + removeResource(name) { + this.logger.debug(`CallSession:removeResource: removing ${name}`); + this.resources.delete(name); + } + + async createOrRetrieveEpAndMs(remoteSdp) { + const mrf = this.srf.locals.mrf; + let ms = this.getResource('ms'); + let ep = this.getResource('epIn'); + if (ms && ep) return {ms, ep}; + + // get a media server + if (!ms) { + ms = await mrf.connect(config.get('freeswitch')); + this.addResource('ms', ms); + } + if (!ep) { + ep = await ms.createEndpoint({remoteSdp}); + this.addResource('epIn', ep); + } + return {ms, ep}; + } + + /** + * clear down resources + * (note: we remove in reverse order they were added since mediaserver + * is typically added first and I prefer to destroy it after any resources it holds) + */ + _clearResources() { + for (const [name, resource] of Array.from(this.resources).reverse()) { + try { + this.logger.info(`CallSession:_clearResources: deleting ${name}`); + if (resource.connected) resource.destroy(); + } catch (err) { + this.logger.error(err, `CallSession:_clearResources: error deleting ${name}`); + } + } + } + + _onCallerHangup(evt) { + this.logger.debug('CallSession: caller hung before connection'); + } + _onCallStatusChange(evt) { + this.logger.debug(evt, 'CallSession:_onCallStatusChange'); + } } module.exports = CallSession; diff --git a/lib/middleware.js b/lib/middleware.js index 91ffd7c5..eda394dc 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -63,17 +63,33 @@ module.exports = function(srf, logger) { */ async function invokeWebCallback(req, res, next) { const logger = req.locals.logger; + const app = req.locals.application; const call_sid = uuidv4(); - const account_sid = req.locals.application.account_sid; - const application_sid = req.locals.application.application_sid; + const method = (app.hook_http_method || 'GET').toUpperCase(); + const from = req.getParsedHeader('From'); + const opts = { + url: app.call_hook, + method, + json: true, + qs: { + CallSid: call_sid, + AccountSid: app.account_sid, + From: req.callingNumber, + To: req.calledNumber, + CallStatus: 'ringing', + Direction: 'inbound', + CallerName: from.name || req.callingNumber + } + }; + if (app.hook_basic_auth_user && app.hook_basic_auth_password) { + Object.assign(opts, {auth: {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password}}); + } + if (method === 'POST') { + Object.assign(opts, {json: true, body: req.msg}); + } try { - const app = req.locals.application; assert(app && app.call_hook); - request.post({ - url: app.call_hook, - json: true, - body: req.msg - }, (err, response, body) => { + request(opts, (err, response, body) => { if (err) { logger.error(err, `Error invoking callback ${app.call_hook}`); return res.send(603, 'Bad webhook'); diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index cebae216..cc743ddd 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -1,11 +1,30 @@ const Task = require('./task'); const name = 'dial'; +const makeTask = require('./make_task'); +const assert = require('assert'); class TaskDial extends Task { constructor(logger, opts) { super(logger, opts); this.name = name; this.headers = this.data.headers || {}; + this.answerOnBridge = opts.answerOnBridge === true; + this.timeout = opts.timeout || 60; + this.method = opts.method || 'GET'; + this.dialMusic = opts.dialMusic; + this.timeLimit = opts.timeLimit; + this.strategy = opts.strategy || 'hunt'; + this.target = opts.target; + this.canceled = false; + this.finished = false; + this.localResources = {}; + + if (opts.transcribe) { + this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); + } + if (opts.listen) { + this.listenTask = makeTask(logger, {'listen': opts.transcribe}); + } } static get name() { return name; } @@ -14,12 +33,109 @@ class TaskDial extends Task { * Reject an incoming call attempt with a provided status code and (optionally) reason */ async exec(cs) { - if (!cs.res.finalResponseSent) { - cs.res.send(this.data.status, this.data.reason, { - headers: this.headers - }); + try { + await this._initializeInbound(cs); + //await connectCall(cs); + await this._untilCallEnds(cs); + } catch (err) { + this.logger.info(`TaskDial:exec terminating with error ${err.message}`); } - return false; + + return true; + } + + async _initializeInbound(cs) { + const {req, res} = cs; + + // the caller could hangup in the middle of all this.. + req.on('cancel', this._onCancel.bind(this, cs)); + + try { + const {ep} = await cs.createOrRetrieveEpAndMs(req.body); + + // caller might have hung up while we were doing that + if (this.canceled) throw new Error('caller hung up'); + + // check if inbound leg has already been answered + let uas = cs.getResource('dlgIn'); + + if (!uas) { + // if answerOnBridge send a 183 (early media), otherwise go ahead and answer the call + if (this.answerOnBridge && !req.finalResponseSent) { + if (!this.dialMusic) res.send(180); + else { + res.send(183, {body: ep.remote.sdp}); + } + } + else { + uas = await cs.srf.createUAS(req, res, {localSdp: ep.local.sdp}); + cs.addResource('dlgIn', uas); + uas.on('destroy', this._onCallerHangup.bind(this, cs, uas)); + } + cs.emit('callStatusChange', {status: 'ringing'}); + } + + // play dial music to caller, if provided + if (this.dialMusic) { + ep.play(this.dialMusic, (err) => { + if (err) this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`); + }); + } + } catch (err) { + this.logger.error(err, 'TaskDial:_initializeInbound error'); + this.finished = true; + if (!res.finalResponseSent && !this.canceled) res.send(500); + this._clearResources(cs); + throw err; + } + } + + _clearResources(cs) { + for (const key in this.localResources) { + this.localResources[key].destroy(); + } + this.localResources = {}; + } + + _onCancel(cs) { + this.logger.info('TaskDial: caller hung up before connecting'); + this.canceled = this.finished = true; + this._clearResources(); + cs.emit('callStatusChange', {status: 'canceled'}); + } + + _onCallerHangup(cs, dlg) { + cs.emit('callStatusChange', {status: 'canceled'}); + this.finished = true; + this._clearResources(); + } + + /** + * returns a Promise that resolves when the call ends + */ + _untilCallEnds(cs) { + const {res} = cs; + + return new Promise((resolve) => { + assert(!this.finished); + + //TMP - hang up in 5 secs + setTimeout(() => { + res.send(480); + this._clearResources(); + resolve(); + }, 5000); + //TMP + + /* + const dlgOut = this.localResources.dlgOut; + assert(dlgIn.connected && dlgOut.connected); + + [this.dlgIn, this.dlgOut].forEach((dlg) => { + dlg.on('destroy', () => resolve()); + }); + */ + }); } } diff --git a/lib/tasks/task.js b/lib/tasks/task.js index 922aa1b3..d466cb5b 100644 --- a/lib/tasks/task.js +++ b/lib/tasks/task.js @@ -1,11 +1,13 @@ +const Emitter = require('events'); const debug = require('debug')('jambonz:feature-server'); const assert = require('assert'); const specs = new Map(); const _specData = require('./specs'); for (const key in _specData) {specs.set(key, _specData[key]);} -class Task { +class Task extends Emitter { constructor(logger, data) { + super(); this.logger = logger; this.data = data; } diff --git a/package-lock.json b/package-lock.json index 33ddca84..0450a80d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -706,6 +706,58 @@ "resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz", "integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ==" }, + "drachtio-fn-fsmrf-sugar": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/drachtio-fn-fsmrf-sugar/-/drachtio-fn-fsmrf-sugar-0.0.9.tgz", + "integrity": "sha512-X32AUmURLaeXmMQrY05YmkevAYpT0CIuoEsoiKqkZviSSt88e55naJf8xrWsMP7W5rc4UuYBmgCiBqW3qKoR9Q==", + "requires": { + "drachtio-fsmrf": "^1.4.4" + } + }, + "drachtio-fsmrf": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.8.tgz", + "integrity": "sha512-hVSJuGiwX/onshvtmKyjA1p/wiPmezuiblT5Z3YGSz7tPPK5e+m4fIwAjEvarQSfhrMeiXuQHp9Eewp0+z0J0Q==", + "requires": { + "async": "^1.4.2", + "debug": "^2.2.0", + "delegates": "^0.1.0", + "drachtio-modesl": "^1.2.0", + "drachtio-srf": "^4.4.15", + "lodash": "^4.17.15", + "minimist": "^1.2.0", + "moment": "^2.13.0", + "only": "0.0.2", + "sdp-transform": "^2.7.0", + "uuid": "^3.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "drachtio-modesl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/drachtio-modesl/-/drachtio-modesl-1.2.0.tgz", + "integrity": "sha512-nkua3RfYnT32OvglERO2zWzJZAfQooZIarZVVAye+iGqTwYJ69X7bU7du5SBHz/jBl+LgeWITMP2fMe2TelxmA==", + "requires": { + "debug": "^4.1.1", + "eventemitter2": "^4.1", + "uuid": "^3.1.0", + "xml2js": "^0.4.19" + } + }, "drachtio-mw-registration-parser": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/drachtio-mw-registration-parser/-/drachtio-mw-registration-parser-0.0.2.tgz", @@ -959,6 +1011,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter2": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz", + "integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU=" + }, "events-to-array": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz", @@ -2025,6 +2082,11 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2757,6 +2819,16 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "sdp-transform": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.13.0.tgz", + "integrity": "sha512-3zT7pcjR090E0WCV9eOtFX06iojoNKsyMXqXs7clOs8sy+RoegR0cebmCuCrTKdY2jw1XhT9jkraygJrqAUwzA==" + }, "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -4882,6 +4954,20 @@ "signal-exit": "^3.0.2" } }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 34b919af..0ec11a53 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "config": "^3.2.4", "debug": "^4.1.1", "drachtio-fn-b2b-sugar": "0.0.12", + "drachtio-fsmrf": "1.5.10", "drachtio-srf": "^4.4.27", "jambonz-db-helpers": "^0.1.6", "pino": "^5.14.0",