Nice Paintings, Mondrian

    bot-state-machine

    3.3.0 • Public • Published

    Build Status Coverage

    bot-state-machine

    The server-ready FSM (Finite State Machine) for chat bot, which

    • Supports to define custom commands with options
    • Supports simplified command options
    • Supports sub(nested) states and command declarations in sub states
    • Only allows a single task thread, which means that for a single user, your chat bot could apply only one task at a time globally even in distributed environment. A single-thread chat bot executes less things but fits better for voice input and interactive tasks.
    • Supports distributed task locking with redis syncer, and you can also implement yourself.

    bot-state-machine uses private class fields to ensure data security so that it requires node >= 12

    Install

    $ npm i bot-state-machine

    Basic Usage

    const {StateMachine} = require('bot-state-machine')
    
    // Configurations
    //////////////////////////////////////////////////////
    const sm = new StateMachine()
    const rootState = sm.rootState()
    
    const Buy = rootState.command('buy')
    // bot-state-machine provides a Python-like argument parser,
    // so, `buy TSLA` is equivalent to `buy stock=TSLA`
    .option('stock')
    .action(async function ({options}) {
      await buyStock(options.stock)
      this.say('success')
      // If the action of a command returns `undefined`, then the
      //  state machine will return to the root state after the command executed
    })
    // If the action function rejects, then it will go into the catch function if exists.
    .catch(function (err) {
      this.say('failed')
    })
    
    // Chat
    //////////////////////////////////////////////////////
    
    // We could create as many chat tasks as we want,
    //  so that we could handle arbitrary numbers of requests
    const chat = sm.chat()
    
    const output = await chat.input('buy TSLA') // or 'buy stock=TSLA'
    
    console.log(output) // success

    Flow control: define several sub states for a command

    • A state can have multiple commands
    • A command can have multiple subtle states
    • The state machine redirects to a certain state according to the return value of a command's action or catch
    • A command could only go to
      • one of its sub states
      • one of the parent states
      • or the root state.

    Here is a complex example, and its corresponding test spec locates here

    Example: coin-operated turnstile

    There is a classic example of the Finite-state machine from wikipedia, coin-operated turnstile.

    const sm = new StateMachine()
    
    // Locked is an initial state
    const StateLocked = sm.rootState()
    
    const CommandCoin = StateLocked.command('coin')
    const StateUnlocked = CommandCoin.state('unlocked')
    
    // Putting a coin in the slot to unlock the turnstile
    CommandCoin.action(() => StateUnlocked)
    
    const CommandPush = StateUnlocked.command('push')
    // Pushing the arm, then the turnstile will be locked (go to the initial state)
    .action(() => StateLocked)

    Define global commands

    Commands defined by sm (not root state) are global commands.

    A global command could be called at any state and could not have options, condition, or sub states.

    // A global command to return back to the parent state
    sm.command('back')
    .action(({state}) => state.parent)
    // A global command to cancel everything and return to root state
    sm.command('cancel')

    How to distinguish between different users

    sm.chat(distinctId) has distinctId as the argument. distinctId should be unique for a certain user (audience).

    Users with different distinctIds are separated and have different isolated locks, so that the chat bot can serve many users simultaneously.

    Everytime we execute sm.chat('Bob'), we create a new thread for Bob. And different threads share the same lock for Bob, so the bot could only do one thing for Bob at the same time.

    API References

    const {
      StateMachine,
      SimpleMemorySyncer,
      RedisSyncer
    } = require('bot-state-machine')

    new StateMachine(options)

    • options All options are optional.
      • nonExactMatch? boolean=false
      • format? function(tpl: string, ...values): string = util.format
      • joiner? function(...messages): string
      • actionTimeout? number=5000 timeout in milliseconds before the execution of action and catch result in an COMMAND_TIMEOUT error.
      • lockRefreshInterval? number=1000 advanced option. This option should be less than Syncer::options.lockExpire, and it is used to prevent the lock from being expired before the command action finished executing.
      • lockKey? function(distinctId):string the method to create the lockKey for each distinct user.
      • storeKey? function(distinctId):string to create the key to save the current state for each distinct user.
      • syncer? Syncer=new SimpleMemorySyncer() see Advanced Section

    sm.rootState(): State

    Create a root state.

    sm.command(...names): Command

    • names Array<string> you can create a command with a name and multiple aliases

    Create a global command. A global command could be called at any states.

    A global command could NOT define:

    • condition
    • option
    • sub states

    sm.chat(distinctId, {commands}): Chat

    • distinctId string distinct id to distinguish between different users
    • commands Array<string|Command> A list of commands to restrict the priviledge of the user. If the user input a command which is not in the list, there will be an UNKNOWN_COMMAND error.

    Create a new conversation

    Chat

    await chat.input(message): string

    • message string

    Receives the user input and return a Promise of the output by chat bot.

    Command

    command.state(stateName): State

    • stateName string the name of the sub state. The name should be unique among the sub states of the command.

    command.condition(condition): this

    • condition function(flags):boolean If the function returns false, then the command will skip executing action or catch. If we need give user some feedback or hint, we could use this.say() method in the function. condition supports both async and sync functions.
      • flags object the shadow copy of the key-value pairs of all flags defined in current state.

    Check if the command meet the requirement to execute.

    // Pay attention that we could not use an arrow function here if we need to use `this.say`
    someCommand.condition(function ({enabled}) {
      if (!enable) {
        this.say('not enabled')
      }
    
      return enabled
    })

    command.option(name, config): this

    • name string the name of the option
    • config? object
      • alias? Array<string> the list of aliases of the option
      • default? function(key, flags):any | any defines the default value of the option
      • set? function(value, key, flags):boolean throwable async or sync setter function to coerce the option value. The return value will be the real value of the option.

    Create a option, i.e. an argument, for the command.

    setter function

    You can also validate the option value in the setter function.

    If the validation fails, we can throw an error in the function to provide a verbose error message.

    Options principle & Example

    We could not define a non-default option after an option with default values, for example:

    BuyCommand
    .option('position', {
      default: '100%'
    })
    .option('stock')
    
    // ❌ This will cause an 'NON_DEFAULT_OPTION_FOLLOWS_DEFAULT' error

    Here is a complex example

    const sm = new StateMachine(options)
    
    const root = sm.rootState()
    .flag('default-stock', '')
    
    const BuyCommand = root.command('buy')
    .option('stock', {
      default (key, flags) {
        const defaultStock = flags['default-stock']
    
        if (!defaultStock) {
          throw new Error('stock is required')
        }
    
        return defaultStock
      }
    })
    .option('position', {
      default: 'all-in',
      set (value) {
        if (value === 'all-in') {
          return 1
        }
    
        if (Number.isNaN(value)) {
          throw TypeError(`${value} is not a number`)
        }
    
        return Number(value)
      }
    })
    .action(function ({options}) {
      this.say(`buy ${options.stock}, position: ${options.position}`)
    })
    
    const SetDefaultStock = root.command('set-default-stock')
    .option('stock')
    .action(function ({options}) {
      this.setFlag('default-stock', 'TSLA')
    })
    
    const output = await sm.chat().input(input)
    sequence input error output
    1 buy TSLA 'buy TSLA, position: 1'
    2 buy OPTIONS_NOT_FULFILLED
    3 set-default-stock TSLA ''
    4 buy 'buy TSLA, position: 1'
    5 buy position=0.2 'buy TSLA, position: 0.2'

    command.action(executor): this

    • executor function(arg: CommandArgument): TargetState Either async or sync function to do real things fo the command

    Execute the command and go to the target state.

    interface CommandArgument {
      // The options for the command
      options: object
      // The shadow copy of the flags of the current state
      flags: object
      // The runtime state which the state machine is currently at.
      state: RuntimeState
    }
    interface RuntimeState {
      // The id of the current state
      get id: string
      // The parent state of the current state
      get parent: RuntimeState
    }
    type TargetState = State
      // So that we can go back to a parent state
      | RuntimeState
      // If the command action returns undefined,
      //   then the state machine will go the root state
      | undefined

    Here is an example to show how to use CommandArgument

    someCommand.action(async function ({options, flags, state}) {
      try {
        await doSomethingWith(options)
        this.say('success')
    
        // If succeeded, back to the parent state
        return state.parent
      } catch (e) {
        this.say('fail, reason: %s', e.message)
    
        // Just stay on the current state
        return state
      }
    })

    command.catch(onError): this

    • onError function(err: Error, arg: CommandArgument): TargetState
      • err Error the error thrown by command action
      • arg the same as the argument of the action executor

    If the command action throws an error, then onError will be invoked. If onError throws an error, it will result in a COMMAND_ERROR error, and stay on the current state.

    State

    state.flag(key, defaultValue, onchange): this

    • key string the name of the key
    • defaultValue any the default value of the flag
    • onchange function(newValue, oldValue) invokes if the value of the flag is changed.

    Defines a flag

    state.command(...names): Command

    Defines a command which is only available at the current state.

    state.default(defaultFinder): this

    • defaultFinder function(input: str, flags: object): Command | undefined async or sync function which will be executed if there is no matched command for the given input, and whose return value will be the command to use

    Defines a finder function to find the default command

    const Hello = state
    .command('hello')
    .option('name')
    .action(function ({options}) {
      this.say(`hello ${options.name}`)
    })
    
    state.default(() => Hello)
    input output comments
    hello world 'hello world'
    world 'hello world' Hello is the default command

    Context Methods

    this.say(template, ...values): void

    • template string
    • values Array<any>

    Say something to the user. The argument of the method is the same as Node.js util.format(), and will be formatted by options.format

    this.say('Hello %s!', 'world')

    options.format is designed to provide better support for i18n.

    Could be used in:

    • command condition
    • command action
    • command catch
    • onchange method of state flag

    this.setFlag(name, value): void

    Could be used inn:

    • command action
    • command catch

    Advanced Section

    The default configuration of StateMachine only works for single instance chat bot, and saves store data just in memory.

    If you want to deploy a chat bot cluster with many instances or to use some storage other than memory, you could use other syncers, such as the built-in RedisSyncer to use redis as the storage.

    new RedisSyncer(redis, options)

    • redis ioredis the instance of ioredis or an object has the same interfaces as ioredis
    • options? Object=
      • lockExpire int number of milliseconds util the lock expires.
    const Redis = require('ioredis')
    
    const {RedisSyncer} = require('bot-state-machine')
    
    const sm = new StateMachine({
      syncer: new RedisSyncer(
        new Redis(6379, '127.0.0.1')
      )
    })

    Implement your own syncer

    You could also implement your own syncer, abbr for synchronizer.

    A Syncer need to implement the interface with FOUR methods

    interface SuccessStatus {
      sucess: boolean
    }
    
    interface ReaderResult extends SuccessStatus {
      store?: object
    }
    
    type Promisable<T> = Promise<T> | T
    
    interface SyncerArg {
      // `chatId` is an unique id for the current chat session
      chatId: string
      store: object
      lockKey: string
      storeKey: string
    }
    
    interface ReaderArg {
      chatId: string
      lockKey: string
      storeKey: string
    }
    
    interface RefresherArg {
      chatId: string
      lockKey: string
    }
    
    interface Syncer {
      read (arg: ReaderArg): Promisable<ReaderResult>
      lock (arg: SyncerArg): Promisable<SuccessStatus>
      refreshLock (arg: RefresherArg): Promisable<void>
      unlock (arg: SyncerArg): Promisable<SuccessStatus>
    }

    await read(arg)

    This method is used to read the store from storage. In this method, we need to check the lock status to make sure that the current chat session owns the lock

    await lock(arg)

    In this method, we need to:

    • first, acquire the lock
    • then, update the storage

    await refreshLock(arg)

    Refresh the expiration of lock

    await unlock(arg)

    Release the lock and update the store

    License

    MIT

    Install

    npm i bot-state-machine

    DownloadsWeekly Downloads

    6

    Version

    3.3.0

    License

    MIT

    Unpacked Size

    55.4 kB

    Total Files

    25

    Last publish

    Collaborators

    • kael