import { NOT_FOUND_PATH } from '../constants' ; import { RouteInspector } from '../debug/inspector' ; import { BotState , Input , MatchedValue , Matcher , MatchingProp , Nullable , ProcessInputResult , Route , RouteParams , RoutePath , RoutingState , Session , } from '../models' ; import { cloneObject } from '../utils' ; import { getEmptyAction , getNotFoundAction , getPathParamsFromPathPayload , isPathPayload , } from './router-utils' ; export class Router { routes : Route [ ] ; routeInspector : RouteInspector ; constructor ( routes , routeInspector = undefined ) { this . routes = routes ; this . routeInspector = routeInspector || new RouteInspector ( ) ; } /**
     * Processes an input and return a representation of the new bot state.
     * The algorithm is splitted in two main parts:
     * 1. Getting the current routing state.
     * 2. Given a routing state, resolve the different possible scenarios and return the new bot state.
     * The new bot state can return three type of actions:
     * - action: an action directly resolved from a matching route
     * - emptyAction: optional action that can exists or not only within childRoutes
     * - fallbackAction: any other action that acts as a fallback (404, )
     */ processInput ( input , session , botState ) { botState . retries = botState . retries ?? 0 ; const { currentRoute , matchedRoute , params , isFlowBroken , } = this . getRoutingState ( input , session , botState ) ; const currentRoutePath = currentRoute ?. path ?? null ; const matchedRoutePath = matchedRoute ?. path ?? null ; /**
         * Redirect Scenario:
         * We have matched a redirect route with a given redirection path, so we try to obtain the redirectionRoute with getRouteByPath.
         * Independently of whether the redirectionRoute is found or not, the intent is to trigger a redirection which by definition breaks the flow, so retries are set to 0.
         * It has preference over ignoring retries.
         */ if ( matchedRoute && matchedRoute . redirect ) { botState . retries = 0 ; const redirectionRoute = this . getRouteByPath ( matchedRoute . redirect ) ; if ( redirectionRoute ) { botState . lastRoutePath = matchedRoute . redirect ; return { action : redirectionRoute . action , emptyAction : getEmptyAction ( redirectionRoute . childRoutes ) , fallbackAction : null , botState , params , } ; } botState . lastRoutePath = null ; return { action : null , emptyAction : null , fallbackAction : getNotFoundAction ( input , this . routes ) , botState , params , } ; } /**
         * Ignore Retry Scenario:
         * We have matched a route with an ignore retry, so we return directly the new bot state. The intent is to break the flow, so retries are set to 0.
         */ if ( matchedRoute && matchedRoute . ignoreRetry ) { botState . retries = 0 ; botState . lastRoutePath = matchedRoutePath ; return { action : matchedRoute . action , emptyAction : getEmptyAction ( matchedRoute . childRoutes ) , fallbackAction : null , botState , params , } ; } /**
         * Retry Scenario:
         * We were in a route which had retries enabled, so we check if the number of retries is exceeded.
         * If we have not surpassed the limit of retries and we haven't matched an ignoreRetry route, update them, and then return the new bot state.
         */ if ( isFlowBroken && currentRoute && currentRoute . retry && botState . retries < currentRoute . retry ) { botState . retries = botState . retries !== 0 ? botState . retries + 1 : 1 ; botState . lastRoutePath = currentRoutePath ; if ( matchedRoute && matchedRoutePath !== NOT_FOUND_PATH ) { return { action : currentRoute . action , emptyAction : getEmptyAction ( matchedRoute . childRoutes ) , fallbackAction : matchedRoute . action , botState , params , } ; } return { action : currentRoute . action ?? null , emptyAction : getEmptyAction ( currentRoute . childRoutes ) , fallbackAction : getNotFoundAction ( input , this . routes ) , botState , params , } ; } /**
         * Default Scenario:
         * We have matched a route or not, but we don't need to execute retries logic, so retries stay to 0.
         */ botState . retries = 0 ; /**
         * Matching Route Scenario:
         * We have matched a route, so we return the new bot state.
         */ if ( matchedRoute && matchedRoutePath !== NOT_FOUND_PATH ) { botState . lastRoutePath = matchedRoutePath ; return { action : matchedRoute . action ?? null , emptyAction : getEmptyAction ( matchedRoute . childRoutes ) , fallbackAction : null , botState , params , } ; } /**
         * 404 Scenario (No Route Found):
         * We have not matched any route, so we return the new bot state.
         */ botState . lastRoutePath = currentRoutePath ; return { action : null , emptyAction : null , fallbackAction : getNotFoundAction ( input , this . routes ) , params , botState , } ; } /**
     * Find the route that matches the given input, if it match with some of the entries, return the whole Route of the entry with optional params captured if matcher was a regex.
     * IMPORTANT: It returns a cloned route instead of the route itself to avoid modifying original routes and introduce side effects
     * */ getRoute ( input , routes , session , lastRoutePath ) { let params = { } ; const route = routes . find ( r => Object . entries ( r ) . filter ( ( [ key , _ ] ) => key !== 'action' && key !== 'childRoutes' && key !== 'path' ) . some ( ( [ key , value ] ) => { const match = this . matchRoute ( r , key as MatchingProp , value as Matcher , input as Input , session , lastRoutePath ) ; try { if ( match !== null && typeof match !== 'boolean' && match . groups ) { params = { ... match . groups } ; } } catch ( e ) { } return Boolean ( match ) ; } ) ) ; if ( route ) return { route : cloneObject ( route ) , params } ; return null ; } /**
     * Find the route that matches the given path. Path can include concatenations, e.g: 'Flow1/Subflow1.1'.
     * IMPORTANT: It returns a cloned route instead of the route itself to avoid modifying original routes and introduce side effects
     * */ getRouteByPath ( path , routeList = this . routes ) { if ( ! path ) return null ; const [ currentPath , ... childPath ] = path . split ( '/' ) ; for ( const route of routeList ) { if ( route . path === currentPath ) { if ( route . childRoutes && route . childRoutes . length && childPath . length > 0 ) { const computedRoute = this . getRouteByPath ( childPath . join ( '/' ) , route . childRoutes ) ; if ( computedRoute ) return cloneObject ( computedRoute ) ; } else if ( childPath . length === 0 ) { return cloneObject ( route ) ; } } } return null ; } /**
     * Returns the matched value for a specific route.
     * Matching Props: ('text' | 'payload' | 'intent' | 'type' | 'input' | 'session' | 'request' ...)
     * Matchers: (string: exact match | regex: regular expression match | function: return true)
     * input: user input object, e.g.: {type: 'text', data: 'Hi'}
     * */ matchRoute ( route , prop , matcher , input , session , lastRoutePath ) { let value = null ; if ( Object . keys ( input ) . indexOf ( prop ) > - 1 ) value = input [ prop ] ; else if ( prop === 'input' ) value = input ; else if ( prop === 'session' ) value = session ; else if ( prop === 'request' ) value = { input , session , lastRoutePath } ; const matched = this . matchValue ( matcher , value ) ; if ( matched ) { this . routeInspector . routeMatched ( route , prop , matcher , value ) ; } else { this . routeInspector . routeNotMatched ( route , prop , matcher , value ) ; } return matched ; } /**
     * Runs the matcher against the given value.
     * If there is a match, it will return a truthy value (true, RegExp result), o.w., it will return a falsy value.
     * */ matchValue ( matcher , value ) { if ( typeof matcher === 'string' ) return value === matcher ; if ( matcher instanceof RegExp ) { if ( value === undefined || value === null ) return false ; return matcher . exec ( value ) ; } if ( typeof matcher === 'function' ) return matcher ( value ) ; return false ; } /**
     * It resolves the current state of navigation. Two scenarios:
     * 1. Given a path payload input (__PATH_PAYLOAD__somePath?someParam=someValue), we can resolve the routing state directly from it (using getRouteByPath).
     * 2. Given any other type of input, we resolve the routing state with normal resolution (using getRoute).
     * */ getRoutingState ( input , session , botState ) { const currentRoute = this . getRouteByPath ( botState . lastRoutePath ) ; if ( currentRoute && botState . lastRoutePath ) currentRoute . path = botState . lastRoutePath ; if ( typeof input . payload === 'string' && isPathPayload ( input . payload ) ) { return this . getRoutingStateFromPathPayload ( currentRoute , input . payload ) ; } return this . getRoutingStateFromInput ( currentRoute , input , session ) ; } /**
     * Given a non path payload input, try to run it against the routes, update matching routes information in consequence and dictamine if the flow has been broken.
     * */ getRoutingStateFromInput ( currentRoute , input , session ) { if ( currentRoute && currentRoute . childRoutes ) { const routeParams = this . getRoute ( input , currentRoute . childRoutes , session , currentRoute . path ) ; if ( routeParams ) { return { currentRoute , matchedRoute : { ... routeParams . route , path : routeParams . route && currentRoute . path ? `${ currentRoute . path }/${ routeParams . route . path }` : currentRoute . path , } , params : routeParams . params , isFlowBroken : false , } ; } } /**
         * we couldn't find a route in the state of the currentRoute childRoutes,
         * so let's find in the general routes
         */ const routeParams = this . getRoute ( input , this . routes , session , currentRoute ?. path ?? null ) ; const isFlowBroken = Boolean ( currentRoute ?. path ) ; if ( routeParams ?. route ) { return { currentRoute , matchedRoute : { ... routeParams . route , path : routeParams . route ?. path ?? null , } , params : routeParams . params , isFlowBroken , } ; } return { currentRoute , matchedRoute : null , params : { } , isFlowBroken , } ; } /**
     * Given a path payload input, try to run the path against the routes, update matching routes information in consequence and dictamine if the flow has been broken.
     * */ getRoutingStateFromPathPayload ( currentRoute , pathPayload ) { const { path , params } = getPathParamsFromPathPayload ( pathPayload ) ; /**
         * shorthand function to update the matching information given a path
         */ const getRoutingStateFromPath = ( seekPath ) : RoutingState => { const matchedRoute = this . getRouteByPath ( seekPath ) ; if ( ! matchedRoute ) { return { currentRoute , matchedRoute : null , params : { } , isFlowBroken : true , } ; } return { currentRoute , matchedRoute : { ... matchedRoute , path : seekPath } , params , isFlowBroken : false , } ; } ; /**
         * Given a valid path: 'Flow1/Subflow1' we are in one of the two scenarios below.
         */ if ( currentRoute ?. path ) { const routingState = getRoutingStateFromPath ( `${ currentRoute . path }/${ path }` ) ; if ( routingState . matchedRoute ) return routingState ; } return getRoutingStateFromPath ( path as string ) ; } }