UNPKG

voxa

Version:

A fsm (state machine) framework for Alexa, Dialogflow, Facebook Messenger and Botframework apps using Node.js

623 lines (539 loc) 18.7 kB
/* * Copyright (c) 2018 Rain Agency <contact@rain.agency> * Author: Rain Agency <contact@rain.agency> * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import * as bluebird from "bluebird"; import * as i18next from "i18next"; import * as _ from "lodash"; import { Context as AWSLambdaContext } from "aws-lambda"; import { LambdaLogOptions } from "lambda-log"; import { Ask, IDirective, IDirectiveClass, Reprompt, Say, SayP, Tell, Text, TextP, } from "./directives"; import { errorHandler, UnknownRequestType } from "./errors"; import { isLambdaContext, timeout } from "./lambda"; import { IModel, Model } from "./Model"; import { IRenderer, IRendererConfig, Renderer } from "./renderers/Renderer"; import { IStateHandler, ITransition, IUnhandledStateCb, State, StateMachine, SystemTransition, } from "./StateMachine"; import { IBag, IVoxaEvent, IVoxaIntentEvent } from "./VoxaEvent"; import { IVoxaReply } from "./VoxaReply"; const i18n: i18next.i18n = require("i18next"); export interface IVoxaAppConfig extends IRendererConfig { Model: IModel; RenderClass: IRenderer; views: i18next.Resource; variables?: any; logOptions?: LambdaLogOptions; onUnhandledState?: IUnhandledStateCb; } export type IEventHandler = ( event: IVoxaEvent, response: IVoxaReply, transition?: ITransition, ) => IVoxaReply | void; export type IErrorHandler = ( event: IVoxaEvent, error: Error, ReplyClass: IVoxaReply, ) => IVoxaReply; export class VoxaApp { [key: string]: any; /* * This way we can simply override the method if we want different request types */ get requestTypes(): string[] { // eslint-disable-line class-methods-use-this return ["IntentRequest", "SessionEndedRequest"]; } public eventHandlers: any = {}; public requestHandlers: any; public config: IVoxaAppConfig; public renderer: Renderer; public i18nextPromise: PromiseLike<i18next.TFunction>; // WARNING: the i18n variable should remain as a local instance of the VoxaApp.ts // class, so that its internal configuration is initialized with every Voxa's request. // This ensures its configuration is tied to the locale the request is coming with. // For instance, if a skill has en-US and de-DE locales. There could be an issue // with the global instance of i18n to return english values to the German locale. public i18n: i18next.i18n; public states: State[] = []; public directiveHandlers: IDirectiveClass[] = []; constructor(config: any) { this.i18n = i18n.createInstance(); this.config = config; this.requestHandlers = { SessionEndedRequest: this.handleOnSessionEnded.bind(this), }; _.forEach(this.requestTypes, (requestType) => this.registerRequestHandler(requestType), ); this.registerEvents(); this.onError(errorHandler, true); this.config = _.assign( { Model, RenderClass: Renderer, }, this.config, ); this.validateConfig(); this.i18nextPromise = initializeI118n(this.i18n, this.config.views); this.renderer = new this.config.RenderClass(this.config); // this can be used to plug new information in the request // default is to just initialize the model this.onRequestStarted(this.transformRequest); // run the state machine for intentRequests this.onIntentRequest(this.runStateMachine, true); this.onAfterStateChanged(this.renderDirectives); this.onBeforeReplySent(this.saveSession, true); this.directiveHandlers = [Say, SayP, Ask, Reprompt, Tell, Text, TextP]; } public validateConfig() { if (!this.config.Model.deserialize) { throw new Error("Model should have a deserialize method"); } if ( !this.config.Model.serialize && !(this.config.Model.prototype && this.config.Model.prototype.serialize) ) { throw new Error("Model should have a serialize method"); } } public async handleOnSessionEnded( event: IVoxaIntentEvent, response: IVoxaReply, ): Promise<IVoxaReply> { const sessionEndedHandlers = this.getOnSessionEndedHandlers( event.platform.name, ); const replies = await bluebird.mapSeries( sessionEndedHandlers, (fn: IEventHandler) => fn(event, response), ); const lastReply = _.last(replies); if (lastReply) { return lastReply; } return response; } /* * iterate on all error handlers and simply return the first one that * generates a reply */ public async handleErrors( event: IVoxaEvent, error: Error, reply: IVoxaReply, ): Promise<IVoxaReply> { const errorHandlers = this.getOnErrorHandlers(event.platform.name); const replies: IVoxaReply[] = await bluebird.map( errorHandlers, async (handler: IErrorHandler) => { return await handler(event, error, reply); }, ); let response: IVoxaReply | undefined = _.find(replies); if (!response) { reply.clear(); response = reply; } return response; } // Call the specific request handlers for each request type public async execute( voxaEvent: IVoxaEvent, reply: IVoxaReply, ): Promise<IVoxaReply> { voxaEvent.log.debug("Received new event", { event: voxaEvent.rawEvent }); try { if (!this.requestHandlers[voxaEvent.request.type]) { throw new UnknownRequestType(voxaEvent.request.type); } const requestHandler = this.requestHandlers[voxaEvent.request.type]; const executeHandlers = async () => { switch (voxaEvent.request.type) { case "IntentRequest": case "SessionEndedRequest": { // call all onRequestStarted callbacks serially. await bluebird.mapSeries( this.getOnRequestStartedHandlers(voxaEvent.platform.name), (fn: IEventHandler) => { return fn(voxaEvent, reply); }, ); if (voxaEvent.session.new) { // call all onSessionStarted callbacks serially. await bluebird.mapSeries( this.getOnSessionStartedHandlers(voxaEvent.platform.name), (fn: IEventHandler) => fn(voxaEvent, reply), ); } // Route the request to the proper handler which may have been overriden. return await requestHandler(voxaEvent, reply); } default: { return await requestHandler(voxaEvent, reply); } } }; let response: IVoxaReply; const context = voxaEvent.executionContext; if (isLambdaContext(context)) { const promises = []; const { timer, timerPromise } = timeout(context); promises.push(timerPromise); promises.push(executeHandlers()); response = await bluebird.race(promises); if (timer) { clearTimeout(timer); } } else { response = await executeHandlers(); } return response; } catch (error) { return this.handleErrors(voxaEvent, error, reply); } } /* * Request handlers are in charge of responding to the different request types alexa sends, * in general they will defer to the proper event handler */ public registerRequestHandler(requestType: string): void { // .filter(requestType => !this.requestHandlers[requestType]) if (this.requestHandlers[requestType]) { return; } const eventName = `on${_.upperFirst(requestType)}`; this.registerEvent(eventName); this.requestHandlers[requestType] = async ( voxaEvent: IVoxaEvent, response: IVoxaReply, ): Promise<IVoxaReply> => { const capitalizedEventName = _.upperFirst(_.camelCase(eventName)); const runCallback = (fn: IEventHandler): IVoxaReply | void => fn.call(this, voxaEvent, response); const result = await bluebird.mapSeries( this[`get${capitalizedEventName}Handlers`](), runCallback, ); // if the handlers produced a reply we return the last one const lastReply = _(result) .filter() .last(); if (lastReply) { return lastReply; } // else we return the one we started with return response; }; } /* * Event handlers are array of callbacks that get executed when an event is triggered * they can return a promise if async execution is needed, * most are registered with the voxaEvent handlers * however there are some that don't map exactly to a voxaEvent and we register them in here, * override the method to add new events. */ public registerEvents(): void { // Called when the request starts. this.registerEvent("onRequestStarted"); // Called when the session starts. this.registerEvent("onSessionStarted"); // Called when the user ends the session. this.registerEvent("onSessionEnded"); // Sent whenever there's an unhandled error in the onIntent code this.registerEvent("onError"); // // this are all StateMachine events this.registerEvent("onBeforeStateChanged"); this.registerEvent("onAfterStateChanged"); this.registerEvent("onBeforeReplySent"); } public onUnhandledState(fn: IUnhandledStateCb) { this.config.onUnhandledState = fn; } /* * Create an event handler register for the provided eventName * This will keep 2 separate lists of event callbacks */ public registerEvent(eventName: string): void { this.eventHandlers[eventName] = { core: [], coreLast: [], // we keep a separate list of event callbacks to alway execute them last }; if (!this[eventName]) { const capitalizedEventName = _.upperFirst(_.camelCase(eventName)); this[eventName] = ( callback: IEventHandler, atLast: boolean = false, platform: string = "core", ) => { if (atLast) { this.eventHandlers[eventName][`${platform}Last`] = this.eventHandlers[eventName][`${platform}Last`] || []; this.eventHandlers[eventName][`${platform}Last`].push( callback.bind(this), ); } else { this.eventHandlers[eventName][platform] = this.eventHandlers[eventName][platform] || []; this.eventHandlers[eventName][platform].push(callback.bind(this)); } }; this[`get${capitalizedEventName}Handlers`] = ( platform?: string, ): IEventHandler[] => { let handlers: IEventHandler[]; if (platform) { this.eventHandlers[eventName][platform] = this.eventHandlers[eventName][platform] || []; this.eventHandlers[eventName][`${platform}Last`] = this.eventHandlers[eventName][`${platform}Last`] || []; handlers = _.concat( this.eventHandlers[eventName].core, this.eventHandlers[eventName][platform], this.eventHandlers[eventName].coreLast, this.eventHandlers[eventName][`${platform}Last`], ); } else { handlers = _.concat( this.eventHandlers[eventName].core, this.eventHandlers[eventName].coreLast, ); } return handlers; }; } } public onState( stateName: string, handler: IStateHandler | ITransition, intents: string[] | string = [], platform: string = "core", ): void { const state = new State(stateName, handler, intents, platform); this.states.push(state); } public onIntent( intentName: string, handler: IStateHandler | ITransition, platform: string = "core", ): void { this.onState(intentName, handler, intentName, platform); } public async runStateMachine( voxaEvent: IVoxaIntentEvent, response: IVoxaReply, ): Promise<IVoxaReply> { let fromState = voxaEvent.session.new ? "entry" : _.get(voxaEvent, "session.attributes.state", "entry"); if (fromState === "die") { fromState = "entry"; } const stateMachine = new StateMachine({ onAfterStateChanged: this.getOnAfterStateChangedHandlers( voxaEvent.platform.name, ), onBeforeStateChanged: this.getOnBeforeStateChangedHandlers( voxaEvent.platform.name, ), onUnhandledState: this.config.onUnhandledState, states: this.states, }); voxaEvent.log.debug("Starting the state machine", { fromState }); const transition = await stateMachine.runTransition( fromState, voxaEvent, response, ); if (transition.shouldTerminate) { await this.handleOnSessionEnded(voxaEvent, response); } const onBeforeReplyHandlers = this.getOnBeforeReplySentHandlers( voxaEvent.platform.name, ); voxaEvent.log.debug("Running onBeforeReplySent"); await bluebird.mapSeries(onBeforeReplyHandlers, (fn: IEventHandler) => fn(voxaEvent, response, transition), ); return response; } public async renderDirectives( voxaEvent: IVoxaEvent, response: IVoxaReply, transition: SystemTransition, ): Promise<ITransition> { const directiveClasses: IDirectiveClass[] = _.concat( _.filter(this.directiveHandlers, { platform: "core" }), _.filter(this.directiveHandlers, { platform: voxaEvent.platform.name }), ); const directivesKeyOrder = _.map(directiveClasses, "key"); if (transition.reply) { const replyTransition = await this.getReplyTransitions( voxaEvent, transition, ); transition = _.merge(transition, replyTransition); } const directives: IDirective[] = _(transition) .toPairs() .sortBy((pair: any[]) => { const [key, value] = pair; return _.indexOf(directivesKeyOrder, key); }) .map(_.spread(instantiateDirectives)) .flatten() .concat(transition.directives || []) .filter() .filter((directive: IDirective): boolean => { const constructor: any = directive.constructor; return _.includes( ["core", voxaEvent.platform.name], constructor.platform, ); }) .value(); for (const handler of directives) { await handler.writeToReply(response, voxaEvent, transition); } return transition; function instantiateDirectives(key: string, value: any): IDirective[] { let handlers: IDirectiveClass[] = _.filter( directiveClasses, (classObject: IDirectiveClass) => classObject.key === key, ); if (handlers.length > 1) { handlers = _.filter( handlers, (handler: IDirectiveClass) => handler.platform === voxaEvent.platform.name, ); } return _.map( handlers, (Directive: IDirectiveClass) => new Directive(value), ) as IDirective[]; } } public async saveSession( voxaEvent: IVoxaEvent, response: IVoxaReply, transition: ITransition, ): Promise<void> { const serialize = _.get(voxaEvent, "model.serialize"); // we do require models to have a serialize method and check that when Voxa is initialized, // however, developers could do stuff like `voxaEvent.model = null`, // which seems natural if they want to // clear the model if (!serialize) { voxaEvent.model = new this.config.Model(); } const stateName = transition.to; // We save off the state so that we know where to resume from when the conversation resumes const modelData = await voxaEvent.model.serialize(); const attributes = { ...voxaEvent.session.outputAttributes, model: modelData, state: stateName, }; await response.saveSession(attributes, voxaEvent); } public async transformRequest(voxaEvent: IVoxaEvent): Promise<void> { await this.i18nextPromise; const data = voxaEvent.session.attributes.model as IBag; const model = await this.config.Model.deserialize(data, voxaEvent); voxaEvent.model = model; voxaEvent.log.debug("Initialized model ", { model: voxaEvent.model }); voxaEvent.t = this.i18n.getFixedT(voxaEvent.request.locale); voxaEvent.renderer = this.renderer; } private async getReplyTransitions( voxaEvent: IVoxaEvent, transition: ITransition, ): Promise<ITransition> { if (!transition.reply) { return {}; } let finalReply = {}; let replies = []; if (_.isArray(transition.reply)) { replies = transition.reply; } else { replies = [transition.reply]; } for (const replyItem of replies) { const reply = await voxaEvent.renderer.renderPath(replyItem, voxaEvent); const replyKeys = _.keys(reply); const replyData = _(replyKeys) .map((key) => { return [key, replyItem + "." + key]; }) .fromPairs() .value(); finalReply = _.mergeWith(finalReply, replyData, function customizer( objValue, srcValue, ) { if (!objValue) { return; // use default merge behavior } if (_.isArray(objValue)) { return objValue.concat(srcValue); } return [objValue, srcValue]; }); } return finalReply; } } export function initializeI118n( i18nInstance: i18next.i18n, views: i18next.Resource, ): bluebird<i18next.TFunction> { type IInitializer = ( options: i18next.InitOptions, ) => bluebird<i18next.TFunction>; const initialize = bluebird.promisify(i18nInstance.init, { context: i18nInstance, }) as IInitializer; return initialize({ fallbackLng: "en", load: "all", nonExplicitWhitelist: true, resources: views, }); }