From 862405c2328136f1878398c39147295eab5f0607 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Wed, 22 Sep 2021 07:39:44 -0400 Subject: [PATCH] LCC: add conference hold and unhold actions --- lib/session/call-session.js | 39 ++++++++++++++---- lib/session/confirm-call-session.js | 6 ++- lib/tasks/conference.js | 64 ++++++++++++++++++++++------- lib/tasks/message.js | 14 +++---- lib/tasks/play.js | 16 ++++++-- lib/tasks/say.js | 16 ++++++-- lib/tasks/specs.json | 2 +- lib/tasks/task.js | 55 +++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 39 deletions(-) diff --git a/lib/session/call-session.js b/lib/session/call-session.js index 92d1f886..d7820488 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -34,7 +34,7 @@ class CallSession extends Emitter { * @param {array} opts.tasks - tasks we are to execute * @param {callInfo} opts.callInfo - information about the call */ - constructor({logger, application, srf, tasks, callInfo, accountInfo}) { + constructor({logger, application, srf, tasks, callInfo, accountInfo, memberId, confName, confUuid}) { super(); this.logger = logger; this.application = application; @@ -42,6 +42,9 @@ class CallSession extends Emitter { this.callInfo = callInfo; this.accountInfo = accountInfo; this.tasks = tasks; + this.memberId = memberId; + this.confName = confName; + this.confUuid = confUuid; this.taskIdx = 0; this.stackIdx = 0; this.callGone = false; @@ -196,6 +199,27 @@ class CallSession extends Emitter { return this.accountInfo?.account?.webhook_secret; } + get isInConference() { + return this.memberId && this.confName && this.confUuid; + } + + setConferenceDetails(memberId, confName, confUuid) { + assert(!this.memberId && !this.confName && !this.confUuid); + assert (memberId && confName && confUuid); + + this.logger.debug(`session is now in conference ${confName}:${memberId} - uuid ${confUuid}`); + this.memberId = memberId; + this.confName = confName; + this.confUuid = confUuid; + } + + clearConferenceDetails() { + this.logger.debug(`session has now left conference ${this.confName}:${this.memberId}`); + this.memberId = null; + this.confName = null; + this.confUuid = null; + } + /** * Check for speech credentials for the specified vendor * @param {*} vendor - google or aws @@ -436,15 +460,12 @@ class CallSession extends Emitter { task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMuteStatus')); } - async _lccConfHoldStatus(callSid, hold) { - this.logger.debug(`_lccConfHoldStatus ${hold}`); + async _lccConfHoldStatus(callSid, opts) { const task = this.currentTask; - if (!task || TaskName.Conference !== task.name) { - return this.logger.info('CallSession:_lccConfHoldStatus - invalid command as conference verb is not active'); + if (!task || TaskName.Conference !== task.name || !this.isInConference) { + return this.logger.info('CallSession:_lccConfHoldStatus - invalid command as call is not in conference'); } - // now do the mute/unmute, deaf/undeaf - task.mute(callSid, hold).catch((err) => this.logger.error(err, 'CallSession:_lccMuteStatus')); - task.deaf(callSid, hold).catch((err) => this.logger.error(err, 'CallSession:_lccMuteStatus')); + task.doConferenceHold(this, opts); } /** @@ -517,7 +538,7 @@ class CallSession extends Emitter { await this._lccMuteStatus(callSid, opts.mute_status === 'mute'); } else if (opts.conf_hold_status) { - await this._lccConfHoldStatus(callSid, opts.conf_hold_status === 'hold'); + await this._lccConfHoldStatus(callSid, opts); } // whisper may be the only thing we are asked to do, or it may that diff --git a/lib/session/confirm-call-session.js b/lib/session/confirm-call-session.js index fca38f83..a3ee8bea 100644 --- a/lib/session/confirm-call-session.js +++ b/lib/session/confirm-call-session.js @@ -8,7 +8,7 @@ const CallSession = require('./call-session'); */ class ConfirmCallSession extends CallSession { - constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo}) { + constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName}) { super({ logger, application, @@ -16,7 +16,9 @@ class ConfirmCallSession extends CallSession { callSid: dlg.callSid, tasks, callInfo, - accountInfo + accountInfo, + memberId, + confName }); this.dlg = dlg; this.ep = ep; diff --git a/lib/tasks/conference.js b/lib/tasks/conference.js index be7e2611..17f6c9a4 100644 --- a/lib/tasks/conference.js +++ b/lib/tasks/conference.js @@ -27,7 +27,8 @@ function camelize(str) { function unhandled(logger, cs, evt) { this.participantCount = parseInt(evt.getHeader('Conference-Size')); - logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ; + // logger.debug({evt}, `unhandled conference event: ${evt.getHeader('Action')}`) ; + logger.debug(`unhandled conference event: ${evt.getHeader('Action')}`) ; } function capitalize(s) { @@ -213,6 +214,7 @@ class Conference extends Task { this._playSession.kill(); this._playSession = null; } + cs.clearConferenceDetails(); resolve(); }); @@ -335,6 +337,8 @@ class Conference extends Task { this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`); this.memberId = memberId; this.confUuid = confUuid; + + cs.setConferenceDetails(memberId, this.confName, confUuid); const response = await this.ep.api('conference', [this.confName, 'get', 'count']); if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body); this._notifyConferenceEvent(cs, 'join'); @@ -375,22 +379,50 @@ class Conference extends Task { this.emitter.emit('join', opts); } - async mute(callSid, muted) { - if (this.memberId) { - const prop = muted === true ? 'mute' : 'unmute'; - this.ep.api(`conference ${this.confName} ${prop} ${this.memberId}`) - .catch((err) => this.logger.info({err}, `Error ${prop} participant`)); - return true; + async doConferenceHold(cs, opts) { + assert (cs.isInConference); + + const {conf_hold_status, wait_hook} = opts; + let hookOnly = true; + + if (this.conf_hold_status !== conf_hold_status) { + hookOnly = false; + this.conf_hold_status = conf_hold_status; + const hold = conf_hold_status === 'hold'; + + this.ep.api(`conference ${this.confName} ${hold ? 'mute' : 'unmute'} ${this.memberId}`) + .catch((err) => this.logger.info({err}, 'Error muting or unmuting participant')); + this.ep.api(`conference ${this.confName} ${hold ? 'deaf' : 'undeaf'} ${this.memberId}`) + .catch((err) => this.logger.info({err}, 'Error deafing or undeafing participant')); + } + + if (hookOnly && this._playSession) { + this._playSession.kill(); + this._playSession = null; + } + if (wait_hook && this.conf_hold_status === 'hold') { + const {dlg} = cs; + this._doWaitHookWhileOnHold(cs, dlg, wait_hook); + } + else if (this.conf_hold_status !== 'hold' && this._playSession) { + this._playSession.kill(); + this._playSession = null; } } - async deaf(callSid, deafed) { - if (this.memberId) { - const prop = deafed === true ? 'deaf' : 'undeaf'; - this.ep.api(`conference ${this.confName} ${prop} ${this.memberId}`) - .catch((err) => this.logger.info({err}, `Error ${prop} participant`)); - return true; - } + async _doWaitHookWhileOnHold(cs, dlg, wait_hook) { + do { + try { + const tasks = await this._playHook(cs, dlg, wait_hook); + if (0 === tasks.length) break; + } catch (err) { + if (!this.killed) { + this.logger.info(err, `Conference:_doWait: failed retrieving wait_hook for ${this.confName}`); + } + this._playSession = null; + break; + } + } while (!this.killed && !this.conf_hold_status === 'hold'); } /** @@ -483,6 +515,9 @@ class Conference extends Task { dlg, ep: cs.ep, callInfo: cs.callInfo, + accountInfo: cs.accountInfo, + memberId: this.memberId, + confName: this.confName, tasks }); await this._playSession.exec(); @@ -503,6 +538,7 @@ class Conference extends Task { } async replaceEndpointAndEnd(cs) { + cs.clearConferenceDetails(); if (this.replaced) return; this.replaced = true; try { diff --git a/lib/tasks/message.js b/lib/tasks/message.js index 14513c1e..f1fa65d2 100644 --- a/lib/tasks/message.js +++ b/lib/tasks/message.js @@ -1,6 +1,7 @@ const Task = require('./task'); const {TaskName, TaskPreconditions} = require('../utils/constants'); const bent = require('bent'); +const { v4: uuidv4 } = require('uuid'); class TaskMessage extends Task { constructor(logger, opts) { @@ -8,13 +9,11 @@ class TaskMessage extends Task { this.preconditions = TaskPreconditions.None; this.payload = { - message_sid: this.data.message_sid, + message_sid: this.data.message_sid || uuidv4(), provider: this.data.provider, to: this.data.to, from: this.data.from, - cc: this.data.cc, - text: this.data.text, - media: this.data.media + text: this.data.text }; } @@ -30,7 +29,7 @@ class TaskMessage extends Task { let payload = this.payload; await super.exec(cs); try { - const {getSBC, getSmpp, dbHelpers} = srf.locals; + const {getSmpp, dbHelpers} = srf.locals; const {lookupSmppGateways} = dbHelpers; this.logger.info(`looking up gateways for account_sid: ${accountSid}`); @@ -50,13 +49,14 @@ class TaskMessage extends Task { }; } else { + //TMP: smpp only at the moment, need to add http back in + /* this.logger.info({gw, accountSid, provider: this.payload.provider}, 'Message:exec - no smpp gateways found to send message'); relativeUrl = 'v1/outboundSMS'; const sbcAddress = getSBC(); if (sbcAddress) url = `http://${sbcAddress}:3000/`; - - //TMP: smpp only at the moment, need to add http back in + */ return res.sendStatus(404); } if (url) { diff --git a/lib/tasks/play.js b/lib/tasks/play.js index 1e14a46b..c17bd804 100644 --- a/lib/tasks/play.js +++ b/lib/tasks/play.js @@ -17,8 +17,12 @@ class TaskPlay extends Task { await super.exec(cs); this.ep = ep; try { - while (!this.killed && this.loop--) { - await ep.play(this.url); + while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) { + if (cs.isInConference) { + const {memberId, confName, confUuid} = cs; + await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url); + } + else await ep.play(this.url); } } catch (err) { this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`); @@ -30,7 +34,13 @@ class TaskPlay extends Task { super.kill(cs); if (this.ep.connected && !this.playComplete) { this.logger.debug('TaskPlay:kill - killing audio'); - await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + if (cs.isInConference) { + const {memberId, confName} = cs; + this.killPlayToConfMember(this.ep, memberId, confName); + } + else { + await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + } } } } diff --git a/lib/tasks/say.js b/lib/tasks/say.js index 70149580..7af64c70 100644 --- a/lib/tasks/say.js +++ b/lib/tasks/say.js @@ -68,10 +68,14 @@ class TaskSay extends Task { this.logger.debug({filepath}, 'synthesized files for tts'); - while (!this.killed && this.loop-- && this.ep.connected) { + while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) { let segment = 0; do { - await ep.play(filepath[segment]); + if (cs.isInConference) { + const {memberId, confName, confUuid} = cs; + await this.playToConfMember(this.ep, memberId, confName, confUuid, filepath[segment]); + } + else await ep.play(filepath[segment]); } while (!this.killed && ++segment < filepath.length); } } catch (err) { @@ -84,7 +88,13 @@ class TaskSay extends Task { super.kill(cs); if (this.ep.connected) { this.logger.debug('TaskSay:kill - killing audio'); - await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + if (cs.isInConference) { + const {memberId, confName} = cs; + this.killPlayToConfMember(this.ep, memberId, confName); + } + else { + await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + } } } } diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json index eb164735..8db5c6dc 100644 --- a/lib/tasks/specs.json +++ b/lib/tasks/specs.json @@ -46,7 +46,7 @@ "play": { "properties": { "url": "string", - "loop": "number", + "loop": "number|string", "earlyMedia": "boolean" }, "required": [ diff --git a/lib/tasks/task.js b/lib/tasks/task.js index fab39900..7a99493d 100644 --- a/lib/tasks/task.js +++ b/lib/tasks/task.js @@ -23,6 +23,9 @@ class Task extends Emitter { this._killInProgress = false; this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); + + /* used when we play a prompt to a member in conference */ + this._confPlayCompletionPromise = new Promise((resolve) => this._confPlayCompletionResolver = resolve); } /** @@ -77,6 +80,21 @@ class Task extends Emitter { return this._completionPromise; } + /** + * when a play to conference member completes + */ + notifyConfPlayDone() { + this._confPlayCompletionResolver(); + } + + /** + * when a subclass task has launched various async activities and is now simply waiting + * for them to complete it should call this method to block until that happens + */ + awaitConfPlayDone() { + return this._confPlayCompletionPromise; + } + /** * provided as a convenience for tasks, this simply calls CallSession#normalizeUrl */ @@ -118,6 +136,43 @@ class Task extends Emitter { cs.replaceApplication(tasks); } + async playToConfMember(ep, memberId, confName, confUuid, filepath) { + try { + this.logger.debug(`Task:playToConfMember - playing ${filepath} to ${confName}:${memberId}`); + + // listen for conference events + const handler = this.__onConferenceEvent.bind(this); + ep.conn.on('esl::event::CUSTOM::*', handler) ; + const response = await ep.api(`conference ${confName} play ${filepath} ${memberId}`); + this.logger.debug({response}, 'Task:playToConfMember - api call returned'); + await this.awaitConfPlayDone(); + ep.conn.removeListener('esl::event::CUSTOM::*', handler); + } catch (err) { + this.logger.error({err}, `Task:playToConfMember - error playing ${filepath} to ${confName}:${memberId}`); + } + } + + async killPlayToConfMember(ep, memberId, confName) { + try { + this.logger.debug(`Task:killPlayToConfMember - killing audio to ${confName}:${memberId}`); + const response = await ep.api(`conference ${confName} stop ${memberId}`); + this.logger.debug({response}, 'Task:killPlayToConfMember - api call returned'); + } catch (err) { + this.logger.error({err}, `Task:killPlayToConfMember - error killing audio to ${confName}:${memberId}`); + } + } + + __onConferenceEvent(evt) { + const eventName = evt.getHeader('Event-Subclass') ; + if (eventName === 'conference::maintenance') { + const action = evt.getHeader('Action') ; + if (action === 'play-file-member-done') { + this.logger.debug('done playing file to conf member'); + this.notifyConfPlayDone(); + } + } + } + async transferCallToFeatureServer(cs, sipAddress, opts) { const uuid = uuidv4(); const {addKey} = cs.srf.locals.dbHelpers;