Compare commits

..

19 Commits

Author SHA1 Message Date
Dave Horton
f0bd681ccc implement actionHook for message verb 2021-09-22 13:28:56 -04:00
Dave Horton
ac263de729 fix error responses for sms 2021-09-22 10:54:36 -04:00
Dave Horton
862405c232 LCC: add conference hold and unhold actions 2021-09-22 07:39:44 -04:00
Dave Horton
3cd4c399d4 LCC: add support for conf_hold_status to hold/unhold a participant in a conference 2021-09-20 15:50:00 -04:00
Dave Horton
0d6cb8a2b3 bugfix: establish conference start time for parties that have been waiting 2021-09-16 13:08:15 -04:00
Dave Horton
05c5319cbc minor rasa fix 2021-09-07 13:43:40 -04:00
Dave Horton
d15fdcf663 rasa: add support for eventhook which provides user and bot messages in realtime and supports redirecting to a new app 2021-09-07 13:43:40 -04:00
Dave Horton
19f3cbaa43 initial support for Rasa 2021-09-07 13:43:40 -04:00
Dave Horton
ac8827c885 dialogflow: support for regional endpoints 2021-09-07 13:43:40 -04:00
Dave Horton
d1d082ceaf fix vulnerability 2021-08-30 17:12:44 -04:00
Dave Horton
28415dc750 bugfixes for queue events 2021-08-30 17:12:00 -04:00
Dave Horton
3d0c7fea52 add support for bidirectional audio when using listen verb 2021-08-26 15:19:05 -04:00
Dave Horton
3fed15b3b9 further fixes for customerData 2021-08-11 11:01:11 -04:00
Dave Horton
7c629e6faf bugfix: customerData in webhooks was being snake-cased 2021-08-11 10:47:10 -04:00
Dave Horton
649b3d5715 race condition: dial call killed just as called party picks up 2021-08-10 11:01:10 -04:00
Dave Horton
48fbbd48ad add try-catch block 2021-08-09 16:20:58 -04:00
Dave Horton
dacd3691ed bugfix: enqueue queue_result = bridged if queued call was answered 2021-08-04 08:53:37 -04:00
Dave Horton
df8dac367c bugfix: if waitUrl of enqueue task includes leave but caller is dequeued before leave is reached, ignore leave 2021-08-03 16:46:02 -04:00
Dave Horton
1a2aaf9845 Feature/queue webhooks (#34)
* initial changes for queue webhooks

* send queue leave webhook when dequeued

* bugfix: if enqeue task is killed because it is being replaced with new app supplied by LCC, ignore any app returned from the actionHook as LCC takes precedence

* remove leftover merge brackets
2021-07-31 13:32:40 -04:00
21 changed files with 545 additions and 95 deletions

View File

@@ -8,7 +8,7 @@
"jsx": false,
"modules": false
},
"ecmaVersion": 2018
"ecmaVersion": 2020
},
"plugins": ["promise"],
"rules": {

View File

@@ -45,7 +45,7 @@ module.exports = function(srf, logger) {
// TODO: alert
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
}
logger.debug({accountInfo: req.locals.accountInfo}, `retrieved account info for ${account_sid}`);
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
next();
} catch (err) {
logger.info({err}, `Error retrieving account details for account ${account_sid}`);

View File

@@ -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;
@@ -192,13 +195,37 @@ class CallSession extends Emitter {
return this.constructor.name === 'SmsCallSession';
}
get webhook_secret() {
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
*/
getSpeechCredentials(vendor, type) {
const {writeAlerts, AlertType} = this.srf.locals;
this.logger.debug({vendor, type, speech: this.accountInfo.speech}, `searching for speech for vendor ${vendor}`);
if (this.accountInfo.speech && this.accountInfo.speech.length > 0) {
const credential = this.accountInfo.speech.find((s) => s.vendor === vendor);
if (credential && (
@@ -336,7 +363,7 @@ class CallSession extends Emitter {
*/
_lccCallStatus(opts) {
if (opts.call_status === CallStatus.Completed && this.dlg) {
this.logger.info('CallSession:updateCall hanging up call due to request from api');
this.logger.info('CallSession:_lccCallStatus hanging up call due to request from api');
this._callerHungup();
}
else if (opts.call_status === CallStatus.NoAnswer) {
@@ -364,13 +391,13 @@ class CallSession extends Emitter {
async _lccCallHook(opts) {
const webhooks = [];
let sd;
if (opts.call_hook) webhooks.push(this.requestor.request(opts.call_hook, this.callInfo));
if (opts.call_hook) webhooks.push(this.requestor.request(opts.call_hook, this.callInfo.toJSON()));
if (opts.child_call_hook) {
/* child call hook only allowed from a connected Dial state */
const task = this.currentTask;
sd = task.sd;
if (task && TaskName.Dial === task.name && sd) {
webhooks.push(this.requestor.request(opts.child_call_hook, sd.callInfo));
webhooks.push(this.requestor.request(opts.child_call_hook, sd.callInfo.toJSON()));
}
}
const [tasks1, tasks2] = await Promise.all(webhooks);
@@ -385,7 +412,7 @@ class CallSession extends Emitter {
const {parentLogger} = this.srf.locals;
const childLogger = parentLogger.child({callId: this.callId, callSid: sd.callSid});
const t = normalizeJambones(childLogger, childTasks).map((tdata) => makeTask(childLogger, tdata));
childLogger.info({tasks: listTaskNames(t)}, 'CallSession:updateCall new task list for child call');
childLogger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list for child call');
const cs = await sd.doAdulting({
logger: childLogger,
application: this.application,
@@ -397,7 +424,7 @@ class CallSession extends Emitter {
}
if (tasks) {
const t = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:updateCall new task list');
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
this.replaceApplication(t);
}
else {
@@ -414,23 +441,31 @@ class CallSession extends Emitter {
async _lccListenStatus(opts) {
const task = this.currentTask;
if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) {
return this.logger.info(`CallSession:updateCall - invalid listen_status in task ${task.name}`);
return this.logger.info(`CallSession:_lccListenStatus - invalid listen_status in task ${task.name}`);
}
const listenTask = task.name === TaskName.Listen ? task : task.listenTask;
if (!listenTask) {
return this.logger.info('CallSession:updateCall - invalid listen_status: Dial does not have a listen');
return this.logger.info('CallSession:_lccListenStatus - invalid listen_status: Dial does not have a listen');
}
listenTask.updateListen(opts.listen_status);
}
async _lccMuteStatus(callSid, mute) {
// this whole thing requires us to be in a Dial verb
// this whole thing requires us to be in a Dial or Conference verb
const task = this.currentTask;
if (!task || TaskName.Dial !== task.name) {
return this.logger.info('CallSession:_lccMute - invalid command as dial is not active');
if (!task || ![TaskName.Dial, TaskName.Conference].includes(task.name)) {
return this.logger.info('CallSession:_lccMuteStatus - invalid: neither dial nor conference are not active');
}
// now do the whisper
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
// now do the mute/unmute
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMuteStatus'));
}
async _lccConfHoldStatus(callSid, opts) {
const task = this.currentTask;
if (!task || TaskName.Conference !== task.name || !this.isInConference) {
return this.logger.info('CallSession:_lccConfHoldStatus - invalid command as call is not in conference');
}
task.doConferenceHold(this, opts);
}
/**
@@ -450,7 +485,7 @@ class CallSession extends Emitter {
// allow user to provide a url object, a url string, an array of tasks, or a single task
if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
// retrieve a url
const json = await this.requestor(opts.call_hook, this.callInfo);
const json = await this.requestor(opts.call_hook, this.callInfo.toJSON());
tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
}
else if (Array.isArray(whisper)) {
@@ -481,20 +516,6 @@ class CallSession extends Emitter {
task.whisper(tasks, callSid).catch((err) => this.logger.error(err, 'CallSession:_lccWhisper'));
}
/**
* perform live call control -- mute or unmute an endpoint
* @param {array} opts - array of play or say tasks
*/
async _lccMute(callSid, mute) {
// this whole thing requires us to be in a Dial verb
const task = this.currentTask;
if (!task || TaskName.Dial !== task.name) {
return this.logger.info('CallSession:_lccMute - not possible since we are not in a dial');
}
task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
}
/**
* perform live call control
@@ -516,6 +537,9 @@ class CallSession extends Emitter {
else if (opts.mute_status) {
await this._lccMuteStatus(callSid, opts.mute_status === 'mute');
}
else if (opts.conf_hold_status) {
await this._lccConfHoldStatus(callSid, opts);
}
// 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..
@@ -784,7 +808,8 @@ class CallSession extends Emitter {
}
else {
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.queueEventHookRequestor = new Requestor(this.logger, r[0]);
this.queueEventHookRequestor = new Requestor(this.logger, this.accountSid,
r[0], this.webhook_secret);
this.queueEventHook = r[0];
}
} catch (err) {
@@ -909,14 +934,14 @@ class CallSession extends Emitter {
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
this.notifier.request(this.call_status_hook, this.callInfo);
this.notifier.request(this.call_status_hook, this.callInfo.toJSON());
} catch (err) {
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}
// update calls db
//this.logger.debug(`updating redis with ${JSON.stringify(this.callInfo)}`);
this.updateCallStatus(Object.assign({}, this.callInfo), this.serviceUrl)
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl)
.catch((err) => this.logger.error(err, 'redis error'));
}
}

View File

@@ -8,14 +8,17 @@ const CallSession = require('./call-session');
*/
class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks, callInfo}) {
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName}) {
super({
logger,
application,
srf: dlg.srf,
callSid: dlg.callSid,
tasks,
callInfo
callInfo,
accountInfo,
memberId,
confName
});
this.dlg = dlg;
this.ep = ep;

View File

@@ -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');
@@ -371,9 +375,56 @@ class Conference extends Task {
*/
notifyStartConference(cs, opts) {
this.logger.info({opts}, `Conference:notifyStartConference: conference ${this.confName} has now started`);
this.conferenceStartTime = new Date();
this.emitter.emit('join', opts);
}
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 _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');
}
/**
* Add ourselves to the waitlist of sessions to be notified once
* the conference starts
@@ -464,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();
@@ -484,6 +538,7 @@ class Conference extends Task {
}
async replaceEndpointAndEnd(cs) {
cs.clearConferenceDetails();
if (this.replaced) return;
this.replaced = true;
try {

View File

@@ -242,7 +242,7 @@ class TaskDial extends Task {
this.logger.info(`Dial:_onDtmf got digits on B leg after adulting: ${evt.dtmf}`);
}
else {
requestor.request(this.dtmfHook, {dtmf: match, ...cs.callInfo})
requestor.request(this.dtmfHook, {dtmf: match, ...cs.callInfo.toJSON()})
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
}
}

View File

@@ -9,10 +9,22 @@ class Dialogflow extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.credentials = this.data.credentials;
if (this.data.environment) this.project = `${this.data.project}:${this.data.environment}`;
else this.project = this.data.project;
/* set project id with environment and region (optionally) */
if (this.data.environment && this.data.region) {
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
}
else if (this.data.environment) {
this.project = `${this.data.project}:${this.data.environment}`;
}
else if (this.data.region) {
this.project = `${this.data.project}::${this.data.region}`;
}
else {
this.project = this.data.project;
}
this.lang = this.data.lang || 'en-US';
this.welcomeEvent = this.data.welcomeEvent || '';
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {

View File

@@ -79,12 +79,14 @@ class TaskEnqueue extends Task {
this.notifyUrl = url;
/* invoke account-level webhook for queue event notifications */
cs.performQueueWebhook({
event: 'join',
queue: this.data.name,
length: members,
joinTime: this.waitStartTime
});
try {
cs.performQueueWebhook({
event: 'join',
queue: this.data.name,
length: members,
joinTime: this.waitStartTime
});
} catch (err) {}
}
async _removeFromQueue(cs) {
@@ -114,6 +116,7 @@ class TaskEnqueue extends Task {
this.bridgeDetails = opts;
this.logger.info({bridgeDetails: this.bridgeDetails}, `time to dequeue from ${this.queueName}`);
if (this._playSession) {
this._leave = false;
this._playSession.kill();
this._playSession = null;
}
@@ -233,6 +236,7 @@ class TaskEnqueue extends Task {
});
// resolve when either side hangs up
this.state = QueueResults.Bridged;
this.emitter
.on('hangup', () => {
this.logger.info('TaskEnqueue:_bridgeLocal ending with hangup from dequeue party');
@@ -325,10 +329,9 @@ class TaskEnqueue extends Task {
// check for 'leave' verb and only execute tasks up till then
const tasksToRun = [];
let leave = false;
for (const o of tasks) {
if (o.name === TaskName.Leave) {
leave = true;
this._leave = true;
this.logger.info('waitHook returned a leave task');
break;
}
@@ -343,12 +346,13 @@ class TaskEnqueue extends Task {
dlg,
ep: cs.ep,
callInfo: cs.callInfo,
accountInfo: cs.accountInfo,
tasks: tasksToRun
});
await this._playSession.exec();
this._playSession = null;
}
if (leave) {
if (this._leave) {
this.state = QueueResults.Leave;
this.kill(cs);
}

View File

@@ -10,7 +10,7 @@ const makeTask = require('./make_task');
const assert = require('assert');
class TaskGather extends Task {
constructor(logger, opts) {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
@@ -40,6 +40,8 @@ class TaskGather extends Task {
if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
if (this.play) this.playTask = makeTask(this.logger, {play: this.play}, this);
this.parentTask = parentTask;
}
get name() { return TaskName.Gather; }
@@ -154,7 +156,6 @@ class TaskGather extends Task {
});
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
}
this.logger.debug({vars: opts}, 'setting freeswitch vars');
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
@@ -235,7 +236,11 @@ class TaskGather extends Task {
await this.performAction({digits: this.digitBuffer});
}
else if (reason.startsWith('speech')) {
await this.performAction({speech: evt});
if (this.parentTask) this.parentTask.emit('transcription', evt);
else await this.performAction({speech: evt});
}
else if (reason.startsWith('timeout') && this.parentTask) {
this.parentTask.emit('timeout', evt);
}
this.notifyTaskDone();
}

View File

@@ -122,6 +122,11 @@ class TaskListen extends Task {
if (this.finishOnKey || this.passDtmf) {
ep.on('dtmf', this._dtmfHandler);
}
/* support bi-directional audio */
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
ep.addCustomEventListener(ListenEvents.Disconnect, this._onDisconnect.bind(this, ep));
}
_removeListeners(ep) {
@@ -154,6 +159,29 @@ class TaskListen extends Task {
this.logger.info(evt, 'TaskListen:_onConnectFailure');
this.notifyTaskDone();
}
async _onPlayAudio(ep, evt) {
this.logger.info(`received play_audio event: ${JSON.stringify(evt)}`);
try {
const results = await ep.play(evt.file);
this.logger.debug(`Finished playing file, result: ${JSON.stringify(results)}`);
ep.forkAudioSendText({type: 'playDone', data: Object.assign({id: evt.id}, results)});
}
catch (err) {
this.logger.error({err}, 'Error playing file');
}
}
_onKillAudio(ep) {
this.logger.info('received kill_audio event');
ep.api('uuid_break', ep.uuid);
}
_onDisconnect(ep, cs) {
this.logger.debug('_onDisconnect: TaskListen terminating task');
this.kill(cs);
}
_onError(ep, evt) {
this.logger.info(evt, 'TaskListen:_onError');
this.notifyTaskDone();

View File

@@ -48,6 +48,9 @@ function makeTask(logger, obj, parent) {
case TaskName.Message:
const TaskMessage = require('./message');
return new TaskMessage(logger, data, parent);
case TaskName.Rasa:
const TaskRasa = require('./rasa');
return new TaskRasa(logger, data, parent);
case TaskName.Say:
const TaskSay = require('./say');
return new TaskSay(logger, data, parent);

View File

@@ -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,
provider: this.data.provider,
message_sid: this.data.message_sid || uuidv4(),
carrier: this.data.carrier,
to: this.data.to,
from: this.data.from,
cc: this.data.cc,
text: this.data.text,
media: this.data.media
text: this.data.text
};
}
@@ -28,16 +27,18 @@ class TaskMessage extends Task {
const {srf, accountSid} = cs;
const {res} = cs.callInfo;
let payload = this.payload;
const actionParams = {message_sid: this.payload.message_sid};
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}`);
this.logger.debug(`looking up gateways for account_sid: ${accountSid}`);
const r = await lookupSmppGateways(accountSid);
let gw, url, relativeUrl;
if (r.length > 0) {
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.provider || o.vc.name === this.payload.provider));
gw = r.find((o) => 1 === o.sg.outbound && (!this.payload.carrier || o.vc.name === this.payload.carrier));
}
if (gw) {
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
@@ -50,37 +51,75 @@ class TaskMessage extends Task {
};
}
else {
this.logger.info({gw, accountSid, provider: this.payload.provider},
//TMP: smpp only at the moment, need to add http back in
/*
this.logger.info({gw, accountSid, carrier: this.payload.carrier},
'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);
*/
this.performAction({
...actionParams,
message_status: 'no carriers'
}).catch((err) => {});
if (res) res.sendStatus(404);
return;
}
if (url) {
const post = bent(url, 'POST', 'json', 201);
const post = bent(url, 'POST', 'json', 201, 480);
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
const response = await post(relativeUrl, payload);
this.logger.info({response}, 'Successfully sent SMS');
if (cs.callInfo.res) {
this.logger.info('Message:exec sending 200 OK response to HTTP POST from api server');
res.status(200).json({
sid: cs.callInfo.messageSid,
providerResponse: response
});
const {smpp_err_code, carrier, message_id, message} = response;
if (smpp_err_code) {
this.logger.info({response}, 'SMPP error sending SMS');
this.performAction({
...actionParams,
carrier,
carrier_message_id: message_id,
message_status: 'failure',
message_failure_reason: message
}).catch((err) => {});
if (res) {
res.status(480).json({
...response,
sid: cs.callInfo.messageSid
});
}
}
else {
const {message_id, carrier} = response;
this.logger.info({response}, 'Successfully sent SMS');
this.performAction({
...actionParams,
carrier,
carrier_message_id: message_id,
message_status: 'success',
}).catch((err) => {});
if (res) {
res.status(200).json({
sid: cs.callInfo.messageSid,
carrierResponse: response
});
}
}
// TODO: action Hook
}
else {
this.logger.info('Message:exec - unable to send SMS as there are no available SMS gateways');
res.status(422).json({message: 'no configured SMS gateways'});
this.logger.info('Message:exec - unable to send SMS as SMPP is not configured on the system');
this.performAction({
...actionParams,
message_status: 'smpp configuration error'
}).catch((err) => {});
if (res) res.status(404).json({message: 'no configured SMS gateways'});
}
} catch (err) {
this.logger.error(err, 'TaskMessage:exec - Error sending SMS');
res.status(422).json({message: 'no configured SMS gateways'});
this.logger.error(err, 'TaskMessage:exec - unexpected error sending SMS');
this.performAction({
...actionParams,
message_status: 'system error',
message_failure_reason: err.message
});
if (res) res.status(422).json({message: 'no configured SMS gateways'});
}
}
}

View File

@@ -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'));
}
}
}
}

156
lib/tasks/rasa.js Normal file
View File

@@ -0,0 +1,156 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const makeTask = require('./make_task');
const bent = require('bent');
class Rasa extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.prompt = this.data.prompt;
this.eventHook = this.data?.eventHook;
this.actionHook = this.data?.actionHook;
this.post = bent('POST', 'json', 200);
}
get name() { return TaskName.Rasa; }
get hasReportedFinalAction() {
return this.reportedFinalAction || this.isReplacingApplication;
}
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
try {
/* set event handlers */
this.on('transcription', this._onTranscription.bind(this, cs, ep));
this.on('timeout', this._onTimeout.bind(this, cs, ep));
/* start the first gather */
this.gatherTask = this._makeGatherTask(this.prompt);
this.gatherTask.exec(cs, ep, this)
.catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Rasa error');
throw err;
}
}
async kill(cs) {
super.kill(cs);
this.logger.debug('Rasa:kill');
if (!this.hasReportedFinalAction) {
this.reportedFinalAction = true;
this.performAction({rasaResult: 'caller hungup'})
.catch((err) => this.logger.info({err}, 'rasa - error w/ action webook'));
}
if (this.ep.connected) {
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.removeAllListeners();
this.notifyTaskDone();
}
_makeGatherTask(prompt) {
let opts = {
input: ['speech'],
timeout: this.data.timeout || 10,
recognizer: this.data.recognizer || {
vendor: 'default',
language: 'default'
}
};
if (prompt) {
const sayOpts = this.data.tts ?
{text: prompt, synthesizer: this.data.tts} :
{text: prompt};
opts = {
...opts,
say: sayOpts
};
}
//this.logger.debug({opts}, 'constructing a nested gather object');
const gather = makeTask(this.logger, {gather: opts}, this);
return gather;
}
async _onTranscription(cs, ep, evt) {
//this.logger.debug({evt}, `Rasa: got transcription for callSid ${cs.callSid}`);
const utterance = evt.alternatives[0].transcript;
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'userMessage', message: utterance})
.then((redirected) => {
if (redirected) {
this.logger.info('Rasa_onTranscription: event handler for user message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({rasaResult: 'redirect'}, false);
if (this.gatherTask) this.gatherTask.kill(cs);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
});
}
try {
const payload = {
sender: cs.callSid,
message: utterance
};
this.logger.debug({payload}, 'Rasa:_onTranscription - sending payload to Rasa');
const response = await this.post(this.data.url, payload);
this.logger.debug({response}, 'Rasa:_onTranscription - got response from Rasa');
const botUtterance = Array.isArray(response) ?
response.reduce((prev, current) => {
return current.text ? `${prev} ${current.text}` : '';
}, '') :
null;
if (botUtterance) {
this.logger.debug({botUtterance}, 'Rasa:_onTranscription: got user utterance');
this.gatherTask = this._makeGatherTask(botUtterance);
this.gatherTask.exec(cs, ep, this)
.catch((err) => this.logger.info({err}, 'Rasa gather task returned error'));
if (this.eventHook) {
this.performHook(cs, this.eventHook, {event: 'botMessage', message: response})
.then((redirected) => {
if (redirected) {
this.logger.info('Rasa_onTranscription: event handler for bot message redirected us to new webhook');
this.reportedFinalAction = true;
this.performAction({rasaResult: 'redirect'}, false);
if (this.gatherTask) this.gatherTask.kill(cs);
}
return;
})
.catch(({err}) => {
this.logger.info({err}, 'Rasa_onTranscription: error sending event hook');
});
}
}
} catch (err) {
this.logger.error({err}, 'Rasa_onTranscription: Error sending user utterance to Rasa - ending task');
this.performAction({rasaResult: 'webhookError'});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
_onTimeout(cs, ep, evt) {
this.logger.debug({evt}, 'Rasa: got timeout');
if (!this.hasReportedFinalAction) this.performAction({rasaResult: 'timeout'});
this.reportedFinalAction = true;
this.notifyTaskDone();
}
}
module.exports = Rasa;

View File

@@ -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'));
}
}
}
}

View File

@@ -46,7 +46,7 @@
"play": {
"properties": {
"url": "string",
"loop": "number",
"loop": "number|string",
"earlyMedia": "boolean"
},
"required": [
@@ -78,7 +78,6 @@
"say": "#say"
},
"required": [
"actionHook"
]
},
"conference": {
@@ -124,6 +123,10 @@
"credentials": "object|string",
"project": "string",
"environment": "string",
"region": {
"type": "string",
"enum": ["europe-west1", "europe-west2", "australia-southeast1", "asia-northeast1"]
},
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
@@ -224,6 +227,19 @@
"length"
]
},
"rasa": {
"properties": {
"url": "string",
"recognizer": "#recognizer",
"tts": "#synthesizer",
"prompt": "string",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"url"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"

View File

@@ -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
*/
@@ -99,6 +117,62 @@ class Task extends Emitter {
}
}
async performHook(cs, hook, results) {
const json = await cs.requestor.request(hook, results);
if (json && Array.isArray(json)) {
const makeTask = require('./make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.redirect(cs, tasks);
return true;
}
}
return false;
}
redirect(cs, tasks) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.isReplacingApplication = true;
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;

View File

@@ -14,6 +14,7 @@
"Message": "message",
"Pause": "pause",
"Play": "play",
"Rasa": "rasa",
"Redirect": "redirect",
"RestDial": "rest:dial",
"SipDecline": "sip:decline",

View File

@@ -181,6 +181,15 @@ class SingleDialer extends Emitter {
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
const connectTime = this.dlg.connectTime = moment();
/* race condition: we were killed just as call was answered */
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});
if (this.ep) this.ep.destroy();
return;
}
this.dlg
.on('destroy', () => {
const duration = moment().diff(connectTime, 'seconds');
@@ -246,7 +255,7 @@ class SingleDialer extends Emitter {
async _executeApp(confirmHook) {
try {
// retrieve set of tasks
const tasks = await this.requestor.request(confirmHook, this.callInfo);
const tasks = await this.requestor.request(confirmHook, this.callInfo.toJSON());
// verify it contains only allowed verbs
const allowedTasks = tasks.filter((task) => {

12
package-lock.json generated
View File

@@ -3525,9 +3525,9 @@
}
},
"node_modules/path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path-to-regexp": {
@@ -7662,9 +7662,9 @@
"dev": true
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"path-to-regexp": {

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-feature-server",
"version": "0.3.1",
"version": "0.6.6",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"