mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-20 08:40:38 +00:00
major refactoring
This commit is contained in:
6
app.js
6
app.js
@@ -3,7 +3,7 @@ const srf = new Srf();
|
|||||||
const Mrf = require('drachtio-fsmrf');
|
const Mrf = require('drachtio-fsmrf');
|
||||||
srf.locals.mrf = new Mrf(srf);
|
srf.locals.mrf = new Mrf(srf);
|
||||||
const config = require('config');
|
const config = require('config');
|
||||||
const logger = require('pino')(config.get('logging'));
|
const logger = srf.locals.parentLogger = require('pino')(config.get('logging'));
|
||||||
const {lookupAppByPhoneNumber} = require('jambonz-db-helpers')(config.get('mysql'), logger);
|
const {lookupAppByPhoneNumber} = require('jambonz-db-helpers')(config.get('mysql'), logger);
|
||||||
srf.locals.dbHelpers = {lookupAppByPhoneNumber};
|
srf.locals.dbHelpers = {lookupAppByPhoneNumber};
|
||||||
const {
|
const {
|
||||||
@@ -13,7 +13,7 @@ const {
|
|||||||
invokeWebCallback
|
invokeWebCallback
|
||||||
} = require('./lib/middleware')(srf, logger);
|
} = require('./lib/middleware')(srf, logger);
|
||||||
|
|
||||||
const CallSession = require('./lib/call-session');
|
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||||
|
|
||||||
// disable logging in test mode
|
// disable logging in test mode
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
@@ -42,7 +42,7 @@ if (process.env.NODE_ENV === 'test') {
|
|||||||
srf.use('invite', [initLocals, normalizeNumbers, retrieveApplication, invokeWebCallback]);
|
srf.use('invite', [initLocals, normalizeNumbers, retrieveApplication, invokeWebCallback]);
|
||||||
|
|
||||||
srf.invite((req, res) => {
|
srf.invite((req, res) => {
|
||||||
const session = new CallSession(req, res);
|
const session = new InboundCallSession(req, res);
|
||||||
session.exec();
|
session.exec();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
const Emitter = require('events');
|
|
||||||
const config = require('config');
|
|
||||||
const TaskList = require('./task-list');
|
|
||||||
const request = require('request');
|
|
||||||
const notifiers = require('./utils/notifiers');
|
|
||||||
const {CallStatus, CallDirection, TaskPreconditions} = require('./utils/constants');
|
|
||||||
//require('request-debug')(request);
|
|
||||||
const makeTask = require('./tasks/make_task');
|
|
||||||
const resourcesMixin = require('./utils/resources');
|
|
||||||
const moment = require('moment');
|
|
||||||
const assert = require('assert');
|
|
||||||
const Dialog = require('drachtio-srf').Dialog;
|
|
||||||
const BADPRECONDITIONS = 'preconditions not met';
|
|
||||||
|
|
||||||
class CallSession extends Emitter {
|
|
||||||
constructor(req, res) {
|
|
||||||
super();
|
|
||||||
this.req = req;
|
|
||||||
this.res = res;
|
|
||||||
this.srf = req.srf;
|
|
||||||
this.logger = req.locals.logger;
|
|
||||||
this.application = req.locals.application;
|
|
||||||
this.statusCallback = this.application.call_status_hook;
|
|
||||||
this.statusCallbackMethod = this.application.status_hook_http_method || 'POST';
|
|
||||||
this.idxTask = 0;
|
|
||||||
this.resources = new Map();
|
|
||||||
this.direction = CallDirection.Inbound;
|
|
||||||
this.callAttributes = req.locals.callAttributes;
|
|
||||||
|
|
||||||
// array of TaskLists, the one currently executing is at the front
|
|
||||||
this._executionStack = [new TaskList(this.application.tasks, this.callSid)];
|
|
||||||
this.childCallSids = [];
|
|
||||||
this.calls = new Map();
|
|
||||||
this.calls.set(this.parentCallSid, {ep: null, dlg: null});
|
|
||||||
|
|
||||||
this.hooks = notifiers(this.logger, this.callAttributes);
|
|
||||||
|
|
||||||
this.callGone = false;
|
|
||||||
|
|
||||||
req.on('cancel', this._onCallerHangup.bind(this, req));
|
|
||||||
this.on('callStatusChange', this._onCallStatusChange.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
get callSid() { return this.callAttributes.CallSid; }
|
|
||||||
get parentCallSid() { return this.callAttributes.CallSid; }
|
|
||||||
get actionHook() { return this.hooks.actionHook; }
|
|
||||||
get callingNumber() { return this.req.callingNumber; }
|
|
||||||
get calledNumber() { return this.req.calledNumber; }
|
|
||||||
|
|
||||||
async exec() {
|
|
||||||
let idx = 0;
|
|
||||||
while (this._executionStack.length) {
|
|
||||||
const taskList = this.currentTaskList = this._executionStack.shift();
|
|
||||||
this.logger.debug(`CallSession:exec starting task list with ${taskList.tasks.length} tasks`);
|
|
||||||
while (taskList.length && !this.callGone) {
|
|
||||||
const {task, callSid} = taskList.shift();
|
|
||||||
this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`);
|
|
||||||
try {
|
|
||||||
const resources = await this._evaluatePreconditions(task, callSid);
|
|
||||||
this.currentTask = task;
|
|
||||||
await task.exec(this, resources);
|
|
||||||
this.currentTask = null;
|
|
||||||
this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`);
|
|
||||||
} catch (err) {
|
|
||||||
this.currentTask = null;
|
|
||||||
if (err.message.includes(BADPRECONDITIONS)) {
|
|
||||||
this.logger.info(`CallSession:exec task #${idx}: ${task.name}: ${err.message}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.error(err, `Error executing task #${idx}: ${task.name}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// all done - cleanup
|
|
||||||
this.logger.info('CallSession:exec finished all tasks');
|
|
||||||
if (!this.res.finalResponseSent) {
|
|
||||||
this.logger.info('CallSession:exec auto-generating non-success response to invite');
|
|
||||||
this.res.send(603);
|
|
||||||
}
|
|
||||||
this._clearCalls();
|
|
||||||
this.clearResources(); // still needed? ms may be only thing in here
|
|
||||||
}
|
|
||||||
|
|
||||||
_evaluatePreconditions(task, callSid) {
|
|
||||||
switch (task.preconditions) {
|
|
||||||
case TaskPreconditions.None:
|
|
||||||
return;
|
|
||||||
case TaskPreconditions.Endpoint:
|
|
||||||
return this._evalEndpointPrecondition(task, callSid);
|
|
||||||
case TaskPreconditions.StableCall:
|
|
||||||
return this._evalStableCallPrecondition(task, callSid);
|
|
||||||
case TaskPreconditions.UnansweredCall:
|
|
||||||
return this._evalUnansweredCallPrecondition(task, callSid);
|
|
||||||
default:
|
|
||||||
assert(0, `invalid/unknown or missing precondition type ${task.preconditions} for task ${task.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _evalEndpointPrecondition(task, callSid) {
|
|
||||||
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
|
||||||
const resources = this.calls.get(callSid);
|
|
||||||
if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown CallSid ${callSid}`);
|
|
||||||
if (resources.ep) return resources.ep;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// need to allocate an endpoint
|
|
||||||
const mrf = this.srf.locals.mrf;
|
|
||||||
let ms = this.getResource('ms');
|
|
||||||
if (!ms) {
|
|
||||||
ms = await mrf.connect(config.get('freeswitch'));
|
|
||||||
this.addResource('ms', ms);
|
|
||||||
}
|
|
||||||
const ep = await ms.createEndpoint({remoteSdp: this.req.body});
|
|
||||||
ep.cs = this;
|
|
||||||
resources.ep = ep;
|
|
||||||
if (task.earlyMedia && callSid === this.parentCallSid && this.req && !this.req.finalResponseSent) {
|
|
||||||
this.res.send(183, {body: ep.local.sdp});
|
|
||||||
this.calls.set(callSid, resources);
|
|
||||||
return ep;
|
|
||||||
}
|
|
||||||
const uas = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
|
|
||||||
uas.on('destroy', this._onCallerHangup.bind(this, uas));
|
|
||||||
uas.callSid = callSid;
|
|
||||||
resources.dlg = uas;
|
|
||||||
this.logger.debug(`CallSession:_evalEndpointPrecondition - call was answered with callSid ${callSid}`);
|
|
||||||
this.calls.set(callSid, resources);
|
|
||||||
return ep;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
|
|
||||||
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint - callSid ${callSid}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_evalStableCallPrecondition(task, callSid) {
|
|
||||||
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
|
|
||||||
const resources = this.calls.get(callSid);
|
|
||||||
if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown callSid ${callSid}`);
|
|
||||||
if (!resources.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered - callSid ${callSid}`);
|
|
||||||
return resources.dlg;
|
|
||||||
}
|
|
||||||
|
|
||||||
_evalUnansweredCallPrecondition(task, callSid) {
|
|
||||||
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
|
||||||
if (callSid !== this.parentCallSid || !this.req) {
|
|
||||||
throw new Error(`${BADPRECONDITIONS}: no inbound call - callSid ${callSid}`);
|
|
||||||
}
|
|
||||||
if (this.req.finalResponseSent) {
|
|
||||||
throw new Error(`${BADPRECONDITIONS}: final sip status already sent - callSid ${callSid}`);
|
|
||||||
}
|
|
||||||
return {req: this.req, res: this.res};
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearCalls() {
|
|
||||||
for (const [callSid, resources] of Array.from(this.calls).reverse()) {
|
|
||||||
try {
|
|
||||||
this.logger.debug(`CallSession:_clearCalls clearing call sid ${callSid}`);
|
|
||||||
[resources.ep, resources.dlg].forEach((r) => {
|
|
||||||
if (r && r.connected) r.destroy();
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, `clearResources: clearing call sid ${callSid}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.calls.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* These below methods are needed mainly by the dial verb, which
|
|
||||||
* deals with a variety of scenarios that can't simply be
|
|
||||||
* described by the precondition concept as other verbs can
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* retrieve the media server and endpoint for this call, allocate them if needed
|
|
||||||
*/
|
|
||||||
async createOrRetrieveEpAndMs() {
|
|
||||||
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.req.body});
|
|
||||||
this.addResource('epIn', ep);
|
|
||||||
}
|
|
||||||
return {ms, ep};
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectInboundCallToIvr(earlyMedia = false) {
|
|
||||||
|
|
||||||
// if this is not an inbound call scenario, nothing to do
|
|
||||||
if (!this.parentCallSid) {
|
|
||||||
this.logger.debug('CallSession:connectInboundCallToIvr - session was not triggered by an inbound call');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for a stable inbound call already connected to the ivr
|
|
||||||
const ms = this.getResource('ms');
|
|
||||||
const resources = this.calls.get(this.parentCallSid);
|
|
||||||
if (ms && resources.ep && resources.dlg) {
|
|
||||||
this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already connected to IVR');
|
|
||||||
return {ms, ep: resources.ep, dlg: resources.dlg};
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for an early media connection, where caller wants same
|
|
||||||
if (ms && resources.ep && earlyMedia) {
|
|
||||||
this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already has early media connection');
|
|
||||||
return {ms, ep: resources.ep};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ok, we need to connect the inbound call to the ivr
|
|
||||||
try {
|
|
||||||
assert(!this.req.finalResponseSent);
|
|
||||||
this.logger.debug('CallSession:connectInboundCallToIvr - creating endpoint for inbound call');
|
|
||||||
const {ep, ms} = await this.createOrRetrieveEpAndMs();
|
|
||||||
|
|
||||||
if (earlyMedia) {
|
|
||||||
this.res.send(183, {body: ep.local.sdp});
|
|
||||||
this.calls.set(this.parentCallSid, {ep});
|
|
||||||
return {ep, ms, res: this.res};
|
|
||||||
}
|
|
||||||
const dlg = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
|
|
||||||
this.logger.debug(`CallSession:connectInboundCallToIvr - answered callSid ${this.parentCallSid}`);
|
|
||||||
this.calls.set(this.parentCallSid, {ep, dlg});
|
|
||||||
return {ep, ms, dlg};
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'CallSession:connectInboundCallToIvr error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async answerParentCall(remoteSdp) {
|
|
||||||
assert(this.parentCallSid, 'CallSession:answerParentCall - no parent call sid');
|
|
||||||
const resources = this.calls.get(this.parentCallSid);
|
|
||||||
resources.dlg = await this.srf.createUAS(this.req, this.res, {localSdp: remoteSdp});
|
|
||||||
resources.set(this.parentCallSid, resources);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* allocate a new endpoint for this call, caller's responsibility to destroy
|
|
||||||
*/
|
|
||||||
async createEndpoint(remoteSdp) {
|
|
||||||
try {
|
|
||||||
let ms = this.getResource('ms');
|
|
||||||
if (!ms) {
|
|
||||||
const mrf = this.srf.locals.mrf;
|
|
||||||
ms = await mrf.connect(config.get('freeswitch'));
|
|
||||||
this.addResource('ms', ms);
|
|
||||||
}
|
|
||||||
const ep = await ms.createEndpoint({remoteSdp});
|
|
||||||
return ep;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, `CallSession:createEndpoint: error creating endpoint for remoteSdp ${remoteSdp}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the currently-executing application with a new application
|
|
||||||
* NB: any tasks in the current stack that have not been executed are flushed
|
|
||||||
* @param {object|array} payload - new application to execute
|
|
||||||
*/
|
|
||||||
replaceApplication(payload) {
|
|
||||||
const taskData = Array.isArray(payload) ? payload : [payload];
|
|
||||||
const tasks = [];
|
|
||||||
for (const t in taskData) {
|
|
||||||
try {
|
|
||||||
const task = makeTask(this.logger, taskData[t]);
|
|
||||||
tasks.push(task);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info({data: taskData[t]}, `invalid web callback payload: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.application.tasks = tasks;
|
|
||||||
this.idxTask = 0;
|
|
||||||
this.logger.debug(`CallSession:replaceApplication - set ${tasks.length} new tasks`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* got CANCEL or BYE from inbound leg
|
|
||||||
*/
|
|
||||||
_onCallerHangup(obj, evt) {
|
|
||||||
this.callGone = true;
|
|
||||||
if (obj instanceof Dialog) {
|
|
||||||
this.logger.debug('CallSession: caller hung up');
|
|
||||||
/* cant destroy endpoint as current task may need to get final transcription
|
|
||||||
const resources = this.calls.get(obj.callSid);
|
|
||||||
if (resources.ep && resources.ep.connected) {
|
|
||||||
resources.ep.destroy();
|
|
||||||
resources.ep = null;
|
|
||||||
this.calls.set(obj.callSid, resources);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.logger.debug('CallSession: caller hung before answer');
|
|
||||||
}
|
|
||||||
if (this.currentTask) this.currentTask.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* got BYE from inbound leg
|
|
||||||
*/
|
|
||||||
_onCallStatusChange(evt) {
|
|
||||||
this.logger.debug(evt, 'CallSession:_onCallStatusChange');
|
|
||||||
if (this.statusCallback) {
|
|
||||||
if (evt.status === CallStatus.InProgress) this.connectTime = moment();
|
|
||||||
const params = Object.assign(this.callAttributes, {CallStatus: evt.status, SipStatus: evt.sipStatus});
|
|
||||||
if (evt.status === CallStatus.Completed) {
|
|
||||||
const duration = moment().diff(this.connectTime, 'seconds');
|
|
||||||
this.logger.debug(`CallSession:_onCallStatusChange duration was ${duration}`);
|
|
||||||
Object.assign(params, {Duration: duration});
|
|
||||||
}
|
|
||||||
const opts = {
|
|
||||||
url: this.statusCallback,
|
|
||||||
method: this.statusCallbackMethod,
|
|
||||||
json: true,
|
|
||||||
qs: 'GET' === this.statusCallbackMethod ? params : null,
|
|
||||||
body: 'POST' === this.statusCallbackMethod ? params : null
|
|
||||||
};
|
|
||||||
request(opts, (err) => {
|
|
||||||
if (err) this.logger.info(`Error sending call status to ${this.statusCallback}: ${err.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(CallSession.prototype, resourcesMixin);
|
|
||||||
|
|
||||||
module.exports = CallSession;
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
//const debug = require('debug')('jambonz:feature-server');
|
//const debug = require('debug')('jambonz:feature-server');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const request = require('request');
|
|
||||||
//require('request-debug')(request);
|
|
||||||
const uuidv4 = require('uuid/v4');
|
const uuidv4 = require('uuid/v4');
|
||||||
const makeTask = require('./tasks/make_task');
|
|
||||||
const normalizeJamones = require('./utils/normalize-jamones');
|
|
||||||
const {CallStatus, CallDirection} = require('./utils/constants');
|
const {CallStatus, CallDirection} = require('./utils/constants');
|
||||||
|
const CallInfo = require('./session/call-info');
|
||||||
|
const retrieveApp = require('./utils/retrieve-app');
|
||||||
|
|
||||||
module.exports = function(srf, logger) {
|
module.exports = function(srf, logger) {
|
||||||
const {lookupAppByPhoneNumber} = srf.locals.dbHelpers;
|
const {lookupAppByPhoneNumber} = srf.locals.dbHelpers;
|
||||||
|
|
||||||
function initLocals(req, res, next) {
|
function initLocals(req, res, next) {
|
||||||
req.locals = req.locals || {};
|
const callSid = uuidv4();
|
||||||
req.locals.logger = logger.child({callId: req.get('Call-ID')});
|
req.locals = {
|
||||||
|
callSid,
|
||||||
|
logger: logger.child({callId: req.get('Call-ID'), callSid})
|
||||||
|
};
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ module.exports = function(srf, logger) {
|
|||||||
async function retrieveApplication(req, res, next) {
|
async function retrieveApplication(req, res, next) {
|
||||||
const logger = req.locals.logger;
|
const logger = req.locals.logger;
|
||||||
try {
|
try {
|
||||||
const app = req.locals.application = await lookupAppByPhoneNumber(req.locals.calledNumber);
|
const app = await lookupAppByPhoneNumber(req.locals.calledNumber);
|
||||||
if (!app) {
|
if (!app) {
|
||||||
logger.info(`rejecting call to DID ${req.locals.calledNumber}: no application associated`);
|
logger.info(`rejecting call to DID ${req.locals.calledNumber}: no application associated`);
|
||||||
return res.send(480, {
|
return res.send(480, {
|
||||||
@@ -52,7 +53,23 @@ module.exports = function(srf, logger) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: temp hack pre-refactoring to latest db schema: bang the data into expected shape
|
||||||
|
|
||||||
|
req.locals.application = app;
|
||||||
|
//end hack
|
||||||
logger.debug(app, `retrieved application for ${req.locals.calledNumber}`);
|
logger.debug(app, `retrieved application for ${req.locals.calledNumber}`);
|
||||||
|
const from = req.getParsedHeader('From');
|
||||||
|
req.locals.callInfo = new CallInfo({
|
||||||
|
callSid: req.locals.callSid,
|
||||||
|
accountSid: app.account_sid,
|
||||||
|
applicationSid: app.application_sid,
|
||||||
|
from: req.callingNumber,
|
||||||
|
to: req.calledNumber,
|
||||||
|
direction: CallDirection.Inbound,
|
||||||
|
callerName: from.name || req.callingNumber,
|
||||||
|
callId: req.get('Call-ID')
|
||||||
|
});
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
|
||||||
@@ -66,52 +83,23 @@ module.exports = function(srf, logger) {
|
|||||||
async function invokeWebCallback(req, res, next) {
|
async function invokeWebCallback(req, res, next) {
|
||||||
const logger = req.locals.logger;
|
const logger = req.locals.logger;
|
||||||
const app = req.locals.application;
|
const app = req.locals.application;
|
||||||
const call_sid = uuidv4();
|
|
||||||
const method = (app.hook_http_method || 'POST').toUpperCase();
|
const method = (app.hook_http_method || 'POST').toUpperCase();
|
||||||
const from = req.getParsedHeader('From');
|
const qs = Object.assign({}, req.locals.callInfo, {
|
||||||
req.locals.callAttributes = {
|
sipStatus: 100,
|
||||||
CallSid: call_sid,
|
callStatus: CallStatus.Trying,
|
||||||
AccountSid: app.account_sid,
|
|
||||||
From: req.callingNumber,
|
|
||||||
To: req.calledNumber,
|
|
||||||
Direction: CallDirection.Inbound,
|
|
||||||
CallerName: from.name || req.callingNumber,
|
|
||||||
SipCallID: req.get('Call-ID')
|
|
||||||
};
|
|
||||||
const qs = Object.assign({}, req.locals.callAttributes, {
|
|
||||||
CallStatus: CallStatus.Trying,
|
|
||||||
SipStatus: 100,
|
|
||||||
RequestorIP: req.get('X-Forwarded-For'),
|
RequestorIP: req.get('X-Forwarded-For'),
|
||||||
RequestorName: req.get('X-Originating-Carrier')
|
RequestorName: req.get('X-Originating-Carrier')
|
||||||
});
|
});
|
||||||
const opts = {
|
let auth;
|
||||||
url: app.call_hook,
|
|
||||||
method,
|
|
||||||
json: true,
|
|
||||||
qs
|
|
||||||
};
|
|
||||||
if (app.hook_basic_auth_user && app.hook_basic_auth_password) {
|
if (app.hook_basic_auth_user && app.hook_basic_auth_password) {
|
||||||
logger.debug(`using basic auth with ${app.hook_basic_auth_user}:${app.hook_basic_auth_password}`);
|
logger.debug(`using basic auth with ${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}});
|
auth = Object.assign({}, {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password});
|
||||||
}
|
}
|
||||||
if (method === 'POST') Object.assign(opts, {body: req.msg});
|
|
||||||
try {
|
try {
|
||||||
request(opts, (err, response, body) => {
|
app.tasks = await retrieveApp(logger, app.call_hook, method, auth, qs, method === 'POST' ? req.msg : null);
|
||||||
if (err) {
|
|
||||||
logger.error(err, `Error invoking callback ${app.call_hook}`);
|
|
||||||
return res.send(500, 'Webhook Failure');
|
|
||||||
}
|
|
||||||
logger.debug(body, `application payload: ${body}`);
|
|
||||||
try {
|
|
||||||
app.tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata));
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(err, 'Invalid Webhook Response');
|
logger.error(err, 'Error retrieving or parsing application');
|
||||||
res.send(500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(err, 'Error invoking web callback');
|
|
||||||
res.send(500);
|
res.send(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
lib/session/call-info.js
Normal file
22
lib/session/call-info.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
class CallInfo {
|
||||||
|
constructor(opts) {
|
||||||
|
this.callSid = opts.callSid;
|
||||||
|
this.parentCallSid = opts.parentCallSid;
|
||||||
|
this.direction = opts.direction;
|
||||||
|
this.from = opts.from;
|
||||||
|
this.to = opts.to;
|
||||||
|
this.callId = opts.callId;
|
||||||
|
this.sipStatus = opts.sipStatus;
|
||||||
|
this.callStatus = opts.callStatus;
|
||||||
|
this.callerId = opts.callerId;
|
||||||
|
this.accountSid = opts.accountSid;
|
||||||
|
this.applicationSid = opts.applicationSid;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCallStatus(callStatus, sipStatus) {
|
||||||
|
this.callStatus = callStatus;
|
||||||
|
if (sipStatus) this.sipStatus = sipStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CallInfo;
|
||||||
191
lib/session/call-session.js
Normal file
191
lib/session/call-session.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
const Emitter = require('events');
|
||||||
|
const config = require('config');
|
||||||
|
const {CallDirection, TaskPreconditions, CallStatus} = require('../utils/constants');
|
||||||
|
const moment = require('moment');
|
||||||
|
const assert = require('assert');
|
||||||
|
const BADPRECONDITIONS = 'preconditions not met';
|
||||||
|
|
||||||
|
class CallSession extends Emitter {
|
||||||
|
constructor({logger, application, srf, tasks, callSid}) {
|
||||||
|
super();
|
||||||
|
this.logger = logger;
|
||||||
|
this.application = application;
|
||||||
|
this.srf = srf;
|
||||||
|
this.callSid = callSid;
|
||||||
|
this.tasks = tasks;
|
||||||
|
|
||||||
|
this.callGone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec() {
|
||||||
|
let idx = 0;
|
||||||
|
this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`);
|
||||||
|
while (this.tasks.length && !this.callGone) {
|
||||||
|
const task = this.tasks.shift();
|
||||||
|
this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`);
|
||||||
|
try {
|
||||||
|
const resources = await this._evaluatePreconditions(task);
|
||||||
|
this.currentTask = task;
|
||||||
|
await task.exec(this, resources);
|
||||||
|
this.currentTask = null;
|
||||||
|
this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.currentTask = null;
|
||||||
|
if (err.message.includes(BADPRECONDITIONS)) {
|
||||||
|
this.logger.info(`CallSession:exec task #${idx}: ${task.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.logger.error(err, `Error executing task #${idx}: ${task.name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// all done - cleanup
|
||||||
|
this.logger.info('CallSession:exec all tasks complete');
|
||||||
|
this._onTasksDone();
|
||||||
|
this._clearCalls();
|
||||||
|
this.ms && this.ms.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTasksDone() {
|
||||||
|
// meant to be implemented by subclass if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
_callReleased() {
|
||||||
|
this.logger.debug('CallSession:_callReleased - caller hung up');
|
||||||
|
this.callGone = true;
|
||||||
|
if (this.currentTask) this.currentTask.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the currently-executing application with a new application
|
||||||
|
* NB: any tasks in the current stack that have not been executed are flushed
|
||||||
|
*/
|
||||||
|
replaceApplication(tasks) {
|
||||||
|
this.tasks = tasks;
|
||||||
|
this.logger.info(`CallSession:replaceApplication - set ${tasks.length} new tasks`);
|
||||||
|
}
|
||||||
|
_evaluatePreconditions(task) {
|
||||||
|
switch (task.preconditions) {
|
||||||
|
case TaskPreconditions.None:
|
||||||
|
return;
|
||||||
|
case TaskPreconditions.Endpoint:
|
||||||
|
return this._evalEndpointPrecondition(task);
|
||||||
|
case TaskPreconditions.StableCall:
|
||||||
|
return this._evalStableCallPrecondition(task);
|
||||||
|
case TaskPreconditions.UnansweredCall:
|
||||||
|
return this._evalUnansweredCallPrecondition(task);
|
||||||
|
default:
|
||||||
|
assert(0, `invalid/unknown or missing precondition type ${task.preconditions} for task ${task.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _evalEndpointPrecondition(task) {
|
||||||
|
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
||||||
|
|
||||||
|
const answerCall = async() => {
|
||||||
|
const uas = await this.srf.createUAS(this.req, this.res, {localSdp: this.ep.local.sdp});
|
||||||
|
uas.on('destroy', this._callerHungup.bind(this));
|
||||||
|
uas.callSid = this.callSid;
|
||||||
|
uas.connectTime = moment();
|
||||||
|
this.dlg = uas;
|
||||||
|
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||||
|
this.logger.debug('CallSession:_evalEndpointPrecondition - answered call');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.ep) {
|
||||||
|
if (!task.earlyMedia || this.dlg) return this.ep;
|
||||||
|
|
||||||
|
// we are going from an early media connection to answer
|
||||||
|
await answerCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// need to allocate an endpoint
|
||||||
|
if (!this.ms) this.ms = await this.getMS();
|
||||||
|
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||||
|
ep.cs = this;
|
||||||
|
this.ep = ep;
|
||||||
|
|
||||||
|
if (this.direction === CallDirection.Inbound) {
|
||||||
|
if (task.earlyMedia && !this.req.finalResponseSent) {
|
||||||
|
this.res.send(183, {body: ep.local.sdp});
|
||||||
|
return ep;
|
||||||
|
}
|
||||||
|
answerCall();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// outbound call TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
return ep;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
|
||||||
|
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_evalStableCallPrecondition(task) {
|
||||||
|
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
|
||||||
|
if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`);
|
||||||
|
return this.dlg;
|
||||||
|
}
|
||||||
|
|
||||||
|
_evalUnansweredCallPrecondition(task, callSid) {
|
||||||
|
if (!this.req) throw new Error('invalid precondition unanswered_call for outbound call');
|
||||||
|
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
|
||||||
|
if (this.req.finalResponseSent) {
|
||||||
|
throw new Error(`${BADPRECONDITIONS}: final sip status already sent`);
|
||||||
|
}
|
||||||
|
return {req: this.req, res: this.res};
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearCalls() {
|
||||||
|
if (this.dlg && this.dlg.connected) this.dlg.destroy();
|
||||||
|
if (this.ep && this.ep.connected) this.ep.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
_callerHungup() {
|
||||||
|
assert(false, 'subclass responsibility to override this method');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMS() {
|
||||||
|
if (!this.ms) {
|
||||||
|
const mrf = this.srf.locals.mrf;
|
||||||
|
this.ms = await mrf.connect(config.get('freeswitch'));
|
||||||
|
}
|
||||||
|
return this.ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrRetrieveEpAndMs() {
|
||||||
|
const mrf = this.srf.locals.mrf;
|
||||||
|
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
|
||||||
|
|
||||||
|
// get a media server
|
||||||
|
if (!this.ms) {
|
||||||
|
this.ms = await mrf.connect(config.get('freeswitch'));
|
||||||
|
}
|
||||||
|
if (!this.ep) {
|
||||||
|
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||||
|
}
|
||||||
|
return {ms: this.ms, ep: this.ep};
|
||||||
|
}
|
||||||
|
_notifyCallStatusChange(callStatus) {
|
||||||
|
this.logger.debug({app: this.application}, `CallSession:_notifyCallStatusChange: ${JSON.stringify(callStatus)}`);
|
||||||
|
try {
|
||||||
|
const auth = {};
|
||||||
|
if (this.application.hook_basic_auth_user && this.application.hook_basic_auth_password) {
|
||||||
|
Object.assign(auth, {user: this.application.hook_basic_auth_user, password: this.hook_basic_auth_password});
|
||||||
|
}
|
||||||
|
this.notifyHook(this.application.call_status_hook,
|
||||||
|
this.application.hook_http_method,
|
||||||
|
auth,
|
||||||
|
callStatus);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${JSON.stringify(callStatus)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CallSession;
|
||||||
26
lib/session/confirm-call-session.js
Normal file
26
lib/session/confirm-call-session.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const CallSession = require('./call-session');
|
||||||
|
const {CallDirection} = require('../utils/constants');
|
||||||
|
|
||||||
|
class ConfirmCallSession extends CallSession {
|
||||||
|
constructor({logger, application, dlg, ep, tasks}) {
|
||||||
|
super({
|
||||||
|
logger,
|
||||||
|
application,
|
||||||
|
srf: dlg.srf,
|
||||||
|
callSid: dlg.callSid,
|
||||||
|
tasks
|
||||||
|
});
|
||||||
|
this.dlg = dlg;
|
||||||
|
this.ep = ep;
|
||||||
|
this.direction = CallDirection.Outbound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* empty implementation to override superclass so we do not delete dlg and ep
|
||||||
|
*/
|
||||||
|
_clearCalls() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ConfirmCallSession;
|
||||||
119
lib/session/inbound-call-session.js
Normal file
119
lib/session/inbound-call-session.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
const CallSession = require('./call-session');
|
||||||
|
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||||
|
const hooks = require('../utils/notifiers');
|
||||||
|
const moment = require('moment');
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
class InboundCallSession extends CallSession {
|
||||||
|
constructor(req, res) {
|
||||||
|
super({
|
||||||
|
logger: req.locals.logger,
|
||||||
|
srf: req.srf,
|
||||||
|
application: req.locals.application,
|
||||||
|
callSid: req.locals.callInfo.callSid,
|
||||||
|
tasks: req.locals.application.tasks
|
||||||
|
});
|
||||||
|
this.req = req;
|
||||||
|
this.res = res;
|
||||||
|
this.srf = req.srf;
|
||||||
|
this.logger = req.locals.logger;
|
||||||
|
this.callInfo = req.locals.callInfo;
|
||||||
|
this.direction = CallDirection.Inbound;
|
||||||
|
const {notifyHook} = hooks(this.logger, this.callInfo);
|
||||||
|
this.notifyHook = notifyHook;
|
||||||
|
|
||||||
|
req.on('cancel', this._callReleased.bind(this));
|
||||||
|
|
||||||
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
|
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
|
||||||
|
}
|
||||||
|
|
||||||
|
get speechSynthesisVendor() {
|
||||||
|
return this.application.speech_synthesis_vendor;
|
||||||
|
}
|
||||||
|
get speechSynthesisVoice() {
|
||||||
|
return this.application.speech_synthesis_voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get speechRecognizerVendor() {
|
||||||
|
return this.application.speech_recognizer_vendor;
|
||||||
|
}
|
||||||
|
get speechRecognizerLanguage() {
|
||||||
|
return this.application.speech_recognizer_language;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTasksDone() {
|
||||||
|
if (!this.res.finalResponseSent) {
|
||||||
|
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||||
|
this.res.send(603);
|
||||||
|
}
|
||||||
|
else if (this.dlg.connected) {
|
||||||
|
assert(this.dlg.connectTime);
|
||||||
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
|
this.logger.debug('InboundCallSession:_onTasksDone hanging up call since all tasks are done');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectInboundCallToIvr(earlyMedia = false) {
|
||||||
|
|
||||||
|
// check for a stable inbound call already connected to the ivr
|
||||||
|
if (this.ep && this.dlg) {
|
||||||
|
this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already connected to IVR');
|
||||||
|
return {ep: this.ep, dlg: this.dlg};
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for an early media connection, where caller wants same
|
||||||
|
if (this.ep && earlyMedia) {
|
||||||
|
this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already has early media connection');
|
||||||
|
return {ep: this.ep};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok, we need to connect the inbound call to the ivr
|
||||||
|
try {
|
||||||
|
assert(!this.req.finalResponseSent);
|
||||||
|
this.logger.debug('CallSession:connectInboundCallToIvr - creating endpoint for inbound call');
|
||||||
|
const {ep} = await this.createOrRetrieveEpAndMs();
|
||||||
|
this.ep = ep;
|
||||||
|
|
||||||
|
if (earlyMedia) {
|
||||||
|
this.res.send(183, {body: ep.local.sdp});
|
||||||
|
this.emit('callStatusChange', {sipStatus: 183, callStatus: CallStatus.EarlyMedia});
|
||||||
|
return {ep, res: this.res};
|
||||||
|
}
|
||||||
|
const dlg = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
|
||||||
|
dlg.on('destroy', this._callerHungup.bind(this));
|
||||||
|
dlg.connectTime = moment();
|
||||||
|
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||||
|
this.logger.debug(`CallSession:connectInboundCallToIvr - answered callSid ${this.callSid}`);
|
||||||
|
this.ep = ep;
|
||||||
|
this.dlg = dlg;
|
||||||
|
return {ep, dlg};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err, 'CallSession:connectInboundCallToIvr error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async propagateAnswer() {
|
||||||
|
if (!this.dlg) {
|
||||||
|
assert(this.ep);
|
||||||
|
this.dlg = await this.srf.createUAS(this.req, this.res, {localSdp: this.ep.local.sdp});
|
||||||
|
this.dlg.connectTime = moment();
|
||||||
|
this.dlg.on('destroy', this._callerHungup.bind(this));
|
||||||
|
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||||
|
this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_callerHungup() {
|
||||||
|
assert(this.dlg.connectTime);
|
||||||
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
|
this.logger.debug('InboundCallSession: caller hung up');
|
||||||
|
this._callReleased();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InboundCallSession;
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
class TaskList {
|
|
||||||
constructor(tasks, callSid) {
|
|
||||||
this.tasks = tasks;
|
|
||||||
this.callSid = callSid;
|
|
||||||
}
|
|
||||||
|
|
||||||
shift() {
|
|
||||||
const task = this.tasks.shift();
|
|
||||||
if (task) return {task, callSid: this.callSid};
|
|
||||||
}
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this.tasks.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = TaskList;
|
|
||||||
@@ -1,356 +1,228 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const {CallStatus, CallDirection, TaskName, TaskPreconditions} = require('../utils/constants');
|
const {CallStatus, CallDirection, TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
const SipError = require('drachtio-srf').SipError;
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const uuidv4 = require('uuid/v4');
|
const placeCall = require('../utils/place-outdial');
|
||||||
const request = require('request');
|
const config = require('config');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
|
const debug = require('debug')('jambonz:feature-server');
|
||||||
|
|
||||||
function isFinalCallStatus(status) {
|
|
||||||
return [CallStatus.Completed, CallStatus.NoAnswer, CallStatus.Failed, CallStatus.Busy].includes(status);
|
|
||||||
}
|
|
||||||
class TaskDial extends Task {
|
class TaskDial extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.preconditions = TaskPreconditions.None;
|
this.preconditions = TaskPreconditions.None;
|
||||||
|
|
||||||
this.action = opts.action;
|
this.earlyMedia = this.data.answerOnBridge === true;
|
||||||
this.earlyMedia = opts.answerOnBridge === true;
|
this.callerId = this.data.callerId;
|
||||||
this.callerId = opts.callerId;
|
this.dialMusic = this.data.dialMusic;
|
||||||
this.dialMusic = opts.dialMusic;
|
|
||||||
this.headers = this.data.headers || {};
|
this.headers = this.data.headers || {};
|
||||||
this.method = opts.method || 'POST';
|
this.method = this.data.method || 'POST';
|
||||||
this.statusCallback = opts.statusCallback;
|
this.statusCallback = this.data.statusCallback;
|
||||||
this.statusCallbackMethod = opts.statusCallbackMethod || 'POST';
|
this.statusCallbackMethod = this.data.statusCallbackMethod || 'POST';
|
||||||
this.target = opts.target;
|
this.target = this.data.target;
|
||||||
this.timeout = opts.timeout || 60;
|
this.timeout = this.data.timeout || 60;
|
||||||
this.timeLimit = opts.timeLimit;
|
this.timeLimit = this.data.timeLimit;
|
||||||
|
this.url = this.data.url;
|
||||||
|
|
||||||
if (opts.listen) {
|
if (this.data.listen) {
|
||||||
this.listenTask = makeTask(logger, {'listen': opts.listen});
|
this.listenTask = makeTask(logger, {'listen': this.data.listen});
|
||||||
}
|
}
|
||||||
if (opts.transcribe) {
|
if (this.data.transcribe) {
|
||||||
this.transcribeTask = makeTask(logger, {'transcribe' : opts.transcribe});
|
this.transcribeTask = makeTask(logger, {'transcribe' : this.data.transcribe});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canceled = false;
|
this.results = {};
|
||||||
this.callAttributes = {};
|
this.bridged = false;
|
||||||
this.dialCallStatus = CallStatus.Failed;
|
this.dials = new Map();
|
||||||
this.dialCallSid = null;
|
|
||||||
this.dialCallDuration = null;
|
|
||||||
|
|
||||||
this.on('callStatusChange', this._onCallStatusChange.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Dial; }
|
get name() { return TaskName.Dial; }
|
||||||
|
|
||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
|
super.exec(cs);
|
||||||
try {
|
try {
|
||||||
this._initializeCallData(cs);
|
if (cs.direction === CallDirection.Inbound) {
|
||||||
await this._initializeInbound(cs);
|
await this._initializeInbound(cs);
|
||||||
|
}
|
||||||
await this._attemptCalls(cs);
|
await this._attemptCalls(cs);
|
||||||
await this._waitForCompletion(cs);
|
await this.awaitTaskDone();
|
||||||
|
this.performAction(this.method, this.results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`TaskDial:exec terminating with error ${err.message}`);
|
this.logger.error(`TaskDial:exec terminating with error ${err.message}`);
|
||||||
|
this.kill();
|
||||||
}
|
}
|
||||||
await this._actionHook(cs);
|
|
||||||
this.clearResources();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_initializeCallData(cs) {
|
async kill() {
|
||||||
this.logger.debug(`TaskDial:_initializeCallData parent call sid is ${cs.callSid}`);
|
super.kill();
|
||||||
Object.assign(this.callAttributes, {
|
if (this.connectTime) {
|
||||||
AccountSid: cs.AccountSid,
|
const duration = moment().diff(this.connectTime, 'seconds');
|
||||||
ParentCallSid: cs.callSid,
|
this.results.dialCallDuration = duration;
|
||||||
Direction: CallDirection.Outbound
|
this.logger.debug(`Dial:kill call ended after ${duration} seconds`);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
this._killOutdials();
|
||||||
|
if (this.dlg) {
|
||||||
|
assert(this.ep);
|
||||||
|
if (this.dlg.connected) this.dlg.destroy();
|
||||||
|
debug(`Dial:kill deleting endpoint ${this.ep.uuid}`);
|
||||||
|
this.ep.destroy();
|
||||||
|
}
|
||||||
|
if (this.listenTask) await this.listenTask.kill();
|
||||||
|
if (this.transcribeTask) await this.transcribeTask.kill();
|
||||||
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initializeInbound(cs) {
|
async _initializeInbound(cs) {
|
||||||
const {req} = cs;
|
const {ep} = await cs.connectInboundCallToIvr(this.earlyMedia);
|
||||||
|
this.epOther = ep;
|
||||||
|
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
|
||||||
|
|
||||||
// the caller could hangup in the middle of all this..
|
|
||||||
req.on('cancel', this._onCancel.bind(this, cs));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await cs.connectInboundCallToIvr(this.earlyMedia);
|
|
||||||
if (!result) throw new Error('outbound dial via API not supported yet');
|
|
||||||
|
|
||||||
const {ep, dlg, res} = result;
|
|
||||||
assert(ep);
|
|
||||||
// play dial music to caller, if provided
|
|
||||||
if (this.dialMusic) {
|
if (this.dialMusic) {
|
||||||
ep.play(this.dialMusic, (err) => {
|
// play dial music to caller while we outdial
|
||||||
if (err) this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`);
|
ep.play(this.dialMusic).catch((err) => {
|
||||||
|
this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.epIn = ep;
|
|
||||||
this.dlgIn = dlg;
|
|
||||||
this.res = res;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'TaskDial:_initializeInbound error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _attemptCalls(cs) {
|
async _attemptCalls(cs) {
|
||||||
const {req, srf} = cs;
|
const {req, srf} = cs;
|
||||||
|
|
||||||
// send all outbound calls back to originating SBC for simplicity
|
const sbcAddress = cs.direction === CallDirection.Inbound ?
|
||||||
const sbcAddress = `${req.source_address}:${req.source_port}`;
|
`${req.source_address}:${req.source_port}` :
|
||||||
|
config.get('sbcAddress');
|
||||||
const callSid = uuidv4();
|
|
||||||
let newCallId, to, from;
|
|
||||||
try {
|
|
||||||
// create an endpoint for the outbound call
|
|
||||||
const epOut = await cs.createEndpoint();
|
|
||||||
this.addResource('epOut', epOut);
|
|
||||||
|
|
||||||
const {uri, opts} = this._prepareOutdialAttempt(this.target[0], sbcAddress,
|
|
||||||
this.callerId || req.callingNumber, epOut.local.sdp);
|
|
||||||
|
|
||||||
let streamConnected = false;
|
|
||||||
|
|
||||||
const connectStreams = async(remoteSdp) => {
|
|
||||||
streamConnected = true;
|
|
||||||
epOut.modify(remoteSdp);
|
|
||||||
this.epIn.bridge(epOut);
|
|
||||||
if (!this.dlgIn) {
|
|
||||||
this.dlgIn = await cs.srf.answerParentCall(this.epIn.local.sdp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// outdial requested destination
|
|
||||||
const uac = await srf.createUAC(uri, opts, {
|
|
||||||
cbRequest: (err, reqSent) => {
|
|
||||||
this.outboundInviteInProgress = reqSent;
|
|
||||||
newCallId = req.get('Call-ID');
|
|
||||||
from = reqSent.callingNumber,
|
|
||||||
to = reqSent.calledNumber;
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
CallStatus: CallStatus.Trying,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
SipStatus: 100
|
|
||||||
});
|
|
||||||
},
|
|
||||||
cbProvisional: (prov) => {
|
|
||||||
if ([180, 183].includes(prov.status)) {
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
CallStatus: prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
SipStatus: prov.status
|
|
||||||
});
|
|
||||||
if (!streamConnected && prov.body) connectStreams(prov.body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// outbound call was established
|
|
||||||
uac.connectTime = moment();
|
|
||||||
uac.callSid = this.dialCallSid = callSid;
|
|
||||||
uac.from = from;
|
|
||||||
uac.to = to;
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.InProgress,
|
|
||||||
SipStatus: 200
|
|
||||||
});
|
|
||||||
uac.on('destroy', () => {
|
|
||||||
const duration = this.dialCallDuration = moment().diff(uac.connectTime, 'seconds');
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.Completed,
|
|
||||||
Duration: duration
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (!streamConnected) connectStreams(uac.remote.sdp);
|
|
||||||
this.outboundInviteInProgress = null;
|
|
||||||
this.addResource('dlgOut', uac);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof SipError) {
|
|
||||||
switch (err.status) {
|
|
||||||
case 487:
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.NoAnswer,
|
|
||||||
SipStatus: err.status
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 486:
|
|
||||||
case 600:
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.Busy,
|
|
||||||
SipStatus: err.status
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.emit('callStatusChange', {callSid,
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.Failed,
|
|
||||||
SipStatus: err.status
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (err.status !== 487) {
|
|
||||||
this.logger.info(`TaskDial:_connectCall outdial failed with ${err.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: callSid,
|
|
||||||
SipCallId: newCallId,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
CallStatus: CallStatus.Failed,
|
|
||||||
SipStatus: 500
|
|
||||||
});
|
|
||||||
this.logger.error(err, 'TaskDial:_connectCall error');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_prepareOutdialAttempt(target, sbcAddress, callerId, sdp) {
|
|
||||||
const opts = {
|
const opts = {
|
||||||
headers: this.headers,
|
headers: this.headers,
|
||||||
proxy: `sip:${sbcAddress}`,
|
proxy: `sip:${sbcAddress}`,
|
||||||
callingNumber: callerId,
|
callingNumber: this.callerId || req.callingNumber
|
||||||
localSdp: sdp
|
|
||||||
};
|
|
||||||
let uri;
|
|
||||||
|
|
||||||
switch (target.type) {
|
|
||||||
case 'phone':
|
|
||||||
uri = `sip:${target.number}@${sbcAddress}`;
|
|
||||||
break;
|
|
||||||
case 'sip':
|
|
||||||
uri = target.uri;
|
|
||||||
if (target.auth) Object.assign(opts, {auth: target.auth});
|
|
||||||
break;
|
|
||||||
case 'user':
|
|
||||||
uri = `sip:${target.name}`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
assert(0, `TaskDial:_prepareOutdialAttempt invalid target type ${target.type}; please fix specs.json`);
|
|
||||||
}
|
|
||||||
return {uri, opts};
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCancel(cs) {
|
|
||||||
this.logger.info('TaskDial: caller hung up before connecting');
|
|
||||||
this.canceled = true;
|
|
||||||
cs.emit('callStatusChange', {status: CallStatus.NoAnswer});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCallerHangup(cs, dlg) {
|
|
||||||
this.logger.info('TaskDial: caller hung up');
|
|
||||||
cs.emit('callStatusChange', {status: CallStatus.Completed});
|
|
||||||
if (this.outboundInviteInProgress) this.outboundInviteInProgress.cancel();
|
|
||||||
|
|
||||||
// we are going to hang up the B leg shortly..so
|
|
||||||
const dlgOut = this.getResource('dlgOut');
|
|
||||||
if (dlgOut) {
|
|
||||||
const duration = this.dialCallDuration = moment().diff(dlgOut.connectTime, 'seconds');
|
|
||||||
this.emit('callStatusChange', {
|
|
||||||
CallSid: dlgOut.callSid,
|
|
||||||
SipCallId: dlgOut.sip.callId,
|
|
||||||
From: dlgOut.from,
|
|
||||||
To: dlgOut.to,
|
|
||||||
CallStatus: CallStatus.Completed,
|
|
||||||
Duration: duration
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* returns a Promise that resolves when either party hangs up
|
|
||||||
*/
|
|
||||||
_waitForCompletion(cs) {
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const dlgOut = this.getResource('dlgOut');
|
|
||||||
assert(this.dlgIn && dlgOut);
|
|
||||||
assert(this.dlgIn.connected && dlgOut.connected);
|
|
||||||
|
|
||||||
[this.dlgIn, dlgOut].forEach((dlg) => dlg.on('destroy', () => resolve()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_onCallStatusChange(evt) {
|
|
||||||
this.logger.debug(evt, 'TaskDial:_onCallStatusChange');
|
|
||||||
|
|
||||||
// save the most recent final call status of a B leg, until we get one that is completed
|
|
||||||
if (isFinalCallStatus(evt.CallStatus) && this.dialCallStatus !== CallStatus.Completed) {
|
|
||||||
this.dialCallStatus = evt.CallStatus;
|
|
||||||
}
|
|
||||||
if (this.statusCallback) {
|
|
||||||
const params = Object.assign({}, this.callAttributes, evt);
|
|
||||||
const opts = {
|
|
||||||
url: this.statusCallback,
|
|
||||||
method: this.statusCallbackMethod,
|
|
||||||
json: true,
|
|
||||||
qs: 'GET' === this.statusCallbackMethod ? params : null,
|
|
||||||
body: 'POST' === this.statusCallbackMethod ? params : null
|
|
||||||
};
|
|
||||||
request(opts, (err) => {
|
|
||||||
if (err) this.logger.info(`TaskDial:Error sending call status to ${this.statusCallback}: ${err.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _actionHook(cs) {
|
|
||||||
if (this.action) {
|
|
||||||
const params = {DialCallStatus: this.dialCallStatus};
|
|
||||||
Object.assign(params, {
|
|
||||||
DialCallSid: this.dialCallSid,
|
|
||||||
DialCallDuration: this.dialCallDuration
|
|
||||||
});
|
|
||||||
const opts = {
|
|
||||||
url: this.action,
|
|
||||||
method: this.method,
|
|
||||||
json: true,
|
|
||||||
qs: 'GET' === this.method ? params : null,
|
|
||||||
body: 'POST' === this.method ? params : null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// construct bare-bones callInfo for the new outbound call attempt
|
||||||
request(opts, (err, response, body) => {
|
const callInfo = Object.assign({}, cs.callInfo);
|
||||||
if (err) this.logger.info(`TaskDial:_actionHook sending call status to ${this.action}: ${err.message}`);
|
callInfo.parentCallSid = cs.callSid;
|
||||||
if (body) {
|
callInfo.direction = CallDirection.Outbound;
|
||||||
this.logger.debug(body, 'got new application payload');
|
['callSid', 'callID', 'from', 'to', 'callerId', 'sipStatus', 'callStatus'].forEach((k) => delete callInfo[k]);
|
||||||
cs.replaceApplication(body);
|
|
||||||
}
|
const ms = await cs.getMS();
|
||||||
resolve();
|
this.target.forEach((t) => {
|
||||||
|
try {
|
||||||
|
t.url = t.url || this.url;
|
||||||
|
const sd = placeCall({
|
||||||
|
logger: this.logger,
|
||||||
|
application: cs.application,
|
||||||
|
srf,
|
||||||
|
ms,
|
||||||
|
sbcAddress,
|
||||||
|
target: t,
|
||||||
|
opts,
|
||||||
|
callInfo
|
||||||
});
|
});
|
||||||
|
this.dials.set(sd.callSid, sd);
|
||||||
|
|
||||||
|
sd
|
||||||
|
.on('callStatusChange', (obj) => {
|
||||||
|
switch (obj.callStatus) {
|
||||||
|
case CallStatus.Trying:
|
||||||
|
break;
|
||||||
|
case CallStatus.EarlyMedia:
|
||||||
|
if (this.target.length === 1 && !this.target[0].url && !this.dialMusic) {
|
||||||
|
this._bridgeEarlyMedia(sd);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CallStatus.InProgress:
|
||||||
|
this.logger.debug('Dial:_attemptCall -- call was answered');
|
||||||
|
break;
|
||||||
|
case CallStatus.Failed:
|
||||||
|
case CallStatus.Busy:
|
||||||
|
case CallStatus.NoAnswer:
|
||||||
|
this.dials.delete(sd.callSid);
|
||||||
|
if (this.dials.size === 0 && !this.connectTime) {
|
||||||
|
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
|
||||||
|
this.kill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (this.results.dialCallStatus !== CallStatus.Completed) {
|
||||||
|
Object.assign(this.results, {
|
||||||
|
dialCallStatus: obj.callStatus,
|
||||||
|
dialCallSid: sd.callSid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.on('accept', () => {
|
||||||
|
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
|
||||||
|
this._connectSingleDial(cs, sd);
|
||||||
|
})
|
||||||
|
.on('decline', () => {
|
||||||
|
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
|
||||||
|
this.dials.delete(sd.callSid);
|
||||||
|
if (this.dials.size === 0 && !this.connectTime) {
|
||||||
|
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
|
||||||
|
this.kill();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err, 'Dial:_attemptCalls');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectSingleDial(cs, sd) {
|
||||||
|
if (!this.bridged) {
|
||||||
|
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
||||||
|
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||||
|
this.epOther.bridge(sd.ep);
|
||||||
|
this.bridged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ding! ding! ding! we have a winner
|
||||||
|
this._selectSingleDial(cs, sd);
|
||||||
|
this._killOutdials(); // NB: order is important
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectSingleDial(cs, sd) {
|
||||||
|
this.connectTime = moment();
|
||||||
|
this.dials.delete(sd.callSid);
|
||||||
|
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
|
||||||
|
this.ep = sd.ep;
|
||||||
|
this.dlg = sd.dlg;
|
||||||
|
this.callSid = sd.callSid;
|
||||||
|
if (this.earlyMedia) {
|
||||||
|
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||||
|
cs.propagateAnswer();
|
||||||
|
}
|
||||||
|
this.dlg.on('destroy', () => {
|
||||||
|
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
|
||||||
|
this.ep.unbridge();
|
||||||
|
this.kill();
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(this.results, {
|
||||||
|
dialCallStatus: CallStatus.Completed,
|
||||||
|
dialCallSid: sd.callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep, this);
|
||||||
|
if (this.listenTask) this.listenTask.exec(cs, this.ep, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_killOutdials() {
|
||||||
|
for (const [callSid, sd] of Array.from(this.dials)) {
|
||||||
|
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
|
||||||
|
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`));
|
||||||
|
}
|
||||||
|
this.dials.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_bridgeEarlyMedia(sd) {
|
||||||
|
if (this.epOther && !this.bridged) {
|
||||||
|
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||||
|
this.epOther.bridge(sd.ep);
|
||||||
|
this.bridged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TaskDial;
|
module.exports = TaskDial;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class TaskGather extends Task {
|
|||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
[
|
[
|
||||||
'action', 'finishOnKey', 'hints', 'input', 'language', 'method', 'numDigits',
|
'action', 'finishOnKey', 'hints', 'input', 'method', 'numDigits',
|
||||||
'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter',
|
'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter',
|
||||||
'speechTimeout', 'timeout', 'say'
|
'speechTimeout', 'timeout', 'say'
|
||||||
].forEach((k) => this[k] = this.data[k]);
|
].forEach((k) => this[k] = this.data[k]);
|
||||||
@@ -17,13 +17,17 @@ class TaskGather extends Task {
|
|||||||
this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST';
|
this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST';
|
||||||
this.method = this.method || 'POST';
|
this.method = this.method || 'POST';
|
||||||
this.timeout = (this.timeout || 5) * 1000;
|
this.timeout = (this.timeout || 5) * 1000;
|
||||||
this.language = this.language || 'en-US';
|
this.interim = this.partialResultCallback;
|
||||||
this.digitBuffer = '';
|
if (this.data.recognizer) {
|
||||||
//this._earlyMedia = this.data.earlyMedia === true;
|
this.language = this.data.recognizer.language || 'en-US';
|
||||||
|
this.vendor = this.data.recognizer.vendor;
|
||||||
if (this.say) {
|
|
||||||
this.sayTask = makeTask(this.logger, {say: this.say});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.digitBuffer = '';
|
||||||
|
this._earlyMedia = this.data.earlyMedia === true;
|
||||||
|
|
||||||
|
if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Gather; }
|
get name() { return TaskName.Gather; }
|
||||||
@@ -34,15 +38,14 @@ class TaskGather extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exec(cs, ep) {
|
async exec(cs, ep) {
|
||||||
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this.actionHook = cs.actionHook;
|
|
||||||
|
|
||||||
this.taskInProgress = true;
|
|
||||||
try {
|
try {
|
||||||
if (this.sayTask) {
|
if (this.sayTask) {
|
||||||
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||||
this.sayTask.on('playDone', (err) => {
|
this.sayTask.on('playDone', (err) => {
|
||||||
if (this.taskInProgress) this._startTimer();
|
if (!this.killed) this._startTimer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else this._startTimer();
|
else this._startTimer();
|
||||||
@@ -56,11 +59,10 @@ class TaskGather extends Task {
|
|||||||
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._waitForCompletion();
|
await this.awaitTaskDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err, 'TaskGather:exec error');
|
this.logger.error(err, 'TaskGather:exec error');
|
||||||
}
|
}
|
||||||
this.taskInProgress = false;
|
|
||||||
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
||||||
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
|
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
|
||||||
}
|
}
|
||||||
@@ -71,10 +73,6 @@ class TaskGather extends Task {
|
|||||||
this._resolve('killed');
|
this._resolve('killed');
|
||||||
}
|
}
|
||||||
|
|
||||||
async _waitForCompletion() {
|
|
||||||
return new Promise((resolve) => this.resolver = resolve);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onDtmf(ep, evt) {
|
_onDtmf(ep, evt) {
|
||||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||||
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
|
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
|
||||||
@@ -89,7 +87,7 @@ class TaskGather extends Task {
|
|||||||
const opts = {
|
const opts = {
|
||||||
GOOGLE_SPEECH_USE_ENHANCED: true,
|
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||||
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
|
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
|
||||||
GOOGLE_SPEECH_MODEL: 'phone_call'
|
GOOGLE_SPEECH_MODEL: 'command_and_search'
|
||||||
};
|
};
|
||||||
if (this.hints) {
|
if (this.hints) {
|
||||||
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||||
@@ -107,7 +105,7 @@ class TaskGather extends Task {
|
|||||||
_startTranscribing(ep) {
|
_startTranscribing(ep) {
|
||||||
ep.startTranscription({
|
ep.startTranscription({
|
||||||
interim: this.partialResultCallback ? true : false,
|
interim: this.partialResultCallback ? true : false,
|
||||||
language: this.language
|
language: this.language || this.callSession.speechRecognizerLanguage
|
||||||
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
|
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,46 +122,30 @@ class TaskGather extends Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_killAudio() {
|
_killAudio() {
|
||||||
if (this.sayTask) {
|
|
||||||
this.sayTask.kill();
|
this.sayTask.kill();
|
||||||
this.sayTask = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTranscription(ep, evt) {
|
_onTranscription(ep, evt) {
|
||||||
this.logger.debug(evt, 'TaskGather:_onTranscription');
|
this.logger.debug(evt, 'TaskGather:_onTranscription');
|
||||||
if (evt.is_final) {
|
if (evt.is_final) this._resolve('speech', evt);
|
||||||
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
else if (this.partialResultCallback) this.notifyHook(this.partialResultCallback, 'POST', null, {speech: evt});
|
||||||
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
|
|
||||||
this._resolve('speech', evt);
|
|
||||||
}
|
|
||||||
else if (this.partialResultCallback) {
|
|
||||||
this.actionHook(this.partialResultCallback, 'POST', {
|
|
||||||
Speech: evt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_onEndOfUtterance(ep, evt) {
|
_onEndOfUtterance(ep, evt) {
|
||||||
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
|
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
|
||||||
this._startTranscribing(ep);
|
this._startTranscribing(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
_resolve(reason, evt) {
|
async _resolve(reason, evt) {
|
||||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||||
assert(this.resolver);
|
|
||||||
|
|
||||||
if (reason.startsWith('dtmf')) {
|
if (reason.startsWith('dtmf')) {
|
||||||
this.actionHook(this.action, this.method, {
|
this.performAction(this.method, null, {digits: this.digitBuffer});
|
||||||
Digits: this.digitBuffer
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
else if (reason.startsWith('speech')) {
|
else if (reason.startsWith('speech')) {
|
||||||
this.actionHook(this.action, this.method, {
|
this.performAction(this.method, null, {speech: evt});
|
||||||
Speech: evt
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
this.resolver();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class TaskHangup extends Task {
|
|||||||
* Hangup the call
|
* Hangup the call
|
||||||
*/
|
*/
|
||||||
async exec(cs, dlg) {
|
async exec(cs, dlg) {
|
||||||
|
super.exec(cs);
|
||||||
try {
|
try {
|
||||||
await dlg.destroy({headers: this.headers});
|
await dlg.destroy({headers: this.headers});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName, TaskPreconditions, ListenEvents} = require('../utils/constants');
|
const {TaskName, TaskPreconditions, ListenEvents} = require('../utils/constants');
|
||||||
const makeTask = require('./make_task');
|
const makeTask = require('./make_task');
|
||||||
const assert = require('assert');
|
|
||||||
|
|
||||||
class TaskListen extends Task {
|
class TaskListen extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts) {
|
||||||
@@ -9,57 +8,49 @@ class TaskListen extends Task {
|
|||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
[
|
[
|
||||||
'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
'action', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
|
||||||
'sampleRate', 'timeout', 'transcribe'
|
'sampleRate', 'timeout', 'transcribe'
|
||||||
].forEach((k) => this[k] = this.data[k]);
|
].forEach((k) => this[k] = this.data[k]);
|
||||||
|
|
||||||
this.mixType = this.mixType || 'mono';
|
this.mixType = this.mixType || 'mono';
|
||||||
this.sampleRate = this.sampleRate || 8000;
|
this.sampleRate = this.sampleRate || 8000;
|
||||||
this.earlyMedia = this.data.earlyMedia === true;
|
this.earlyMedia = this.data.earlyMedia === true;
|
||||||
|
this.results = {};
|
||||||
|
|
||||||
if (this.transcribe) {
|
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||||
this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe});
|
|
||||||
}
|
|
||||||
|
|
||||||
this._dtmfHandler = this._onDtmf.bind(this);
|
this._dtmfHandler = this._onDtmf.bind(this);
|
||||||
|
|
||||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Listen; }
|
get name() { return TaskName.Listen; }
|
||||||
|
|
||||||
async exec(cs, ep) {
|
async exec(cs, ep) {
|
||||||
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
try {
|
try {
|
||||||
if (this.playBeep) await this._playBeep(ep);
|
if (this.playBeep) await this._playBeep(ep);
|
||||||
if (this.transcribeTask) {
|
if (this.transcribeTask) {
|
||||||
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
this.logger.debug('TaskListen:exec - starting nested transcribe task');
|
||||||
this.transcribeTask.exec(cs, ep, this); // kicked off, _not_ waiting for it to complete
|
this.transcribeTask.exec(cs, ep, this);
|
||||||
}
|
}
|
||||||
await this._startListening(ep);
|
await this._startListening(ep);
|
||||||
await this._completionPromise;
|
await this.awaitTaskDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
|
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
|
||||||
}
|
}
|
||||||
if (this.transcribeTask) this.transcribeTask.kill();
|
if (this.transcribeTask) this.transcribeTask.kill();
|
||||||
this._removeListeners(ep);
|
this._removeListeners(ep);
|
||||||
this.listenComplete = true;
|
|
||||||
this.emit('listenDone');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill() {
|
async kill() {
|
||||||
super.kill();
|
super.kill();
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
if (this.ep.connected && !this.listenComplete) {
|
if (this.transcribeTask) await this.transcribeTask.kill();
|
||||||
this.listenComplete = true;
|
if (this.ep.connected) {
|
||||||
if (this.transcribeTask) {
|
|
||||||
await this.transcribeTask.kill();
|
|
||||||
this.transcribeTask = null;
|
|
||||||
}
|
|
||||||
await this.ep.forkAudioStop()
|
await this.ep.forkAudioStop()
|
||||||
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
|
||||||
}
|
}
|
||||||
this._completionResolver();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _playBeep(ep) {
|
async _playBeep(ep) {
|
||||||
@@ -119,11 +110,11 @@ class TaskListen extends Task {
|
|||||||
}
|
}
|
||||||
_onConnectFailure(ep, evt) {
|
_onConnectFailure(ep, evt) {
|
||||||
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
||||||
this._completionResolver();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
_onError(ep, evt) {
|
_onError(ep, evt) {
|
||||||
this.logger.info(evt, 'TaskListen:_onError');
|
this.logger.info(evt, 'TaskListen:_onError');
|
||||||
this._completionResolver();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,30 +9,28 @@ class TaskPlay extends Task {
|
|||||||
this.url = this.data.url;
|
this.url = this.data.url;
|
||||||
this.loop = this.data.loop || 1;
|
this.loop = this.data.loop || 1;
|
||||||
this.earlyMedia = this.data.earlyMedia === true;
|
this.earlyMedia = this.data.earlyMedia === true;
|
||||||
this.playComplete = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Play; }
|
get name() { return TaskName.Play; }
|
||||||
|
|
||||||
async exec(cs, ep) {
|
async exec(cs, ep) {
|
||||||
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
try {
|
try {
|
||||||
while (!this.playComplete && this.loop--) {
|
while (!this.killed && this.loop--) {
|
||||||
await ep.play(this.url);
|
await ep.play(this.url);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||||
}
|
}
|
||||||
this.playComplete = true;
|
|
||||||
this.emit('playDone');
|
this.emit('playDone');
|
||||||
}
|
}
|
||||||
|
|
||||||
kill() {
|
async kill() {
|
||||||
super.kill();
|
super.kill();
|
||||||
if (this.ep.connected && !this.playComplete) {
|
if (this.ep.connected && !this.playComplete) {
|
||||||
this.logger.debug('TaskPlay:kill - killing audio');
|
this.logger.debug('TaskPlay:kill - killing audio');
|
||||||
this.playComplete = true;
|
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ const Task = require('./task');
|
|||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
|
|
||||||
class TaskSay extends Task {
|
class TaskSay extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts, parentTask) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.text = this.data.text;
|
this.text = this.data.text;
|
||||||
|
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||||
|
if (this.data.synthesizer) {
|
||||||
this.voice = this.data.synthesizer.voice;
|
this.voice = this.data.synthesizer.voice;
|
||||||
this.earlyMedia = this.data.earlyMedia === true;
|
|
||||||
|
|
||||||
switch (this.data.synthesizer.vendor) {
|
switch (this.data.synthesizer.vendor) {
|
||||||
case 'google':
|
case 'google':
|
||||||
this.ttsEngine = 'google_tts';
|
this.ttsEngine = 'google_tts';
|
||||||
@@ -17,29 +17,29 @@ class TaskSay extends Task {
|
|||||||
default:
|
default:
|
||||||
throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`);
|
throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`);
|
||||||
}
|
}
|
||||||
this.sayComplete = false;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Say; }
|
get name() { return TaskName.Say; }
|
||||||
|
|
||||||
async exec(cs, ep) {
|
async exec(cs, ep) {
|
||||||
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
try {
|
try {
|
||||||
await ep.speak({
|
await ep.speak({
|
||||||
ttsEngine: 'google_tts',
|
ttsEngine: 'google_tts',
|
||||||
voice: this.voice,
|
voice: this.voice || this.callSession.speechSynthesisVoice,
|
||||||
text: this.text
|
text: this.text
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, 'TaskSay:exec error');
|
this.logger.info(err, 'TaskSay:exec error');
|
||||||
}
|
}
|
||||||
this.emit('playDone');
|
this.emit('playDone');
|
||||||
this.sayComplete = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kill() {
|
kill() {
|
||||||
super.kill();
|
super.kill();
|
||||||
if (this.ep.connected && !this.sayComplete) {
|
if (this.ep.connected) {
|
||||||
this.logger.debug('TaskSay:kill - killing audio');
|
this.logger.debug('TaskSay:kill - killing audio');
|
||||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects an incoming call with user-specified status code and reason
|
||||||
|
*/
|
||||||
class TaskSipDecline extends Task {
|
class TaskSipDecline extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
@@ -11,10 +14,8 @@ class TaskSipDecline extends Task {
|
|||||||
|
|
||||||
get name() { return TaskName.SipDecline; }
|
get name() { return TaskName.SipDecline; }
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject an incoming call attempt with a provided status code and (optionally) reason
|
|
||||||
*/
|
|
||||||
async exec(cs, {res}) {
|
async exec(cs, {res}) {
|
||||||
|
super.exec(cs);
|
||||||
res.send(this.data.status, this.data.reason, {
|
res.send(this.data.status, this.data.reason, {
|
||||||
headers: this.headers
|
headers: this.headers
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,8 +34,7 @@
|
|||||||
"earlyMedia": "boolean"
|
"earlyMedia": "boolean"
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"text",
|
"text"
|
||||||
"synthesizer"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"gather": {
|
"gather": {
|
||||||
@@ -72,6 +71,7 @@
|
|||||||
"enum": ["GET", "POST"]
|
"enum": ["GET", "POST"]
|
||||||
},
|
},
|
||||||
"target": ["#target"],
|
"target": ["#target"],
|
||||||
|
"url": "string",
|
||||||
"timeLimit": "number",
|
"timeLimit": "number",
|
||||||
"timeout": "number",
|
"timeout": "number",
|
||||||
"transcribe": "#transcribe"
|
"transcribe": "#transcribe"
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
},
|
},
|
||||||
"listen": {
|
"listen": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"action": "string",
|
||||||
"finishOnKey": "string",
|
"finishOnKey": "string",
|
||||||
"maxLength": "number",
|
"maxLength": "number",
|
||||||
"metadata": "object",
|
"metadata": "object",
|
||||||
@@ -103,13 +104,12 @@
|
|||||||
},
|
},
|
||||||
"transcribe": {
|
"transcribe": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": "string",
|
"transcriptionCallback": "string",
|
||||||
"recognizer": "#recognizer",
|
"recognizer": "#recognizer",
|
||||||
"earlyMedia": "boolean"
|
"earlyMedia": "boolean"
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"action",
|
"transcriptionCallback"
|
||||||
"recognizer"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
@@ -164,10 +164,7 @@
|
|||||||
"hints": "array",
|
"hints": "array",
|
||||||
"profanityFilter": "boolean",
|
"profanityFilter": "boolean",
|
||||||
"interim": "boolean",
|
"interim": "boolean",
|
||||||
"mixType": {
|
"dualChannel": "boolean"
|
||||||
"type": "string",
|
|
||||||
"enum": ["mono", "stereo", "mixed"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"vendor"
|
"vendor"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const debug = require('debug')('jambonz:feature-server');
|
const debug = require('debug')('jambonz:feature-server');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const resourcesMixin = require('../utils/resources');
|
|
||||||
const {TaskPreconditions} = require('../utils/constants');
|
const {TaskPreconditions} = require('../utils/constants');
|
||||||
|
const hooks = require('../utils/notifiers');
|
||||||
const specs = new Map();
|
const specs = new Map();
|
||||||
const _specData = require('./specs');
|
const _specData = require('./specs');
|
||||||
for (const key in _specData) {specs.set(key, _specData[key]);}
|
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||||
@@ -15,12 +15,24 @@ class Task extends Emitter {
|
|||||||
this.data = data;
|
this.data = data;
|
||||||
|
|
||||||
this._killInProgress = false;
|
this._killInProgress = false;
|
||||||
|
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||||
}
|
}
|
||||||
|
|
||||||
get killed() {
|
get killed() {
|
||||||
return this._killInProgress;
|
return this._killInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get callSession() {
|
||||||
|
return this.cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(cs) {
|
||||||
|
this.cs = cs;
|
||||||
|
const {actionHook, notifyHook} = hooks(this.logger, cs.callInfo);
|
||||||
|
this.actionHook = actionHook;
|
||||||
|
this.notifyHook = notifyHook;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* called to kill (/stop) a running task
|
* called to kill (/stop) a running task
|
||||||
* what to do is up to each type of task
|
* what to do is up to each type of task
|
||||||
@@ -31,6 +43,24 @@ class Task extends Emitter {
|
|||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyTaskDone() {
|
||||||
|
this._completionResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitTaskDone() {
|
||||||
|
return this._completionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async performAction(method, auth, results) {
|
||||||
|
if (this.action) {
|
||||||
|
const tasks = await this.actionHook(this.action, method, auth, results);
|
||||||
|
if (tasks && Array.isArray(tasks)) {
|
||||||
|
this.logger.debug(`${this.name} replacing application with ${tasks.length} tasks`);
|
||||||
|
this.callSession.replaceApplication(tasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static validate(name, data) {
|
static validate(name, data) {
|
||||||
debug(`validating ${name} with data ${JSON.stringify(data)}`);
|
debug(`validating ${name} with data ${JSON.stringify(data)}`);
|
||||||
// validate the instruction is supported
|
// validate the instruction is supported
|
||||||
@@ -88,7 +118,5 @@ class Task extends Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(Task.prototype, resourcesMixin);
|
|
||||||
|
|
||||||
module.exports = Task;
|
module.exports = Task;
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,50 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
|
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
|
||||||
const assert = require('assert');
|
|
||||||
|
|
||||||
class TaskTranscribe extends Task {
|
class TaskTranscribe extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts, parentTask) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
this.preconditions = TaskPreconditions.Endpoint;
|
this.preconditions = TaskPreconditions.Endpoint;
|
||||||
|
|
||||||
this.action = this.data.action;
|
this.transcriptionCallback = this.data.transcriptionCallback;
|
||||||
this.language = this.data.language || 'en-US';
|
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||||
this.vendor = this.data.vendor;
|
if (this.data.recognizer) {
|
||||||
this.interim = this.data.interim === true;
|
this.language = this.data.recognizer.language || 'en-US';
|
||||||
this.mixType = this.data.mixType;
|
this.vendor = this.data.recognizer.vendor;
|
||||||
this.earlyMedia = this.data.earlyMedia === true;
|
this.interim = this.data.recognizer.interim === true;
|
||||||
|
this.dualChannel = this.data.recognizer.dualChannel === true;
|
||||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Transcribe; }
|
get name() { return TaskName.Transcribe; }
|
||||||
|
|
||||||
async exec(cs, ep, parentTask) {
|
async exec(cs, ep, parentTask) {
|
||||||
|
super.exec(cs);
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this.actionHook = ep.cs.actionHook;
|
|
||||||
this.transcribeInProgress = true;
|
|
||||||
try {
|
try {
|
||||||
await this._initSpeech(ep);
|
|
||||||
await this._startTranscribing(ep);
|
await this._startTranscribing(ep);
|
||||||
await this._completionPromise;
|
await this.awaitTaskDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||||
}
|
}
|
||||||
this.transcribeInProgress = true;
|
|
||||||
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
|
||||||
|
ep.removeCustomEventListener(TranscriptionEvents.NoAudioDetected);
|
||||||
|
ep.removeCustomEventListener(TranscriptionEvents.MaxDurationExceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill() {
|
async kill() {
|
||||||
super.kill();
|
super.kill();
|
||||||
if (this.ep.connected && this.transcribeInProgress) {
|
if (this.ep.connected) {
|
||||||
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||||
|
|
||||||
// hangup after 1 sec if we don't get a final transcription
|
// hangup after 1 sec if we don't get a final transcription
|
||||||
this._timer = setTimeout(() => this._completionResolver(), 1000);
|
this._timer = setTimeout(() => this.notifyTaskDone(), 1000);
|
||||||
}
|
}
|
||||||
else {
|
else this.notifyTaskDone();
|
||||||
this._completionResolver();
|
await this.awaitTaskDone();
|
||||||
}
|
|
||||||
await this._completionPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initSpeech(ep) {
|
async _startTranscribing(ep) {
|
||||||
const opts = {
|
const opts = {
|
||||||
GOOGLE_SPEECH_USE_ENHANCED: true,
|
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||||
GOOGLE_SPEECH_MODEL: 'phone_call'
|
GOOGLE_SPEECH_MODEL: 'phone_call'
|
||||||
@@ -56,43 +52,48 @@ class TaskTranscribe extends Task {
|
|||||||
if (this.hints) {
|
if (this.hints) {
|
||||||
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||||
}
|
}
|
||||||
if (this.profanityFilter === true) {
|
if (this.profanityFilter) {
|
||||||
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||||
}
|
}
|
||||||
|
if (this.dualChannel) {
|
||||||
|
Object.assign(opts, {'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL': true});
|
||||||
|
}
|
||||||
await ep.set(opts)
|
await ep.set(opts)
|
||||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_initSpeech error setting fs vars'));
|
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing'));
|
||||||
|
|
||||||
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||||
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
||||||
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
|
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
|
||||||
|
|
||||||
|
await this._transcribe(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startTranscribing(ep) {
|
async _transcribe(ep) {
|
||||||
await ep.startTranscription({
|
await this.ep.startTranscription({
|
||||||
interim: this.interim ? true : false,
|
interim: this.interim ? true : false,
|
||||||
language: this.language
|
language: this.language || this.callSession.speechRecognizerLanguage,
|
||||||
|
channels: this.dualChannel ? 2 : 1
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_onTranscription(ep, evt) {
|
_onTranscription(ep, evt) {
|
||||||
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
||||||
this.actionHook(this.action, 'POST', {
|
this.notifyHook(this.transcriptionCallback, 'POST', {speech: evt});
|
||||||
Speech: evt
|
|
||||||
});
|
|
||||||
if (this.killed) {
|
if (this.killed) {
|
||||||
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
|
||||||
this._clearTimer();
|
this._clearTimer();
|
||||||
this._completionResolver();
|
this.notifyTaskDone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onNoAudio(ep) {
|
_onNoAudio(ep) {
|
||||||
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||||
this._startTranscribing(ep);
|
this._transcribe(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onMaxDurationExceeded(ep) {
|
_onMaxDurationExceeded(ep) {
|
||||||
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||||
this._startTranscribing(ep);
|
this._transcribe(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearTimer() {
|
_clearTimer() {
|
||||||
|
|||||||
@@ -1,36 +1,45 @@
|
|||||||
const request = require('request');
|
const request = require('request');
|
||||||
require('request-debug')(request);
|
//require('request-debug')(request);
|
||||||
|
const makeTask = require('../tasks/make_task');
|
||||||
|
const normalizeJamones = require('./normalize-jamones');
|
||||||
|
|
||||||
const debug = require('debug')('jambonz:feature-server');
|
const debug = require('debug')('jambonz:feature-server');
|
||||||
|
|
||||||
function hooks(logger, callAttributes) {
|
function hooks(logger, callAttributes) {
|
||||||
debug(`notifiers: callAttributes ${JSON.stringify(callAttributes)}`);
|
debug(`notifiers: callAttributes ${JSON.stringify(callAttributes)}`);
|
||||||
function actionHook(url, method, opts) {
|
function actionHook(url, method, auth, opts, expectResponse = false) {
|
||||||
debug(`notifiers: opts ${JSON.stringify(opts)}`);
|
|
||||||
const params = Object.assign({}, callAttributes, opts);
|
const params = Object.assign({}, callAttributes, opts);
|
||||||
const obj = {
|
let basicauth, qs, body;
|
||||||
url,
|
if (auth && typeof auth === 'object' && Object.keys(auth) === 2) basicauth = auth;
|
||||||
method,
|
if ('GET' === method.toUpperCase()) qs = params;
|
||||||
json: true,
|
else body = params;
|
||||||
qs: 'GET' === method ? params : callAttributes,
|
const obj = {url, method, auth: basicauth, json: expectResponse || body, qs, body};
|
||||||
body: 'POST' === method ? opts : null
|
logger.debug({opts: obj}, 'actionHook');
|
||||||
};
|
|
||||||
logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`);
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
request(obj, (err, response, body) => {
|
request(obj, (err, response, body) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.info(`TaskDial:_actionHook error ${method} ${url}: ${err.message}`);
|
logger.info(`actionHook error ${method} ${url}: ${err.message}`);
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
if (body) {
|
if (body) {
|
||||||
this.logger.debug(body, `TaskDial:_actionHook response ${method} ${url}`);
|
logger.debug(body, `actionHook response ${method} ${url}`);
|
||||||
|
if (expectResponse) {
|
||||||
|
const tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata));
|
||||||
|
return resolve(tasks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resolve(body);
|
resolve(body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyHook(url, method, auth, opts) {
|
||||||
|
return actionHook(url, method, auth, opts, false);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionHook
|
actionHook,
|
||||||
|
notifyHook
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,215 @@
|
|||||||
const Emitter = require('events');
|
const Emitter = require('events');
|
||||||
const {CallStatus} = require('./constants');
|
const {CallStatus} = require('./constants');
|
||||||
|
const uuidv4 = require('uuid/v4');
|
||||||
|
const SipError = require('drachtio-srf').SipError;
|
||||||
|
const {TaskPreconditions} = require('../utils/constants');
|
||||||
|
const assert = require('assert');
|
||||||
|
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||||
|
const hooks = require('./notifiers');
|
||||||
|
const moment = require('moment');
|
||||||
|
|
||||||
class SingleDialer extends Emitter {
|
class SingleDialer extends Emitter {
|
||||||
constructor(logger, opts) {
|
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
|
||||||
super();
|
super();
|
||||||
|
assert(target.type);
|
||||||
|
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.cs = opts.cs;
|
this.target = target;
|
||||||
this.ms = opts.ms;
|
this.sbcAddress = sbcAddress;
|
||||||
|
this.opts = opts;
|
||||||
|
this.application = application;
|
||||||
|
this.url = opts.url;
|
||||||
|
this.method = opts.method;
|
||||||
|
|
||||||
|
this._callSid = uuidv4();
|
||||||
|
this.bindings = logger.bindings();
|
||||||
|
this.callInfo = Object.assign({}, callInfo, {callSid: this._callSid});
|
||||||
|
this.sipStatus;
|
||||||
|
this.callGone = false;
|
||||||
|
|
||||||
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
get callState() {
|
get callSid() {
|
||||||
return this._callState;
|
return this._callSid;
|
||||||
}
|
}
|
||||||
|
get callStatus() {
|
||||||
|
return this.callInfo.callStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(srf, ms, opts) {
|
||||||
|
let uri, to;
|
||||||
|
switch (this.target.type) {
|
||||||
|
case 'phone':
|
||||||
|
assert(this.target.number);
|
||||||
|
uri = `sip:${this.opts.number}@${this.sbcAddress}`;
|
||||||
|
to = this.target.number;
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
assert(this.target.name);
|
||||||
|
uri = `sip:${this.target.name}`;
|
||||||
|
to = this.target.name;
|
||||||
|
break;
|
||||||
|
case 'sip':
|
||||||
|
assert(this.target.uri);
|
||||||
|
uri = this.target.uri;
|
||||||
|
to = this.target.name;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// should have been caught by parser
|
||||||
|
assert(false, `invalid dial type ${this.target.type}: must be phone, user, or sip`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ep = await ms.createEndpoint();
|
||||||
|
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||||
|
let sdp;
|
||||||
|
const connectStream = async(remoteSdp) => {
|
||||||
|
if (remoteSdp !== sdp) {
|
||||||
|
this.ep.modify(sdp = remoteSdp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(opts, {
|
||||||
|
proxy: `sip:${this.sbcAddress}`,
|
||||||
|
localSdp: this.ep.local.sdp
|
||||||
|
});
|
||||||
|
if (this.target.auth) opts.auth = this.target.auth;
|
||||||
|
this.dlg = await srf.createUAC(uri, opts, {
|
||||||
|
cbRequest: (err, req) => {
|
||||||
|
if (err) return this.logger.error(err, 'SingleDialer:exec Error creating call');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* launch the outdial
|
* INVITE has been sent out
|
||||||
|
* (a) create a logger for this call
|
||||||
|
* (b) augment this.callInfo with additional call info
|
||||||
*/
|
*/
|
||||||
exec() {
|
this.logger = srf.locals.parentLogger.child({
|
||||||
|
callSid: this.callSid,
|
||||||
|
parentCallSid: this.bindings.callSid,
|
||||||
|
callId: req.get('Call-ID')
|
||||||
|
});
|
||||||
|
this.inviteInProgress = req;
|
||||||
|
const status = {callStatus: CallStatus.Trying, sipStatus: 100};
|
||||||
|
Object.assign(this.callInfo, {callId: req.get('Call-ID'), from: req.callingNumber, to});
|
||||||
|
const {actionHook, notifyHook} = hooks(this.logger, this.callInfo);
|
||||||
|
this.actionHook = actionHook;
|
||||||
|
this.notifyHook = notifyHook;
|
||||||
|
this.emit('callStatusChange', status);
|
||||||
|
},
|
||||||
|
cbProvisional: (prov) => {
|
||||||
|
const status = {sipStatus: prov.status};
|
||||||
|
if ([180, 183].includes(prov.status) && prov.body) {
|
||||||
|
status.callStatus = CallStatus.EarlyMedia;
|
||||||
|
if (connectStream(prov.body)) this.emit('earlyMedia');
|
||||||
|
}
|
||||||
|
else status.callStatus = CallStatus.Ringing;
|
||||||
|
this.emit('callStatusChange', status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connectStream(this.dlg.remote.sdp);
|
||||||
|
this.dlg.callSid = this.callSid;
|
||||||
|
this.inviteInProgress = null;
|
||||||
|
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
|
||||||
|
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
|
||||||
|
const connectTime = this.dlg.connectTime = moment();
|
||||||
|
|
||||||
|
this.dlg.on('destroy', () => {
|
||||||
|
const duration = moment().diff(connectTime, 'seconds');
|
||||||
|
this.logger.debug('SingleDialer:exec called party hung up');
|
||||||
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
|
this.ep.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.url) this._executeApp(this.url);
|
||||||
|
else this.emit('accept');
|
||||||
|
} catch (err) {
|
||||||
|
const status = {callStatus: CallStatus.Failed};
|
||||||
|
if (err instanceof SipError) {
|
||||||
|
status.sipStatus = err.status;
|
||||||
|
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
||||||
|
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
||||||
|
this.logger.debug(`SingleDialer:exec outdial failure ${err.status}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.logger.error(err, 'SingleDialer:exec');
|
||||||
|
status.sipStatus = 500;
|
||||||
|
}
|
||||||
|
this.emit('callStatusChange', status);
|
||||||
|
if (this.ep) this.ep.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* kill the call in progress, or stable dialog, whichever
|
* kill the call in progress or the stable dialog, whichever we have
|
||||||
*/
|
*/
|
||||||
async kill() {
|
async kill() {
|
||||||
|
if (this.inviteInProgress) await this.inviteInProgress.cancel();
|
||||||
|
else if (this.dlg && this.dlg.connected) {
|
||||||
|
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||||
|
this.logger.debug('SingleDialer:kill hanging up called party');
|
||||||
|
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||||
|
}
|
||||||
|
if (this.ep) {
|
||||||
|
this.logger.debug(`SingleDialer:kill - deleting endpoint ${this.ep.uuid}`);
|
||||||
|
await this.ep.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* execute a jambones application on this call / endpoint
|
* Run an application on the call after answer, e.g. call screening.
|
||||||
* @param {*} jambones document
|
* Once the application completes in some fashion, emit an 'accepted' event
|
||||||
|
* if the call is still up/connected, a 'decline' otherwise.
|
||||||
|
* Note: the application to run may not include a dial or sip:decline verb
|
||||||
|
* @param {*} url - url for application
|
||||||
*/
|
*/
|
||||||
async runApp(document) {
|
async _executeApp(url) {
|
||||||
|
this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
|
||||||
|
try {
|
||||||
|
const tasks = await this.actionHook(this.url, this.method);
|
||||||
|
const allowedTasks = tasks.filter((task) => {
|
||||||
|
return [
|
||||||
|
TaskPreconditions.StableCall,
|
||||||
|
TaskPreconditions.Endpoint
|
||||||
|
].includes(task.preconditions);
|
||||||
|
});
|
||||||
|
if (tasks.length !== allowedTasks.length) {
|
||||||
|
throw new Error('unsupported verb in dial url');
|
||||||
}
|
}
|
||||||
|
|
||||||
async _createEndpoint() {
|
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
|
||||||
|
const cs = new ConfirmCallSession(this.logger, this.application, this.dlg, this.ep, tasks);
|
||||||
|
await cs.exec();
|
||||||
|
this.emit(this.dlg.connected ? 'accept' : 'decline');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.debug(err, 'SingleDialer:_executeApp: error');
|
||||||
|
this.emit('decline');
|
||||||
|
if (this.dlg.connected) this.dlg.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _outdial() {
|
_notifyCallStatusChange(callStatus) {
|
||||||
|
try {
|
||||||
|
const auth = {};
|
||||||
|
if (this.application.hook_basic_auth_user && this.application.hook_basic_auth_password) {
|
||||||
|
Object.assign(auth, {user: this.application.hook_basic_auth_user, password: this.hook_basic_auth_password});
|
||||||
|
}
|
||||||
|
this.notifyHook(this.application.call_status_hook,
|
||||||
|
this.application.hook_http_method,
|
||||||
|
auth,
|
||||||
|
callStatus);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info(err, `SingleDialer:_notifyCallStatusChange: error sending ${JSON.stringify(callStatus)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeOutdial(logger, opts) {
|
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
|
||||||
const singleDialer = new SingleDialer(logger, opts);
|
const sd = new SingleDialer({logger, sbcAddress, target, opts, application, callInfo});
|
||||||
singleDialer.exec();
|
sd.exec(srf, ms, opts);
|
||||||
return singleDialer;
|
return sd;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = placeOutdial;
|
module.exports = placeOutdial;
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
const assert = require('assert');
|
|
||||||
|
|
||||||
//this obj is meant to be mixed in into another class
|
|
||||||
//NB: it is required that the class have a 'logger' property
|
|
||||||
module.exports = {
|
|
||||||
resources: new Map(),
|
|
||||||
addResource(name, resource) {
|
|
||||||
this.logger.debug(`addResource: adding ${name}`);
|
|
||||||
|
|
||||||
// duck-typing: resources must have a destroy function and a 'connected' proerty
|
|
||||||
assert(typeof resource.destroy === 'function');
|
|
||||||
assert('connected' in resource);
|
|
||||||
|
|
||||||
this.resources.set(name, resource);
|
|
||||||
},
|
|
||||||
getResource(name) {
|
|
||||||
return this.resources.get(name);
|
|
||||||
},
|
|
||||||
hasResource(name) {
|
|
||||||
return this.resources.has(name);
|
|
||||||
},
|
|
||||||
removeResource(name) {
|
|
||||||
this.logger.debug(`removeResource: removing ${name}`);
|
|
||||||
this.resources.delete(name);
|
|
||||||
},
|
|
||||||
async clearResource(name) {
|
|
||||||
const r = this.resources.get(name);
|
|
||||||
if (r) {
|
|
||||||
this.logger.debug(`clearResource deleting ${name}`);
|
|
||||||
try {
|
|
||||||
if (r.connected) r.destroy();
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
this.logger.error(err, `clearResource error deleting ${name}`);
|
|
||||||
}
|
|
||||||
this.resources.delete(r);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async clearResources() {
|
|
||||||
for (const [name, resource] of Array.from(this.resources).reverse()) {
|
|
||||||
try {
|
|
||||||
this.logger.info(`deleting ${name}`);
|
|
||||||
if (resource.connected) await resource.destroy();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, `clearResources: error deleting ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.resources.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
30
lib/utils/retrieve-app.js
Normal file
30
lib/utils/retrieve-app.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const request = require('request');
|
||||||
|
//require('request-debug')(request);
|
||||||
|
const makeTask = require('../tasks/make_task');
|
||||||
|
const normalizeJamones = require('./normalize-jamones');
|
||||||
|
|
||||||
|
|
||||||
|
function retrieveUrl(logger, url, method, auth, qs, body) {
|
||||||
|
logger.debug(`body: ${body}`);
|
||||||
|
const opts = {url, method, auth, qs, json: true};
|
||||||
|
if (body) {
|
||||||
|
logger.debug('adding body');
|
||||||
|
Object.assign(opts, {body});
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request(opts, (err, response, body) => {
|
||||||
|
if (err) throw err;
|
||||||
|
resolve(body);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retrieveApp(logger, url, method, auth, qs, body) {
|
||||||
|
let json;
|
||||||
|
|
||||||
|
if (typeof url === 'object') json = url;
|
||||||
|
else json = await retrieveUrl(logger, url, method, auth, qs, body);
|
||||||
|
return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = retrieveApp;
|
||||||
13
package.json
13
package.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "jambones-feature-server",
|
"name": "jambonz-feature-server",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7,16 +7,17 @@
|
|||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"sip",
|
"sip",
|
||||||
"drachtio"
|
"drachtio",
|
||||||
|
"jambonz"
|
||||||
],
|
],
|
||||||
"author": "Dave Horton",
|
"author": "Dave Horton",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/jambonz/jambones-feature-server.git"
|
"url": "https://github.com/jambonz/jambonz-feature-server.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/jambonz/jambones-feature-server/issues"
|
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node app",
|
"start": "node app",
|
||||||
@@ -28,9 +29,9 @@
|
|||||||
"config": "^3.2.4",
|
"config": "^3.2.4",
|
||||||
"debug": "^4.1.1",
|
"debug": "^4.1.1",
|
||||||
"drachtio-fn-b2b-sugar": "0.0.12",
|
"drachtio-fn-b2b-sugar": "0.0.12",
|
||||||
"drachtio-fsmrf": "^1.5.11",
|
"drachtio-fsmrf": "^1.5.12",
|
||||||
"drachtio-srf": "^4.4.27",
|
"drachtio-srf": "^4.4.27",
|
||||||
"jambonz-db-helpers": "^0.1.7",
|
"jambonz-db-helpers": "^0.1.8",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"pino": "^5.14.0",
|
"pino": "^5.14.0",
|
||||||
"request": "^2.88.0",
|
"request": "^2.88.0",
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"transcribe": {
|
"transcribe": {
|
||||||
"action": "http://example.com/transcribe",
|
"transcriptionCallback": "http://example.com/transcribe",
|
||||||
"recognizer": {
|
"recognizer": {
|
||||||
"vendor": "google",
|
"vendor": "google",
|
||||||
"language" : "en-US",
|
"language" : "en-US",
|
||||||
"mixType" : "stereo",
|
"dualChannel" : true,
|
||||||
"interim": true
|
"interim": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ test('app payload parsing tests', (t) => {
|
|||||||
t.end();
|
t.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('exiting');
|
||||||
|
|
||||||
|
|
||||||
const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction'));
|
const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction'));
|
||||||
const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property'));
|
const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property'));
|
||||||
|
|||||||
Reference in New Issue
Block a user