chatskills
Version:
Run Alexa apps on the command-line. Run them in Slack. Run them anywhere! Supports Amazon Alexa skills and intents.
487 lines (404 loc) • 19 kB
JavaScript
/*
Build a chatbot using Alexa-style skills and intents.
Copyright (c) 2017 Kory Becker
http://primaryobjects.com/kory-becker
License MIT
*/
var alexa = require('alexa-app');
function ReqResHelper(namespace, sessionId) {
this.namespace = namespace;
this.sessionId = sessionId || 1;
this.sessions = {};
this.initialize = function() {
var text = ChatSkillsManager.id + ', ask ' + this.namespace + ' empty.';
this.sessions[this.sessionId] = ChatSkillsManager.session(text, this.sessionId);
return this;
}
this.session = function(key, value) {
this.sessions[this.sessionId].slots[key] = value;
return this;
}
this.say = function(text) {
console.log(text);
return this;
}
this.reprompt = function(text) {
// Unsupported.
return this;
}
this.shouldEndSession = function(isEnd) {
this.sessions[this.sessionId] = isEnd ? null : this.sessions[this.sessionId];
return this;
}
this.initialize();
};
var DateTimeEncoder = {
dates: {},
times: {},
dateRegEx: /(0?[1-9]|1[012])[- /.](0?[1-9]|[12][0-9]|3[01])[- /.](19|20)?[0-9]{2}/, // http://stackoverflow.com/a/29648721/2596404
timeRegEx: /([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]/, // http://stackoverflow.com/a/7536768/2596404
encode: function(text) {
text = DateTimeEncoder.encodeDateTime(text, true);
text = DateTimeEncoder.encodeDateTime(text, false);
return text;
},
decode: function(text) {
text = DateTimeEncoder.decodeDateTime(text, true);
text = DateTimeEncoder.decodeDateTime(text, false);
return text;
},
encodeDateTime: function(text, isDate) {
// Replace dates and times with temporary placeholder for regEx split.
var hash = isDate ? DateTimeEncoder.dates : DateTimeEncoder.times;
var match;
while (match = text.match(isDate ? DateTimeEncoder.dateRegEx : DateTimeEncoder.timeRegEx)) {
var key = (isDate ? '___DATE' : '___TIME') + (Object.keys(hash).length + 1);
text = text.replace(match[0], key + ' ');
hash[key] = match[0];
}
return text;
},
decodeDateTime: function(text, isDate) {
// Restore dates and times.
var hash = isDate ? DateTimeEncoder.dates : DateTimeEncoder.times;
var match = text.match(isDate ? /___DATE\d+/ : /___TIME\d+/);
if (match && match.length > 0) {
text = hash[match];
}
return text;
}
};
var ChatSkillsManager = {
verbose: false,
timeout: 3600, // session timeout in seconds (0 to disable)
id: 'Chatskills',
sessions: {},
apps: {},
name: function(name) {
this.id = name;
},
add: function(namespace) {
// Add a new skill (app namespace).
this.apps[namespace] = new alexa.app(namespace);
return this.apps[namespace];
},
app: function(namespace) {
return this.add(namespace);
},
launch: function(namespace, sessionId) {
var app = null;
if (typeof namespace == 'string') {
// Launch app.
app = this.apps[namespace];
}
else if (namespace) {
app = namespace;
}
else {
if (this.verbose) {
console.log("Error: Please provide a namespace or app to chatskills.launch(namespace, sessionId). Example: chatskills.launch('myskill') or chatskills.launch(app)");
}
}
if (app) {
// Instantiation will start a new session for this skill.
var req = new ReqResHelper(app.name, sessionId);
if (app.launchFunc) {
// Run the skill's app.launch() method.
app.launchFunc(req, req);
}
}
},
expire: function(sessionId) {
var session = this.sessions[sessionId];
if (session && this.timeout) {
var difference = (new Date() - session.date) / 1000;
if (difference > this.timeout) {
// Session expired.
if (this.verbose) {
console.log('Session ' + session.id + ' expired.');
}
delete this.sessions[sessionId];
}
}
return this.sessions[sessionId];
},
session: function(input, sessionId) {
// Get the current session and manage session expiration.
var session = this.expire(sessionId);
var regEx = new RegExp(this.id + '[,\\-\\!\\? ]+ask ([a-zA-Z0-9]+)[,\\. ](.*)', 'i');
var matches = input.match(regEx);
// Determine a skill to start: "[bot], ask [namespace] [input]".
if (matches && matches.length == 3) {
// It's a request for our bot.
var namespace = matches[1];
input = matches[2];
// See if a skill exists with this namespace.
var app = this.apps[namespace];
if (!app) {
// Skill not found.
if (this.verbose) {
console.log("Error: The skill '" + namespace + "' doesn't exist. Add one using: chatskills.add('" + namespace + "')");
}
return;
}
else {
// We've started a skill! Establish a new session.
session = this.sessions[sessionId] = {
id: sessionId,
date: new Date(),
app: app,
input: input,
slots: {}
};
if (this.verbose) {
console.log('Session ' + sessionId + ' started.');
}
}
}
else if (!session) {
// Not a request for our bot and no existing session.
if (this.verbose) {
console.log("Info: Ignoring. Example request: '" + this.id + ", ask SKILL_NAME text'.");
}
return;
}
else {
// Continue existing session.
session.date = new Date();
session.input = input;
}
return session;
},
respond: function(input, sessionId, callback) {
if (typeof sessionId == 'function') {
// No sessionId provided, default to 1 (localhost).
callback = sessionId;
sessionId = 1;
}
// If no sessionId and no callback provided, set a default sessionId.
sessionId = sessionId || 1;
// Get a new or existing session.
var currentSession = this.session(input, sessionId);
if (currentSession) {
var app = currentSession.app;
input = currentSession.input;
var result = null;
// Go through each intent in the skill to find a valid response.
for (var i in Object.keys(app.intents)) {
var key = Object.keys(app.intents)[i];
// Get utterances for this intent.
var utterances = [];
app.utterances().split('\n').forEach(function(template) {
// Get the intent name from this template line.
var matches = template.match(/([a-zA-Z0-9\.]+)[\t ]/);
if (matches && matches[1] == key) {
// The intent matches ours, let's use it. First, strip out intent name.
var start = template.indexOf('\t');
if (start === -1) {
start = template.indexOf(' ');
}
template = template.substring(start + 1);
// Add this utterance for processing.
utterances.push(template);
}
});
result = ChatSkillsManager.parse(input, utterances, app.intents[key], app.dictionary);
if (result.isValid) {
var shouldContinueSession = false;
// This intent is valid for the input. Set slots.
for (var j in result.pairs) {
var pair = result.pairs[j];
currentSession.slots[pair.name] = pair.value;
};
// Call intent.
var continueSession = app.intents[key].handler(
{
// Request
input: input,
slots: currentSession.slots,
variables: result.pairs,
get: function(key) {
return currentSession.slots[key];
},
set: function(key, value) {
currentSession.slots[key] = value;
},
session: function(key, value) {
return value ? this.set(key, value) : this.get(key);
},
slot: function(key) {
return this.get(key);
}
},
{
// Response
say: function(text) {
if (callback) {
callback(text);
}
return this;
},
card: function(obj) {
if (callback) {
callback(null, obj);
}
return this;
},
reprompt: function(text) {
// Unsupported.
return this;
},
session: function(key, value) {
currentSession.slots[key] = value;
},
shouldEndSession: function(isEnd) {
// Support for: res.shouldEndSession(false) to tell chatskills to keep session alive (even though default return from response is false, meaning end session).
shouldContinueSession = !isEnd;
return this;
}
});
if (!continueSession && !shouldContinueSession) {
// Intent returned false, so end the session.
delete this.sessions[sessionId];
if (this.verbose) {
console.log('Session ' + sessionId + ' ended.');
}
}
// We've already found a valid intent, skip processing the rest. This prevents multiple responses from the same text.
break;
}
}
if (!result || !result.isValid) {
if (callback) {
callback();
}
}
}
else {
if (callback) {
callback();
}
}
},
parse: function(text, utterances, schema, dictionary) {
var result = { isValid: true, pairs: [] };
var slots = schema.slots;
for (var h in utterances) {
var template = utterances[h];
var regEx = /[ \n\r\t,\!`\(\)\[\]:;\"\?\/\\\<\+\=>]+/;
result = { isValid: true, pairs: [] };
if (template && template.length > 0) {
//console.log('Template: ' + template);
//console.log('Text: ' + text);
// Remove leading and trailing periods.
text = text.replace(/(^\.+)|(\.+$)/g, '');
// Replace dates and times with temporary placeholder for regEx split.
text = DateTimeEncoder.encode(text);
// Find all variables and fill in values.
var tokens = template.split(regEx);
var words = text.split(regEx).filter(function(e) { return e }); // remove empty strings.
if (tokens.length == words.length) {
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
var word = words[i];
word = DateTimeEncoder.decode(word);
//console.log('Token: ' + token);
//console.log('Word: ' + word);
if (token.toLowerCase() != word.toLowerCase()) {
// A word doesn't match, but is it a variable?
var tokenParts = token.match(/{([a-zA-Z0-9]+)\|([a-zA-Z0-9]+)}/); // {alias|Date} or {alias|Custom} etc.
var tokenParts2 = token.match(/{([a-zA-Z0-9]+)}/); // {Date} or {Number} etc.
if ((tokenParts && tokenParts.length == 3) || (tokenParts2 && tokenParts2.length > 0)) {
if (tokenParts2 && tokenParts2.length == 2) {
tokenParts = [tokenParts2[0], tokenParts2[1], tokenParts2[1]];
}
//console.log(tokenParts[2] + ' = ' + word);
// Found a variable.
var name = tokenParts[2];
// Check if the value matches the variable type.
var isValidType = true;
var type = slots[name];
switch (type) {
case 'NUMBER':
case 'AMAZON.NUMBER': isValidType = (parseFloat(word) && isFinite(word)); break;
case 'DATE':
case 'AMAZON.DATE': isValidType = word.match(DateTimeEncoder.dateRegEx); break;
case 'TIME':
case 'AMAZON.TIME': isValidType = word.match(DateTimeEncoder.timeRegEx); break;
case 'LITERAL':
case 'AMAZON.LITERAL': isValidType = true; break;
default: {
isValidType = false;
// Check if this is a custom slot type.
var variableParts = template.match(/\{(.+)\|(.+)\}/);
var variableParts2 = template.match(/\{(.+)\}/);
if ((!variableParts || variableParts.length != 3) && (variableParts2 && variableParts2.length == 2)) {
// This is a custom type with {-|CustomName}.
isValidType = true;
}
else if (variableParts && variableParts.length == 3) {
// This is a slot variable, check if the value exists in the dictionary.
if (dictionary) {
var utteranceValue = variableParts[1];
if (utteranceValue.toLowerCase() == word.toLowerCase()) {
// The spoken word matches this utterance.
for (var j in Object.keys(dictionary)) {
var key = Object.keys(dictionary)[j];
for (var dictionaryIndex in dictionary[key]) {
var dictionaryValue = dictionary[key][dictionaryIndex];
if (utteranceValue.toLowerCase() == dictionaryValue.toLowerCase()) {
//console.log('Found ' + utteranceValue + ' == ' + dictionaryValue + ' in: ' + key);
isValidType = true;
break;
}
}
if (isValidType) {
break;
}
}
}
}
}
}
};
if (isValidType) {
// It's a valid variable and type.
result.pairs.push({ name: name, value: word });
}
else {
// It's a variable, but the type is wrong (ie., text supplied where a number should be, etc).
result.isValid = false;
break;
}
}
else if (token && /^(\{.+\})$/.test(token)) {
// token is a custom slot type {SomeType}.
var name = token.substring(1, token.length - 1);
if (slots[name]) {
// This is a valid custom slot type.
result.pairs.push({ name: name, value: word });
}
}
else {
result.isValid = false;
break;
}
}
}
}
else {
result.isValid = false;
continue;
}
}
else {
result.isValid = false;
}
if (result.isValid) {
break;
}
};
return result;
}
};
module.exports = ChatSkillsManager;