Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/ama-team/voxengine-scenario-framework

A framework for building VoxEngine scenarios
https://github.com/ama-team/voxengine-scenario-framework

voxengine voximplant

Last synced: about 1 month ago
JSON representation

A framework for building VoxEngine scenarios

Awesome Lists containing this project

README

        

# (Unofficial) VoxEngine Scenario Framework

[![npm](https://img.shields.io/npm/v/@ama-team/voxengine-scenario-framework.svg?style=flat-square)](https://www.npmjs.com/package/@ama-team/voxengine-scenario-framework)
[![CircleCI/Master](https://img.shields.io/circleci/project/github/ama-team/voxengine-scenario-framework/master.svg?style=flat-square)](https://circleci.com/gh/ama-team/voxengine-scenario-framework/tree/master)
[![Coveralls/Master](https://img.shields.io/coveralls/ama-team/voxengine-scenario-framework/master.svg?style=flat-square)](https://coveralls.io/github/ama-team/voxengine-scenario-framework)
[![Scrutinizer/Master](https://img.shields.io/scrutinizer/g/ama-team/voxengine-scenario-framework/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/ama-team/voxengine-scenario-framework?branch=master)
[![Code Climate](https://img.shields.io/codeclimate/github/ama-team/voxengine-scenario-framework.svg?style=flat-square)](https://codeclimate.com/github/ama-team/voxengine-scenario-framework)

This repository contains a framework which takes opinionated approach
to handling VoxEngine scenarios. It takes care of different states,
transitions between them, data saving and logging.

To install this framework, simply run following command:

```
npm i @ama-team/voxengine-scenario-framework -S
```

Please note that framework uses [@ama-team/voxengine-sdk][] under the
hood, which may solve some other low-level problems for you.

## What's the problem?

Scenarios are complex and usually quite asynchronous things. Developing
them the usual way usually results in ton of spaghetti code and unclear
conditions hidden here and there, as well as no specific ordering
on certain events that have to be ordered (for example, you can't emit
more than one HTTP request after `VoxEngine.terminate()`). Promises
are partial response to it, but it easily becomes as ugly when
unnecessary conditions come into play - e.g. you need to terminate the
callback scenario when both calls are finished, but there is a chance
that second call won't be made, so you need to take this into account
and either create humongous control conditions or resolving promises
that didn't actually resolve.

We need something stronger.

## State model

We can break down each scenario into set of states. Imagine the
simplest scenario fo notifying clients by call:

1. Target phone number called
2. Call failed, go to #5
3. Call succeeded, go to #4
3. Say the phrase, then go to #5
4. Report to HTTP backend and terminate

It can be easily represented as in code:

```js
var Framework = require('@ama-team/voxengine-scenario-framework')

var scenario = {
trigger: Framework.TriggerType.Http,
states: {
entrypoint: {
entrypoint: true,
transition: function () {
var number = this.arguments.number
this.state.call = VoxEngine.callPSTN(number)
return {trigger: 'connected'}
}
},
failed: {
transition: function () {},
triggers: {
id: 'terminated',
hints: {success: false}
}
},
connected: {
transition: function () {
return new Promise(function (resolve) {
var call = VoxEngine.callPSTN(number)
this.state.call = call
call.addEventListener(CallEvents.Connected, function () {
resolve({trigger: 'communicated'})
})
call.addEventListener(CallEvents.Failed, function () {
resolve({transitionedTo: 'failed'})
})
})
}
},
communicated: {
transition: function() {
return new Promise(function (resolve) {
var phrase = this.arguments.phrase
this.state.call.say(phrase, Language.US_ENGLISH_FEMALE)
this.state.call.addEventListener(CallEvents.PlaybackFinished, function() {
this.state.call.hangup()
this.state.success = true
resolve()
})
})
},
triggers: {
id: 'terminated',
hints: {success: true}
}
},
terminated: {
terminal: true,
transition: function (_, hints) {
var options = new Net.HttpRequestOptions()
options.postData = JSON.stringify({success: hints.success})
return Net.httpRequestAsync('http://some-backend', options)
}
}
}
}
```

While this scenario is more complex than all the same logic but
written in straightforward way, it shows how another approach looks
like. Now you have explicit states and transitions that are required
to travel from one state to another; each transition may tell engine
that it ended not so well, resulting in a completely another state.
The scenario itself now boils only to crucial points (states) and
possible outcomes during a transition. Besides that, framework also
helps in passing arguments inside scenario and cornering some sharp
edges. So, let's inspect what we have here.

## Scenario schema

*this section contains examples in YAML rather than in javascript
for higher readability*

First of all, scenario has three auxiliary properties describing
itself. They are completely optional, but may save you some debugging
time.

```yml
id:
version:
environment:
```

Then there is states structure:

```yml
states:
:
entrypoint:
terminal:
transition:
abort:
triggers: # optional
id:
hints:
```

Scenario has to have exactly one entrypoint state and at least one
terminal state - otherwise it won't be launched.

Then there is metadata/metaprocessing section:

```yml
# whether the scenario is launched by http call or phone call
trigger:
# default arguments
arguments:
# default context state, doesn't relate to states discussed above
state:
# DI-wannabe container wih services you may like to use in transitions
container:
onTermination:
onError:
# used to deserialize arguments from custom data
deserializer:
timeouts:
```

Handler is a slightly complex structure:

```yml
handler:
timeout:
onTimeout:
handler:
timeout:
```

However, you can always specify it just as a function, and engine will
simply expand it.

After specifying this structure running it is as simple as calling
`Framework.run(scenario)`.

## Passing data between states

In lots of scenarios you will need to pass some data around and/or know
previous state from which engine is transitioning. Transition function
signature specifies three arguments:

```js
function transition (previousStateId, hints, cancellationToken) {}
```

First argument will contain the name of previous state. Hints is an
that may contain any data you want, and you may set whenever you
trigger some state:

```js
var states = {
preterminal: {
transition: function () {
return {trigger: {id: 'terminal', hints: {sendDebugInfo: true}}}
}
},
terminal: {
transition: function (previous, hints) {
if (hints.sendDebugInfo) {
// Do something
}
}
}
}
```

```js
var states = {
preterminal: {
triggers: {
id: 'terminal',
hints: {sendDebugInfo: true}
}
}
}
```

Moreover, hints may be a function that will be called in the same
context in the moment of trigger processing.

The third argument is an advanced aspect discussed later.

## Storing states between the states

End user will certainly need to store some data between states (e.g.
call instances), and there are two options for that. First, you may
go with standard javascript way of enclosing scopes: you may define
`var` outside of scenario:

```js
var call
var scenario = {
states: {
entrypoint: function () {
call = VoxEngine.callPSTN('911')
}
}
}
```

However, if you want to isolate your handlers, `this.state` context
property can be used for that:

```js
var scenario = {
states: {
entrypoint: function () {
this.state.call = VoxEngine.callPSTN('911')
}
}
}
```

It's completely up to you which way to choose.

## The context

All user-supplied code is executed inside the context - that means that
same specific object will be passed as `this`. This object has
following properties:

```yml
arguments:
state:
container:
trigger:
# log url
log:
transitionTo: >
trace: >
debug: >
info: >
notice: >
warn: >
error: >
```

Logger methods are forwarded to Slf4j-alike logger from
[@ama-team/voxengine-sdk][]. It has a nice feature of resolving
`{}` placeholders into arguments, so

```js
this.warn('{} has jumped over {}', 'quick fox', 'lazy dog')
````

Will result in a phrase you've seen a thousand times. You will find
more information on the [library page][@ama-team/voxengine-sdk].

## Non-triggering states / coding outside of the box

Basically, the term state itself doesn't imply that there is any kind
of immediate transition to another state. In case transition doesn't
return the `{trigger: something}` structure and there is no `.triggers`
property on the state, the framework will stay in specific state until
something calls the `.transitionTo` method on context:

```js
var state = {
transition: function () {
var trigger = this.transitionTo.bind(this, {trigger: 'terminated'})
this.state.call.addEventListener(CallEvents.Disconnected, trigger)
}
}
```

This also means scenario may hang near-infinite in some state until
VoxEngine kicks whole scenario out, so be careful playing with this.
Currently, scenario/state timeouts are not supported, and i don't
know how to implement them properly (because in 95%+ of cases it
would be necessary to take some action on timeout rather than just
terminate whole scenario).

If `.transitionTo()` is called during another transition, previous
transition gets aborted: it's abort handler is called, and it's
cancellation token (third argument) gets cancelled. Because there is no
direct way to abort running code, transition that may be aborted should
regularly check if it's token has been cancelled (`token.isCancelled()`)
before proceeding further:

```js
var httpCallsMadeState = function (p, h, token) {
return client
.performRequest('/ping')
.then(function () {
return token.isCancelled() ? null : client.performRequest('/pong')
})
}
```

## Arguments

Scenarios (at least HTTP-triggered) usually need arguments to run.
This framework allows to specify some hardcoded arguments and to
deserialize them from customData using the `.deserializer` scenario
property. Framework will take hardcoded arguments, apply deserializer
on customData (either VoxEngine.customData or call.customData,
depending on scenario trigger type), and then recursively merge them.
Please note that if deserializer fails (throws error or returns
rejected thenable), the whole scenario will be aborted. Deserializer
is **allowed** to take some time and return a promise (basically, it's
a regular `handler` with possible timeout and stuff), so you can
perform HTTP calls inside.

By default, framework will try to decode JSON out of customData and
silently proceed on fail.

Please note that VoxEngine docs [state][VoxEngine.customData] that
there is 200-byte limit on custom data, so if you need a lot of data,
it's better just to store it using HTTP backend and pass only ID.

## Timeouts

Every lengthy action should have a timeout to prevent infinite hangs.
There are two options that control it: individual timeout settings on
handlers and scenario-wide default values (specified in `.timeouts`
property):

```js
var scenario = {
states: {
entrypoint: {
transition: {
// timeout of 20 will be used
handler: function () {},
onTimeout: {
// timeout of 20 will be used because of override
handler: function () {},
timeout: 20
}
}
}
},
timeouts: {
transition: 20,
onTransitionTimeout: 10
}
}
```

Timeouts are set in milliseconds, every value but number >=
0 is treated as a 'no timeout'.

In case of timeout cancellation token is cancelled as well.

## Terminating

Usually there is some kind of post-scenario things to be done, like
waiting for all background HTTP requests or printing results to log.
For tasks like that you can specify a termination handler in scenario:

```js
var scenario = {
onTermination: function () {
return awaitSomething()
}
}
```

Termination handler acts the very same as other handlers, but receives
`TInitializationStageResult` and `TScenarioStageResult` as arguments.

Framework calls VoxEngine.terminate in the end, if `behavior.terminate`
option is set to true (which is by default).

## Errors and error handling

There are several places when error may be thrown:

- Argument deserializer. In that case termination handler will be
called instantly and scenario won't be executed.
- Active transition. This will cause `scenario.onError` handler to be
triggered, and, if it doesn't respond with
`{trigger: some other state}`, halt the scenario with an error,
still calling `scenario.onTermination` handler.
- Termination handler. This will do nothing but halt it as javascript
does with every piece of code throwing an exception.
- And, finally, the framework itself. This will cause piece of code to
end with `Tripped` status, so watch for these to report.

The onError handler signature is simple:

```js
var onError = function (error, previousState, targetState, hints) {}
```

onError may take some time to figure out what to do and may return a
promise, as any other handler, as well as be timed out.

## Logging

Framework logs everything it can, which is usually not what you really
want. However, the logger is taken from [@ama-team/voxengine-sdk][] and
is controlled accordingly:

```js
var SDK = require('@ama-team/voxengine-sdk')
var Logger = SDK.Logger
var Slf4j = Logger.Slf4j

// decreasing overall verbosity
Slf4j.setLevel(Logger.Level.Warn)
// increasing scenario log verbosity
Slf4j.setLevel('ama-team.vsf.context', Logger.Level.Debug)
```

By default, all INFO and higher level messages are logged.

## Validation

To prevent invalid scenario from uploading, you may validate it first.
To do so, just run `Framework.validate` to receive a `ValidationSet`
object. If it's severity is
`Framework.Schema.Validator.Severity.Fatal`, scenario is invalid and
can't be used.

## Concurrency notes

This library is built with run-to-completion model in mind. VoxImplant
engineers confirmed that this model is used by their interpreter.

## Other notes

- **Do not use VoxEngine.easyProcess.** It will terminate scenario
prematurely, as it [binds onto VoxEngine.terminate][VoxEngine.easyProcess]
- Please note that at the moment of this document being written ES6
**had not been supported by VoxImplant**. While you can use transpiler
to transform your scripts to ES5, i personally recommend not to use
ES6 until it is officially supported and write everything in ES5 - it
may save you nerves during debug.
- It is also strongly recommended to strip all comments from framework,
but not to minify it (at least agressively) to simplify debug in case
something won't work as expected.
- There is also a package `@ama-team/voxengine-definitions` that
contains jsdoc definitions for VoxEngine internals. Be sure to install
it if you need autocompletion in IDE other than provided by official
web UI.
- `@ama-team/voximplant-publisher` *should* be finished someday.

## Dev branch state

[![CircleCI/Dev](https://img.shields.io/circleci/project/github/ama-team/voxengine-scenario-framework/dev.svg?style=flat-square)](https://circleci.com/gh/ama-team/voxengine-scenario-framework/tree/dev)
[![Coveralls/Dev](https://img.shields.io/coveralls/ama-team/voxengine-scenario-framework/dev.svg?style=flat-square)](https://coveralls.io/github/ama-team/voxengine-scenario-framework)
[![Scrutinizer/Dev](https://img.shields.io/scrutinizer/g/ama-team/voxengine-scenario-framework/dev.svg?style=flat-square)](https://scrutinizer-ci.com/g/ama-team/voxengine-scenario-framework?branch=dev)

[@ama-team/voxengine-sdk]: https://npmjs.org/package/@ama-team/voxengine-sdk
[VoxEngine.customData]: https://voximplant.com/docs/references/appengine/VoxEngine.html#VoxEngine_customData
[VoxEngine.easyProcess]: http://voximplant.com/help/faq/what-code-is-behind-voxengine-easyprocess-function/