iopa-bot
Version:
API-First Bot Framework for Internet of Things (IoT), based on Internet of Protocols Alliance (IOPA) specification
641 lines (640 loc) • 30.8 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const ReactiveCards = require("reactive-cards");
const reactive_dialogs_1 = require("reactive-dialogs");
const Iopa = require("iopa");
const { IOPA, SERVER } = Iopa.constants;
const constants_1 = require("../constants");
const forEachAsync_1 = require("../util/forEachAsync");
const parse_url_1 = require("../polyfill/parse_url");
const session_1 = require("./session");
const defaultPauseInterval = 200;
const STARTS_WITH_EXTERNAL_REGEXP = /^(http|https|ftp|tel|sms)/i;
exports.useReactiveDialogs = (context) => {
return context[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.ReactiveDialogs];
};
const RDM_VERSION = '2.0';
class ReactiveDialogManager {
constructor(app) {
this.flows = {};
this.flowsMeta = {};
this.tableLists = {};
this.tableMeta = {};
this.launchIntentsToFlows = {};
this._localResourceProtocolMapper = (partial_url) => partial_url || '';
this.app = app;
app.properties[SERVER.CancelTokenSource] =
app.properties[SERVER.CancelTokenSource] ||
new Iopa.util.CancellationTokenSource();
app.properties[SERVER.CancelToken] =
app.properties[SERVER.CancelTokenSource].token;
this.commandHandlers = new Map();
app.properties[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.ReactiveDialogs] = {
'iopa.Version': constants_1.BOT.VERSION,
use: (jsx, meta) => {
this.register(app, jsx, meta);
},
render: (element, context, next) => {
return this.render(element, context, next);
},
renderFlow: (id, stepId, context, next) => {
return this.renderFlowById(id, stepId, context, next);
},
end: (context) => __awaiter(this, void 0, void 0, function* () {
return this.endFlow(context, { reason: 'capability.end' });
}),
registerCommand: (command, handler) => {
this.commandHandlers.set(command, handler);
return () => {
this.commandHandlers.delete(command);
};
},
_commandHandlers: this.commandHandlers,
meta: this.flowsMeta,
lists: this.tableLists,
tables: this.tableMeta,
set localResourceProtocolMapper(mapper) {
this._localResourceProtocolMapper = mapper;
ReactiveCards.setLocalResourceProtocolMapper(mapper);
},
get localResourceProtocolMapper() {
return this._localResourceProtocolMapper;
}
};
app.reactivedialogs =
app.properties[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.ReactiveDialogs];
app.properties[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.ReactiveDialogs][IOPA.Version] = constants_1.BOT.VERSION;
app.intent('reactivedialogs:intents:start', {
slots: {
FlowId: true
},
utterances: ['/dialog {-|FlowId}']
}, (context, next) => __awaiter(this, void 0, void 0, function* () {
context.response.responseHandled = true;
const flowId = context[constants_1.BOT.Slots]['FlowId'];
return this.renderFlowById(flowId, null, context, next);
}));
this.commandHandlers.set('end', (_command, _props, context) => __awaiter(this, void 0, void 0, function* () {
this.endFlow(context, { reason: 'command:end' });
return false;
}));
this.commandHandlers.set('pause', (_command, props, context) => __awaiter(this, void 0, void 0, function* () {
yield delay(context, props.delay || defaultPauseInterval);
return true;
}));
this.commandHandlers.set('return', (_command, props, context) => __awaiter(this, void 0, void 0, function* () {
const botSession = session_1.useBotSession(context)[0];
const prevDialog = botSession[constants_1.BOT.CurrentDialog];
if (prevDialog && prevDialog.previousId) {
yield this.renderFlowById(botSession[constants_1.BOT.Skill], prevDialog.previousId, context, () => Promise.resolve());
}
return false;
}));
}
invoke(context, next) {
const flows = exports.useReactiveDialogs(context);
if (context['urn:bot:dialog:invoke']) {
let flowId = context['urn:bot:dialog:invoke'];
let dialogId = null;
if (flowId.indexOf('#') >= 0) {
let split = flowId.split('#', 2);
flowId = split[0];
dialogId = split[1];
}
return flows.renderFlow(flowId, dialogId, context, next);
}
if (!context[constants_1.BOT.Intent]) {
return next();
}
const botSession = session_1.useBotSession(context)[0];
var isV2Dialog = !!botSession[constants_1.BOT.SkillVersion];
if (!isV2Dialog)
return next();
console.log('>> skill', botSession[constants_1.BOT.Skill]);
console.log('>> intent', context[constants_1.BOT.Intent]);
console.log('>> dialog', JSON.stringify(botSession[constants_1.BOT.CurrentDialog] ? botSession[constants_1.BOT.CurrentDialog].id : "", null, 2));
if (!botSession[constants_1.BOT.CurrentDialog]) {
return this._matchBeginFlow(context, next);
}
else {
return this._continueFlow(context, next);
}
}
_matchBeginFlow(context, next) {
const reactive = exports.useReactiveDialogs(context);
const intent = context[constants_1.BOT.Intent];
const flowId = this.launchIntentsToFlows[intent];
if (!flowId) {
console.log('No current V2 dialog, and could not find as launch intent');
return next();
}
return reactive.renderFlow(flowId, null, context, next);
}
_continueFlow(context, next) {
return __awaiter(this, void 0, void 0, function* () {
const [botSession, setBotSession] = session_1.useBotSession(context);
const intent = context[constants_1.BOT.Intent];
var flowId = botSession[constants_1.BOT.Skill];
var flow = this.flows[flowId];
if (!flow) {
console.log(`Dialog Flow ${flowId} in session no longer available in registry`);
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')('dialog-abend', {
id: botSession[constants_1.BOT.Skill],
reason: `Dialog Flow ${flowId} in session no longer available in registry`
}, context);
}
setBotSession(null);
return this._matchBeginFlow(context, next);
}
if (botSession[constants_1.BOT.SkillVersion] &&
flow.props.version.split('.')[0] !==
botSession[constants_1.BOT.SkillVersion].split('.')[0]) {
console.log(`Dialog Flow ${flowId} major version ${flow.props.version} updated while participant was mid session on version ${botSession[constants_1.BOT.SkillVersion]}`);
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')('dialog-abend', {
id: botSession[constants_1.BOT.Skill],
reason: `Dialog Flow ${flowId} major version ${flow.props.version} updated while participant was mid session on version ${botSession[constants_1.BOT.SkillVersion]}`
}, context);
}
setBotSession(null);
return this._matchBeginFlow(context, next);
}
const { id: dialogId, lastDirective, lastPromptActions, iopaBotVersion } = botSession[constants_1.BOT.CurrentDialog];
if (iopaBotVersion !== RDM_VERSION) {
return next();
}
const dialogSeqNo = flow.props.children.findIndex(directive => directive.type == 'dialog' && directive.props.id == dialogId);
if (dialogSeqNo == -1) {
console.log(`Current session dialog step ${dialogId} in flow ${flowId} no longer available in registry`);
if (this.commandHandlers.has('dialog-abend')) {
this.commandHandlers.get('dialog-abend')('dialog-abend', {
id: botSession[constants_1.BOT.Skill],
reason: `Current session dialog step ${dialogId} in flow ${flowId} no longer available in registry`
}, context);
}
setBotSession(null);
return this._matchBeginFlow(context, next);
}
setBotSession({
[constants_1.BOT.Variables]: Object.assign(Object.assign({}, botSession[constants_1.BOT.Variables]), { [`${dialogId}${lastDirective ? `:${lastDirective}` : ''}`]: intent, [`${dialogId}${lastDirective ? `:${lastDirective}:raw` : ':raw'}`]: context[constants_1.BOT.Text] })
});
if (dialogSeqNo == flow.props.children.length - 1) {
setBotSession({
[constants_1.BOT.LastDialogEndedDate]: new Date().getTime()
});
yield this.endFlow(context, { reason: 'last-response' });
return next();
}
const dialog = flow.props.children[dialogSeqNo];
if (lastDirective == null ||
lastDirective >= dialog.props.children.length) {
console.log(`Last directive sequence #${lastDirective} of dialog #${dialogId} in flow ${flowId} no longer available in registry`);
}
if (!lastPromptActions) {
yield this.proceedToNextDirective(context, flow, dialog, dialogSeqNo, lastDirective);
return next();
}
else {
const intentFilters = lastPromptActions.map(action => action.props.intents || action.props.utterances || [toString(action)]);
const selectedActionSeqNo = intentFilters.findIndex(filters => filters.includes(intent) || filters.includes('*'));
if (selectedActionSeqNo == -1) {
const notUnderstoodDialog = `${dialogId}-not-understood`;
const notUnderstoodSkill = `not-understood`;
const nextStep = flow.props.children.find(dialog => dialog.props.id == notUnderstoodDialog) ||
flow.props.children.find(dialog => dialog.props.id == notUnderstoodSkill);
if (nextStep) {
yield this.renderDialogStep(flow, nextStep, context);
return next();
}
return this._matchBeginFlow(context, next);
}
const action = lastPromptActions[selectedActionSeqNo];
if (action.props.url) {
action.props.type = 'openurl';
}
switch (action.props.type) {
case 'submit':
yield this.proceedToNextDirective(context, flow, dialog, dialogSeqNo, lastDirective);
return next();
case 'openurl':
yield this.renderActionOpenUrl(action, context);
return next();
default:
console.log(`card type ${action.props.type} not yet supported in reactive-dialogs manager`);
yield this.proceedToNextDirective(context, flow, dialog, dialogSeqNo, lastDirective);
return next();
}
}
});
}
proceedToNextDirective(context, flow, dialog, dialogSeqNo, lastDirective) {
return __awaiter(this, void 0, void 0, function* () {
if (lastDirective !== null &&
lastDirective < dialog.props.children.length - 1) {
return Promise.resolve();
}
else if (dialogSeqNo < flow.props.children.length - 1) {
const nextStep = flow.props.children[dialogSeqNo + 1];
return this.renderDialogStep(flow, nextStep, context);
}
else {
return this.endFlow(context, { reason: 'last-directive' });
}
});
}
register(app, jsx, meta = {}) {
const flow = jsx({});
if (!flow) {
return;
}
if ((flow.type !== 'flow') && (flow.type !== 'table')) {
return throwErr('Tried to register a flow that is not a reactive-dialogs type');
}
if (flow.type == 'table') {
const tableId = flow.props.id;
const lists = flow.props.children;
this.tableMeta[tableId] = Object.assign({ lists: [] }, meta);
lists.forEach(list => {
const listid = list.props.id;
const items = list.props.children;
this.tableLists[listid] = items;
this.tableMeta[tableId].lists.push(listid);
});
return;
}
const flowId = flow.props.id;
if (flowId in this.flows) {
return throwErr(`Tried to register a dialog flow with id ${flowId} that already has been registered; restart engine first`);
}
this.flows[flowId] = flow;
this.flowsMeta[flowId] = meta;
const skill = app.properties[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.Skills].add(flowId);
if (!meta.global) {
skill.global(false);
}
flow.props.children.forEach(dialog => {
this.registerDialogStep(dialog, skill);
});
if (flow.props.utterances && flow.props.utterances.length > 0) {
if (!Array.isArray(flow.props.utterances)) {
throwErr('utterances on <flow> must be an array of strings');
}
const skills = app.properties[SERVER.Capabilities][constants_1.BOT.CAPABILITIES.Skills].skills;
const launchSkill = flow.props.canLaunchFromGlobal
? skills.default
: skill;
const existingIntent = launchSkill.lookupIntent(flow.props.utterances);
if (existingIntent) {
this.launchIntentsToFlows[existingIntent] = flowId;
}
else {
const launchName = `reactiveDialogs:flow:${flowId}:launchIntent`;
launchSkill.intent(launchName, { utterances: flow.props.utterances });
this.launchIntentsToFlows[launchName] = flowId;
}
}
}
registerDialogStep(dialog, skill) {
dialog.props.children
.filter(dialogChild => dialogChild.type == 'card' || typeof dialogChild.type == 'function')
.forEach(dialogChild => {
if (dialogChild.type == 'card') {
this.registerDialogCard(dialogChild, skill);
}
else {
this.registerDialogFuction(dialogChild, skill);
}
});
}
registerDialogFuction(fn, skill) {
const dialogChild = fn.type(Object.assign({}, fn.props, fn.type.defaultProps));
if (dialogChild == null) {
return;
}
if (dialogChild.type == 'card') {
this.registerDialogCard(dialogChild, skill);
}
else if (typeof dialogChild.type == 'function') {
this.registerDialogFuction(dialogChild, skill);
}
}
registerDialogCard(card, skill) {
card.props.children
.filter(cardChild => cardChild.type == 'actionset')
.forEach(actionset => {
this.registerDialogCardActions(actionset, skill);
});
}
registerDialogCardActions(actionset, skill) {
actionset.props.children
.filter(action => action.type == 'action')
.forEach(action => {
let response = toString(action).toLowerCase();
const utterances = action.props.utterances || [response];
const name = this.registerUtterances(utterances[0].replace(/[\W_]+/g, ''), utterances, skill);
action.props.intents = action.props.intents || [];
action.props.intents.push(name);
});
}
registerUtterances(name, utterances, skill) {
const schemaUtterances = utterances.map(s => s.toLowerCase()).sort();
const existingIntent = skill.lookupIntent(schemaUtterances);
if (existingIntent) {
return existingIntent;
}
skill.intent(name, { utterances: schemaUtterances });
return name;
}
render(element, context, next) {
return throwErr('Inline render of unregistered reactive-dialogs elements not yet implemented');
}
renderFlowById(id, dialogId, context, next) {
return __awaiter(this, void 0, void 0, function* () {
const [botSession, setBotSession] = session_1.useBotSession(context);
if (id && botSession[constants_1.BOT.Skill] && id !== botSession[constants_1.BOT.Skill]) {
if (this.commandHandlers.has('dialog-end')) {
this.commandHandlers.get('dialog-end')('dialog-end', {
id: botSession[constants_1.BOT.Skill],
reason: `switch ${id}`
}, context);
}
}
const flowId = id || botSession[constants_1.BOT.Skill];
if (!flowId) {
console.log(`Cannot infer blank id to render when not in a current flow; continuing with pipeline`);
return next();
}
const flow = this.flows[flowId];
if (!flow) {
console.log(`Dialog Flow ${flowId} not found in V2 handler; continuing with pipeline`);
return next();
}
if (flow.props.children.length == 0) {
console.log(`Dialog Flow ${flowId} is empty`);
return Promise.resolve();
}
let dialogStep;
if (!dialogId &&
!id &&
botSession[constants_1.BOT.CurrentDialog] &&
botSession[constants_1.BOT.CurrentDialog].id) {
const currentDialogId = botSession[constants_1.BOT.CurrentDialog].id;
const currentSeq = flow.props.children.findIndex(dialog => dialog.props.id == currentDialogId);
if (currentSeq < flow.props.children.length - 1) {
dialogStep = flow.props.children[currentSeq + 1];
}
else {
dialogStep = flow.props.children[0];
}
}
else if (dialogId) {
dialogStep = flow.props.children.find(c => c.props.id === dialogId);
if (!dialogStep) {
console.error(`Step ${dialogId} not found on dialog ${flowId};`);
return Promise.resolve();
}
}
else {
dialogStep = flow.props.children[0];
}
if (!botSession[constants_1.BOT.Skill] || flow.props.id !== botSession[constants_1.BOT.Skill]) {
if (this.commandHandlers.has('dialog-start')) {
this.commandHandlers.get('dialog-start')('dialog-start', {
id: flow.props.id,
intent: context['urn:bot:dialog:invoke']
? 'urn:bot:dialog:invoke'
: context[constants_1.BOT.Intent]
}, context);
}
}
setBotSession({
[constants_1.BOT.Skill]: flow.props.id,
[constants_1.BOT.SkillVersion]: flow.props.version,
[constants_1.BOT.CurrentDialog]: null,
[constants_1.BOT.Variables]: botSession[constants_1.BOT.Variables] || {}
});
yield this.renderDialogStep(flow, dialogStep, context);
return next();
});
}
renderDialogStep(flow, dialog, context) {
return __awaiter(this, void 0, void 0, function* () {
console.log('Starting flow dialog step #', dialog.props.id);
const [botSession, setBotSession] = session_1.useBotSession(context);
if (!botSession) {
return;
}
const prevDialog = botSession[constants_1.BOT.CurrentDialog];
const currentDialog = {
id: dialog.props.id,
previousId: prevDialog ? prevDialog.id : undefined,
iopaBotVersion: RDM_VERSION,
lastDirective: null,
lastPromptActions: null
};
setBotSession({
[constants_1.BOT.CurrentDialog]: currentDialog,
[constants_1.BOT.isMultiChoicePrompt]: false
});
const isNotWaitingOnPrompt = yield forEachAsync_1.asyncForEachIfTrue(dialog.props.children, (directive, i) => __awaiter(this, void 0, void 0, function* () {
if (this.app.properties[SERVER.CancelToken].isCancelled)
return false;
console.log(`Performing dialog step ${dialog.props.id} directive ${i}`);
currentDialog.lastDirective = i;
setBotSession({
[constants_1.BOT.CurrentDialog]: currentDialog
});
const isNotWaitingOnPrompt = yield this.renderDirective(directive, context);
return isNotWaitingOnPrompt;
}));
if (isNotWaitingOnPrompt) {
const isLastItem = flow.props.children[flow.props.children.length - 1] === dialog;
if (isLastItem) {
return this.endFlow(context, { reason: 'last-response' });
}
const currentSeq = flow.props.children.findIndex(d => d.props.id == dialog.props.id);
if (currentSeq !== -1) {
const nextStep = flow.props.children[currentSeq + 1];
return this.renderDialogStep(flow, nextStep, context);
}
}
return;
});
}
endFlow(context, props) {
return __awaiter(this, void 0, void 0, function* () {
const [botSession, setBotSession] = session_1.useBotSession(context);
console.log(`Ending dialog flow ${botSession[constants_1.BOT.Skill]}`);
if (this.commandHandlers.has('dialog-end')) {
this.commandHandlers.get('dialog-end')('dialog-end', Object.assign({ id: botSession[constants_1.BOT.Skill], success: true }, props), context);
}
yield setBotSession(null);
context.response[constants_1.BOT.ShouldEndSession] = true;
});
}
renderDirective(element, context) {
const vdom = reactive_dialogs_1.render(element);
switch (vdom.type) {
case 'text':
return this.renderText(vdom, context);
case 'card':
this.saveActionsFromCard(vdom, context);
return this.renderCard(vdom, context);
case 'action':
return this.renderAction(vdom, context);
default:
throwErr(`invalid dialog flow: <${element.type}> not a valid dialog directive or card type`);
return Promise.resolve(false);
}
}
renderText(element, context) {
return __awaiter(this, void 0, void 0, function* () {
const text = toString(element);
const pause = element.props.pause || defaultPauseInterval;
yield context.response.sendAll([text]);
yield delay(context, pause || defaultPauseInterval);
return true;
});
}
renderCard(element, context) {
return __awaiter(this, void 0, void 0, function* () {
const [botSession, setBotSession] = session_1.useBotSession(context);
const actionset = element.props.children.find(child => child.type == 'actionset');
if (actionset) {
actionset.props.children.forEach(action => {
if (action.props.type === 'openurl') {
if (!(STARTS_WITH_EXTERNAL_REGEXP.test(action.props.url))) {
action.props.type = 'submit';
}
action.props.data = action.props.utterances
? action.props.utterances[0]
: toString(action).toLowerCase();
}
});
yield setBotSession({ [constants_1.BOT.isMultiChoicePrompt]: true });
}
const meta = this.flowsMeta[botSession[constants_1.BOT.Skill]];
const resourceRoot = (meta && meta["nkar"]) ? `${meta["nkar"]}/` : '';
const card = ReactiveCards.render(element, resourceRoot);
const pause = element.props.pause || defaultPauseInterval;
yield context.response.sendAll([{ text: '', attachments: [card] }]);
yield delay(context, pause || defaultPauseInterval);
return !card.actions || (card.actions.length == 0);
});
}
saveActionsFromCard(element, context) {
const [botSession, setBotSession] = session_1.useBotSession(context);
const currentDialog = botSession[constants_1.BOT.CurrentDialog];
const actionset = element.props.children.find(actionset => actionset.type == 'actionset');
if (!actionset) {
return;
}
currentDialog.lastPromptActions = actionset.props.children.filter(action => action.type == 'action');
setBotSession({ [constants_1.BOT.CurrentDialog]: currentDialog });
}
renderAction(element, context) {
switch (element.props.type) {
case 'openurl':
return this.renderActionOpenUrl(element, context);
case 'showcard':
case 'submit':
default:
throwErr(`Invalid action type '${element.props.type}' when used as a direct child of <step>`);
return Promise.resolve(true);
}
}
renderActionOpenUrl(element, context) {
return __awaiter(this, void 0, void 0, function* () {
if (!element.props.url) {
return this.renderActionDialogFlow('', '', element, context);
}
if (element.props.url.indexOf(':') == -1) {
element.props.url = `dialog:/` + element.props.url;
}
const url = parse_url_1.parse_url(element.props.url);
switch (url.protocol) {
case 'dialog:':
const flowId = url.pathname.replace(/^\/*/, '');
const dialogId = url.hash ? url.hash.replace(/^#/, '') : undefined;
if (!url.hash && !flowId) {
console.log('found blank action url in dialog, continuing');
return Promise.resolve(true);
}
yield this.renderActionDialogFlow(flowId, dialogId, element, context);
return Promise.resolve(false);
case 'https:':
case 'http:':
case 'tel:':
case 'sms:':
yield this.renderActionCommand('openurl', { url }, element, context);
return Promise.resolve(false);
case 'command:':
return this.renderActionCommand(url.pathname.replace(/^\/*/, ''), getJsonFromUrl(url.query), element, context);
default:
throwErr(`unknown protocol ${url.protocol} on ${element.props.url}`);
return Promise.resolve(true);
}
});
}
renderActionDialogFlow(id, dialogId, element, context) {
return __awaiter(this, void 0, void 0, function* () {
const reactive = exports.useReactiveDialogs(context);
yield reactive.renderFlow(id, dialogId, context, () => Promise.resolve());
return false;
});
}
logStartOfDialog(context) { }
logAbandondedDialog(context) { }
logCompletedDialog(context) { }
renderActionCommand(command, params, element, context) {
const reactive = exports.useReactiveDialogs(context);
const handler = reactive._commandHandlers.get(command);
if (handler) {
return handler(command, Object.assign({ url: element.props.url, data: element.props.data }, params), context);
}
else {
throwErr(`No handler registered for the command ${command} on ${element.props.url}`);
return Promise.resolve(true);
}
}
}
exports.default = ReactiveDialogManager;
const toString = child => {
return child.props.children.join('');
};
const delay = (context, interval) => {
return new Promise(resolve => {
setTimeout(resolve, context.response[constants_1.BOT.isDelayDisabled] ? 40 : interval);
});
};
function throwErr(...args) {
var message = Array.prototype.slice.call(args).join(' ');
throw new Error(message);
}
function camelize(str) {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index == 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, '');
}
function getJsonFromUrl(url) {
var query = url.substr(1);
var result = {};
query.split("&").forEach(function (part) {
var item = part.split("=");
result[item[0]] = decodeURIComponent(item[1]);
});
return result;
}
exports.getJsonFromUrl = getJsonFromUrl;