work on say and gather

This commit is contained in:
Dave Horton
2020-01-13 14:01:19 -05:00
parent 1debb193c2
commit 1a656f3f0e
16 changed files with 1034 additions and 236 deletions

View File

@@ -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});
}
this.canceled = false;
this.callAttributes = {};
this.dialCallStatus = CallStatus.Failed;
this.dialCallSid = null;
this.dialCallDuration = null;
this.on('callStatusChange', this._onCallStatusChange.bind(this));
}
static get name() { return name; }
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);
}
};
// 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;
}
this.localResources = {};
}
_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();
resolve();
}, 5000);
//TMP
/*
const dlgOut = this.localResources.dlgOut;
assert(dlgIn.connected && dlgOut.connected);
[this.dlgIn, this.dlgOut].forEach((dlg) => {
dlg.on('destroy', () => resolve());
});
*/
[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();
});
});
}
}
}
module.exports = TaskDial;

160
lib/tasks/gather.js Normal file
View 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
View 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;

View File

@@ -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
View 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;

View File

@@ -1,25 +1,23 @@
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, {
headers: this.headers
});
}
return false;
async exec(cs, {res}) {
res.send(this.data.status, this.data.reason, {
headers: this.headers
});
}
}

View File

@@ -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"
}
}
}

View File

@@ -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;