mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-19 04:17:44 +00:00
work on say and gather
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
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 BADPRECONDITIONS = 'preconditions not met';
|
||||
|
||||
class CallSession extends Emitter {
|
||||
constructor(req, res) {
|
||||
@@ -9,47 +19,144 @@ class CallSession extends Emitter {
|
||||
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);
|
||||
|
||||
req.on('cancel', this._onCallerHangup.bind(this));
|
||||
|
||||
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; }
|
||||
|
||||
async exec() {
|
||||
let idx = 0;
|
||||
for (const task of this.application.tasks) {
|
||||
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) {
|
||||
const {task, callSid} = taskList.shift();
|
||||
this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`);
|
||||
try {
|
||||
this.logger.debug(`CallSession: executing task #${++idx}: ${task.name}`);
|
||||
const continueOn = await task.exec(this);
|
||||
if (!continueOn) break;
|
||||
const resources = await this._evaluatePreconditions(task, callSid);
|
||||
await task.exec(this, resources);
|
||||
this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err, task}, 'Error executing task');
|
||||
if (err.message.includes(BADPRECONDITIONS)) {
|
||||
this.logger.info(`CallSession:exec task #${idx}: ${task.name}: ${err.message}`);
|
||||
}
|
||||
this.logger.info('CallSession: finished all tasks');
|
||||
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: auto-generating non-success response to invite');
|
||||
this.logger.info('CallSession:exec auto-generating non-success response to invite');
|
||||
this.res.send(603);
|
||||
}
|
||||
this._clearResources();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
addResource(name, resource) {
|
||||
this.logger.debug(`CallSession:addResource: adding ${name}`);
|
||||
this.resources.set(name, resource);
|
||||
async _evalEndpointPrecondition(task, callSid) {
|
||||
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});
|
||||
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});
|
||||
resources.dlg = uas;
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
getResource(name) {
|
||||
return this.resources.get(name);
|
||||
_evalStableCallPrecondition(task, callSid) {
|
||||
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;
|
||||
}
|
||||
|
||||
removeResource(name) {
|
||||
this.logger.debug(`CallSession:removeResource: removing ${name}`);
|
||||
this.resources.delete(name);
|
||||
_evalUnansweredCallPrecondition(task, callSid) {
|
||||
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};
|
||||
}
|
||||
|
||||
async createOrRetrieveEpAndMs(remoteSdp) {
|
||||
_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();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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');
|
||||
@@ -61,34 +168,136 @@ class CallSession extends Emitter {
|
||||
this.addResource('ms', ms);
|
||||
}
|
||||
if (!ep) {
|
||||
ep = await ms.createEndpoint({remoteSdp});
|
||||
ep = await ms.createEndpoint({remoteSdp: this.req.body});
|
||||
this.addResource('epIn', ep);
|
||||
}
|
||||
return {ms, ep};
|
||||
}
|
||||
|
||||
/**
|
||||
* clear down resources
|
||||
* (note: we remove in reverse order they were added since mediaserver
|
||||
* is typically added first and I prefer to destroy it after any resources it holds)
|
||||
*/
|
||||
_clearResources() {
|
||||
for (const [name, resource] of Array.from(this.resources).reverse()) {
|
||||
try {
|
||||
this.logger.info(`CallSession:_clearResources: deleting ${name}`);
|
||||
if (resource.connected) resource.destroy();
|
||||
} catch (err) {
|
||||
this.logger.error(err, `CallSession:_clearResources: error deleting ${name}`);
|
||||
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.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 from inbound leg
|
||||
*/
|
||||
_onCallerHangup(evt) {
|
||||
this.logger.debug('CallSession: caller hung before connection');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,9 +1,10 @@
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
//const debug = require('debug')('jambonz:feature-server');
|
||||
const assert = require('assert');
|
||||
const request = require('request');
|
||||
//require('request-debug')(request);
|
||||
const uuidv4 = require('uuid/v4');
|
||||
const makeTask = require('./tasks/make_task');
|
||||
const {CallStatus, CallDirection} = require('./utils/constants');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {lookupAppByPhoneNumber} = srf.locals.dbHelpers;
|
||||
@@ -65,30 +66,32 @@ module.exports = function(srf, logger) {
|
||||
const logger = req.locals.logger;
|
||||
const app = req.locals.application;
|
||||
const call_sid = uuidv4();
|
||||
const method = (app.hook_http_method || 'GET').toUpperCase();
|
||||
const method = (app.hook_http_method || 'POST').toUpperCase();
|
||||
const from = req.getParsedHeader('From');
|
||||
const opts = {
|
||||
url: app.call_hook,
|
||||
method,
|
||||
json: true,
|
||||
qs: {
|
||||
const qs = req.locals.callAttributes = {
|
||||
CallSid: call_sid,
|
||||
AccountSid: app.account_sid,
|
||||
From: req.callingNumber,
|
||||
To: req.calledNumber,
|
||||
CallStatus: 'ringing',
|
||||
Direction: 'inbound',
|
||||
CallerName: from.name || req.callingNumber
|
||||
}
|
||||
CallStatus: CallStatus.Trying,
|
||||
SipStatus: 100,
|
||||
Direction: CallDirection.Inbound,
|
||||
CallerName: from.name || req.callingNumber,
|
||||
SipCallID: req.get('Call-ID'),
|
||||
RequestorIP: req.get('X-Forwarded-For'),
|
||||
RequestorName: req.get('X-Originating-Carrier')
|
||||
};
|
||||
const opts = {
|
||||
url: app.call_hook,
|
||||
method,
|
||||
json: true,
|
||||
qs
|
||||
};
|
||||
if (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}});
|
||||
}
|
||||
if (method === 'POST') {
|
||||
Object.assign(opts, {json: true, body: req.msg});
|
||||
}
|
||||
if (method === 'POST') Object.assign(opts, {body: req.msg});
|
||||
try {
|
||||
assert(app && app.call_hook);
|
||||
request(opts, (err, response, body) => {
|
||||
if (err) {
|
||||
logger.error(err, `Error invoking callback ${app.call_hook}`);
|
||||
@@ -102,7 +105,7 @@ module.exports = function(srf, logger) {
|
||||
const task = makeTask(logger, taskData[t]);
|
||||
app.tasks.push(task);
|
||||
} catch (err) {
|
||||
logger.info({data: taskData[t]}, `invalid web callback payload: ${err.message}`);
|
||||
logger.error({err, data: taskData[t]}, `invalid web callback payload: ${err.message}`);
|
||||
res.send(500, 'Application Error', {
|
||||
headers: {
|
||||
'X-Reason': err.message
|
||||
|
||||
17
lib/task-list.js
Normal file
17
lib/task-list.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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,142 +1,356 @@
|
||||
const Task = require('./task');
|
||||
const name = 'dial';
|
||||
const makeTask = require('./make_task');
|
||||
const {CallStatus, CallDirection, TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const assert = require('assert');
|
||||
const uuidv4 = require('uuid/v4');
|
||||
const request = require('request');
|
||||
const moment = require('moment');
|
||||
|
||||
function isFinalCallStatus(status) {
|
||||
return [CallStatus.Completed, CallStatus.NoAnswer, CallStatus.Failed, CallStatus.Busy].includes(status);
|
||||
}
|
||||
class TaskDial extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.name = name;
|
||||
this.headers = this.data.headers || {};
|
||||
this.answerOnBridge = opts.answerOnBridge === true;
|
||||
this.timeout = opts.timeout || 60;
|
||||
this.method = opts.method || 'GET';
|
||||
this.dialMusic = opts.dialMusic;
|
||||
this.timeLimit = opts.timeLimit;
|
||||
this.strategy = opts.strategy || 'hunt';
|
||||
this.target = opts.target;
|
||||
this.canceled = false;
|
||||
this.finished = false;
|
||||
this.localResources = {};
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
|
||||
this.action = opts.action;
|
||||
this.earlyMedia = opts.answerOnBridge === true;
|
||||
this.callerId = opts.callerId;
|
||||
this.dialMusic = opts.dialMusic;
|
||||
this.headers = this.data.headers || {};
|
||||
this.method = opts.method || 'POST';
|
||||
this.statusCallback = opts.statusCallback;
|
||||
this.statusCallbackMethod = opts.statusCallbackMethod || 'POST';
|
||||
this.target = opts.target;
|
||||
this.timeout = opts.timeout || 60;
|
||||
this.timeLimit = opts.timeLimit;
|
||||
|
||||
if (opts.transcribe) {
|
||||
this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe});
|
||||
}
|
||||
if (opts.listen) {
|
||||
this.listenTask = makeTask(logger, {'listen': opts.transcribe});
|
||||
}
|
||||
if (opts.transcribe) {
|
||||
this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe});
|
||||
}
|
||||
|
||||
static get name() { return name; }
|
||||
this.canceled = false;
|
||||
this.callAttributes = {};
|
||||
this.dialCallStatus = CallStatus.Failed;
|
||||
this.dialCallSid = null;
|
||||
this.dialCallDuration = null;
|
||||
|
||||
this.on('callStatusChange', this._onCallStatusChange.bind(this));
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dial; }
|
||||
|
||||
/**
|
||||
* Reject an incoming call attempt with a provided status code and (optionally) reason
|
||||
*/
|
||||
async exec(cs) {
|
||||
try {
|
||||
this._initializeCallData(cs);
|
||||
await this._initializeInbound(cs);
|
||||
//await connectCall(cs);
|
||||
await this._untilCallEnds(cs);
|
||||
await this._attemptCalls(cs);
|
||||
await this._waitForCompletion(cs);
|
||||
} catch (err) {
|
||||
this.logger.info(`TaskDial:exec terminating with error ${err.message}`);
|
||||
this.logger.error(`TaskDial:exec terminating with error ${err.message}`);
|
||||
}
|
||||
await this._actionHook(cs);
|
||||
this.clearResources();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_initializeCallData(cs) {
|
||||
this.logger.debug(`TaskDial:_initializeCallData parent call sid is ${cs.callSid}`);
|
||||
Object.assign(this.callAttributes, {
|
||||
AccountSid: cs.AccountSid,
|
||||
ParentCallSid: cs.callSid,
|
||||
Direction: CallDirection.Outbound
|
||||
});
|
||||
}
|
||||
|
||||
async _initializeInbound(cs) {
|
||||
const {req, res} = cs;
|
||||
const {req} = cs;
|
||||
|
||||
// the caller could hangup in the middle of all this..
|
||||
req.on('cancel', this._onCancel.bind(this, cs));
|
||||
|
||||
try {
|
||||
const {ep} = await cs.createOrRetrieveEpAndMs(req.body);
|
||||
|
||||
// caller might have hung up while we were doing that
|
||||
if (this.canceled) throw new Error('caller hung up');
|
||||
|
||||
// check if inbound leg has already been answered
|
||||
let uas = cs.getResource('dlgIn');
|
||||
|
||||
if (!uas) {
|
||||
// if answerOnBridge send a 183 (early media), otherwise go ahead and answer the call
|
||||
if (this.answerOnBridge && !req.finalResponseSent) {
|
||||
if (!this.dialMusic) res.send(180);
|
||||
else {
|
||||
res.send(183, {body: ep.remote.sdp});
|
||||
}
|
||||
}
|
||||
else {
|
||||
uas = await cs.srf.createUAS(req, res, {localSdp: ep.local.sdp});
|
||||
cs.addResource('dlgIn', uas);
|
||||
uas.on('destroy', this._onCallerHangup.bind(this, cs, uas));
|
||||
}
|
||||
cs.emit('callStatusChange', {status: 'ringing'});
|
||||
}
|
||||
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) {
|
||||
ep.play(this.dialMusic, (err) => {
|
||||
if (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');
|
||||
this.finished = true;
|
||||
if (!res.finalResponseSent && !this.canceled) res.send(500);
|
||||
this._clearResources(cs);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_clearResources(cs) {
|
||||
for (const key in this.localResources) {
|
||||
this.localResources[key].destroy();
|
||||
async _attemptCalls(cs) {
|
||||
const {req, srf} = cs;
|
||||
|
||||
// send all outbound calls back to originating SBC for simplicity
|
||||
const sbcAddress = `${req.source_address}:${req.source_port}`;
|
||||
|
||||
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);
|
||||
}
|
||||
this.localResources = {};
|
||||
};
|
||||
|
||||
// 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 = {
|
||||
headers: this.headers,
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: callerId,
|
||||
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 = this.finished = true;
|
||||
this._clearResources();
|
||||
cs.emit('callStatusChange', {status: 'canceled'});
|
||||
this.canceled = true;
|
||||
cs.emit('callStatusChange', {status: CallStatus.NoAnswer});
|
||||
}
|
||||
|
||||
_onCallerHangup(cs, dlg) {
|
||||
cs.emit('callStatusChange', {status: 'canceled'});
|
||||
this.finished = true;
|
||||
this._clearResources();
|
||||
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 the call ends
|
||||
* returns a Promise that resolves when either party hangs up
|
||||
*/
|
||||
_untilCallEnds(cs) {
|
||||
const {res} = cs;
|
||||
_waitForCompletion(cs) {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
assert(!this.finished);
|
||||
const dlgOut = this.getResource('dlgOut');
|
||||
assert(this.dlgIn && dlgOut);
|
||||
assert(this.dlgIn.connected && dlgOut.connected);
|
||||
|
||||
//TMP - hang up in 5 secs
|
||||
setTimeout(() => {
|
||||
res.send(480);
|
||||
this._clearResources();
|
||||
[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) => {
|
||||
request(opts, (err, response, body) => {
|
||||
if (err) this.logger.info(`TaskDial:_actionHook sending call status to ${this.action}: ${err.message}`);
|
||||
if (body) {
|
||||
this.logger.debug(body, 'got new application payload');
|
||||
cs.replaceApplication(body);
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
//TMP
|
||||
|
||||
/*
|
||||
const dlgOut = this.localResources.dlgOut;
|
||||
assert(dlgIn.connected && dlgOut.connected);
|
||||
|
||||
[this.dlgIn, this.dlgOut].forEach((dlg) => {
|
||||
dlg.on('destroy', () => resolve());
|
||||
});
|
||||
*/
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDial;
|
||||
|
||||
160
lib/tasks/gather.js
Normal file
160
lib/tasks/gather.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const assert = require('assert');
|
||||
|
||||
class TaskGather extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
[
|
||||
'action', 'finishOnKey', 'hints', 'input', 'language', 'method', 'numDigits',
|
||||
'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter',
|
||||
'speechTimeout', 'timeout', 'say'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST';
|
||||
this.method = this.method || 'POST';
|
||||
this.timeout = (this.timeout || 5) * 1000;
|
||||
this.language = this.language || 'en-US';
|
||||
this.digitBuffer = '';
|
||||
|
||||
if (this.say) {
|
||||
this.sayTask = makeTask(this.logger, {say: this.say});
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return TaskName.Gather; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
this.ep = ep;
|
||||
this.actionHook = cs.actionHook;
|
||||
|
||||
this.taskInProgress = true;
|
||||
try {
|
||||
if (this.sayTask) {
|
||||
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
|
||||
this.sayTask.on('playDone', this._onPlayDone.bind(this, ep));
|
||||
}
|
||||
else this._startTimer();
|
||||
|
||||
if (this.input.includes('speech')) {
|
||||
const opts = {
|
||||
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
|
||||
GOOGLE_SPEECH_MODEL: 'phone_call'
|
||||
};
|
||||
if (this.hints) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
|
||||
}
|
||||
if (this.profanityFilter === true) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||
}
|
||||
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'Error set'));
|
||||
ep.addCustomEventListener('google_transcribe::transcription', this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener('google_transcribe::no_audio_detected', this._onNoAudioDetected.bind(this, ep));
|
||||
ep.addCustomEventListener('google_transcribe::max_duration_exceeded', this._onMaxDuration.bind(this, ep));
|
||||
this.logger.debug('starting transcription');
|
||||
ep.startTranscription({
|
||||
interim: this.partialResultCallback ? true : false,
|
||||
language: this.language
|
||||
}).catch((err) => this.logger.error(err, 'TaskGather:exec error starting transcription'));
|
||||
}
|
||||
|
||||
if (this.input.includes('dtmf')) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
||||
}
|
||||
|
||||
await this._waitForCompletion();
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskGather:exec error');
|
||||
}
|
||||
this.taskInProgress = false;
|
||||
ep.removeAllListeners();
|
||||
}
|
||||
|
||||
kill() {
|
||||
this._killAudio();
|
||||
this._resolve('killed');
|
||||
}
|
||||
|
||||
async _waitForCompletion() {
|
||||
return new Promise((resolve) => this.resolver = resolve);
|
||||
}
|
||||
|
||||
_onPlayDone(ep, err, evt) {
|
||||
if (err || !this.taskInProgress) return;
|
||||
this.logger.debug(evt, 'TaskGather:_onPlayDone, starting input timer');
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
_onDtmf(ep, evt) {
|
||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
|
||||
else {
|
||||
this.digitBuffer += evt.dtmf;
|
||||
if (this.digitBuffer.length === this.numDigits) this._resolve('dtmf-num-digits');
|
||||
}
|
||||
this._killAudio();
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
assert(!this._timeoutTimer);
|
||||
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
|
||||
}
|
||||
|
||||
_clearTimer() {
|
||||
if (this._timeoutTimer) {
|
||||
clearTimeout(this._timeoutTimer);
|
||||
this._timeoutTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_killAudio() {
|
||||
if (this.sayTask) {
|
||||
this.sayTask.kill();
|
||||
this.sayTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
_onTranscription(ep, evt) {
|
||||
this.logger.debug(evt, 'TaskGather:_onTranscription');
|
||||
if (evt.is_final) {
|
||||
this._resolve('speech', evt);
|
||||
}
|
||||
else if (this.partialResultCallback) {
|
||||
this.actionHook(this.partialResultCallback, 'POST', {
|
||||
Speech: evt
|
||||
});
|
||||
}
|
||||
}
|
||||
_onNoAudioDetected(ep, evt) {
|
||||
this.logger.info(evt, 'TaskGather:_onNoAudioDetected');
|
||||
}
|
||||
_onMaxDuration(ep, evt) {
|
||||
this.logger.info(evt, 'TaskGather:_onMaxDuration');
|
||||
}
|
||||
|
||||
_resolve(reason, evt) {
|
||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||
assert(this.resolver);
|
||||
|
||||
if (reason.startsWith('dtmf')) {
|
||||
this.actionHook(this.action, this.method, {
|
||||
Digits: this.digitBuffer
|
||||
});
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
this.actionHook(this.action, this.method, {
|
||||
Speech: evt
|
||||
});
|
||||
}
|
||||
this._clearTimer();
|
||||
this.resolver();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskGather;
|
||||
24
lib/tasks/hangup.js
Normal file
24
lib/tasks/hangup.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
|
||||
class TaskHangup extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.headers = this.data.headers || {};
|
||||
}
|
||||
|
||||
get name() { return TaskName.Hangup; }
|
||||
|
||||
/**
|
||||
* Hangup the call
|
||||
*/
|
||||
async exec(cs, dlg) {
|
||||
try {
|
||||
await dlg.destroy({headers: this.headers});
|
||||
} catch (err) {
|
||||
this.logger.error(err, `TaskHangup:exec - Error hanging up call with sip call id ${dlg.sip.callId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskHangup;
|
||||
@@ -1,9 +1,9 @@
|
||||
const Task = require('./task');
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
const TaskDial = require('./dial');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const errBadInstruction = new Error('invalid instruction payload');
|
||||
|
||||
function makeTask(logger, opts) {
|
||||
logger.debug(opts, 'makeTask');
|
||||
if (typeof opts !== 'object' || Array.isArray(opts)) throw errBadInstruction;
|
||||
const keys = Object.keys(opts);
|
||||
if (keys.length !== 1) throw errBadInstruction;
|
||||
@@ -11,8 +11,21 @@ function makeTask(logger, opts) {
|
||||
const data = opts[name];
|
||||
Task.validate(name, data);
|
||||
switch (name) {
|
||||
case TaskSipDecline.name: return new TaskSipDecline(logger, data);
|
||||
case TaskDial.name: return new TaskDial(logger, data);
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
return new TaskSipDecline(logger, data);
|
||||
case TaskName.Dial:
|
||||
const TaskDial = require('./dial');
|
||||
return new TaskDial(logger, data);
|
||||
case TaskName.Hangup:
|
||||
const TaskHangup = require('./hangup');
|
||||
return new TaskHangup(logger, data);
|
||||
case TaskName.Say:
|
||||
const TaskSay = require('./say');
|
||||
return new TaskSay(logger, data);
|
||||
case TaskName.Gather:
|
||||
const TaskGather = require('./gather');
|
||||
return new TaskGather(logger, data);
|
||||
}
|
||||
|
||||
// should never reach
|
||||
|
||||
48
lib/tasks/say.js
Normal file
48
lib/tasks/say.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
class TaskSay extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = this.data.text;
|
||||
this.voice = this.data.synthesizer.voice;
|
||||
this.earlyMedia = this.data.earlyMedia === true;
|
||||
|
||||
switch (this.data.synthesizer.vendor) {
|
||||
case 'google':
|
||||
this.ttsEngine = 'google_tts';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`);
|
||||
}
|
||||
this.sayComplete = false;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Say; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
this.ep = ep;
|
||||
try {
|
||||
await ep.speak({
|
||||
ttsEngine: 'google_tts',
|
||||
voice: this.voice,
|
||||
text: this.text
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message !== 'hangup') this.logger.info(err, 'TaskSay:exec error');
|
||||
}
|
||||
this.emit('playDone');
|
||||
this.sayComplete = true;
|
||||
}
|
||||
|
||||
kill() {
|
||||
if (this.ep.connected && !this.sayComplete) {
|
||||
this.logger.debug('TaskSay:kill - killing audio');
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSay;
|
||||
@@ -1,26 +1,24 @@
|
||||
const Task = require('./task');
|
||||
const name = 'sip:decline';
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
class TaskSipDecline extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.name = name;
|
||||
this.preconditions = TaskPreconditions.UnansweredCall;
|
||||
|
||||
this.headers = this.data.headers || {};
|
||||
}
|
||||
|
||||
static get name() { return name; }
|
||||
get name() { return TaskName.SipDecline; }
|
||||
|
||||
/**
|
||||
* Reject an incoming call attempt with a provided status code and (optionally) reason
|
||||
*/
|
||||
async exec(cs) {
|
||||
if (!cs.res.finalResponseSent) {
|
||||
cs.res.send(this.data.status, this.data.reason, {
|
||||
async exec(cs, {res}) {
|
||||
res.send(this.data.status, this.data.reason, {
|
||||
headers: this.headers
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSipDecline;
|
||||
|
||||
@@ -9,26 +9,63 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"hangup": {
|
||||
"properties": {
|
||||
"headers": "object"
|
||||
},
|
||||
"required": [
|
||||
]
|
||||
},
|
||||
"say": {
|
||||
"properties": {
|
||||
"text": "string",
|
||||
"loop": "number",
|
||||
"synthesizer": "#synthesizer"
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"synthesizer"
|
||||
]
|
||||
},
|
||||
"gather": {
|
||||
"properties": {
|
||||
"action": "string",
|
||||
"finishOnKey": "string",
|
||||
"hints": "array",
|
||||
"input": "array",
|
||||
"language": "string",
|
||||
"numDigits": "number",
|
||||
"partialResultCallback": "string",
|
||||
"profanityFilter": "boolean",
|
||||
"speechTimeout": "number",
|
||||
"timeout": "number",
|
||||
"say": "#say"
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
]
|
||||
},
|
||||
"dial": {
|
||||
"properties": {
|
||||
"action": "string",
|
||||
"answerOnBridge": "boolean",
|
||||
"callerId": "string",
|
||||
"dialMusic": "string",
|
||||
"headers": "object",
|
||||
"listen": "#listen",
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"statusCallback": "string",
|
||||
"statusCallbackMethod": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"target": ["#target"],
|
||||
"timeLimit": "number",
|
||||
"timeout": "number",
|
||||
"headers": "object",
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["hunt", "simring"]
|
||||
},
|
||||
"transcribe": "#transcribe",
|
||||
"listen": "#listen"
|
||||
"transcribe": "#transcribe"
|
||||
},
|
||||
"required": [
|
||||
"target"
|
||||
@@ -75,8 +112,13 @@
|
||||
"type": "string",
|
||||
"enum": ["phone", "sip", "user"]
|
||||
},
|
||||
"url": "string",
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"number": "string",
|
||||
"uri": "string",
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"name": "string"
|
||||
},
|
||||
@@ -93,5 +135,14 @@
|
||||
"user",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"synthesizer": {
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"type": "string",
|
||||
"enum": ["google"]
|
||||
},
|
||||
"voice": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const assert = require('assert');
|
||||
const resourcesMixin = require('../utils/resources');
|
||||
const {TaskPreconditions} = require('../utils/constants');
|
||||
const specs = new Map();
|
||||
const _specData = require('./specs');
|
||||
for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||
@@ -8,10 +10,19 @@ for (const key in _specData) {specs.set(key, _specData[key]);}
|
||||
class Task extends Emitter {
|
||||
constructor(logger, data) {
|
||||
super();
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
this.logger = logger;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* called to kill (/stop) a running task
|
||||
* what to do is up to each type of task
|
||||
*/
|
||||
kill() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
static validate(name, data) {
|
||||
debug(`validating ${name} with data ${JSON.stringify(data)}`);
|
||||
// validate the instruction is supported
|
||||
@@ -24,6 +35,7 @@ class Task extends Emitter {
|
||||
if (dKey in specData.properties) {
|
||||
const dVal = data[dKey];
|
||||
const dSpec = specData.properties[dKey];
|
||||
debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`);
|
||||
|
||||
if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
|
||||
// simple types
|
||||
@@ -31,6 +43,9 @@ class Task extends Emitter {
|
||||
throw new Error(`${name}: property ${dKey} has invalid data type`);
|
||||
}
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec === 'array') {
|
||||
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
|
||||
}
|
||||
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
|
||||
const name = dSpec[0].slice(1);
|
||||
for (const item of dVal) {
|
||||
@@ -49,8 +64,9 @@ class Task extends Emitter {
|
||||
}
|
||||
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
|
||||
// reference to another datatype (i.e. nested type)
|
||||
// TODO: validate recursively
|
||||
const name = dSpec.slice(1);
|
||||
//const obj = {};
|
||||
//obj[name] = dVal;
|
||||
Task.validate(name, dVal);
|
||||
}
|
||||
else {
|
||||
@@ -64,5 +80,7 @@ class Task extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(Task.prototype, resourcesMixin);
|
||||
|
||||
module.exports = Task;
|
||||
|
||||
|
||||
36
lib/utils/constants.json
Normal file
36
lib/utils/constants.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Dial": "dial",
|
||||
"Gather": "gather",
|
||||
"Hangup": "hangup",
|
||||
"Listen": "listen",
|
||||
"Play": "play",
|
||||
"redirect": "redirect",
|
||||
"SipDecline": "sip:decline",
|
||||
"SipNotify": "sip:notify",
|
||||
"SipRedirect": "sip:redirect",
|
||||
"Say": "say",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
"Ringing": "ringing",
|
||||
"EarlyMedia": "early-media",
|
||||
"InProgress": "in-progress",
|
||||
"Queued": "queued",
|
||||
"Failed": "failed",
|
||||
"Busy": "busy",
|
||||
"NoAnswer": "no-answer",
|
||||
"Completed": "completed"
|
||||
},
|
||||
"CallDirection": {
|
||||
"Inbound": "inbound",
|
||||
"Outbound": "outbound"
|
||||
},
|
||||
"TaskPreconditions": {
|
||||
"None": "none",
|
||||
"Endpoint": "endpoint",
|
||||
"StableCall": "stable-call",
|
||||
"UnansweredCall": "unanswered-call"
|
||||
}
|
||||
}
|
||||
37
lib/utils/notifiers.js
Normal file
37
lib/utils/notifiers.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const request = require('request');
|
||||
require('request-debug')(request);
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
|
||||
function hooks(logger, callAttributes) {
|
||||
debug(`notifiers: callAttributes ${JSON.stringify(callAttributes)}`);
|
||||
function actionHook(url, method, opts) {
|
||||
debug(`notifiers: opts ${JSON.stringify(opts)}`);
|
||||
const params = Object.assign({}, callAttributes, opts);
|
||||
const obj = {
|
||||
url,
|
||||
method,
|
||||
json: true,
|
||||
qs: 'GET' === method ? params : null,
|
||||
body: 'POST' === method ? params : null
|
||||
};
|
||||
logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
request(obj, (err, response, body) => {
|
||||
if (err) {
|
||||
this.logger.info(`TaskDial:_actionHook error ${method} ${url}: ${err.message}`);
|
||||
return reject(err);
|
||||
}
|
||||
if (body) {
|
||||
this.logger.debug(body, `TaskDial:_actionHook response ${method} ${url}`);
|
||||
}
|
||||
resolve(body);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
actionHook
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = hooks;
|
||||
50
lib/utils/resources.js
Normal file
50
lib/utils/resources.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
81
package-lock.json
generated
81
package-lock.json
generated
@@ -706,58 +706,6 @@
|
||||
"resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz",
|
||||
"integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ=="
|
||||
},
|
||||
"drachtio-fn-fsmrf-sugar": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/drachtio-fn-fsmrf-sugar/-/drachtio-fn-fsmrf-sugar-0.0.9.tgz",
|
||||
"integrity": "sha512-X32AUmURLaeXmMQrY05YmkevAYpT0CIuoEsoiKqkZviSSt88e55naJf8xrWsMP7W5rc4UuYBmgCiBqW3qKoR9Q==",
|
||||
"requires": {
|
||||
"drachtio-fsmrf": "^1.4.4"
|
||||
}
|
||||
},
|
||||
"drachtio-fsmrf": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.8.tgz",
|
||||
"integrity": "sha512-hVSJuGiwX/onshvtmKyjA1p/wiPmezuiblT5Z3YGSz7tPPK5e+m4fIwAjEvarQSfhrMeiXuQHp9Eewp0+z0J0Q==",
|
||||
"requires": {
|
||||
"async": "^1.4.2",
|
||||
"debug": "^2.2.0",
|
||||
"delegates": "^0.1.0",
|
||||
"drachtio-modesl": "^1.2.0",
|
||||
"drachtio-srf": "^4.4.15",
|
||||
"lodash": "^4.17.15",
|
||||
"minimist": "^1.2.0",
|
||||
"moment": "^2.13.0",
|
||||
"only": "0.0.2",
|
||||
"sdp-transform": "^2.7.0",
|
||||
"uuid": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"requires": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
}
|
||||
}
|
||||
},
|
||||
"drachtio-modesl": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/drachtio-modesl/-/drachtio-modesl-1.2.0.tgz",
|
||||
"integrity": "sha512-nkua3RfYnT32OvglERO2zWzJZAfQooZIarZVVAye+iGqTwYJ69X7bU7du5SBHz/jBl+LgeWITMP2fMe2TelxmA==",
|
||||
"requires": {
|
||||
"debug": "^4.1.1",
|
||||
"eventemitter2": "^4.1",
|
||||
"uuid": "^3.1.0",
|
||||
"xml2js": "^0.4.19"
|
||||
}
|
||||
},
|
||||
"drachtio-mw-registration-parser": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/drachtio-mw-registration-parser/-/drachtio-mw-registration-parser-0.0.2.tgz",
|
||||
@@ -1011,11 +959,6 @@
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true
|
||||
},
|
||||
"eventemitter2": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz",
|
||||
"integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU="
|
||||
},
|
||||
"events-to-array": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz",
|
||||
@@ -2819,16 +2762,6 @@
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"sax": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"sdp-transform": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.13.0.tgz",
|
||||
"integrity": "sha512-3zT7pcjR090E0WCV9eOtFX06iojoNKsyMXqXs7clOs8sy+RoegR0cebmCuCrTKdY2jw1XhT9jkraygJrqAUwzA=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
|
||||
@@ -4954,20 +4887,6 @@
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"requires": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
}
|
||||
},
|
||||
"xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"drachtio-fsmrf": "1.5.10",
|
||||
"drachtio-srf": "^4.4.27",
|
||||
"jambonz-db-helpers": "^0.1.6",
|
||||
"moment": "^2.24.0",
|
||||
"pino": "^5.14.0",
|
||||
"request": "^2.88.0",
|
||||
"request-debug": "^0.2.0"
|
||||
|
||||
Reference in New Issue
Block a user