Skip to content

Commit

Permalink
feat: port dialog command to cli v2
Browse files Browse the repository at this point in the history
  • Loading branch information
pbheemag committed Mar 19, 2020
1 parent 0036a14 commit c88938c
Show file tree
Hide file tree
Showing 29 changed files with 3,339 additions and 21 deletions.
3 changes: 2 additions & 1 deletion bin/askx.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require('@src/commands/configure').createCommand(commander);
require('@src/commands/deploy').createCommand(commander);
require('@src/commands/v2new').createCommand(commander);
require('@src/commands/init').createCommand(commander);
require('@src/commands/dialog').createCommand(commander);

commander
.description('Command Line Interface for Alexa Skill Kit')
Expand All @@ -22,7 +23,7 @@ commander
.version(require('../package.json').version)
.parse(process.argv);

const ALLOWED_ASK_ARGV_2 = ['api', 'configure', 'deploy', 'new', 'init', 'util', 'help', '-v', '--version', '-h', '--help'];
const ALLOWED_ASK_ARGV_2 = ['api', 'configure', 'deploy', 'new', 'init', 'dialog', 'util', 'help', '-v', '--version', '-h', '--help'];
if (process.argv[2] && ALLOWED_ASK_ARGV_2.indexOf(process.argv[2]) === -1) {
console.log('Command not recognized. Please run "askx" to check the user instructions.');
}
62 changes: 62 additions & 0 deletions lib/commands/dialog/helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const R = require('ramda');

module.exports = {
validateDialogArgs
};

/**
* Validates if a skill is enabled for simulation. Calls Skill Management apis (SMAPI) to achieve this.
* @param {*} dialogMode encapsulates configuration required validate skill information
* @param {*} callback
*/
function validateDialogArgs(dialogMode, callback) {
const { smapiClient, skillId, stage, locale } = dialogMode;

smapiClient.skill.manifest.getManifest(skillId, stage, (err, response) => {
if (err) {
return callback(err);
}
if (response.statusCode !== 200) {
return callback(smapiErrorMsg('get-manifest', response));
}
const apis = R.view(R.lensPath(['body', 'manifest', 'apis']), response);
if (!apis) {
return callback('Ensure "manifest.apis" object exists in the skill manifest.');
}

const apisKeys = Object.keys(apis);
if (!apisKeys || apisKeys.length !== 1) {
return callback('Dialog command only supports custom skill type.');
}

if (apisKeys[0] !== 'custom') {
return callback(`Dialog command only supports custom skill type, but current skill is a "${apisKeys[0]}" type.`);
}

const locales = R.view(R.lensPath(['body', 'manifest', 'publishingInformation', 'locales']), response);
if (!locales) {
return callback('Ensure the "manifest.publishingInformation.locales" exists in the skill manifest before simulating your skill.');
}

if (!R.view(R.lensProp(locale), locales)) {
return callback(
`Locale ${locale} was not found for your skill. `
+ 'Ensure the locale you want to simulate exists in your publishingInformation.'
);
}

smapiClient.skill.getSkillEnablement(skillId, stage, (enableErr, enableResponse) => {
if (enableErr) {
return callback(enableErr);
}
if (enableResponse.statusCode > 300) {
return callback(smapiErrorMsg('get-skill-enablement', enableResponse));
}
callback();
});
});
}

function smapiErrorMsg(operation, res) {
return `SMAPI ${operation} request error: ${res.statusCode} - ${res.body.message}`;
}
128 changes: 128 additions & 0 deletions lib/commands/dialog/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
const path = require('path');

const SmapiClient = require('@src/clients/smapi-client');
const { AbstractCommand } = require('@src/commands/abstract-command');
const optionModel = require('@src/commands/option-model');
const DialogReplayFile = require('@src/model/dialog-replay-file');
const ResourcesConfig = require('@src/model/resources-config');
const CONSTANTS = require('@src/utils/constants');
const profileHelper = require('@src/utils/profile-helper');
const Messenger = require('@src/view/messenger');
const SpinnerView = require('@src/view/spinner-view');
const stringUtils = require('@src/utils/string-utils');

const InteractiveMode = require('./interactive-mode');
const ReplayMode = require('./replay-mode');

const helper = require('./helper');

class DialogCommand extends AbstractCommand {
name() {
return 'dialog';
}

description() {
return 'simulate your skill via an interactive dialog with Alexa';
}

requiredOptions() {
return [];
}

optionalOptions() {
return ['skill-id', 'locale', 'stage', 'replay', 'profile', 'debug'];
}

handle(cmd, cb) {
let dialogMode;
try {
dialogMode = this._dialogModeFactory(this._getDialogConfig(cmd));
} catch (err) {
Messenger.getInstance().error(err);
return cb(err);
}

const spinner = new SpinnerView();
spinner.start('Checking if skill is ready to simulate...');
helper.validateDialogArgs(dialogMode, (dialogArgsValidationError) => {
if (dialogArgsValidationError) {
spinner.terminate(SpinnerView.TERMINATE_STYLE.FAIL, 'Failed to validate command options');
Messenger.getInstance().error(dialogArgsValidationError);
return cb(dialogArgsValidationError);
}
spinner.terminate();
dialogMode.start((controllerError) => {
if (controllerError) {
Messenger.getInstance().error(controllerError);
return cb(controllerError);
}
cb();
});
});
}

/**
* Function processes dialog arguments and returns a consolidated object.
* @param {Object} cmd encapsulates arguments provided to the dialog command.
* @return { skillId, locale, stage, profile, debug, replayFile, smapiClient, userInputs <for replay mode> }
*/
_getDialogConfig(cmd) {
const debug = Boolean(cmd.debug);
let { skillId, locale, stage, profile } = cmd;
profile = profileHelper.runtimeProfile(profile);
stage = stage || CONSTANTS.SKILL.STAGE.DEVELOPMENT;
let userInputs;
if (cmd.replay) {
const dialogReplayConfig = new DialogReplayFile(cmd.replay);
skillId = dialogReplayConfig.getSkillId();
if (!stringUtils.isNonBlankString(skillId)) {
throw 'Replay file must contain skillId';
}
locale = dialogReplayConfig.getLocale();
if (!stringUtils.isNonBlankString(locale)) {
throw 'Replay file must contain locale';
}
userInputs = this._validateUserInputs(dialogReplayConfig.getUserInput());
} else {
if (!stringUtils.isNonBlankString(skillId)) {
try {
new ResourcesConfig(path.join(process.cwd(), CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG));
skillId = ResourcesConfig.getInstance().getSkillId(profile);
} catch (err) {
throw 'Failed to read project resource file.';
}
if (!stringUtils.isNonBlankString(skillId)) {
throw `Failed to obtain skill-id from project resource file ${CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG}`;
}
}
locale = locale || process.env.ASK_DEFAULT_DEVICE_LOCALE;
if (!stringUtils.isNonBlankString(locale)) {
throw 'Locale has not been specified.';
}
}
const smapiClient = new SmapiClient({ profile, doDebug: debug });
return { skillId, locale, stage, profile, debug, replay: cmd.replay, smapiClient, userInputs };
}

_dialogModeFactory(config) {
if (config.replay) {
return new ReplayMode(config);
}
return new InteractiveMode(config);
}

_validateUserInputs(userInputs) {
const validatedInputs = [];
userInputs.forEach((input) => {
const trimmedInput = input.trim();
if (!stringUtils.isNonBlankString(trimmedInput)) {
throw "Replay file's userInput cannot contain empty string.";
}
validatedInputs.push(trimmedInput);
});
return validatedInputs;
}
}

module.exports = DialogCommand;
module.exports.createCommand = new DialogCommand(optionModel).createCommand();
35 changes: 35 additions & 0 deletions lib/commands/dialog/interactive-mode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const chalk = require('chalk');

const DialogReplView = require('@src/view/dialog-repl-view');
const DialogController = require('@src/controllers/dialog-controller');

module.exports = class InteractiveMode extends DialogController {
constructor(config) {
super(config);
this.header = config.header || 'Welcome to ASK Dialog\n'
+ 'In interactive mode, type your utterance text onto the console and hit enter\n'
+ 'Alexa will then evaluate your input and give a response!';
}

start(callback) {
let interactiveReplView;
try {
interactiveReplView = new DialogReplView({
prompt: chalk.yellow.bold('User > '),
header: this.header,
footer: 'Goodbye!',
evalFunc: (input, replCallback) => {
this.evaluateUtterance(input, interactiveReplView, replCallback);
},
});
} catch (error) {
return callback(error);
}

this.setupSpecialCommands(interactiveReplView, (error) => {
if (error) {
return callback(error);
}
});
}
};
62 changes: 62 additions & 0 deletions lib/commands/dialog/replay-mode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const chalk = require('chalk');
const { Readable } = require('stream');

const DialogController = require('@src/controllers/dialog-controller');
const DialogReplView = require('@src/view/dialog-repl-view');
const InteractiveMode = require('./interactive-mode');

module.exports = class ReplayMode extends DialogController {
constructor(config) {
super(config);
this.config = config;
this.userInputs = config.userInputs;
this.replay = config.replay;
}

start(callback) {
const replayInputStream = new Readable({ read() {} });
let replayReplView;
try {
replayReplView = new DialogReplView({
prompt: chalk.yellow.bold('User > '),
header: this._getHeader(),
footer: 'Goodbye!',
inputStream: replayInputStream,
evalFunc: (replayInput, replayCallback) => {
this._evaluateInput(replayInput, replayReplView, replayInputStream, replayCallback, callback);
}
});
} catch (error) {
return callback(error);
}

this.setupSpecialCommands(replayReplView, (error) => {
if (error) {
return callback(error);
}
});
replayInputStream.push(`${this.userInputs.shift()}\n`, 'utf8');
}

_getHeader() {
return 'Welcome to ASK Dialog\n'
+ `Replaying a multi turn conversation with Alexa from ${this.replay}\n`
+ 'Alexa will then evaluate your input and give a response!';
}

_evaluateInput(replayInput, replayReplView, replayInputStream, replayCallback, callback) {
this.evaluateUtterance(replayInput, replayReplView, () => {
if (this.userInputs.length > 0) {
replayCallback();
replayInputStream.push(`${this.userInputs.shift()}\n`, 'utf8');
} else {
replayReplView.clearSpecialCommands();
replayReplView.close();
this.config.header = 'Switching to interactive dialog.\n'
+ 'To automatically quit after replay, append \'.quit\' to the userInput of your replay file.';
const interactiveReplView = new InteractiveMode(this.config);
interactiveReplView.start(callback);
}
});
}
};
12 changes: 11 additions & 1 deletion lib/commands/option-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
"alias": "s",
"stringInput": "REQUIRED"
},
"replay": {
"name": "replay",
"description": "Specify a replay file to simulate dialog with Alexa",
"alias": "r",
"stringInput": "REQUIRED",
"rule": [{
"type": "REGEX",
"regex": "^[./]?[\\w\\-. /]+\\.(json)"
}]
},
"isp-id": {
"name": "isp-id",
"description": "isp-id for the in skill product",
Expand Down Expand Up @@ -374,7 +384,7 @@
"stringInput": "REQUIRED",
"rule": [{
"type": "ENUM",
"values": ["uniqueCustomers", "totalEnablements", "totalUtterances", "successfulUtterances", "failedUtterances", "totalSessions",
"values": ["uniqueCustomers", "totalEnablements", "totalUtterances", "successfulUtterances", "failedUtterances", "totalSessions",
"successfulSessions", "incompleteSessions", "userEndedSessions", "skillEndedSessions"]
}]
},
Expand Down
Loading

0 comments on commit c88938c

Please sign in to comment.