UNPKG

voxa

Version:

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

270 lines (231 loc) 8.25 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 _ from "lodash"; import { UnknownState } from "../errors"; import { IVoxaIntentEvent } from "../VoxaEvent"; import { IVoxaReply } from "../VoxaReply"; import { State } from "./State"; import { ITransition, SystemTransition } from "./transitions"; export type IStateMachineCb = ( event: IVoxaIntentEvent, reply: IVoxaReply, transition: ITransition, ) => Promise<ITransition>; export type IUnhandledStateCb = ( event: IVoxaIntentEvent, stateName: string, ) => Promise<ITransition>; export type IOnBeforeStateChangedCB = ( event: IVoxaIntentEvent, reply: IVoxaReply, state: State, ) => Promise<void>; export interface IStateMachineConfig { states: State[]; onBeforeStateChanged?: IOnBeforeStateChangedCB[]; onAfterStateChanged?: IStateMachineCb[]; onUnhandledState?: IUnhandledStateCb; } export class StateMachine { public states: State[]; public currentState!: State; public onBeforeStateChangedCallbacks: IOnBeforeStateChangedCB[]; public onAfterStateChangeCallbacks: IStateMachineCb[]; public onUnhandledStateCallback?: IUnhandledStateCb; constructor(config: IStateMachineConfig) { this.states = _.cloneDeep(config.states); this.onBeforeStateChangedCallbacks = config.onBeforeStateChanged || []; this.onAfterStateChangeCallbacks = config.onAfterStateChanged || []; this.onUnhandledStateCallback = config.onUnhandledState; this.states.push(new State("die", { flow: "terminate" })); } public async runTransition( fromState: string, voxaEvent: IVoxaIntentEvent, reply: IVoxaReply, recursions: number = 0, ): Promise<SystemTransition> { if (recursions > 10) { throw new Error("State Machine Recursion Error"); } const transition = await this.stateTransition( fromState, voxaEvent, reply, recursions, ); let sysTransition = await this.checkOnUnhandledState( voxaEvent, reply, transition, ); sysTransition = await this.onAfterStateChanged( voxaEvent, reply, sysTransition, ); if (sysTransition.shouldTerminate) { reply.terminate(); } if (sysTransition.shouldContinue) { return this.runTransition( sysTransition.to, voxaEvent, reply, recursions + 1, ); } return sysTransition; } private async stateTransition( fromState: string, voxaEvent: IVoxaIntentEvent, reply: IVoxaReply, recursions: number, ): Promise<ITransition> { try { if (fromState === "entry") { this.currentState = this.getCurrentState( voxaEvent.intent.name, voxaEvent.intent.name, voxaEvent.platform.name, ); } else { this.currentState = this.getCurrentState( fromState, voxaEvent.intent.name, voxaEvent.platform.name, ); } } catch (error) { /* * Returning to the global handler here only makes sense if we didn't already made that, * meaning that it could only be done in the first recursion. There's tests covering this scenario * in tests/States.spec.ts */ if (fromState !== "entry" && recursions < 1) { return this.stateTransition("entry", voxaEvent, reply, recursions); } if (error instanceof UnknownState) { if (!this.onUnhandledStateCallback) { throw new Error(`${voxaEvent.intent.name} went unhandled`); } return this.onUnhandledStateCallback(voxaEvent, voxaEvent.intent.name); } throw error; } await this.runOnBeforeStateChanged(voxaEvent, reply); let transition: ITransition = await this.currentState.handle(voxaEvent); voxaEvent.log.debug(`${this.currentState.name} transition resulted in`, { transition, }); try { if (!transition && fromState !== "entry") { this.currentState = this.getCurrentState( voxaEvent.intent.name, voxaEvent.intent.name, voxaEvent.platform.name, ); transition = await this.currentState.handle(voxaEvent); } } catch (error) { if (error instanceof UnknownState) { return transition; } throw error; } return transition; } private async onAfterStateChanged( voxaEvent: IVoxaIntentEvent, reply: IVoxaReply, transition: SystemTransition, ): Promise<SystemTransition> { voxaEvent.log.debug("Running onAfterStateChangeCallbacks"); await bluebird.mapSeries(this.onAfterStateChangeCallbacks, (fn) => { return fn(voxaEvent, reply, transition); }); voxaEvent.log.debug("Transition is now", { transition }); return transition; } private async checkOnUnhandledState( voxaEvent: IVoxaIntentEvent, reply: IVoxaReply, transition: ITransition, ): Promise<SystemTransition> { if (!_.isEmpty(transition) || transition) { return new SystemTransition(transition); } if (!this.onUnhandledStateCallback) { throw new Error(`${voxaEvent.intent.name} went unhandled`); } const tr = await this.onUnhandledStateCallback( voxaEvent, this.currentState.name, ); return new SystemTransition(tr); } private async runOnBeforeStateChanged( voxaEvent: IVoxaIntentEvent, reply: IVoxaReply, ) { const onBeforeState = this.onBeforeStateChangedCallbacks; voxaEvent.log.debug("Running onBeforeStateChanged"); await bluebird.mapSeries(onBeforeState, (fn: IOnBeforeStateChangedCB) => { return fn(voxaEvent, reply, this.currentState); }); } private getCurrentState( currentStateName: string, intentName: string, platform: string, ): State { const states: State[] = _(this.states) .filter({ name: currentStateName }) .filter((s: State) => { return s.platform === platform || s.platform === "core"; }) // Sometimes a user might have defined more than one controller for the same state, // in that case we want to get the one for the current intent .filter((s: State) => { return s.intents.length === 0 || _.includes(s.intents, intentName); }) .value(); if (states.length === 0) { throw new UnknownState(currentStateName); } if (states.length === 1) { return states[0]; } // If the code reaches this point, that means the `states` array may contain // one state without an intents array filter and/or // one or more controllers with an intents array that contains the intent name. // The controller with an intents array is given more priority than the one with no intents array, // so the first controller that contains the intent name in its intents array is returned. return ( states.find( (s: State) => !!s.intents.length && s.intents.includes(intentName), ) || states[0] // If no state with name is found, the first state is returned by default as an State object is always needed ); } }