@orbital-frame/core v0.0.2-alpha.5
O R B I T A L - F R A M E
@orbital-frame/core is a framework for building chatbots that work similarly
to the UNIX command line, complete with commands, pipes, variables, signals,
etc. A reference implementation is provided in @orbital-frame/jehuty.
Installing
npm install --save @orbital-frame/coreCreating a bot
import orbitalFrame from '@orbital-frame/core'
import hubotAdapter from '@orbital-frame/adapter-hubot' // you must include an adapter for your chat platform. See documentation below for creating your own adapters
import commands from './commands' // these are commands that you define
import plugins from './plugins' // these are plugins that you define
import hubotConfig from './config' // your adapter configuration
const jehuty = hubot => orbitalFrame(hubotAdapter(hubot, hubotConfig), {
name: 'jehuty', // name that the bot will respond to. For instance `@jehuty echo "hi"`
commands,
plugins
})
export default hubot => jehuty(hubot).run() // where your framework instance comes from will vary depending on your chat platform but hubot passes this in as `robot` to every script in the `scripts` directoryOptions
- name
String [default: "orbital-frame"]The name of the bot. This is used to match user input to trigger the bot's listener. - ps2
String [default: ">"]This is the string or character that the bot uses to send input to interactive commands. - commands
Array<Command> [default: []]Commands that are available to run (see COMMANDS below for documentation on creating your own commands). - plugins
Array<Plugin> [default: []]Loaded plugins (see PLUGINS below for documentation on creating your own plugins). - rootUsers
Array<Number> [default: []]User IDs for users who are root. Root users have de facto, unrevokable superuser powers and may promote other users to superuser as well. - storageEngine
StorageEngine [default: MemoryEngine]Key/value storage used by thepersistenceService. By default, the MemoryEngine is used which does not persist data between restarts. For an example of creating your own storage engine, look at the source for the Memory Engine.
Adapters
Orbital Frame uses adapters to gain functionality for interacting with various
chat services. Currently, only the Hubot (@orbital-frame/adapter-hubot) adapter is available and this is what
@orbital-frame/jehuty runs on.
Creating adapters
An adapter should return an object with the following form:
- ps1
(optional)A symbol/string which is prepended to the bot name used to hail the bot. For instance, slack uses@ - hear
Fn <RegExp matcher, Fn callback>Listen for user input and invokecallbackon match withmatcher. Must invoke callback with an object of the form:- message
Object- user
Object- id
String|NumberUnique user ID - name
StringThe user's name
- id
- text
StringThe message text - channel
StringThe channel in which the message was received. If the chat service does not support channels, you can return a constant value like"root"
- user
- send
String message -> NilSend a message in the same context the message was received in
- message
- send
String channel, String messageSend a message to a channel - async getUsers
-> Array<User>Get all users in the chat - async getChannels
-> Array<Channel>Get all channels in the chat
See the hubot adapter as an example.
Runtime
The Orbital Frame lifecycle consists of the following stages:
- loadPlugins Loads plugins into the Orbital Frame lifecycle
- loadCommands Loads commands into the Orbital Frame lifecycle
- listen Sets up a responder for every time this bot is mentioned. NOTE: the exit phase triggers when the responder has been triggered, not when the responder has been set up
- process Processes a message produced from the bot's invocation
- execute Executes a command built from the message
- respond Returns the command's output
Jobs
When user input is entered it is assigned to a job. A job is in one of four states:
- pending A job begins its lifecycle in the pending state
- running Once a job begins execution, it is moved to the running state and remains there until it is either fulfilled or rejected
- fulfilled Upon success, a job moves to the terminal fulfilled state
- rejected Upon error, a job moves to the terminal rejected state
Along with its current state, a job contains its ID, a user-local ID, the ID of the user who started the job, the job's context which is used for interaction with the chat service, a command object for the command that belongs to the job, the source code input by the user which spawned the job, the date the job was started, the date the job was finished (or null if the job hasn't reached a terminal state), and the job's output if it is in a finished state.
Services
Orbital Frame uses dependency injection (DI) to expose its various configured subsystems for use within the lifecycle and user-defined commands and plugins.
channelService
The channel service retrieves channels from the chat service the bot is running on.
async list-> Array<Channel>Get all channelsasync findObject searchCriteria -> Array<channel>Find channels matching the given criteriaasync findOneObject searchCriteria -> channel [throws Error on no channel found]Returns the first channel matching the given criteria
Example
const example = async ({ channelService }) => {
const allChannels = await channelService.list()
const channel123 = await channelService.findOne({ id: 123 })
}commandService
The command service enables the loading of commands into the bot.
get registry-> Array<Command>Get all loaded commandsloadArray<Commands> | Command -> Nilload a command
Example
import sampleCommand from './sample'
const example = ({ commandService }) => {
const loadedCommands = commandService.registry
commandService.load(sampleCommand)
}compilerService
The compiler service takes a source string and produces an executable command.
compileString source -> FncompileWithMetadataString source -> { metadata: Object, command: Fn }Build an executable command and metadata describing the command from a source string
Example
const example = ({ compilerService }) => {
const source = 'VAR=test; echo $VAR | transform-text --uppercase'
const command = compilerService.compile(source)
const { metadata } = compilerService.compileWithMetadata(source)
const output = command()
metadata.pipelines[0].commands[0].name // "echo"
}configService
The config service holds configuration information for the bot:**
name-> StringThe name of the botps1-> StringThe leading character that must be placed before the bot's name to trigger a response (for slack this is@)ps2-> StringThe leading character that must be input before a subshell command to trigger a response. This is used for interactive commands using the interaction servicecommands-> Array<Command>A list of commands registered with the botplugins-> Array<Plugin>A list of plugins registered with the botadapter-> AdapterThe adapter the bot is running on. Note that using the adapter directly will couple your command/plugin to the adapter itself so all dependencies on the adapter itself pass through an abstraction layer in the core itself.
Example
const example = ({ configService }) => {
const { name, commands, plugins, adapter } = configService
}environmentService
The environment service is used to store and retrieve variables.
setString, Any -> NilAssign a value to a variable in the environmentgetString -> AnyRetrieve a value for a variable in the environment
Example
const example = ({ environmentService, compilerService }) => {
environmentService.set('TEST_VAR', 'hello')
const value = environmentService.get('TEST_VAR')
const command = compilerService.compile('echo $TEST_VAR')
command() // This will echo "hello" as set in the environment
}interactionService
The interaction service is used to make interactive commands, such as commands
that prompt the user or start up an embedded shell to run its own commands.
MESSAGES INTERCEPTED BY prompt MUST START WITH WHATEVER YOUR ps2 IS SET TO
IN YOUR CONFIGURATION (> by default) IN ORDER TO DISTINGUISH SUBCOMMANDS FROM
NON-ORBITAL FRAME INPUT
createInteractionChannelArray<Users> = []Create a channel for interacting with a group of users (the user which created the channel belongs to the group)promptString message -> Promise<Message>Prompt the user for inputobserveNil -> StreamCreate an interaction listener streamsendString messageSend text to the user
foregroundNumber userId, Number jobId -> NilForeground a backgrounded interaction
Example
// For more on commands see "Commands" below
const interactiveCommand = ({ interactionService }) => ({
name: 'test-interactive',
description: 'Test interactive commands',
format ({ name, age }) {
return `Name: ${name}, Age: ${age}`
},
async execute () {
const interaction = await interactionService.createInteractionChannel()
const { text: name } = await interaction.prompt('What is your name?')
const { text: age } = await interaction.prompt('What is your age?')
return { name, age }
}
})jobService
The job service associates commands with users and provides operations for retrieving information for jobs.
subscribeNumber jobId, Fn callback -> Subscriptionattach an update listener to a job. Whenever the job with ID jobId is updated, your callback will be invoked with the updated job. Returns an object with anunsubscribefunction for removing your callbackasync list-> Array<Job>Get all jobsasync findObject searchCriteria -> Array<Job>Find jobs matching the given criteriaasync findOneObject searchCriteria -> Job [throws Error on no job found]Returns the first job matching the given criteria
Example
const example = async ({ jobService, userService }) => {
const user = await userService.find({ name: 'konapun' })
const runningJobs = await jobService.find({ user, status: 'running' })
const finishedJobs = await jobService.find({ user, status: 'finished' })
const returnValues = await finishedJobs.map(job => job.returnValue)
const subscription = jobService.subscribe(runningJobs[0].id, updated => {
console.log('Job was updated:', updated)
})
subscription.unsubscribe()
}listenerService
The listener service sets up a matcher with an action.
listenString -> StreamReaderSet up a listener and receive a stream reader to get responses written to the stream
Example
const example = ({ listenerService }) => {
listenerService.listen('hey')
.pipe(message => {
console.log('Received message')
})
}messengerService
The messenger service sends output to the adapter the bot is running on.
respondContext, String -> NilSend a message in response to the sending contextsendChannel, String -> NilSend a message to a channel
Example
import {phase} from '@orbital-frame/core'
const examplePlugin = ({ messengerService }) => {
[phase.EXECUTE]: {
error (err, { context }) {
messengerService.respond(context, `Error: ${err.message}`)
}
}
}permissionService
The permission service allows promoting/demoting users to/from superuser and guarding blocks of code which require superuser permission.
async promoteNumber userId -> BoolPromote a user to a superuser. Only a superuser can promote a user.async demoteNumber userId -> BoolDemote a user to a normal user. Only a superuser can demote a uer.isSuperuserNumber userId -> BoolReturns whether or not a user is a superuser.async guardFn block -> AnyOnly runblockif the user associated with the currently executing job is a superuser.
Example
export const example = ({ permissionService }) => ({
name: 'example',
async execute () {
permissionService.guard(() => {
return 'this output is only available to superusers'
})
}
})export const promote = ({ permissionService }) => ({
name: 'promote',
async execute ([ userId ]) {
permissionService.promote(userId)
}
})persistenceService
The persistence service retains data between restarts. Some services, like the permissionService, utilize the persistenceService to persist superuser status.
- async get
String key -> AnyGet data stored atkey. - async set
String key, Any value -> NilSet value for keykeytovalue. - curry
String key -> CurryApiSet a key to be used with all subsequent get/set calls from the CurryApi below:- async get
Nil -> AnySet data stored at the key used as the argument tocurry. - async set
Any value -> NilSet the value forkeyused as the argument tocurrytovalue.
- async get
- namespace
String namespace -> NamespaceApiSet a namespace to automatically use for each key in the following API:- async get
String key -> AnyGet data stored at namespacedkey. This is equivalent to doing agetfrom the main API with your key${namespace}.${key}. - async set
String key, Any value -> NilSet value for namespaced keykeyto valuevalue. This is equivalent to doing asetfrom the main API with your key${namespace}.${key}. - curry
String key -> CurryApiLikecurryfrom the main API but the key is updated to use the namespace value. This is equivalent to doing acurryfrom the main API with your key${namespace}.${key}- async get
Nil -> AnySet data stored at the key used as the argument tocurrywhere the key is the namespaced key. - async set
Any value -> NilSet the value forkeyused as the argument tocurrytovaluewhere the key is the namespaced key.
- async get
- async get
Example
export const example = ({ persistenceService }) => ({
name: 'persistence-example',
async execute (key, value) {
const ns = 'orbital-frame.command.persistence-example'
const db = persistenceService.namespace(ns).curry(key)
if (value) {
db.set(value)
} else {
db.get()
}
}
})pluginService
The plugin service is responsible for registering plugins. (See below for documentation on creating your own plugins)
loadPlugin | Array<Plugin> -> NilLoad one or more plugins
Example
import myPlugin from './my-plugin'
const example = ({ pluginService }) => {
pluginService.load(myPlugin)
}signalService
The signal service allows commands to specify signal handlers and allows other commands to send signals to running jobs. Unlike real UNIX, orbital-frame does not have access to allocated resources like file handles or anything else that may need to be destroyed upon SIGKILL so signals can only be sent to "friendly" jobs that manually specify their own signal handlers. Attempts to send a signal to a job that doesn't handle that signal will result in a catchable error being thrown.
createSignalHandlerNilCreate a signal handler for a process which will respond to signals sent by other commands.onSignalSignal signal, Fn handlerSet up a function to be invoked upon signal.
sendNumber jobId, Signal signalSend a signal to a job which has a signal handler installed. Throws an error if job cannot receive signal.
Available Signals
- SIGINT (signal number 1) - analogous to SIGINT in UNIX; a command implementing a handler for this signal should cleanup and halt immediately if possible
- SIGSTP (signal number 2) - analogous to SIGSTP in UNIX; a command implementing a handler for this signal should pause and allow itself to be resumed by SIGRES
- SIGRES (signal number 3) - (no UNIX analog); a command implementing a handler for this signal should resume if paused by SIGSTP
Example
Handler
const example = ({ interactionService, signalService }) => ({
name: 'observer',
description: 'Testing observable interactions',
async execute () {
const interaction = await interactionService.createInteractionChannel()
const signalHandler = await signalService.createSignalHandler()
const stream = interaction.observe()
let paused = false
return new Promise(resolve => {
signalHandler.onSignal(signalService.signal.SIGSTP, () => {
paused = true
})
signalHandler.onSignal(signalService.signal.SIGRES, () => {
paused = false
})
signalHandler.onSignal(signalService.signal.SIGINT, () => {
stream.end()
resolve('Caught signal SIGINT; exiting')
})
stream.pipe(({ user, text }) => {
if (text === 'exit') {
resolve('Exiting')
stream.end()
} else if (!paused) {
interaction.send(`User ${user.name} sent message: ${text}`)
}
})
})
}
})Sender
export default ({ signalService }) => ({
name: 'kill',
synopsis: 'kill [JOB ID]',
description: 'Send a signal to a job',
options: {
1: {
alias: 'SIGINT',
type: 'boolean',
description: 'Request a job to interrupt'
},
2: {
alias: 'SIGSTP',
type: 'boolean',
description: 'Request a job to stop'
},
3: {
alias: 'SIGRES',
type: 'boolean',
description: 'Request a job to resume'
}
},
async execute ([ jobId ], { SIGSTP, SIGRES }) {
const signal = SIGRES ? 3 : SIGSTP ? 2 : 1
signalService.send(jobId, signal)
}
})userService
The user service retrieves users running on the bot adapter.
async getCurrentUserBool fullProjection -> Userget the user from the currently running process. IffullProjectionis true, the entire user object will be included. If false (default), only the id will be returned in the user object.async listNil -> Array<User>get all usersasync findObject searchCriteria -> Array<User>Find users matching the given criteriaasync findOneObject searchCriteria -> User [throws Error on no user found]Returns the first user matching the given criteria
Example
const example = async ({ userService }) => {
const currentUser = await userService.getCurrentUser()
const found = await userService.findOne({ id: 123 })
console.log(found.name) // -> "konapun"
}Plugins
Each phase in the Orbital Frame lifecycle is pluggable on enter, exit, and error
and receives arguments being sent to the current phase from the previous phase
on enter), arguments being sent to the next phase on exit, or the error
object and exit args on error. By default, each plugged phase returns its
arguments unchanged but may intercept these arguments as needed which will
propogate downstream in the lifecycle.
Handling Specific Errors
The following are specific errors that may be checked for using instanceof if
you wish to only handle a certain class of error in your error phase:
- CommandNotFoundError thrown when the user requests to run a command which has not been registered under any name
- CompilationError thrown when an error is encountered during the compilation phase
- ParseError thrown when an error is encountered by the parser
- PermissionError thrown when a user attempts to run permission-gated code
- SearchError thrown when no items can be found that match search criteria
- StateError thrown when an operation is rejected because of conflicting state
- ValidationError thrown when an error occurs due to an unexpected schema or property
Example
import { phase, error } from '@orbital-frame/core'
import damerauLevenshtein from 'talisman/metrics/distance/damerau-levenshtein'
const defaults = {
sensitivity: 2
}
const didYouMean = options => ({ commandService, messengerService }) => ({
[phase.EXECUTE]: {
error (e, { context }) {
if (!(e instanceof error.CommandNotFoundError)) return
const { sensitivity } = { ...defaults, ...options }
const command = context.message.text.split(/\s+/).splice(1).join(' ')
const matches = Object.keys(commandService.registry).map(name => {
const distance = damerauLevenshtein(command, name)
return { name, distance }
}).filter(({ distance }) => distance <= sensitivity)
if (matches.length > 0) {
messengerService.respond(context, `Did you mean:\n${matches.map(({ name }) => ` ${name}`).join('\n')}`)
}
}
}
})
export { didYouMean }
export default didYouMean()Example Plugin
import {phase} from '@orbital-frame/core'
function plugin () {
return {
[phase.LOAD_PLUGINS]: { // phases before exiting LOAD_PLUGINS aren't available for extension via plugins since they're not yet loaded
exit () {
console.log('Loaded plugins')
console.log('----------')
},
error (e) {
console.error('Error loading plugins:', e)
}
},
[phase.LOAD_COMMANDS]: {
enter () {
console.log('Loading commands')
},
exit () {
console.log('Loaded commands')
console.log('----------')
},
error (e) {
console.error('Error loading commands:', e)
}
},
[phase.LISTEN]: {
enter () {
console.log('Listening')
},
exit () {
console.log('Listened')
console.log('----------')
}
},
[phase.PROCESS]: {
enter () {
console.log('Processing')
},
exit () {
console.log('Processed')
console.log('----------')
},
error (e, args) {
console.log('Error processing input:', e, args)
}
},
[phase.EXECUTE]: {
enter () {
console.log('Executing')
},
exit () {
console.log('Executed')
console.log('----------')
},
error (e, args) {
console.log('Error executing command:', e, args)
}
},
[phase.RESPOND]: {
enter () {
console.log('Responding')
},
exit () {
console.log('Responded')
console.log('----------')
}
}
}
}
export default pluginCommands
Commands are the primary means of extension for an Orbital Frame instance. A command is a function which takes as input injected services (in the same way as a plugin function) and returns an object with the following structure:
- name the name the command will be invoked with
- synopsis usage details for the command
- description help text for the command
- options a mapping of single letter short options to:
- alias long option alias for short option
- description help text for option
- type one of
number,string, orboolean - required whether or not the option is required
- default a default value for the option if the option isn't explicitly set
- valid
Object<String, Any>, Array<Any> -> Booleanvalidator for the option value
- execute
Array<Any> arguments, Object<String, Any> options, Object<String, Any> metadata -> Anya function which takes an array of arguments, a map of option keys to values from the command line, and execution metadata and returns a value - format
Any -> Stringa function which takes as input the output fromexecuteand returns a formatted string for display
Commands are assigned a unique ID on execute which can be accessed within
execute as this.pid or in the execute function's third argument which is its
metadata. In order to get the pid from this context you MUST use
function notation instead of arrow notation. Either style of function can
retrieve the pid from execute's third argument. The pid can be used as a unique
key for later retrieval.
Example Command
import { take, shuffle } from 'lodash'
export default () => ({
name: 'choose',
description: 'Choose from multiple choices',
options: {
n: {
alias: 'number',
description: 'Take n choices',
type: 'number',
default: 1
}
},
format (choices) {
return choices.join(' ')
},
execute (args, opts) {
return take(shuffle(args), opts.number)
}
})Interactive Commands
Commands can be made interactive by using the interactionService described
above. Messages must start with > in order to be intercepted by prompt.
const interactiveCommand = ({ interactionService }) => ({
name: 'test-interactive',
description: 'Test interactive commands',
format ({ name, age }) {
return `Name: ${name}, Color: ${color}`
},
async execute () {
const interaction = await interactionService.createInteractionChannel()
const { text: name } = await interaction.prompt('What is your name?')
const { text: color } = await interaction.prompt('What is your favorite color scheme?')
return { name, color }
}
})Commands can implement their own subshell by using observe:
export default ({ interactionService, signalService }) => ({
name: 'observer',
description: 'Testing observable interactions',
async execute () {
const interaction = await interactionService.createInteractionChannel()
const signalHandler = await signalService.createSignalHandler()
const stream = interaction.observe()
let paused = false
return new Promise(resolve => {
signalHandler.onSignal(signalService.signal.SIGSTP, () => {
paused = true
})
signalHandler.onSignal(signalService.signal.SIGRES, () => {
paused = false
})
signalHandler.onSignal(signalService.signal.SIGINT, () => {
stream.end()
resolve('Caught signal SIGINT; exiting')
})
stream.pipe(({ user, text }) => {
if (text === 'exit') {
resolve('Exiting')
stream.end()
} else if (!paused) {
// Your own unique text parsing can go here. Subshell commands will still be input with a > character by default (unless you've overridden `ps2` in your config)
interaction.send(`User ${user.name} sent message: ${text}`)
}
})
})
}
})Example usage
@jehuty test-interactive
What is your name? # Prompt generated by Orbital Frame
>konapun # User input. Note how ">" is needed to distinguish text for the interacive command from non-Orbital Frame message text
What is your favorite color scheme? # Prompt generated by Orbital Frame
>monokai # User input
Name: konapun, Color: monokai # Command outputPending Features/TODOs
5 years ago
5 years ago
5 years ago
6 years ago
6 years ago