added initial support for REST-initiated outdials

This commit is contained in:
Dave Horton
2020-02-01 16:16:00 -05:00
parent 44a1b45357
commit 2525b8c70a
28 changed files with 985 additions and 127 deletions

View File

@@ -0,0 +1,155 @@
const config = require('config');
const router = require('express').Router();
const sysError = require('./error');
const makeTask = require('../../tasks/make_task');
const RestCallSession = require('../../session/rest-call-session');
const CallInfo = require('../../session/call-info');
const {CallDirection} = require('../../utils/constants');
const parseUrl = require('parse-url');
const SipError = require('drachtio-srf').SipError;
const Srf = require('drachtio-srf');
const drachtio = config.get('outdials.drachtio');
const sbcs = config.get('outdials.sbc');
const Mrf = require('drachtio-fsmrf');
let idxDrachtio = 0;
let idxSbc = 0;
const srfs = drachtio.map((d) => {
const srf = new Srf();
srf.connect(d);
srf
.on('connect', (err, hp) => {
console.log(err, `Connected to drachtio at ${hp}`);
srf.locals.mrf = new Mrf(srf);
})
.on('error', (err) => console.log(err));
return srf;
});
async function validate(logger, payload) {
const data = Object.assign({}, {
from: payload.from,
to: payload.to,
call_hook: payload.call_hook
});
const u = parseUrl(payload.call_hook.url);
const myPort = u.port ? `:${u.port}` : '';
payload.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`,
method: payload.call_hook.method
};
if (payload.call_hook.username && payload.call_hook.password) {
payload.originalRequest.auth = {
username: payload.call_hook.username,
password: payload.call_hook.password
};
}
return makeTask(logger, {'rest:dial': data});
}
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
logger.debug({body: req.body}, 'got createCall request');
try {
let uri;
const restDial = await validate(logger, req.body);
const sbcAddress = sbcs[idxSbc++ % sbcs.length];
const srf = srfs[idxDrachtio++ % srfs.length];
const target = restDial.to;
const opts = {
'callingNumber': restDial.from
};
switch (target.type) {
case 'phone':
uri = `sip:${target.number}@${sbcAddress}`;
break;
case 'user':
uri = `sip:${target.name}`;
break;
case 'sip':
uri = target.sipUri;
break;
}
/* create endpoint for outdial */
const mrf = srf.locals.mrf;
const ms = await mrf.connect(config.get('freeswitch'));
logger.debug('createCall: successfully connected to media server');
const ep = await ms.createEndpoint();
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
ms.destroy();
/* launch outdial */
let sdp, sipLogger;
const connectStream = async(remoteSdp) => {
if (remoteSdp !== sdp) {
ep.modify(sdp = remoteSdp);
return true;
}
return false;
};
Object.assign(opts, {
proxy: `sip:${sbcAddress}`,
localSdp: ep.local.sdp
});
if (target.auth) opts.auth = this.target.auth;
const application = req.body;
try {
const dlg = await srf.createUAC(uri, opts, {
cbRequest: (err, inviteReq) => {
if (err) {
this.logger.error(err, 'createCall Error creating call');
res.status(500).send('Call Failure');
ep.destroy();
}
/* call is in flight */
const tasks = [restDial];
const callInfo = new CallInfo({
direction: CallDirection.Outbound,
req: inviteReq,
to: req.body.to,
tag: req.body.tag,
accountSid: req.body.account_sid,
applicationSid: req.body.application_sid
});
const cs = new RestCallSession({logger, application, srf, req: inviteReq, ep, tasks, callInfo});
cs.exec(req);
res.status(201).json({sid: cs.callSid});
sipLogger = logger.child({
callSid: cs.callSid,
callId: callInfo.callId
});
sipLogger.info(`outbound REST call attempt to ${JSON.stringify(target)} has been sent`);
},
cbProvisional: (prov) => {
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
restDial.emit('callStatus', prov.status, !!prov.body);
}
});
connectStream(dlg.remote.sdp);
restDial.emit('callStatus', 200);
restDial.emit('connect', dlg);
}
catch (err) {
if (err instanceof SipError) {
sipLogger.info(`REST outdial failed with ${err.status}`);
}
else {
sipLogger.error({err}, 'REST outdial failed');
}
ep.destroy();
}
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;

View File

@@ -0,0 +1,20 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../utils/errors');
function sysError(logger, res, err) {
if (err instanceof DbErrorBadRequest) {
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});
}
if (err instanceof DbErrorUnprocessableRequest) {
logger.info(err, 'unprocessable request');
return res.status(422).json({msg: err.message});
}
if (err.code === 'ER_DUP_ENTRY') {
logger.info(err, 'duplicate entry on insert');
return res.status(422).json({msg: err.message});
}
logger.error(err, 'Database error');
res.status(500).json({msg: err.message});
}
module.exports = sysError;

View File

@@ -0,0 +1,5 @@
const api = require('express').Router();
api.use('/createCall', require('./create-call'));
module.exports = api;

16
lib/http-routes/index.js Normal file
View File

@@ -0,0 +1,16 @@
const express = require('express');
const api = require('./api');
const routes = express.Router();
routes.use('/v1', api);
// health checks
routes.get('/', (req, res) => {
res.sendStatus(200);
});
routes.get('/health', (req, res) => {
res.sendStatus(200);
});
module.exports = routes;

View File

@@ -0,0 +1,23 @@
class DbError extends Error {
constructor(msg) {
super(msg);
}
}
class DbErrorBadRequest extends DbError {
constructor(msg) {
super(msg);
}
}
class DbErrorUnprocessableRequest extends DbError {
constructor(msg) {
super(msg);
}
}
module.exports = {
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest
};

View File

@@ -1,6 +1,6 @@
//const debug = require('debug')('jambonz:feature-server');
const uuidv4 = require('uuid/v4');
const {CallStatus, CallDirection} = require('./utils/constants');
const {CallDirection} = require('./utils/constants');
const CallInfo = require('./session/call-info');
const retrieveApp = require('./utils/retrieve-app');
const parseUrl = require('parse-url');
@@ -71,7 +71,7 @@ module.exports = function(srf, logger) {
const logger = req.locals.logger;
const app = req.locals.application;
const call_hook = app.call_hook;
const method = (call_hook.method || 'POST').toUpperCase();
const method = call_hook.method.toUpperCase();
let auth;
if (call_hook.username && call_hook.password) {
auth = {username: call_hook.username, password: call_hook.password};
@@ -81,7 +81,8 @@ module.exports = function(srf, logger) {
const myPort = u.port ? `:${u.port}` : '';
app.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`,
auth
auth,
method
};
logger.debug({url: call_hook.url, method}, 'invokeWebCallback');
const obj = Object.assign({}, req.locals.callInfo);
@@ -91,8 +92,8 @@ module.exports = function(srf, logger) {
app.tasks = await retrieveApp(logger, call_hook.url, method, auth, obj);
next();
} catch (err) {
logger.error(err, 'Error retrieving or parsing application');
res.send(500);
logger.info(`Error retrieving or parsing application: ${err.message}`);
res.send(480, {headers: {'X-Reason': err.message}});
}
}

View File

@@ -5,6 +5,7 @@ class CallInfo {
constructor(opts) {
this.direction = opts.direction;
if (this.direction === CallDirection.Inbound) {
// inbound call
const {app, req} = opts;
this.callSid = req.locals.callSid,
this.accountSid = app.account_sid,
@@ -19,19 +20,32 @@ class CallInfo {
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
}
else if (opts.parentCallInfo) {
console.log(`is opts.parentCallInfo a CallInfo ${opts.parentCallInfo instanceof CallInfo}`);
const {req, parentCallInfo} = opts;
this.callSid = uuidv4();
// outbound call that is a child of an existing call
const {req, parentCallInfo, to, callSid} = opts;
this.callSid = callSid || uuidv4();
this.parentCallSid = parentCallInfo.callSid;
this.accountSid = parentCallInfo.accountSid;
this.applicationSid = parentCallInfo.applicationSid;
this.from = req.callingNumber;
this.to = req.calledNumber;
this.to = to || req.calledNumber;
this.callerName = this.from.name || req.callingNumber;
this.callId = req.get('Call-ID');
this.callStatus = CallStatus.Trying,
this.sipStatus = 100;
}
else {
// outbound call triggered by REST
const {req, accountSid, applicationSid, to, tag} = opts;
this.callSid = uuidv4();
this.accountSid = accountSid;
this.applicationSid = applicationSid;
this.callStatus = CallStatus.Trying,
this.callId = req.get('Call-ID');
this.sipStatus = 100;
this.from = req.callingNumber;
this.to = to;
if (tag) this._customerData = tag;
}
}
updateCallStatus(callStatus, sipStatus) {
@@ -43,6 +57,10 @@ class CallInfo {
this._customerData = obj;
}
get customerData() {
return this._customerData;
}
toJSON() {
const obj = {
callSid: this.callSid,
@@ -59,9 +77,10 @@ class CallInfo {
['parentCallSid', 'originatingSipIP', 'originatingSipTrunkName'].forEach((prop) => {
if (this[prop]) obj[prop] = this[prop];
});
if (typeof this.duration === 'number') obj.duration = this.duration;
if (this._customerData && Object.keys(this._customerData).length) {
obj.customerData = this._customerData;
if (this._customerData) {
Object.assign(obj, {customerData: this._customerData});
}
return obj;
}

View File

@@ -39,6 +39,24 @@ class CallSession extends Emitter {
return this.callInfo.direction;
}
get call_status_hook() {
return this.application.call_status_hook;
}
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;
}
async exec() {
this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`);
while (this.tasks.length && !this.callGone) {
@@ -196,11 +214,16 @@ class CallSession extends Emitter {
}
return {ms: this.ms, ep: this.ep};
}
_notifyCallStatusChange({callStatus, sipStatus}) {
this.logger.debug(`CallSession:_notifyCallStatusChange: ${callStatus} ${sipStatus}`);
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
const call_status_hook = this.call_status_hook;
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
this.notifyHook(this.application.call_status_hook);
this.notifyHook(call_status_hook);
} catch (err) {
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}

View File

@@ -1,18 +1,17 @@
const CallSession = require('./call-session');
const {CallDirection} = require('../utils/constants');
class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks}) {
constructor({logger, application, dlg, ep, tasks, callInfo}) {
super({
logger,
application,
srf: dlg.srf,
callSid: dlg.callSid,
tasks
tasks,
callInfo
});
this.dlg = dlg;
this.ep = ep;
this.direction = CallDirection.Outbound;
}
/**

View File

@@ -21,20 +21,6 @@ class InboundCallSession extends CallSession {
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');

View File

@@ -0,0 +1,34 @@
const CallSession = require('./call-session');
const {CallStatus} = require('../utils/constants');
const moment = require('moment');
class RestCallSession extends CallSession {
constructor({logger, application, srf, req, ep, tasks, callInfo}) {
super({
logger,
application,
srf,
callSid: callInfo.callSid,
tasks,
callInfo
});
this.req = req;
this.ep = ep;
}
setDialog(dlg) {
this.dlg = dlg;
dlg.on('destroy', this._callerHungup.bind(this));
dlg.connectTime = moment();
}
_callerHungup() {
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 = RestCallSession;

View File

@@ -4,7 +4,6 @@ const {CallStatus, CallDirection, TaskName, TaskPreconditions, MAX_SIMRINGS} = r
const assert = require('assert');
const placeCall = require('../utils/place-outdial');
const config = require('config');
const moment = require('moment');
const debug = require('debug')('jambonz:feature-server');
function compareTasks(t1, t2) {
@@ -68,6 +67,14 @@ class TaskDial extends Task {
this.dials = new Map();
}
get dlg() {
if (this.sd) return this.sd.dlg;
}
get ep() {
if (this.sd) return this.sd.ep;
}
get name() { return TaskName.Dial; }
async exec(cs) {
@@ -87,24 +94,25 @@ class TaskDial extends Task {
async kill() {
super.kill();
if (this.dlg) {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.results.dialCallDuration = duration;
this.logger.debug(`Dial:kill call ended after ${duration} seconds`);
}
this._killOutdials();
if (this.sd) {
this.sd.kill();
this.sd = null;
}
if (this.listenTask) await this.listenTask.kill();
if (this.transcribeTask) await this.transcribeTask.kill();
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.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.notifyTaskDone();
}
_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();
}
async _initializeInbound(cs) {
const {ep} = await cs.connectInboundCallToIvr(this.earlyMedia);
this.epOther = ep;
@@ -153,6 +161,13 @@ class TaskDial extends Task {
this.dials.set(sd.callSid, sd);
sd
.on('callCreateFail', () => {
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
this.kill();
}
})
.on('callStatusChange', (obj) => {
switch (obj.callStatus) {
case CallStatus.Trying:
@@ -170,7 +185,7 @@ class TaskDial extends Task {
case CallStatus.Busy:
case CallStatus.NoAnswer:
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.dlg) {
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
clearTimeout(timerRing);
this.kill();
@@ -191,7 +206,7 @@ class TaskDial extends Task {
.on('decline', () => {
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.dlg) {
if (this.dials.size === 0 && !this.sd) {
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
this.kill();
}
@@ -228,17 +243,14 @@ class TaskDial extends Task {
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
this.dials.delete(sd.callSid);
this.ep = sd.ep;
this.dlg = sd.dlg;
this.dlg.connectTime = moment();
this.sd = sd;
this.callSid = sd.callSid;
if (this.earlyMedia) {
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
cs.propagateAnswer();
}
let timerMaxCallDuration;
if (this.timeLimit) {
timerMaxCallDuration = setTimeout(() => {
this.timerMaxCallDuration = setTimeout(() => {
this.logger.info(`Dial:_selectSingleDial tearing down call as it has reached ${this.timeLimit}s`);
this.ep.unbridge();
this.kill();
@@ -246,7 +258,7 @@ class TaskDial extends Task {
}
this.dlg.on('destroy', () => {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
if (timerMaxCallDuration) clearTimeout(timerMaxCallDuration);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.ep.unbridge();
this.kill();
});
@@ -260,14 +272,6 @@ class TaskDial extends Task {
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);

View File

@@ -4,19 +4,21 @@ const makeTask = require('./make_task');
const moment = require('moment');
class TaskListen extends Task {
constructor(logger, opts) {
constructor(logger, opts, parentTask) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
[
'action', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
'sampleRate', 'timeout', 'transcribe'
'action', 'auth', 'method', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
'sampleRate', 'timeout', 'transcribe', 'wsAuth'
].forEach((k) => this[k] = this.data[k]);
this.mixType = this.mixType || 'mono';
this.sampleRate = this.sampleRate || 8000;
this.method = this.method || 'POST';
this.earlyMedia = this.data.earlyMedia === true;
this.hook = this.normalizeUrl(this.url, 'GET', this.wsAuth);
this.nested = typeof parentTask !== 'undefined';
this.results = {};
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
@@ -35,9 +37,9 @@ class TaskListen extends Task {
this.logger.debug('TaskListen:exec - starting nested transcribe task');
this.transcribeTask.exec(cs, ep, this);
}
await this._startListening(ep);
await this._startListening(cs, ep);
await this.awaitTaskDone();
if (this.action) await this.performAction(this.method, null, this.results);
if (this.action) await this.performAction(this.method, this.auth, this.results, !this.nested);
} catch (err) {
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
}
@@ -47,8 +49,10 @@ class TaskListen extends Task {
async kill() {
super.kill();
this.logger.debug(`TaskListen:kill endpoint connected? ${this.ep && this.ep.connected}`);
this._clearTimer();
if (this.ep.connected) {
if (this.ep && this.ep.connected) {
this.logger.debug('TaskListen:kill closing websocket');
await this.ep.forkAudioStop()
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
}
@@ -65,13 +69,26 @@ class TaskListen extends Task {
.catch((err) => this.logger.info(err, 'TaskListen:_playBeep Error playing beep'));
}
async _startListening(ep) {
async _startListening(cs, ep) {
this._initListeners(ep);
const metadata = Object.assign(
{sampleRate: this.sampleRate, mixType: this.mixType},
cs.callInfo.toJSON(),
this.metadata);
this.logger.debug({metadata, hook: this.hook}, 'TaskListen:_startListening');
if (this.hook.username && this.hook.password) {
this.logger.debug({username: this.hook.username, password: this.hook.password},
'TaskListen:_startListening basic auth');
await this.ep.set({
'MOD_AUDIO_BASIC_AUTH_USERNAME': this.hook.username,
'MOD_AUDIO_BASIC_AUTH_PASSWORD': this.hook.password
});
}
await ep.forkAudioStart({
wsUrl: this.url,
wsUrl: this.hook.url,
mixType: this.mixType,
sampling: this.sampleRate,
metadata: this.metadata
metadata
});
this.recordStartTime = moment();
if (this.maxLength) {

View File

@@ -1,6 +1,6 @@
const Task = require('./task');
const {TaskName} = require('../utils/constants');
const errBadInstruction = new Error('invalid instruction payload');
const errBadInstruction = new Error('malformed jambonz application payload');
function makeTask(logger, obj) {
const keys = Object.keys(obj);
@@ -42,13 +42,16 @@ function makeTask(logger, obj) {
case TaskName.Redirect:
const TaskRedirect = require('./redirect');
return new TaskRedirect(logger, data);
case TaskName.RestDial:
const TaskRestDial = require('./rest_dial');
return new TaskRestDial(logger, data);
case TaskName.Tag:
const TaskTag = require('./tag');
return new TaskTag(logger, data);
}
// should never reach
throw new Error(`invalid task ${name} (please update specs.json and make_task.js)`);
throw new Error(`invalid jambonz verb '${name}'`);
}
module.exports = makeTask;

View File

@@ -9,7 +9,7 @@ class TaskRedirect extends Task {
super(logger, opts);
this.action = this.data.action;
this.method = this.data.method || 'POST';
this.method = (this.data.method || 'POST').toUpperCase();
this.auth = this.data.auth;
}

82
lib/tasks/rest_dial.js Normal file
View File

@@ -0,0 +1,82 @@
const Task = require('./task');
const {TaskName} = require('../utils/constants');
/**
* Manages an outdial made via REST API
*/
class TaskRestDial extends Task {
constructor(logger, opts) {
super(logger, opts);
this.from = this.data.from;
this.to = this.data.to;
this.call_hook = this.data.call_hook;
this.timeout = this.data.timeout || 60;
this.on('connect', this._onConnect.bind(this));
this.on('callStatus', this._onCallStatus.bind(this));
}
get name() { return TaskName.RestDial; }
/**
* INVITE has just been sent at this point
*/
async exec(cs, req) {
super.exec(cs);
this.req = req;
this._setCallTimer();
await this.awaitTaskDone();
}
kill() {
super.kill();
this._clearCallTimer();
if (this.req) {
this.req.cancel();
this.req = null;
}
this.notifyTaskDone();
}
async _onConnect(dlg) {
this.req = null;
const cs = this.callSession;
cs.setDialog(dlg);
const obj = Object.assign({}, cs.callInfo);
const tasks = await this.actionHook(this.call_hook, obj);
if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
cs.replaceApplication(tasks);
}
this.notifyTaskDone();
}
_onCallStatus(status) {
this.logger.debug(`CallStatus: ${status}`);
if (status >= 200) {
this.req = null;
this._clearCallTimer();
if (status !== 200) this.notifyTaskDone();
}
}
_setCallTimer() {
this.timer = setTimeout(this._onCallTimeout.bind(this), this.timeout * 1000);
}
_clearCallTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
}
_onCallTimeout() {
this.logger.debug('TaskRestDial: timeout expired without answer, killing task');
this.timer = null;
this.kill();
}
}
module.exports = TaskRestDial;

View File

@@ -83,9 +83,14 @@
"listen": {
"properties": {
"action": "string",
"auth": "#auth",
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
@@ -96,6 +101,7 @@
"timeout": "number",
"transcribe": "#transcribe",
"url": "string",
"wsAuth": "#auth",
"earlyMedia": "boolean"
},
"required": [
@@ -115,6 +121,19 @@
"action"
]
},
"rest:dial": {
"properties": {
"call_hook": "object",
"from": "string",
"to": "#target",
"timeout": "number"
},
"required": [
"call_hook",
"from",
"to"
]
},
"tag": {
"properties": {
"data": "object"
@@ -155,11 +174,11 @@
},
"auth": {
"properties": {
"user": "string",
"username": "string",
"password": "string"
},
"required": [
"user",
"username",
"password"
]
},

View File

@@ -12,7 +12,7 @@ class TaskTag extends Task {
async exec(cs) {
super.exec(cs);
cs.callInfo.customerData = this.data;
this.logger.debug({customerData: cs.callInfo.customerData}, 'TaskTag:exec set customer data');
this.logger.debug({customerData: this.data}, 'TaskTag:exec set customer data');
}
}

View File

@@ -56,16 +56,26 @@ class Task extends Emitter {
return this._completionPromise;
}
async performAction(method, auth, results) {
if (this.action) {
let action = this.action;
if (action.startsWith('/')) {
const or = this.callSession.originalRequest;
action = `${or.baseUrl}${this.action}`;
this.logger.debug({originalUrl: this.action, normalizedUrl: action}, 'Task:performAction normalized url');
if (!auth && or.auth) auth = or.auth;
normalizeUrl(url, method, auth) {
const hook = {url, method};
if (auth && auth.username && auth.password) Object.assign(hook, auth);
if (url.startsWith('/')) {
const or = this.callSession.originalRequest;
if (or) {
hook.url = `${or.baseUrl}${url}`;
hook.method = hook.method || or.method || 'POST';
if (!hook.auth && or.auth) Object.assign(hook, or.auth);
}
const tasks = await this.actionHook(action, method, auth, results);
}
this.logger.debug({hook}, 'Task:normalizeUrl');
return hook;
}
async performAction(method, auth, results, expectResponse = true) {
if (this.action) {
const hook = this.normalizeUrl(this.action, method, auth);
const tasks = await this.actionHook(hook, results, expectResponse);
if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.callSession.replaceApplication(tasks);

View File

@@ -6,6 +6,7 @@
"Listen": "listen",
"Play": "play",
"Redirect": "redirect",
"RestDial": "rest:dial",
"SipDecline": "sip:decline",
"SipNotify": "sip:notify",
"SipRedirect": "sip:redirect",

View File

@@ -1,9 +1,9 @@
function normalizeJambones(logger, obj) {
logger.debug(`normalizeJambones: ${JSON.stringify(obj)}`);
if (!Array.isArray(obj)) throw new Error('invalid JSON: jambones docs must be array');
if (!Array.isArray(obj)) throw new Error('malformed jambonz payload: must be array');
const document = [];
for (const tdata of obj) {
if (typeof tdata !== 'object') throw new Error('invalid JSON: jambones docs must be array of objects');
if (typeof tdata !== 'object') throw new Error('malformed jambonz payload: must be array of objects');
if ('verb' in tdata) {
// {verb: 'say', text: 'foo..bar'..}
const name = tdata.verb;
@@ -21,8 +21,8 @@ function normalizeJambones(logger, obj) {
document.push(tdata);
}
else {
logger.info(tdata, `invalid JSON: invalid verb form, numkeys ${Object.keys(tdata).length}`);
throw new Error('invalid JSON: invalid verb form');
logger.info(tdata, 'malformed jambonz payload: missing verb property');
throw new Error('malformed jambonz payload: missing verb property');
}
}
logger.debug(`returning document with ${document.length} tasks`);

View File

@@ -1,27 +1,26 @@
const request = require('request');
require('request-debug')(request);
//require('request-debug')(request);
const retrieveApp = require('./retrieve-app');
function hooks(logger, callInfo) {
logger.debug({callInfo}, 'creating action hook');
function actionHook(hook, obj = {}, expectResponse = true) {
const method = hook.method.toUpperCase();
const method = (hook.method || 'POST').toUpperCase();
const auth = (hook.username && hook.password) ?
{username: hook.username, password: hook.password} :
null;
const data = Object.assign({}, obj, callInfo);
logger.debug({data}, `actionhook sending to ${hook.url}`);
if ('GET' === method) {
// remove customer data - only for POSTs since it might be quite complex
delete data.customerData;
}
const data = Object.assign({}, obj, callInfo.toJSON());
logger.debug({hook, data, auth}, 'actionhook');
/* customer data only on POSTs */
if ('GET' === method) delete data.customerData;
const opts = {
url: hook.url,
method,
json: 'POST' === method || expectResponse
};
if (auth) obj.auth = auth;
if (auth) opts.auth = auth;
if ('POST' === method) opts.body = data;
else opts.qs = data;
@@ -40,8 +39,8 @@ function hooks(logger, callInfo) {
});
}
function notifyHook(url, method, auth, opts = {}) {
return actionHook(url, method, auth, opts, false);
function notifyHook(hook, opts = {}) {
return actionHook(hook, opts, false);
}
return {

View File

@@ -8,6 +8,7 @@ const ConfirmCallSession = require('../session/confirm-call-session');
const hooks = require('./notifiers');
const moment = require('moment');
const parseUrl = require('parse-url');
const uuidv4 = require('uuid/v4');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
@@ -25,23 +26,13 @@ class SingleDialer extends Emitter {
this.bindings = logger.bindings();
this.parentCallInfo = callInfo;
/*
this.callInfo = Object.assign({}, callInfo, {
callSid: this._callSid,
parentCallSid: callInfo.callSid,
direction: CallDirection.Outbound,
callStatus: CallStatus.Trying,
sipStatus: 100
});
*/
this.callGone = false;
this.callSid = uuidv4();
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
}
get callSid() {
return this._callSid;
}
get callStatus() {
return this.callInfo.callStatus;
}
@@ -88,21 +79,26 @@ class SingleDialer extends Emitter {
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');
if (err) {
this.logger.error(err, 'SingleDialer:exec Error creating call');
this.emit('callCreateFail', err);
return;
}
/**
* INVITE has been sent out
* (a) create a CallInfo for this call
* (a) create a logger for this call
* (b) augment this.callInfo with additional call info
*/
this.logger.debug(`call sent, creating CallInfo parentCallInfo is CallInfo? ${this.parentCallInfo instanceof CallInfo}`);
this.callInfo = new CallInfo({
direction: CallDirection.Outbound,
parentCallInfo: this.parentCallInfo,
req
req,
to,
callSid: this.callSid
});
this.logger = srf.locals.parentLogger.child({
callSid: this.callInfo.callSid,
callSid: this.callSid,
parentCallSid: this.parentCallInfo.callSid,
callId: this.callInfo.callId
});
@@ -164,6 +160,7 @@ class SingleDialer extends Emitter {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.logger.debug('SingleDialer:kill hanging up called party');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.dlg.destroy();
}
if (this.ep) {
this.logger.debug(`SingleDialer:kill - deleting endpoint ${this.ep.uuid}`);
@@ -181,13 +178,14 @@ class SingleDialer extends Emitter {
async _executeApp(url) {
this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
try {
let auth;
let auth, method;
const app = Object.assign({}, this.application);
if (url.startsWith('/')) {
const savedUrl = url;
const or = app.originalRequest;
url = `${or.baseUrl}${url}`;
auth = or.auth;
method = this.method || or.method || 'POST';
this.logger.debug({originalUrl: savedUrl, normalizedUrl: url}, 'SingleDialer:_executeApp normalized url');
}
else {
@@ -196,9 +194,10 @@ class SingleDialer extends Emitter {
app.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`
};
method = this.method || 'POST';
}
const tasks = await this.actionHook(url, this.method, auth);
const tasks = await this.actionHook({url, method, auth});
const allowedTasks = tasks.filter((task) => {
return [
TaskPreconditions.StableCall,
@@ -210,7 +209,14 @@ class SingleDialer extends Emitter {
}
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession({logger: this.logger, application: app, dlg: this.dlg, ep: this.ep, tasks});
const cs = new ConfirmCallSession({
logger: this.logger,
application: app,
dlg: this.dlg,
ep: this.ep,
callInfo: this.callInfo,
tasks
});
await cs.exec();
this.emit(this.dlg.connected ? 'accept' : 'decline');
} catch (err) {
@@ -220,9 +226,12 @@ class SingleDialer extends Emitter {
}
}
_notifyCallStatusChange({callStatus, sipStatus}) {
this.logger.debug(`SingleDialer:_notifyCallStatusChange: ${callStatus} ${sipStatus}`);
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
this.notifyHook(this.application.call_status_hook);
} catch (err) {

View File

@@ -12,7 +12,9 @@ function retrieveUrl(logger, url, method, auth, obj) {
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) throw err;
if (body) logger.debug({body}, 'retrieveUrl: customer returned an application');
if (response.statusCode === 401) return reject(new Error('HTTP request failed: Unauthorized'));
else if (response.statusCode !== 200) return reject(new Error(`HTTP request failed: ${response.statusCode}`));
if (body) logger.debug({body}, 'retrieveUrl: ${method} ${url} returned an application');
resolve(body);
});
});