From 6f0dbef433b64f01bec523008bdef0d9022bca1f Mon Sep 17 00:00:00 2001 From: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com> Date: Tue, 22 Aug 2023 19:09:18 +0700 Subject: [PATCH] feat moh (#423) * feat moh * feat moh * fix typo * fix typo * fix * fix * wip * wip * wip * wip * wip * wip * wip * wip * wip * git commit -a -m wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix * wip * rebase * fix * fix --- lib/session/call-session.js | 30 +++++++++- lib/tasks/config.js | 7 +++ lib/tasks/dial.js | 113 +++++++++++++++++++++++++++++++++--- lib/utils/place-outdial.js | 18 ++++-- lib/utils/sdp-utils.js | 7 +++ 5 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 lib/utils/sdp-utils.js diff --git a/lib/session/call-session.js b/lib/session/call-session.js index b67f095b..e6c9c968 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -357,6 +357,14 @@ class CallSession extends Emitter { return this._globalSttPunctuation; } + get onHoldMusic() { + return this._onHoldMusic; + } + + set onHoldMusic(url) { + this._onHoldMusic = url; + } + hasGlobalSttPunctuation() { return this._globalSttPunctuation !== undefined; } @@ -1392,6 +1400,8 @@ class CallSession extends Emitter { this.ep = ep; this.logger.debug(`allocated endpoint ${ep.uuid}`); + this._configMsEndpoint(); + this.ep.on('destroy', () => { this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`); }); @@ -1462,6 +1472,7 @@ class CallSession extends Emitter { return; } this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp}); + this._configMsEndpoint(); await this.dlg.modify(this.ep.local.sdp); this.logger.debug('CallSession:replaceEndpoint completed'); @@ -1555,9 +1566,14 @@ class CallSession extends Emitter { res.send(200, {body: this.ep.local.sdp}); } else { - const newSdp = await this.ep.modify(req.body); - res.send(200, {body: newSdp}); - this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE'); + if (this.currentTask.name === TaskName.Dial && this.currentTask.isOnHold) { + this.logger.info('onholdMusic reINVITE after media has been released'); + await this.currentTask.handleReinviteAfterMediaReleased(req, res); + } else { + const newSdp = await this.ep.modify(req.body); + res.send(200, {body: newSdp}); + this.logger.info({offer: req.body, answer: newSdp}, 'handling reINVITE'); + } } } else if (this.currentTask && this.currentTask.name === TaskName.Dial) { @@ -1604,6 +1620,7 @@ class CallSession extends Emitter { } if (!this.ep) { this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body}); + this._configMsEndpoint(); } return {ms: this.ms, ep: this.ep}; } @@ -1758,6 +1775,7 @@ class CallSession extends Emitter { 'X-Reason': 'anchor-media' } }); + this._configMsEndpoint(); } async handleReinviteAfterMediaReleased(req, res) { @@ -1839,6 +1857,12 @@ class CallSession extends Emitter { } } + _configMsEndpoint() { + if (this.onHoldMusic) { + this.ep.set({hold_music: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}); + } + } + /** * notifyTaskError - only used when websocket connection is used instead of webhooks */ diff --git a/lib/tasks/config.js b/lib/tasks/config.js index 826a6ed5..ac6f36ba 100644 --- a/lib/tasks/config.js +++ b/lib/tasks/config.js @@ -40,6 +40,8 @@ class TaskConfig extends Task { this.preconditions = (this.bargeIn.enable || this.record?.action || this.listen?.url || this.data.amd) ? TaskPreconditions.Endpoint : TaskPreconditions.None; + + this.onHoldMusic = this.data.onHoldMusic; } get name() { return TaskName.Config; } @@ -72,6 +74,7 @@ class TaskConfig extends Task { } if (this.data.amd) phrase.push('enable amd'); if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`); + if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`); return `${this.name}{${phrase.join(',')}}`; } @@ -83,6 +86,10 @@ class TaskConfig extends Task { cs.notifyEvents = !!this.data.notifyEvents; } + if (this.onHoldMusic) { + cs.onHoldMusic = this.onHoldMusic; + } + if (this.data.amd) { this.startAmd = cs.startAmd; this.stopAmd = cs.stopAmd; diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index bc791ce1..e16d023a 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -12,10 +12,13 @@ const assert = require('assert'); const placeCall = require('../utils/place-outdial'); const sessionTracker = require('../session/session-tracker'); const DtmfCollector = require('../utils/dtmf-collector'); +const ConfirmCallSession = require('../session/confirm-call-session'); const dbUtils = require('../utils/db-utils'); const debug = require('debug')('jambonz:feature-server'); const {parseUri} = require('drachtio-srf'); const {ANCHOR_MEDIA_ALWAYS} = require('../config'); +const { isOnhold } = require('../utils/sdp-utils'); +const { normalizeJambones } = require('@jambonz/verb-specifications'); function parseDtmfOptions(logger, dtmfCapture) { let parentDtmfCollector, childDtmfCollector; @@ -135,6 +138,10 @@ class TaskDial extends Task { get name() { return TaskName.Dial; } + get isOnHold() { + return this.isIncomingLegHold || this.isOutgoingLegHold; + } + get canReleaseMedia() { const keepAnchor = this.data.anchorMedia || this.cs.isBackGroundListen || @@ -507,7 +514,8 @@ class TaskDial extends Task { callInfo: cs.callInfo, accountInfo: cs.accountInfo, rootSpan: cs.rootSpan, - startSpan: this.startSpan.bind(this) + startSpan: this.startSpan.bind(this), + dialTask: this }); this.dials.set(sd.callSid, sd); @@ -576,11 +584,7 @@ class TaskDial extends Task { } }) .on('reinvite', (req, res) => { - try { - cs.handleReinviteAfterMediaReleased(req, res); - } catch (err) { - this.logger.error(err, 'Error in dial einvite from B leg'); - } + this._onReinvite(req, res); }) .on('refer', (callInfo, req, res) => { @@ -616,6 +620,35 @@ class TaskDial extends Task { this._killOutdials(); // NB: order is important } + async _onReinvite(req, res) { + try { + let isHandled = false; + if (this.cs.onHoldMusic) { + if (isOnhold(req.body) && !this.epOther && !this.ep) { + await this.cs.handleReinviteAfterMediaReleased(req, res); + // Onhold but media is already released + // reconnect A Leg and Response B leg + await this.reAnchorMedia(this.cs, this.sd); + this.isOutgoingLegHold = true; + isHandled = true; + this._onHoldHook(); + } else if (!isOnhold(req.body) && this.epOther && this.ep && this.isOutgoingLegHold && this.canReleaseMedia) { + // Offhold, time to release media + const newSdp = await this.ep.modify(req.body); + await res.send(200, {body: newSdp}); + await this._releaseMedia(this.cs, this.sd); + isHandled = true; + this.isOutgoingLegHold = false; + } + } + if (!isHandled) { + this.cs.handleReinviteAfterMediaReleased(req, res); + } + } catch (err) { + this.logger.error(err, 'Error in dial einvite from B leg'); + } + } + _onMaxCallDuration(cs) { this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`); this.ep && this.ep.unbridge(); @@ -720,9 +753,29 @@ class TaskDial extends Task { } async handleReinviteAfterMediaReleased(req, res) { - const sdp = await this.dlg.modify(req.body); - this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg'); - res.send(200, {body: sdp}); + let isHandled = false; + if (isOnhold(req.body) && !this.epOther && !this.ep) { + const sdp = await this.dlg.modify(req.body); + res.send(200, {body: sdp}); + // Onhold but media is already released + await this.reAnchorMedia(this.cs, this.sd); + isHandled = true; + this.isIncomingLegHold = true; + this._onHoldHook(); + } else if (!isOnhold(req.body) && this.epOther && this.ep && this.isIncomingLegHold && this.canReleaseMedia) { + // Offhold, time to release media + const newSdp = await this.epOther.modify(req.body); + await res.send(200, {body: newSdp}); + await this._releaseMedia(this.cs, this.sd); + isHandled = true; + this.isIncomingLegHold = false; + } + + if (!isHandled) { + const sdp = await this.dlg.modify(req.body); + this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg'); + res.send(200, {body: sdp}); + } } _onAmdEvent(cs, evt) { @@ -733,6 +786,48 @@ class TaskDial extends Task { this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook'); }); } + + async _onHoldHook(allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) { + if (this.data.onHoldHook) { + // send silence for keep Voice quality + await this.epOther.play('silence_stream://500'); + let allowedTasks; + do { + try { + const b3 = this.getTracingPropagation(); + const httpHeaders = b3 && {b3}; + const json = await this.cs.application.requestor. + request('verb:hook', this.data.onHoldHook, this.cs.callInfo.toJSON(), httpHeaders); + const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata)); + allowedTasks = tasks.filter((t) => allowed.includes(t.name)); + if (tasks.length !== allowedTasks.length) { + this.logger.debug({tasks, allowedTasks}, 'unsupported task'); + throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`); + } + this.logger.debug(`DialTask:_onHoldHook: executing ${tasks.length} tasks`); + if (tasks.length) { + this._playSession = new ConfirmCallSession({ + logger: this.logger, + application: this.cs.application, + dlg: this.isIncomingLegHold ? this.dlg : this.cs.dlg, + ep: this.isIncomingLegHold ? this.ep : this.cs.ep, + callInfo: this.cs.callInfo, + accountInfo: this.cs.accountInfo, + tasks, + rootSpan: this.cs.rootSpan + }); + await this._playSession.exec(); + this._playSession = null; + } + } catch (error) { + this.logger.info(error, 'DialTask:_onHoldHook: failed retrieving waitHook'); + this._playSession = null; + break; + } + } while (allowedTasks && allowedTasks.length > 0 && !this.killed && this.isOnHold); + this.logger.info('Finish onHoldHook'); + } + } } module.exports = TaskDial; diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js index b119e3af..4182d7f4 100644 --- a/lib/utils/place-outdial.js +++ b/lib/utils/place-outdial.js @@ -15,7 +15,7 @@ const RootSpan = require('./call-tracer'); const uuidv4 = require('uuid-random'); class SingleDialer extends Emitter { - constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan}) { + constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask}) { super(); assert(target.type); @@ -37,6 +37,7 @@ class SingleDialer extends Emitter { this.callGone = false; this.callSid = uuidv4(); + this.dialTask = dialTask; this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); } @@ -247,9 +248,14 @@ class SingleDialer extends Emitter { .on('modify', async(req, res) => { try { if (this.ep) { - const newSdp = await this.ep.modify(req.body); - res.send(200, {body: newSdp}); - this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE'); + if (this.dialTask && this.dialTask.isOnHold) { + this.logger.info('dial is onhold, emit event'); + this.emit('reinvite', req, res); + } else { + const newSdp = await this.ep.modify(req.body); + res.send(200, {body: newSdp}); + this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE'); + } } else { this.logger.info('SingleDialer:exec: handling reINVITE with released media, emit event'); @@ -430,11 +436,11 @@ class SingleDialer extends Emitter { } function placeOutdial({ - logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan + logger, srf, ms, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask }) { const myOpts = deepcopy(opts); const sd = new SingleDialer({ - logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan + logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask }); sd.exec(srf, ms, myOpts); return sd; diff --git a/lib/utils/sdp-utils.js b/lib/utils/sdp-utils.js new file mode 100644 index 00000000..82ffecd8 --- /dev/null +++ b/lib/utils/sdp-utils.js @@ -0,0 +1,7 @@ +const isOnhold = (sdp) => { + return sdp && (sdp.includes('a=sendonly') || sdp.includes('a=inactive')); +}; + +module.exports = { + isOnhold +};