Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/kaelzhang/bot-state-machine
Finite state machine for chat bot
https://github.com/kaelzhang/bot-state-machine
chat-bot finite-state-machine fsm
Last synced: 4 days ago
JSON representation
Finite state machine for chat bot
- Host: GitHub
- URL: https://github.com/kaelzhang/bot-state-machine
- Owner: kaelzhang
- License: other
- Created: 2019-12-19T03:07:44.000Z (almost 5 years ago)
- Default Branch: master
- Last Pushed: 2023-04-08T02:15:02.000Z (over 1 year ago)
- Last Synced: 2024-11-07T01:16:42.687Z (6 days ago)
- Topics: chat-bot, finite-state-machine, fsm
- Language: JavaScript
- Homepage:
- Size: 219 KB
- Stars: 7
- Watchers: 3
- Forks: 0
- Open Issues: 4
-
Metadata Files:
- Readme: README.md
- Changelog: HISTORY.md
- License: LICENSE
Awesome Lists containing this project
README
[![Build Status](https://travis-ci.org/kaelzhang/bot-state-machine.svg?branch=master)](https://travis-ci.org/kaelzhang/bot-state-machine)
[![Coverage](https://codecov.io/gh/kaelzhang/bot-state-machine/branch/master/graph/badge.svg)](https://codecov.io/gh/kaelzhang/bot-state-machine)# 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](#new-redissyncerredis-options), and you can also implement yourself.`bot-state-machine` uses [private class fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields#Private_instance_fields) to ensure data security so that it requires node >= 12
## Install
```sh
$ npm i bot-state-machine
```## Basic Usage
```js
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](example/nested-states.js) is a complex example, and its corresponding test spec locates [here](test/integrated.test.js)
### Example: coin-operated turnstile
There is a classic example of the [Finite-state machine](https://en.wikipedia.org/wiki/Finite-state_machine) from wikipedia, [coin-operated turnstile](https://en.wikipedia.org/wiki/Finite-state_machine#Example:_coin-operated_turnstile).
```js
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.
```js
// A global command to return back to the parent state
sm.command('back')
.action(({state}) => state.parent)
``````js
// 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
```js
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`](#advanced-section)### sm.rootState(): State
Create a root state.
### sm.command(...names): Command
- **names** `Array` 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=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 an `UNKNOWN_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 by `this.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 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.
```js
// 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` 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:
```js
BuyCommand
.option('position', {
default: '100%'
})
.option('stock')// ❌ This will cause an 'NON_DEFAULT_OPTION_FOLLOWS_DEFAULT' error
```Here is a complex example
```js
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.
```ts
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
}
``````ts
interface RuntimeState {
// The id of the current state
get id: string
// The parent state of the current state
get parent: RuntimeState
}
``````ts
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`
```js
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 executorIf 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
```js
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`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`
```js
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`](https://npmjs.org/package/ioredis) or an object has the same interfaces as `ioredis`
- **options?** `Object=`
- **lockExpire** `int` number of milliseconds util the lock expires.```js
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
```ts
interface SuccessStatus {
sucess: boolean
}interface ReaderResult extends SuccessStatus {
store?: object
}type Promisable = Promise | 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
lock (arg: SyncerArg): Promisable
refreshLock (arg: RefresherArg): Promisable
unlock (arg: SyncerArg): Promisable
}
```### 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
```sh
# First we should start a redis-server
redis-server# Then run tests
npm run test
```## License
[MIT](LICENSE)