diff --git a/lib/http-routes/api/create-call.js b/lib/http-routes/api/create-call.js index 418643e9..69fbaebc 100644 --- a/lib/http-routes/api/create-call.js +++ b/lib/http-routes/api/create-call.js @@ -72,6 +72,17 @@ router.post('/', async(req, res) => { break; } + if (target.type === 'phone' && target.trunk) { + const {lookupCarrier} = dbUtils(this.logger, srf); + const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk); + this.logger.info( + `createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`); + if (voip_carrier_sid) { + opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid; + } + } + + /* create endpoint for outdial */ const ms = getFreeswitch(); if (!ms) throw new Error('no available Freeswitch for outbound call creation'); @@ -160,7 +171,11 @@ router.post('/', async(req, res) => { } }); connectStream(dlg.remote.sdp); - cs.emit('callStatusChange', {callStatus: CallStatus.InProgress, sipStatus: 200}); + cs.emit('callStatusChange', { + callStatus: CallStatus.InProgress, + sipStatus: 200, + sipReason: 'OK' + }); restDial.emit('callStatus', 200); restDial.emit('connect', dlg); } @@ -171,10 +186,18 @@ router.post('/', async(req, res) => { else if (487 === err.status) callStatus = CallStatus.NoAnswer; if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`); else console.log(`REST outdial failed with ${err.status}`); - if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: err.status}); + if (cs) cs.emit('callStatusChange', { + callStatus, + sipStatus: err.status, + sipReason: err.reason + }); } else { - if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: 500}); + if (cs) cs.emit('callStatusChange', { + callStatus, + sipStatus: 500, + sipReason: 'Internal Server Error' + }); if (sipLogger) sipLogger.error({err}, 'REST outdial failed'); else console.error(err); } diff --git a/lib/http-routes/api/update-call.js b/lib/http-routes/api/update-call.js index d4bfc2a7..6cb329b7 100644 --- a/lib/http-routes/api/update-call.js +++ b/lib/http-routes/api/update-call.js @@ -12,6 +12,9 @@ function retrieveCallSession(callSid, opts) { throw new DbErrorBadRequest('call_status_hook can be updated only when call_hook is also being updated'); } const cs = sessionTracker.get(callSid); + if (!cs) { + throw new DbErrorUnprocessableRequest('call session is gone'); + } if (opts.call_status === CallStatus.Completed && !cs.hasStableDialog) { throw new DbErrorUnprocessableRequest('current call state is incompatible with requested action'); @@ -45,8 +48,18 @@ router.post('/:callSid', async(req, res) => { logger.info(`updateCall: callSid not found ${callSid}`); return res.sendStatus(404); } - res.sendStatus(202); - cs.updateCall(req.body, callSid); + + if (req.body.sip_request) { + const response = await cs.updateCall(req.body, callSid); + res.status(200).json({ + status: response.status, + reason: response.reason + }); + } + else { + res.sendStatus(202); + cs.updateCall(req.body, callSid); + } } catch (err) { sysError(logger, res, err); } diff --git a/lib/session/adulting-call-session.js b/lib/session/adulting-call-session.js index b2de62fe..1fe79a9f 100644 --- a/lib/session/adulting-call-session.js +++ b/lib/session/adulting-call-session.js @@ -30,15 +30,25 @@ class AdultingCallSession extends CallSession { return this.sd.dlg; } + /** + * Note: this is not an error. It is only here to avoid an assert ("no setter for dlg") + * when there is a call in Session:_clearResources to null out dlg and ep + */ + set dlg(newDlg) {} + get ep() { return this.sd.ep; } + /* see note above */ + set ep(newEp) {} + get callSid() { return this.callInfo.callSid; } - + _callerHungup() { + } } module.exports = AdultingCallSession; diff --git a/lib/session/call-info.js b/lib/session/call-info.js index 92267626..8afa18a9 100644 --- a/lib/session/call-info.js +++ b/lib/session/call-info.js @@ -27,6 +27,7 @@ class CallInfo { this.to = req.calledNumber; this.callId = req.get('Call-ID'); this.sipStatus = 100; + this.sipReason = 'Trying'; this.callStatus = CallStatus.Trying; this.originatingSipIp = req.get('X-Forwarded-For'); this.originatingSipTrunkName = req.get('X-Originating-Carrier'); @@ -45,6 +46,7 @@ class CallInfo { this.callId = req.get('Call-ID'); this.callStatus = CallStatus.Trying, this.sipStatus = 100; + this.sipReason = 'Trying'; } else if (this.direction === CallDirection.None) { // outbound SMS @@ -65,6 +67,7 @@ class CallInfo { this.callStatus = CallStatus.Trying, this.callId = req.get('Call-ID'); this.sipStatus = 100; + this.sipReason = 'Trying'; this.from = from || req.callingNumber; this.to = to; if (tag) this._customerData = tag; @@ -81,9 +84,10 @@ class CallInfo { * @param {string} callStatus - current call status * @param {number} sipStatus - current sip status */ - updateCallStatus(callStatus, sipStatus) { + updateCallStatus(callStatus, sipStatus, sipReason) { this.callStatus = callStatus; if (sipStatus) this.sipStatus = sipStatus; + if (sipReason) this.sipReason = sipReason; } /** @@ -106,6 +110,7 @@ class CallInfo { to: this.to, callId: this.callId, sipStatus: this.sipStatus, + sipReason: this.sipReason, callStatus: this.callStatus, callerId: this.callerId, accountSid: this.accountSid, diff --git a/lib/session/call-session.js b/lib/session/call-session.js index cc90f4d1..e7e4ef06 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -521,6 +521,29 @@ class CallSession extends Emitter { task.doConferenceMuteNonModerators(this, opts); } + async _lccSipRequest(callSid, opts) { + const {sip_request} = opts; + const {method, content_type, content, headers = {}} = sip_request; + if (!this.hasStableDialog) { + this.logger.info('CallSession:_lccSipRequest - invalid command as we do not have a stable call'); + return; + } + try { + const res = await this.dlg.request({ + method, + headers: { + ...headers, + 'Content-Type': content_type, + 'Content': content + } + }); + this.logger.debug({res}, `CallSession:_lccSipRequest got response to ${method}`); + return res; + } catch (err) { + this.logger.error({err}, `CallSession:_lccSipRequest - error sending ${method}`); + } + } + /** * perform live call control -- whisper to one party or the other on a call * @param {array} opts - array of play or say tasks @@ -596,6 +619,10 @@ class CallSession extends Emitter { else if (opts.conf_mute_status) { await this._lccConfMuteStatus(callSid, opts); } + else if (opts.sip_request) { + const res = await this._lccSipRequest(callSid, opts); + return {status: res.status, reason: res.reason}; + } // whisper may be the only thing we are asked to do, or it may that // we are doing a whisper after having muted, paused reccording etc.. @@ -752,7 +779,11 @@ class CallSession extends Emitter { } catch (err) { if (err === CALLER_CANCELLED_ERR_MSG) { this.logger.error(err, 'caller canceled quickly before we could respond, ending call'); - this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487}); + this._notifyCallStatusChange({ + callStatus: CallStatus.NoAnswer, + sipStatus: 487, + sipReason: 'Request Terminated' + }); this._callReleased(); } else { @@ -862,9 +893,10 @@ class CallSession extends Emitter { this.dlg.on('destroy', this._callerHungup.bind(this)); this.wrapDialog(this.dlg); this.dlg.callSid = this.callSid; - this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress}); + this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress}); this.dlg.on('modify', this._onReinvite.bind(this)); + this.dlg.on('refer', this._onRefer.bind(this)); this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`); } @@ -890,6 +922,22 @@ class CallSession extends Emitter { } } + /** + * Handle incoming REFER if we are in a dial task + * @param {*} req + * @param {*} res + */ + _onRefer(req, res) { + const task = this.currentTask; + const sd = task.sd; + if (task && TaskName.Dial === task.name && sd) { + task.handleRefer(this, req, res); + } + else { + res.send(501); + } + } + /** * create and endpoint if we don't have one; otherwise simply return * the current media server and endpoint that are associated with this call @@ -1075,7 +1123,7 @@ class CallSession extends Emitter { * @param {number} sipStatus - current sip status * @param {number} [duration] - duration of a completed call, in seconds */ - _notifyCallStatusChange({callStatus, sipStatus, duration}) { + _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) { if (this.callMoved) return; /* race condition: we hang up at the same time as the caller */ @@ -1088,7 +1136,7 @@ class CallSession extends Emitter { (!duration && callStatus !== CallStatus.Completed), 'duration MUST be supplied when call completed AND ONLY when call completed'); - this.callInfo.updateCallStatus(callStatus, sipStatus); + this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); if (typeof duration === 'number') this.callInfo.duration = duration; try { this.notifier.request('call:status', this.call_status_hook, this.callInfo.toJSON()); diff --git a/lib/session/confirm-call-session.js b/lib/session/confirm-call-session.js index a3ee8bea..a83b27ce 100644 --- a/lib/session/confirm-call-session.js +++ b/lib/session/confirm-call-session.js @@ -30,6 +30,10 @@ class ConfirmCallSession extends CallSession { _clearResources() { } + _callerHungup() { + } + + } module.exports = ConfirmCallSession; diff --git a/lib/session/inbound-call-session.js b/lib/session/inbound-call-session.js index e72ece47..ecd6f8be 100644 --- a/lib/session/inbound-call-session.js +++ b/lib/session/inbound-call-session.js @@ -24,11 +24,19 @@ class InboundCallSession extends CallSession { req.once('cancel', this._onCancel.bind(this)); this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); - this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100}); + this._notifyCallStatusChange({ + callStatus: CallStatus.Trying, + sipStatus: 100, + sipReason: 'Trying' + }); } _onCancel() { - this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487}); + this._notifyCallStatusChange({ + callStatus: CallStatus.NoAnswer, + sipStatus: 487, + sipReason: 'Request Terminated' + }); this._callReleased(); } @@ -56,7 +64,10 @@ class InboundCallSession extends CallSession { _callerHungup() { assert(this.dlg.connectTime); const duration = moment().diff(this.dlg.connectTime, 'seconds'); - this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + this.emit('callStatusChange', { + callStatus: CallStatus.Completed, + duration + }); this.logger.debug('InboundCallSession: caller hung up'); this._callReleased(); this.req.removeAllListeners('cancel'); diff --git a/lib/session/rest-call-session.js b/lib/session/rest-call-session.js index ba9c170a..3eb27334 100644 --- a/lib/session/rest-call-session.js +++ b/lib/session/rest-call-session.js @@ -22,7 +22,11 @@ class RestCallSession extends CallSession { this.ep = ep; this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); - this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100}); + this._notifyCallStatusChange({ + callStatus: CallStatus.Trying, + sipStatus: 100, + sipReason: 'Trying' + }); } /** diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index 2338aac1..bff74750 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -14,6 +14,7 @@ const sessionTracker = require('../session/session-tracker'); const DtmfCollector = require('../utils/dtmf-collector'); const dbUtils = require('../utils/db-utils'); const debug = require('debug')('jambonz:feature-server'); +const {parseUri} = require('drachtio-srf'); function parseDtmfOptions(logger, dtmfCapture) { let parentDtmfCollector, childDtmfCollector; @@ -91,6 +92,7 @@ class TaskDial extends Task { this.timeLimit = this.data.timeLimit; this.confirmHook = this.data.confirmHook; this.confirmMethod = this.data.confirmMethod; + this.referHook = this.data.referHook; this.dtmfHook = this.data.dtmfHook; this.proxy = this.data.proxy; @@ -245,6 +247,40 @@ class TaskDial extends Task { } } + async handleRefer(cs, req, res, callInfo = cs.callInfo) { + if (this.referHook) { + try { + const isChild = !!callInfo.parentCallSid; + const referring_call_sid = isChild ? callInfo.callSid : cs.callSid; + const referred_call_sid = isChild ? callInfo.parentCallSid : this.sd.callSid; + + const to = parseUri(req.getParsedHeader('Refer-To').uri); + const by = parseUri(req.getParsedHeader('Referred-By').uri); + this.logger.info({to}, 'refer to parsed'); + await cs.requestor.request('verb:hook', this.referHook, { + ...callInfo, + refer_details: { + sip_refer_to: req.get('Refer-To'), + sip_referred_by: req.get('Referred-By'), + sip_user_agent: req.get('User-Agent'), + refer_to_user: to.user, + referred_by_user: by.user, + referring_call_sid, + referred_call_sid + } + }); + res.send(202); + this.logger.info('DialTask:handleRefer - sent 202 Accepted'); + } catch (err) { + res.send(err.statusCode || 501); + } + } + else { + this.logger.info('DialTask:handleRefer - got REFER but no referHook, responding 501'); + res.send(501); + } + } + _removeHandlers(sd) { sd.removeAllListeners('accept'); sd.removeAllListeners('decline'); @@ -389,6 +425,7 @@ class TaskDial extends Task { this.dials.set(sd.callSid, sd); sd + .on('refer', (callInfo, req, res) => this.handleRefer(cs, req, res, callInfo)) .on('callCreateFail', () => { clearTimeout(this.timerRing); this.dials.delete(sd.callSid); @@ -457,6 +494,9 @@ class TaskDial extends Task { } catch (err) { this.logger.error(err, 'Error in dial einvite from B leg'); } + }) + .on('refer', (callInfo, req, res) => { + }) .once('adulting', () => { /* child call just adulted and got its own session */ diff --git a/lib/tasks/gather.js b/lib/tasks/gather.js index 7fd29666..af88f6e7 100644 --- a/lib/tasks/gather.js +++ b/lib/tasks/gather.js @@ -26,7 +26,7 @@ class TaskGather extends Task { if (this.dtmfBargein !== false && this.input.includes('digits')) this.dtmfBargein = true; this.timeout = (this.timeout || 15) * 1000; - this.interim = this.partialResultCallback; + this.interim = this.partialResultHook || this.bargein; if (this.data.recognizer) { const recognizer = this.data.recognizer; this.vendor = recognizer.vendor; @@ -209,7 +209,7 @@ class TaskGather extends Task { if (this.hints && this.hints.length > 1) { opts.GOOGLE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(','); } - if (this.altLanguages && this.altLanguages.length > 1) { + if (this.altLanguages && this.altLanguages.length > 0) { opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(','); } if (this.profanityFilter === true) { @@ -259,7 +259,7 @@ class TaskGather extends Task { ep.startTranscription({ vendor: this.vendor, locale: this.language, - interim: this.partialResultCallback || this.bargein, + interim: this.interim, }).catch((err) => { const {writeAlerts, AlertType} = this.cs.srf.locals; this.logger.error(err, 'TaskGather:_startTranscribing error'); diff --git a/lib/tasks/sip_decline.js b/lib/tasks/sip_decline.js index 7944158a..8da23a45 100644 --- a/lib/tasks/sip_decline.js +++ b/lib/tasks/sip_decline.js @@ -19,7 +19,11 @@ class TaskSipDecline extends Task { res.send(this.data.status, this.data.reason, { headers: this.headers }); - cs.emit('callStatusChange', {callStatus: CallStatus.Failed, sipStatus: this.data.status}); + cs.emit('callStatusChange', { + callStatus: CallStatus.Failed, + sipStatus: this.data.status, + sipReason: this.data.reason + }); } } diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json index 93ba81f7..64c1a790 100644 --- a/lib/tasks/specs.json +++ b/lib/tasks/specs.json @@ -140,6 +140,7 @@ "answerOnBridge": "boolean", "callerId": "string", "confirmHook": "object|string", + "referHook": "object|string", "dialMusic": "string", "dtmfCapture": "object", "dtmfHook": "object|string", diff --git a/lib/utils/http-requestor.js b/lib/utils/http-requestor.js index 3ecbb103..922162be 100644 --- a/lib/utils/http-requestor.js +++ b/lib/utils/http-requestor.js @@ -70,8 +70,14 @@ class HttpRequestor extends BaseRequestor { await this.post(url, payload, headers) : await bent(method, 'buffer', 200, 201, 202)(url, payload, headers); } catch (err) { - this.logger.error({err, secret: this.secret, baseUrl: this.baseUrl, url, statusCode: err.statusCode}, - `web callback returned unexpected error code ${err.statusCode}`); + if (err.statusCode) { + this.logger.info({baseUrl: this.baseUrl, url}, + `web callback returned unexpected status code ${err.statusCode}`); + } + else { + this.logger.error({err, baseUrl: this.baseUrl, url}, + 'web callback returned unexpected error'); + } let opts = {account_sid: this.account_sid}; if (err.code === 'ECONNREFUSED') { opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url}; diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js index 4046245b..034b5ea1 100644 --- a/lib/utils/place-outdial.js +++ b/lib/utils/place-outdial.js @@ -164,10 +164,14 @@ class SingleDialer extends Emitter { callId: this.callInfo.callId }); this.inviteInProgress = req; - this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100}); + this.emit('callStatusChange', { + callStatus: CallStatus.Trying, + sipStatus: 100, + sipReason: 'Trying' + }); }, cbProvisional: (prov) => { - const status = {sipStatus: prov.status}; + const status = {sipStatus: prov.status, sipReason: prov.reason}; if ([180, 183].includes(prov.status) && prov.body) { if (status.callStatus !== CallStatus.EarlyMedia) { status.callStatus = CallStatus.EarlyMedia; @@ -182,7 +186,11 @@ class SingleDialer extends Emitter { await connectStream(this.dlg.remote.sdp); this.dlg.callSid = this.callSid; this.inviteInProgress = null; - this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress}); + this.emit('callStatusChange', { + sipStatus: 200, + sipReason: 'OK', + callStatus: CallStatus.InProgress + }); this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`); const connectTime = this.dlg.connectTime = moment(); @@ -190,7 +198,12 @@ class SingleDialer extends Emitter { if (this.killed) { this.logger.info(`SingleDialer:exec race condition - we were killed as call connected: ${this.callSid}`); const duration = moment().diff(connectTime, 'seconds'); - this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + this.emit('callStatusChange', { + callStatus: CallStatus.Completed, + sipStatus: 487, + sipReason: 'Request Terminated', + duration + }); if (this.ep) this.ep.destroy(); return; } @@ -217,6 +230,9 @@ class SingleDialer extends Emitter { } catch (err) { this.logger.error(err, 'Error handling reinvite'); } + }) + .on('refer', (req, res) => { + this.emit('refer', this.callInfo, req, res); }); if (this.confirmHook) this._executeApp(this.confirmHook); @@ -226,6 +242,7 @@ class SingleDialer extends Emitter { const status = {callStatus: CallStatus.Failed}; if (err instanceof SipError) { status.sipStatus = err.status; + status.sipReason = err.reason; if (err.status === 487) status.callStatus = CallStatus.NoAnswer; else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy; this.logger.info(`SingleDialer:exec outdial failure ${err.status}`); @@ -348,13 +365,13 @@ class SingleDialer extends Emitter { }); } - _notifyCallStatusChange({callStatus, sipStatus, duration}) { + _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration}) { assert((typeof duration === 'number' && callStatus === CallStatus.Completed) || (!duration && callStatus !== CallStatus.Completed), 'duration MUST be supplied when call completed AND ONLY when call completed'); if (this.callInfo) { - this.callInfo.updateCallStatus(callStatus, sipStatus); + this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); if (typeof duration === 'number') this.callInfo.duration = duration; try { this.requestor.request('call:status', this.application.call_status_hook, this.callInfo.toJSON());