bot-state-machine
v3.4.0
Published
Finite state machine for chat bot
Downloads
51
Maintainers
Readme
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
orcatch
- 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 distinctId
s 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 ofaction
andcatch
result in anCOMMAND_TIMEOUT
error. - lockRefreshInterval?
number=1000
advanced option. This option should be less thanSyncer::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 thelockKey
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()
seeAdvanced Section
- nonExactMatch?
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, context}): Chat
- distinctId
string
distinct id to distinguish between different users - commands
Array<string|Command>=undefined
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 anUNKNOWN_COMMAND
error. If not specified, any command will available for the current user - context
Object={}
the context object that could be accessed in many functions bythis.context
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 thecommand
.
command.condition(condition): this
- condition
function(flags):boolean
If the function returns false, then the command will skip executingaction
orcatch
. If we need give user some feedback or hint, we could usethis.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.
- flags
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?
string | 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.
- alias?
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
- err
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 ofioredis
or an object has the same interfaces asioredis
- options?
Object=
- lockExpire
int
number of milliseconds util the lock expires.
- lockExpire
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
Development
# First we should start a redis-server
redis-server
# Then run tests
npm run test