mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-20 16:50:39 +00:00
added initial support for REST-initiated outdials
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
82
lib/tasks/rest_dial.js
Normal 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;
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user