Full Code of habbes/ussd-menu-builder for AI

master f182b26ae2ff cached
10 files
85.1 KB
17.1k tokens
29 symbols
1 requests
Download .txt
Repository: habbes/ussd-menu-builder
Branch: master
Commit: f182b26ae2ff
Files: 10
Total size: 85.1 KB

Directory structure:
gitextract_fkq589_g/

├── .eslintrc.json
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── index.d.ts
├── index.js
├── lib/
│   └── ussd-menu.js
├── package.json
└── test/
    └── ussd-menu.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintrc.json
================================================
{
    "env": {
        "es6": true,
        "node": true,
        "mocha": true
    },
    "extends": "eslint:recommended",
    "rules": {
        "indent": [
            "error",
            4
        ],
        "quotes": [
            "error",
            "single"
        ],
        "semi": [
            "error",
            "always"
        ],
        "no-consolse": 0
    }
}

================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*

# Runtime data
pids
*.pid
*.seed

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# Dependency directory
node_modules

# Optional npm cache directory
.npm

# Optional REPL history
.node_repl_history

.vscode
coverage
yarn.lock


================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
 - "10"
 - "9"
 - "8"
 - "6"
 - "node"
script: "npm run coveralls"

================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Copyright (c) 2016 Clément Habinshuti

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# ussd-menu-builder

[![Build Status](https://travis-ci.org/habbes/ussd-menu-builder.svg?branch=master)](https://travis-ci.org/habbes/ussd-menu-builder)
[![Coverage Status](https://coveralls.io/repos/github/habbes/ussd-menu-builder/badge.svg?branch=master)](https://coveralls.io/github/habbes/ussd-menu-builder?branch=master)


Easily compose USSD menus in Node.js, compatible with
[Africastalking API](https://africastalking.com) or [Hubtel API](https://developers.hubtel.com/reference#ussd).

## Installation

```
$ npm install ussd-menu-builder
```
or
```
$ yarn add ussd-menu-builder
```

## Features
- Use intuitive states to compose USSD menus
- Makes it easier to build complex nested menus
- Use simple input matching or regular expressions, custom asynchronous
functions to resolve routes from one state to another
- The state-based approach allows you to easily modularize complex menus
in different files

## Quick Example

```javascript
const UssdMenu = require('ussd-menu-builder');
let menu = new UssdMenu();

// Define menu states
menu.startState({
    run: () => {
        // use menu.con() to send response without terminating session      
        menu.con('Welcome. Choose option:' +
            '\n1. Show Balance' +
            '\n2. Buy Airtime');
    },
    // next object links to next state based on user input
    next: {
        '1': 'showBalance',
        '2': 'buyAirtime'
    }
});

menu.state('showBalance', {
    run: () => {
        // fetch balance
        fetchBalance(menu.args.phoneNumber).then(function(bal){
            // use menu.end() to send response and terminate session
            menu.end('Your balance is KES ' + bal);
        });
    }
});

menu.state('buyAirtime', {
    run: () => {
        menu.con('Enter amount:');
    },
    next: {
        // using regex to match user input to next state
        '*\\d+': 'buyAirtime.amount'
    }
});

// nesting states
menu.state('buyAirtime.amount', {
    run: () => {
        // use menu.val to access user input value
        var amount = Number(menu.val);
        buyAirtime(menu.args.phoneNumber, amount).then(function(res){
            menu.end('Airtime bought successfully.');
        });
    }
});

// Registering USSD handler with Express

app.post('/ussd', function(req, res){
    menu.run(req.body, ussdResult => {
        res.send(ussdResult);
    });
});

```

# Guide
## Introduction
The USSD Menu Builder uses a state machine to create a USSD menu. A state
is created for each menu. Each state has a unique name and a set of rules
used to link to other states based on the user input.

### Creating a menu
Before you can create any states, you first need to create an instance of
the menu.

```javascript
const UssdMenu = require('ussd-menu-builder');
const menu = new UssdMenu();
```

### Running the menu
The **```menu.run(args, resultCallback)```** goes through the menu and finds
the appropriate state to run based on the user input.

The **```args```** object should contain the following keys coming from
the  [Africastalking API](https://africastalking.com):

- **`sessionId`**: unique session ID that persists through the entire USSD session,
can be used to store temporary that may be retrieved from different states
during the session
- **`serviceCode`**: the USSD code registered with your serviceCode
- **`phoneNumber`**: the end user's phone Number
- **`text`**: The raw USSD input. It has the following format ```1*2*4*1```:
a string containing the input at each hop, separated by the asterisk symbol (```*```).
This is parsed by the ```UssdMenu``` to find the appropriate state to run at each hop.

After the matched state runs, the resultCallback is called with the response from the state.

**`Note: `** *The menu also returns a promise that can be resolved if you need to do anything with the final response.
for example:*
```javascript
let resp = await menu.run(args) // resultCallback is not necessarry if you intend to run the menu in an async function

```
Here's an example registering a handler with the [express](https://expressjs.com) framework:
```javascript

app.post('/ussd', (req, res) => {
    let args = {
        phoneNumber: req.body.phoneNumber,
        sessionId: req.body.sessionId,
        serviceCode: req.body.serviceCode,
        text: req.body.text
    };
    menu.run(args, resMsg => {
        res.send(resMsg);
    });
})

```

Handling menu.run response:
```javascript

app.post('/ussd', async (req, res) => {
    let args = {
        phoneNumber: req.body.phoneNumber,
        sessionId: req.body.sessionId,
        serviceCode: req.body.serviceCode,
        text: req.body.text
    };
    let resMsg = await menu.run(args);
    res.send(resMsg);
})

```

## Defining states

The **`menu.state(name, options)`** method is used to define states. I takes the name of the state
and an object with the following properites:
- **`run`**: a function that's called when the state is resolved
- **`next` (optional)**: an object that contains rules of how to match the input of this state
to other states. *This is not required for final states*.
- **`defaultNext` (optional)**: the name of the state to default to if the user
input could not be matched by the rules defined in the `next` object.
If not provided, the same state will be used as a fallback i.e. the same menu will
be displayed to the user.

Here's an example:
```javascript
menu.state('stateName', {
    run: function(){
        menu.con('Choose Option' +
            '\n1. Load Account' +
            '\n2. View Catalogue' +
            '\n3. Check Balance'
        );
    },
    next: {
        '1': 'loadAccount',
        '2': 'catalogue',
        '3': 'balance'
    },
    defaultNext: 'invalidOption'
});
```

### The **`run`** function
Each state defines it's own `run` method which is called when that
state is matched. This is where you should place the logic for a given
state.

#### Retrieving user input
Use **```menu.val```** property to access the current user input.

#### Accessing ussd parameters
You can access the ussd parameters through the **```menu.args```** object.
This parameters should come from the API Gateway and are passed to the
```menu.run``` method.

#### Sending the response
You must use either (not both) of the two methods to send
a response to be displayed to the user:
- **`menu.con(msg)`**: Sends the result to be displayed to the user without
terminating the session i.e. the user can reply with further input.
- **`menu.end(msg)`**: Sends the response to be displayed to the user and
requests the session to be terminated i.e. the user cannot provide further
input. **Note:** *This consequently makes the state a final state and therefore the
```next``` object does not need to be defined*

Example:

```javascript
menu.state('thisState', {
    run: function(){
        let value = menu.val;
        let session = getSession(menu.args.sessionId);
        let phone = menu.args.phoneNumber;
        session.set('phone', phone);
        session.set('value', value);
        menu.end('You entered: ' + value);
    }
});
```

### The Start State
This is the first state or first menu to be displayed by the user.
It is created using the **```menu.startState(options)```**. It uses
the reserved name ```'__start__'```.

``` javascript
menu.startState({
    run: function(){
        ...
    }
    next: {
        ...
    }
});
```

***
**Note**: the ```menu.state()``` and ```menu.startState()``` methods return
the same menu object instance for convenience.

```javascript
menu.startState({
    ...
})
.state('state1', {
    ...
})
.state('state2', {
    ...
})
```
***

### Matching States
To link states you use the ```next``` object to map user input to a state name.
You can match input directly by value or with a regular expression.

#### Matching direct values
Simply add the expected string value as a key in the next object.

#### Matching with regular expressions
Begin the key with an asterisk (**```*```**) to indicate that the key should
be treated like a regular expression e.g. ```'*\\[a-zA-Z]+'``` would match
any input containing only lowercase or uppercase letters.

Remember you can use ```menu.val``` in the matched state to retrieve the actual user input.

Example:
```javascript
menu.state('registration', {
    run: function(){
        menu.con('Enter your name');
    },
    next: {
        '*[a-zA-Z]+': 'registration.name'
    }
});

menu.state('registration.name', {
    run: function(){
        let name = menu.val;
        let session = getSession(menu.args.sessionId);
        session.set('name', name);
        menu.con('Enter your email');
    },
    next: {
        '*\\w+@\\w+\\.\\w+': 'registration.email'
    }
});
```

#### Matching with empty rule on Start State
If the start state does not define a ```run``` method, you provide
an empty string as key in ```next``` to redirect to another state.

```javascript
menu.startState({
    next: {
        '': function(){
            if(user){
                return 'userMenu';
            }
            else {
                return 'registerMenu';
            }
        }
    }
});
```
### Linking states
Beside mapping user input directly to a state name, you can map it to
a function with returns a state name, synchronously with a simple
return statement or asynchronously with a callback or a promise.

#### Mapping to a direct state name
```javascript
menu.state('thisState', {
    ...
    next: {
        'input': 'nextState'
    }
})
```

#### Mapping to a synchronous function
```javascript
menu.state('thisState', {
    ...
    next: {
        'input': function(){
            if(test){
                return 'nextState';
            } else {
                return 'otherState';
            }
        }
    }
});
```
#### Mapping to an async function with callback
```javascript
menu.state('thisState', {
    ...
    next: {
        'input': function(callback){
            runAsyncCode(function(err, res){
                if(res){
                    callback('nextState');
                } else {
                    callback('otherState');
                }
            })
        }
    }
});
```

#### Mapping to an async function with promise
```javascript
menu.state('thisState', {
    ...
    next: {
        'input': function(){
            return new Promise((resolve, reject) => {
                resolve('nextState');
            });
        }
    }
});
```

### Jumping to different state
You can jump to a different state from the ```run``` function of one
state using the **```menu.go(stateName)```** method. This effectively
breaks the state chain (subsequent states will not be reachable) 
and is therefore only useful if jumping to a final state.

```javascript
menu.state('thisState', {
    run: function(){
        menu.go('otherState');
    }
});

menu.state('otherState', {
    run: function(){
        menu.end('Thank you!');
    }
});
```

The **```menu.goStart()```** method can be used to jump to the start state
from within another state.

## Nesting states
The library treats a USSD menu like a chain of interlinked states and therefore
has not internal concept of nesting. However you can achieve complex menus
with nested submenus by linking states appropriately. In addition you
could use a naming convention of your choice to make it clearer to see how
states are related. In these examples I used the following convention of 
separating menu levels with a dot.

## Sessions
You can store temporary user data that persists through an entire session.
The library provides a way for you to define your own custom session
handler so you're free to use whatever storage backend or driver you want.
The menu provides an easy interface to set and retrieve session data
within states based on the implementation you provide.

### Configuring handlers

The **`menu.sessionConfig(config)`** method is used to define your session
handler. It accepts an object with the implementations of the following
methods:

- `start` [**`function(sessionId, callback)`**]: used to initialize a new
session, invoked internally by the `menu.run()` method before any state
is called.
- `end` [**`function(sessionId, callback)`**]: used to delete current session,
invoked internally by the `menu.end()` method.
- `set` [**`function(sessionId, key, value, callback)`**]: used to store
a key-value pair in the current session, invoked internally by
`menu.session.set()`.
- `get` [**`function(sessionId, key, callback)`**]: used to retrieve a
value from the current session by key, invoked internally by 
`menu.session.get()`.

#### Example using local memory for storage

```javascript

let sessions = {};

let menu = new UssdMenu();
menu.sessionConfig({
    start: (sessionId, callback){
        // initialize current session if it doesn't exist
        // this is called by menu.run()
        if(!(sessionId in sessions)) sessions[sessionId] = {};
        callback();
    },
    end: (sessionId, callback){
        // clear current session
        // this is called by menu.end()
        delete sessions[sessionId];
        callback();
    },
    set: (sessionId, key, value, callback) => {
        // store key-value pair in current session
        sessions[sessionId][key] = value;
        callback();
    },
    get: (sessionId, key, callback){
        // retrieve value by key in current session
        let value = sessions[sessionId][key];
        callback(null, value);
    }
});

```

***
**Note:** Instead of callbacks, you may also return promises from
those methods:
```javascript
menu.sessionConfig({
    ...
    get: function(sessionId, key){
        return new Promise((resolve, reject) => {
            let value = sessions[sessionId][key];
            resolve(value);
        });
    }
})
```


### Setting and getting data from the current session

And then to add and retrieve data inside states, use the
`menu.session` object:

```javascript

menu.state('someState', {
    run: () => {
        let firstName = menu.val;
        menu.session.set('firstName', firstName)
        .then( () => {
            menu.con('Enter your last name');
        })
    }
    ...
})
...
menu.state('otherState', {
    run: () => {
        menu.session.get('firstName')
        .then( firstName => {
            // do something with the value
            console.log(firstName);
            ...
            menu.con('Next');
        })
    }
})
...
```

***
**Note**: The `menu.session`'s methods also work with callbacks:
```javascript
menu.session.set('key', 'value', (err) => {
    menu.con('...');
});

menu.session.get('key', (err, value) => {
    console.log(value);
    ...
});
```
***

***
**Note**: It's not required to configure a session handler. You can
access your storage driver directly if you prefer. However if you
do configure a handler using the above method then you should provide
implementations for all the 4 methods as shown above..
***

## Errors

`UssdMenu` instances emit an **`error` event** when an error occurs during the
state resolution process (e.g: **"state not found"** or **"run function not defined"**).

```javascript

menu.startState({
    ...
    next: {
        '1': 'nonExistentState'
    }
});

menu.on('error', (err) => {
    // handle errors
    console.log('Error', err);
});


args.text = '1';
menu.run(args);

```

In addition, errors passed to the callback of the session handler's methods or 
rejected by their promises will also trigger the `error` event for convenience
so that you can handle your handle errors in one place.

```javascript

menu.sessionConfig({
    ...
    get: (sessionId, key, callback){
        callback(new Error('error'));
    }
});

menu.on('error', err => {
    // handle errors
    console.log(err);
});

...

menu.state('someState', {
    run: () => {
        menu.session.get('key').then(val => {
            ...
        });
        // you don't have to catch the error here
    }
});

```

## Hubtel Support

As of version 1.1.0, ussd-menu-builder has added support for Hubtel's USSD API by providing the `provider` option when creating the **UssdMenu** object. There are no changes to the way states are defined, and the HTTP request parameters sent by Hubtel are mapped as usual to `menu.args`, and the result of `menu.run` is mapped to the HTTP response object expected by Hubtel (`menu.con` returns a _Type: Respons & `menu.end` returns a Type: Release). The additional HTTP request parameters like Operator, ClientState, and Sequence are not used.

The key difference with Hubtel is that the service only sends the most recent response message, rather than the full route string. The library handles that using the Sessions feature, which requires that a SessionConfig is defined in order to store the session's full route. This is stored in the key `route`, so if you use that key in your application it could cause issues.


### Example

```javascript
menu = new UssdMenu({ provider: 'hubtel' });
// Define Session Config & States normally
menu.sessionConfig({ ... });
menu.state('thisState', {
    run: function(){
        ...
    });
});

app.post('/ussdHubtel', (req, res) => {
    menu.run(req.body, resMsg => {
        // resMsg would return an object like:
        // { "Type": "Response", "Message": "Some Response" }
        res.json(resMsg);
    });
})
```


================================================
FILE: index.d.ts
================================================
/// <reference types="node" />

// Type definitions for ussd-menu-builder 1.0.0
// Project: ussd-menu-builder
// Definitions by: Jason Schapiro <yono38@gmail.com>

import { EventEmitter } from "events";

export = UssdMenu;

declare class UssdState {
    constructor(menu: UssdMenu);

    defaultNext?: string;

    menu: UssdMenu;

    name: string;

    run(): void;

    val: string;
}

declare class UssdMenu extends EventEmitter {
    constructor(opts?: UssdMenu.UssdMenuOptions);

    session: any;
    provider: UssdMenu.UssdMenuProvider;
    args: UssdMenu.UssdGatewayArgs;
    states: Array<UssdState>;
    result: string;
    val: string;

    callOnResult(): void;

    con(text: string): void;

    end(text: string): void;

    getRoute(args: UssdMenu.UssdGatewayArgs | UssdMenu.HubtelArgs): Promise<string>;

    go(state: string): void;

    goStart(): void;

    mapArgs(args: UssdMenu.UssdGatewayArgs | UssdMenu.HubtelArgs): UssdMenu.UssdGatewayArgs;


    onResult?(result: string | UssdMenu.HubtelResponse): void;

    resolveRoute(route: string, callback: Function): void;

    resolve?(value: string): void;

    run(args: UssdMenu.UssdGatewayArgs, onResult?: Function): Promise<string>;

    runState(state: UssdState): void;

    sessionConfig(config: UssdMenu.UssdSessionConfig): void;

    startState(options: UssdMenu.UssdStateOptions): void;

    state(name: string, options: UssdMenu.UssdStateOptions): UssdMenu;

    testLinkRule(rule: string, val: string): boolean;

    static START_STATE: string;
}

/*~ If you want to expose types from your module as well, you can
 *~ place them in this block.
 */
declare namespace UssdMenu {
    interface NextState {
        [state: string]: Function | string;
    }

    interface UssdGatewayArgs {
        text: string;
        phoneNumber: string;
        sessionId: string;
        serviceCode: string;
    }

    interface HubtelResponse {
        Type: 'Response' | 'Release';
        Message: string;
    }

    interface HubtelArgs {
        Mobile: string;
        SessionId: string;
        ServiceCode: string;
        Type: 'Initiation' | 'Response' | 'Release' | 'Timeout';
        Message: string;
        Operator: 'Tigo' | 'Airtel' | 'MTN' | 'Vodafone' | 'Safaricom';
        Sequence: number;
        ClientState?: any;
    }

    type UssdMenuProvider = 'africasTalking' | 'hubtel';
    interface UssdMenuOptions {
        provider?: UssdMenuProvider;
    }
    
    interface UssdStateOptions {
        run(): void;
        next?: NextState;
        defaultNext?: string;
    }
    
    interface UssdSessionConfig {
        start(sessionId: string, callback?: Function): (Promise<any> | void);
    
        end(sessionId: string, callback?: Function): (Promise<any> | void);
    
        get(sessionId: string, key: string, callback?: Function): (Promise<any> | void);
    
        set(sessionId: string, key: string, value: any, callback?: Function): (Promise<any> | void);
    }
}




================================================
FILE: index.js
================================================

module.exports = require('./lib/ussd-menu');

================================================
FILE: lib/ussd-menu.js
================================================
'use strict';
const async = require('async');
const EventEmitter = require('events');


class UssdMenu extends EventEmitter {

    constructor (opts = {}) {
        super();
        const validProviders = ['hubtel', 'africasTalking'];
        this.provider = opts.provider || 'africasTalking';
        if (!validProviders.includes(this.provider)) {
            throw Error('error', new Error(`Invalid Provider Option: ${this.provider}`));
        }
        this.session = null;
        this.args = null;
        this.states = {};
        this.result = '';
        this.onResult = null;
        this.val = '';
        this.resolve = null;
    }

    callOnResult () {
        if(this.onResult){
            this.onResult(this.result);
        }
        if(this.resolve){
            this.resolve(this.result);
        }
    }

    con (text) {
        if (this.provider === 'hubtel') {
            this.result = {
                Message: text,
                Type: 'Response',
            };
        } else {
            this.result = 'CON ' + text;
        }
        this.callOnResult();
    }

    end (text) {
        if (this.provider === 'hubtel') {
            this.result = {
                Message: text,
                Type: 'Release',
            };
        } else {
            this.result = 'END ' + text;
        }
        this.callOnResult();
        if(this.session){
            this.session.end();
        }
    }

    testLinkRule (rule, val) {
         //if rule starts with *, treat as regex
        if (typeof rule === 'string' && rule[0] === '*') {
            var re = new RegExp(rule.substr(1));
            return re.test(val);
        }
        return rule == val;
    }

    /**
     * find state based on route
     * @param string route a ussd text in form 1*2*7
     * @return UssdState
     */
    resolveRoute (route, callback) {
        // separate route parts
        var parts = route === ''? [] : route.split('*');
        // follow the links from start state
        var state = this.states[UssdMenu.START_STATE];
        
        if(!state.next || Object.keys(state.next).length === 0){
            // if first state has no next link defined
            return callback(null, this.states[state.defaultNext]);
        }

        // if the first state has route rule for empty string,
        // prepend it to route parts
        if ('' in state.next) {
            parts.unshift('');
        }
        
        async.whilst(
            () => parts.length > 0 ,
            (whileCb) => {
                
                // get next link from route
                var part = parts.shift();
                var nextFound = false;
                this.val = part;
                //check if link matches any declared on current next
                async.forEachOfSeries(
                    state.next,
                    (next, link, itCb) => {
                        
                        /* called when next path has been retrieved
                        * either synchronously or with async callback or promise
                        */
                        let nextPathCallback = (nextPath) => {
                            state = this.states[nextPath];
                            if (!state) {
                                return itCb(
                                    new Error(`declared state does not exist: ${nextPath}`));
                            }

                            state.val = part;
                            nextFound = true;
                            return itCb({ intended: true });
                        };
                        
                        if (this.testLinkRule(link, part)) {
                            var nextPath;
                            // get next state based
                            // the type of value linked
                            switch (typeof next) {
                            case 'string':
                                    // get the state based on name
                                nextPath = next;
                                break;
                            case 'function':
                                    // custom function declared
                                nextPath = next(nextPathCallback);
                                break;
                            }
                            if (typeof nextPath === 'string') {
                                // nextPath determined synchronously,
                                // manually call callback
                                return nextPathCallback(nextPath);
                            }
                            else if (nextPath && nextPath.then) {
                                // promise used to retrieve nextPath
                                return nextPath.then(nextPathCallback);
                            }

                        }
                        else {
                            return itCb();
                        }
                    },
                    (err) => {
                        if (err && !err.intended) {
                    
                            return whileCb(err);
                        }
                        if (!nextFound && state.defaultNext) {
                            // if link not found, resort to default if specified
                            state = this.states[state.defaultNext];
                            state.val = part;
                        }

                        whileCb();
                    }
                    );            
                
                // end iterator
            },
            (err) => {
                if(err){
                    return callback(err);
                }
                
                return callback(null, state);
            }
        );
    }

    runState (state) {
        if (!state.run) {
            return this.emit('error', new Error(`run function not defined for state: ${state.name}`));
        }
        state.run(state);
    }

    go (stateName) {
        var state = this.states[stateName];
        state.val = this.val;
        this.runState(state);
    }

    goStart () {
        this.go(UssdMenu.START_STATE);
    }

    /**
     * configure custom session handler
     * @param {Object} config object with implementation
     * for get, set, start and end methods
     */
    sessionConfig (config) {
        /*
        the following 2 functions are used to make session
        method cross-compatible between callbacks and promises
        */

        /**
         * creates a callback function that calls
         * the promise resolve and reject functions as well as
         * the provided callback
         * 
         */
        let makeCb = (resolve, reject, cb) => {
            return (err, res) => {
                if(err){
                    if(cb) cb(err);    
                    reject(err);
                    this.emit('error', err);
                }
                else {
                    if(cb) cb(null, res);
                    resolve(res);                    
                }
            };
        };

        /**
         * if p is a promise, handle its resolve and reject
         * chains and invoke the provided callback
         */
        let resolveIfPromise = (p, resolve, reject, cb) => {
            if(p && p.then){
                p.then( res => {
                    if(cb) cb(null, res);
                    resolve(res);                    
                }).catch(err => {
                    if(cb) cb(err);
                    reject(err);                    
                    this.emit('error', err);
                });
            }
        };

        // implement session methods based on user-defined handlers
        this.session = {
            start: (cb) => {
                return new Promise((resolve, reject) => {
                    let res = config.start(this.args.sessionId, makeCb(resolve, reject, cb));
                    resolveIfPromise(res, resolve, reject, cb);
                });
            },
            get: (key, cb) => {
                return new Promise((resolve, reject) => {
                    let res = config.get(this.args.sessionId, key, makeCb(resolve, reject, cb));
                    resolveIfPromise(res, resolve, reject, cb);
                });            
            },
            set: (key, val, cb) => {
                return new Promise((resolve, reject) => {
                    let res = config.set(this.args.sessionId, key, val, makeCb(resolve, reject, cb));
                    resolveIfPromise(res, resolve, reject, cb);
                }); 
            },
            end: (cb) => {
                return new Promise((resolve, reject) => {
                    let res = config.end(this.args.sessionId, makeCb(resolve, reject, cb));
                    resolveIfPromise(res, resolve, reject, cb);
                });
            }
        };
    }

    /**
     * create a state on the ussd chain
     * @param string name name of the state
     * @param object options
     * @param object options.next object mapping of route 
     *  val to state names
     * @param string options.defaultNext name of state to run
     *  when the given route from this state can't be resolved
     * @param function options.run the method to run when this
     *  state is resolved
     * @return Ussd the same instance of Ussd
     */
    state (name, options) {
        var state = new UssdState(this);
        this.states[name] = state;
        
        state.name = name;
        state.next = options.next;
        state.run = options.run;
        // default defaultNext to same state
        state.defaultNext = options.defaultNext || name;
        
        
        return this;
    }

    /**
     * create the start state of the ussd chain
     */
    startState (options) {
        return this.state(UssdMenu.START_STATE, options); 
    }

    /*
     * maps incoming API arguments to the format used by Africa's Talking
     */
    mapArgs (args) {
        if (this.provider === 'hubtel') {
            this.args = {
                sessionId: args.SessionId,
                phoneNumber: `+${args.Mobile}`,
                serviceCode: args.ServiceCode,
                text: args.Type === 'Initiation' ? this.parseHubtelInitiationText(args) : args.Message,
            };
        } else {
            this.args = args;
        }
    }

    /*
     * If message equals shortcode, ignore.
     * Otherwise parse message as an initial route
     * For example, if servicecode is *714*5# and initial text is
     * *714*5*3*100*1#, then the text/route is 3*100*1
     */
    parseHubtelInitiationText(hubtelArgs) {
        const { ServiceCode: serviceCode, Message: text } = hubtelArgs;
        if (text === `*${serviceCode}#`) {
            return '';
        } else {
            // Remove service code, first asterisk and end hash, treat rest as a route
            const routeStart = serviceCode.length + 2;
            return text.slice(routeStart, -1);
        }
    }

    /*
     * Returns the full route string joined by asterisks, ex: 1*2*41
     * Africa's Talking Provides full route string, but hubtel only has the
     * message text sent and must be concatinated.
     */
    getRoute(args) {
        if (this.provider === 'hubtel') {
            if (this.session === null) {
                return this.emit('error', new Error('Session config required for Hubtel provider'));
            } else if (args.Type === 'Initiation') {
                // Parse initial message for a route
                const route = this.parseHubtelInitiationText(args);
                return this.session.set('route', route).then(() => route);
            } else {
                return this.session.get('route').then(pastRoute => {
                    const route = pastRoute ? `${pastRoute}*${this.args.text}` : this.args.text;
                    return this.session.set('route', route).then(() => route);
                });
            }
        } else {
            return Promise.resolve(this.args.text);
        }
    }

    /**
     * run the ussd menu
     * @param object args request args from the gateway api
     * @param string args.text
     * @param string args.phoneNumber
     * @param string args.sessionId
     * @param string args.serviceCode
     */
    run (args, onResult) {
        this.mapArgs(args);
        this.onResult = onResult;

        let run = () => {
            this.getRoute(args).then(route => {
                this.resolveRoute(route, (err, state) => {
                    if (err) {
                        return this.emit('error', new Error(err));
                    }
                    this.runState(state);
                });
            }).catch(err => {
                console.error('Failed to get route:', err);
                return this.emit('error', new Error(err));
            })
        };

        if(this.session){
            this.session.start().then(run);
        }
        else {
            run();
        }

        return new Promise((resolve,reject)=>{
            this.resolve = resolve;
        });

    }

}

UssdMenu.START_STATE = '__start__';


class UssdState {
    
    constructor (menu) {
        this.menu = menu;
        this.name = null;
        this.run = null;
        this.defaultNext = null;
        this.val = null;
    }   
    
}

module.exports = UssdMenu;

================================================
FILE: package.json
================================================
{
  "name": "ussd-menu-builder",
  "version": "1.2.0",
  "description": "Easily compose USSD menus using states, compatible with Africastalking API",
  "main": "index.js",
  "types": "index.d.ts",
  "scripts": {
    "test": "mocha test",
    "coverage": "istanbul cover node_modules/mocha/bin/_mocha test -- -R spec",
    "coveralls": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/habbes/ussd-menu-builder"
  },
  "keywords": [
    "ussd-menu-builder",
    "ussd",
    "africastalking",
    "ussd-menu",
    "hubtel"
  ],
  "author": "Habbes <hello@habbes.xyz>",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/habbes/ussd-menu-builder/issues"
  },
  "homepage": "https://github.com/habbes/ussd-menu-builder",
  "dependencies": {
    "async": "^2.6.1"
  },
  "devDependencies": {
    "chai": "^4.2.0",
    "coveralls": "^3.0.2",
    "istanbul": "^0.4.5",
    "mocha": "^5.2.0",
    "mocha-lcov-reporter": "^1.3.0"
  }
}


================================================
FILE: test/ussd-menu.js
================================================
'use strict';
const expect = require('chai').expect;
const UssdMenu = require('../lib/ussd-menu');

describe('UssdMenu', function () {
    let menu,
        args = {
            phoneNumber: '+2547123456789',
            serviceCode: '111',
            sessionId: 'sfdsfdsafdsf',
            text: ''
        };
    beforeEach(function () {
        menu = new UssdMenu();
    });

    describe('States', function () {

        it('should create start state', function () {
            menu.startState({
                run: function () {
                    menu.con('1. Next');
                },
                next: {
                    '1': 'next'
                }
            });

            let state = menu.states[UssdMenu.START_STATE];
            expect(state).to.be.an('object');
            expect(state.next).to.be.an('object');
            expect(state.next['1']).to.equal('next');
            expect(state.run).to.be.a('function');
        });

        it('should create states', function () {
            menu.state('state1', {
                run: function () {
                    menu.con('1. State 2');
                },
                next: {
                    '1': 'state2'
                }
            });

            menu.state('state2', {
                run: function () {
                    menu.end('End.');
                }
            });

            let state = menu.states['state1'];
            expect(state).to.be.an('object');
            expect(state.next).to.be.an('object');
            expect(state.next['1']).to.equal('state2');
            expect(state.run).to.be.a('function');

            state = menu.states['state2'];
            expect(state).to.be.an('object');
            expect(state.run).to.be.a('function');
        });
    });

    describe('State Resolution', function () {

        it('should run the start state if no empty rule exists', function (done) {
            args.text = '';
            menu.startState({
                run: function () {
                    done();
                },
                next: {
                    '1': 'state2'
                }
            });

            menu.run(args);
        });

        it('should follow the empty rule on the start state if declared', function (done) {

            args.text = '';
            menu.startState({
                run: function () {
                    done('Error: start state called');
                },
                next: {
                    '': 'state1'
                }
            });

            menu.state('state1', {
                run: function () {
                    done();
                }
            });

            menu.run(args);
        });

        it('should pass the state to the run function', function (done) {

            args.text = '1';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });
            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('1');
                    expect(menu.val).to.equal('1');
                    expect(state.menu).to.equal(menu);
                    expect(state.menu.args).to.deep.equal(args);
                    done();
                }
            });

            menu.run(args);
        });

        it('should resolve simple string rules', function (done) {

            args.text = '1*4';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });

            menu.state('state1', {
                next: {
                    '4': 'state1.4'
                }
            });

            menu.state('state1.4', {
                run: function (state) {
                    expect(state.val).to.equal('4');
                    done();
                }
            });

            menu.run(args);
        });

        it('should resolve regex rules when starting with *', function (done) {

            args.text = '1*James';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });

            menu.state('state1', {
                next: {
                    '*\\w+': 'state1.name'
                }
            });

            menu.state('state1.name', {
                run: function (state) {
                    expect(state.val).to.equal('James');
                    done();
                }
            });

            menu.run(args);
        });

        it('should resolve regex first if declared before conflicting rule', function (done) {

            args.text = 'input';
            menu.startState({
                next: {
                    '*\\w+': 'state1',
                    'input': 'state2' // conflicts with \w+ regex
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('input');
                    done();
                }
            });

            menu.state('state2', {
                run: function (state) {
                    done('state2 not supposed to be called');
                }
            });

            menu.run(args);
        });

        it('should not resolve regex first if declared after conflicting rule', function (done) {

            args.text = 'rule';
            menu.startState({
                next: {
                    'rule': 'state1',
                    '*\\w+': 'state2'
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('rule');
                    done();
                }
            });

            menu.state('state2', {
                run: function (state) {
                    done('state2 not supposed to be called');
                }
            });

            menu.run(args);
        });

        it('should successfully resolve state based on sync function', function (done) {

            args.text = '1';
            menu.startState({
                next: {
                    '1': function () {
                        return 'state1';
                    }
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('1');
                    done();
                }
            });

            menu.run(args);
        });

        it('should successfully resolve state based on async function using callback', function (done) {

            args.text = '1';
            menu.startState({
                next: {
                    '1': function (callback) {
                        return callback('state1');
                    }
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('1');
                    done();
                }
            });

            menu.run(args);
        });

        it('should successfully resolve state based on async function using promise', function (done) {

            args.text = '1';
            menu.startState({
                next: {
                    '1': function () {
                        return new Promise((resolve, reject) => {
                            return resolve('state1');
                        });
                    }
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('1');
                    done();
                }
            });

            menu.run(args);
        });

        it('should fall back to declared default if link not found', function (done) {
            args.text = '1*invalid';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });

            menu.state('state1', {
                next: {
                    '1': 'state1.1',
                    '2': 'state1.2',
                },

                defaultNext: 'state1.default'
            });

            menu.state('state1.default', {
                run: function (state) {
                    expect(state.val).to.equal('invalid');
                    done();
                }
            });

            menu.run(args);
        });

        if ('should use same state as default if no default is declared', function (done) {
            args.text = '1*invalid';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });

            menu.state('state1', {
                run: function (state) {
                    expect(state.val).to.equal('invalid');
                    done();
                },
                next: {
                    '1': 'state1.1',
                    '2': 'state1.2',
                }
            });
        });

        it('should redirect to a different state using the go method', function (done) {
            args.text = '1';
            menu.startState({
                next: {
                    '1': 'state1',
                    '2': 'state2'
                }
            });
            menu.state('state1', {
                run: function (state) {
                    menu.go('state2');
                }
            });
            menu.state('state2', {
                run: function (state) {
                    expect(state.val).to.equal('1'); //retains the val of the referring state
                    expect(menu.val).to.equal('1');
                    done();
                }
            });
            menu.run(args);

        });

        it('should redirect to the start state using goStart method', function (done) {
            args.text = '1';
            menu.startState({
                run: function (state) {
                    expect(state.val).to.equal('1');
                    expect(menu.val).to.equal('1');
                    done();
                },
                next: {
                    '1': 'state1',
                    '2': 'state2'
                }
            });
            menu.state('state1', {
                run: function (state) {
                    menu.goStart();
                }
            });

            menu.run(args);
        });

    });



    describe('Response', function () {
        let args = {
            phoneNumber: '+254123456789',
            serviceCode: '111',
            sessionId: 'dsfsfsdfsd',
            text: ''
        };
        it('should successfully return a CON response', function (done) {

            let message = 'Choose option';
            menu.startState({
                run: function () {
                    menu.con(message);
                }
            });

            menu.run(args, function (res) {
                expect(res).to.equal('CON ' + message);
                done();
            });
        });

        it('should successfully return an END response', function (done) {

            let message = 'Thank you';
            menu.startState({
                run: function () {
                    menu.end(message);
                }
            });
            menu.run(args, function (res) {
                expect(res).to.equal('END ' + message);
                done();
            });
        });

    });


    describe('Sessions', function () {

        describe('Callback-based config', function () {
            let menu;
            let session;
            let args = {
                serviceCode: '*111#',
                phoneNumber: '123456',
                sessionId: '324errw44we'
            };
            let config = {
                start: (id, cb) => {
                    if (!(id in session)) session[id] = {};
                    cb();
                },
                end: (id, cb) => {
                    delete session[id];
                    cb();
                },
                get: (id, key, cb) => {
                    let val = session[id][key];
                    cb(null, val);
                },
                set: (id, key, val, cb) => {
                    session[id][key] = val;
                    cb();
                }
            };


            it('should manage session using promises', function (done) {
                session = {};
                menu = new UssdMenu();
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.session.set('name', 'Habbes').then(() => {
                            expect(session[args.sessionId].name).to.equal('Habbes');
                            menu.con('Next');
                        })
                            .catch(err => {
                                done(err);
                            });
                    },
                    next: {
                        '1': 'state1'
                    }
                });

                menu.state('state1', {
                    run: () => {
                        menu.session.get('name').then(val => {
                            expect(val).to.equal('Habbes');
                            menu.end();
                        })
                            .catch(err => {
                                console.log('STATE1 error', err);
                                done(err);
                            });
                    }
                });

                args.text = '';
                menu.run(args, () => {
                    expect(session[args.sessionId]).to.deep.equal({ name: 'Habbes' });
                    args.text = '1';
                    menu.run(args, () => {
                        process.nextTick(() => {
                            // expect session to be deleted
                            expect(session[args.sessionId]).to.not.be.ok;
                            done();
                        });

                    });
                });



            });


            it('should manage session using callbacks', function (done) {
                session = {};
                menu = new UssdMenu();
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.session.set('name', 'Habbes', err => {
                            if (err) return done(err);
                            expect(session[args.sessionId].name).to.equal('Habbes');
                            menu.con('Next');
                        });
                    },
                    next: {
                        '1': 'state1'
                    }
                });

                menu.state('state1', {
                    run: () => {
                        menu.session.get('name', (err, val) => {
                            if (err) return done(err);
                            expect(val).to.equal('Habbes');
                            menu.end();
                        });
                    }
                });

                args.text = '';
                menu.run(args, () => {
                    expect(session[args.sessionId]).to.deep.equal({ name: 'Habbes' });
                    args.text = '1';
                    menu.run(args, _ => {
                        process.nextTick(() => {
                            // expect session to be deleted
                            expect(session[args.sessionId]).to.not.be.ok;
                            done();
                        });

                    });
                });



            });


        });


        describe('Promise-based config', function () {
            let menu;
            let session;
            let args = {
                serviceCode: '*111#',
                phoneNumber: '123456',
                sessionId: '324errw44we'
            };
            let config = {
                start: (id) => {
                    return new Promise((resolve, reject) => {
                        if (!(id in session)) session[id] = {};
                        return resolve();
                    });
                },
                end: (id) => {
                    return new Promise((resolve, reject) => {
                        delete session[id];
                        return resolve();
                    });
                },
                get: (id, key) => {
                    return new Promise((resolve, reject) => {
                        let val = session[id][key];
                        return resolve(val);
                    });
                },
                set: (id, key, val) => {
                    return new Promise((resolve, reject) => {
                        session[id][key] = val;
                        return resolve();
                    });
                }
            };


            it('should manage session using promises', function (done) {
                session = {};
                menu = new UssdMenu();
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.session.set('name', 'Habbes').then(() => {
                            expect(session[args.sessionId].name).to.equal('Habbes');
                            menu.con('Next');
                        })
                            .catch(done);
                    },
                    next: {
                        '1': 'state1'
                    }
                });

                menu.state('state1', {
                    run: () => {
                        menu.session.get('name').then(val => {
                            expect(val).to.equal('Habbes');
                            menu.end();
                        })
                            .catch(done);
                    }
                });

                args.text = '';
                menu.run(args, () => {
                    expect(session[args.sessionId]).to.deep.equal({ name: 'Habbes' });
                    args.text = '1';
                    menu.run(args, _ => {
                        process.nextTick(() => {
                            // expect session to be deleted
                            expect(session[args.sessionId]).to.not.be.ok;
                            done();
                        });

                    });
                });



            });


            it('should manage session using callbacks', function (done) {
                session = {};
                menu = new UssdMenu();
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.session.set('name', 'Habbes', err => {
                            if (err) return done(err);
                            expect(session[args.sessionId].name).to.equal('Habbes');
                            menu.con('Next');
                        });
                    },
                    next: {
                        '1': 'state1'
                    }
                });

                menu.state('state1', {
                    run: () => {
                        menu.session.get('name', (err, val) => {
                            if (err) return done(err);
                            expect(val).to.equal('Habbes');
                            menu.end();
                        });
                    }
                });

                args.text = '';
                menu.run(args, () => {
                    expect(session[args.sessionId]).to.deep.equal({ name: 'Habbes' });
                    args.text = '1';
                    menu.run(args, _ => {
                        process.nextTick(() => {
                            // expect session to be deleted
                            expect(session[args.sessionId]).to.not.be.ok;
                            done();
                        });

                    });
                });



            });


        });

    });

    describe('Error handling', function () {

        it('should emit error when route cannot be reached', function (done) {
            menu = new UssdMenu();
            menu.startState({
                run: () => {
                    menu.con('Next');
                },
                next: {
                    '1': 'unknown'
                }
            });
            args.text = '1';
            menu.on('error', err => {
                expect(err).to.be.an('error');
                done();
            });
            menu.run(args);
        });

        it('should emit error when run function not defined on matched route', function (done) {
            menu = new UssdMenu();
            menu.startState({
                run: () => {
                    menu.con('Next');
                },
                next: {
                    '1': 'state1'
                }
            });
            menu.state('state1', {});
            args.text = '1';
            menu.on('error', err => {
                expect(err).to.be.an('error');
                done();
            });
            menu.run(args);

        });

        describe('Session Handler errors', function () {

            it('should emit error when session start handler returns error in callback', function (done) {
                let config = {
                    start: (sessionId, cb) => {
                        cb(new Error('start error'));
                    },
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('start error');
                    done();
                });
                menu.sessionConfig(config);
                menu.run(args);

            });

            it('should emit error when session start handler returns error in promise', function (done) {
                let config = {
                    start: () => {
                        return new Promise((resolve, reject) => {
                            return reject(new Error('start error'))
                        });
                    },
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('start error');
                    done();
                });
                menu.sessionConfig(config);
                menu.run(args);

            });

            it('should emit error when session start handler throws error in promise', function (done) {
                let config = {
                    start: () => {
                        return new Promise(() => {
                            throw new Error('start error');
                        });
                    },
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('start error');
                    done();
                });
                menu.sessionConfig(config);
                menu.run(args);

            });

            it('should emit error when set handler returns error in callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: (sessionId, key, val, cb) => {
                        cb(new Error('set error'));
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value');
                    }
                });
                menu.run(args);

            });

            it('should emit error when set handler returns error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: () => {
                        return new Promise((resolve, reject) => {
                            reject(new Error('set error'));
                        });
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value');
                    }
                });
                menu.run(args);

            });

            it('should emit error when set handler throws error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: () => {
                        return new Promise(() => {
                            throw new Error('set error');
                        });
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value');
                    }
                });
                menu.run(args);

            });


            it('should pass error in callback to session.set when set handler passes error to callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: (id, key, val, cb) => {
                        cb(new Error('set error'));
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value', err => {
                            expect(err).to.be.an('error');
                            expect(err.message).to.equal('set error');
                            done();
                        });
                    }
                });
                menu.run(args);
            });


            it('should catch error in promise in session.set when set handler passes error to callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: (id, key, val, cb) => {
                        cb(new Error('set error'));
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value')
                            .catch(err => {
                                expect(err).to.be.an('error');
                                expect(err.message).to.equal('set error');
                                done();
                            });
                    }
                });
                menu.run(args);
            });


            it('should pass error in callback to session.set when set handler rejects error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: () => {
                        return new Promise((resolve, reject) => {
                            reject(new Error('set error'));
                        });
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value', err => {
                            expect(err).to.be.an('error');
                            expect(err.message).to.equal('set error');
                            done();
                        });
                    }
                });
                menu.run(args);
            });


            it('should catch error in promise in session.set when set handler rejects error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    set: () => {
                        return new Promise((resolve, reject) => {
                            reject(new Error('set error'));
                        });
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.set('key', 'value')
                            .catch(err => {
                                expect(err).to.be.an('error');
                                expect(err.message).to.equal('set error');
                                done();
                            });
                    }
                });
                menu.run(args);
            });



            it('should emit error when get handler returns error in callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: (sessionId, key, cb) => {
                        cb(new Error('set error'));
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.get('key');
                    }
                });
                menu.run(args);

            });


            it('should emit error when get handler returns error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: () => {
                        return new Promise((resolve, reject) => {
                            return reject(new Error('set error'));
                        });
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.get('key');
                    }
                });
                menu.run(args);

            });


            it('should emit error when get handler throws error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: () => {
                        return new Promise(() => {
                            throw new Error('set error');
                        });
                    },
                };
                menu.sessionConfig(config);
                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('set error');
                    done();
                });

                menu.startState({
                    run: () => {
                        menu.session.get('key');
                    }
                });
                menu.run(args);

            });

            it('should pass error in callback to session.get when get handler passes error to callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: (id, key, cb) => {
                        cb(new Error('get error'));
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.get('key', err => {
                            expect(err).to.be.an('error');
                            expect(err.message).to.equal('get error');
                            done();
                        });
                    }
                });
                menu.run(args);
            });


            it('should catch error in promise in session.get when get handler passes error to callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: (id, key, cb) => {
                        cb(new Error('get error'));
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.get('key')
                            .catch(err => {
                                expect(err).to.be.an('error');
                                expect(err.message).to.equal('get error');
                                done();
                            });
                    }
                });
                menu.run(args);
            });


            it('should pass error in callback to session.get when get handler rejects error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: () => {
                        return new Promise((resolve, reject) => {
                            reject(new Error('get error'));
                        });
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.get('key', err => {
                            expect(err).to.be.an('error');
                            expect(err.message).to.equal('get error');
                            done();
                        });
                    }
                });
                menu.run(args);
            });


            it('should catch error in promise in session.get when get handler passes error to callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    get: () => {
                        return new Promise((resolve, reject) => {
                            reject(new Error('get error'));
                        });
                    },
                };
                menu.sessionConfig(config);

                menu.startState({
                    run: () => {
                        menu.session.get('key')
                            .catch(err => {
                                expect(err).to.be.an('error');
                                expect(err.message).to.equal('get error');
                                done();
                            });
                    }
                });
                menu.run(args);
            });

            // SESSION END HANDLER


            it('should emit error when session end handler returns error in callback', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    end: (id, cb) => {
                        cb(new Error('end error'));
                    }
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('end error');
                    done();
                });
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.end();
                    }
                });
                menu.run(args);

            });

            it('should emit error when session end handler returns error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    end: () => {
                        return new Promise((resolve, reject) => {
                            return reject(new Error('end error'));
                        });
                    }
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('end error');
                    done();
                });
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.end();
                    }
                });
                menu.run(args);

            });


            it('should emit error when session end handler throws error in promise', function (done) {
                let config = {
                    start: (id, cb) => {
                        cb();
                    },
                    end: () => {
                        return new Promise(() => {
                            throw new Error('end error');
                        });
                    }
                };

                menu.on('error', err => {
                    expect(err).to.be.an('error');
                    expect(err.message).to.equal('end error');
                    done();
                });
                menu.sessionConfig(config);
                menu.startState({
                    run: () => {
                        menu.end();
                    }
                });
                menu.run(args);

            });


        });

    });

    describe('Hubtel Support', function () {
        let menu;
        let session = {};
        let args;

        let config = {
            start: (id) => {
                return new Promise((resolve, reject) => {
                    if (!(id in session)) session[id] = {};
                    return resolve();
                });
            },
            end: (id) => {
                return new Promise((resolve, reject) => {
                    delete session[id];
                    return resolve();
                });
            },
            get: (id, key) => {
                return new Promise((resolve, reject) => {
                    let val = session[id][key];
                    return resolve(val);
                });
            },
            set: (id, key, val) => {
                return new Promise((resolve, reject) => {
                    session[id][key] = val;
                    return resolve();
                });
            }
        };

        beforeEach(function () {
            menu = new UssdMenu({ provider: 'hubtel' });
            session = {};
            args = {
                Mobile: '233208183783',
                SessionId: 'bd7bc392496b4b28af2033ba83f5e400',
                ServiceCode: '713*4',
                Type: 'Response',
                Message: '',
                Operator: 'MTN',
                Sequence: 2
            };
        });

        it('should emit error when invalid provider in menu config', function (done) {
            try {
                menu = new UssdMenu({ provider: 'otherTelco' });
            } catch (err) {
                expect(err).to.be.an('error');
                done();
            }
        });

        it('should emit error when session config not set up', function (done) {
            menu = new UssdMenu({ provider: 'hubtel' });
            menu.startState({
                run: () => {
                    menu.con('Next');
                }
            });
            menu.on('error', err => {
                expect(err).to.be.an('error');
                expect(err.message).to.equal('Session config required for Hubtel provider');
                done();
            });
            menu.run(args);
        });

        it('should emit error if unable to map route', function (done) {
            menu = new UssdMenu({ provider: 'hubtel' });
            args.Message = '1';
            args.Type = 'Initiation';

            let config = {
                start: (id, cb) => {
                    cb();
                },
                get: (id, key) => {
                    return new Promise((resolve, reject) => {
                        let val = session[id][key];
                        return resolve(val);
                    });
                },
                set: (id, key, val) => {
                    return new Promise((resolve, reject) => {
                        if (key === 'route') {
                            return reject('Cannot set route key');
                        } else {
                            session[id][key] = val;
                            return resolve();
                        }
                    });
                },
                end: () => {
                    return new Promise((resolve, reject) => {
                        return reject(new Error('end error'));
                    });
                }
            };

            menu.sessionConfig(config);
            menu.startState({
                run: () => {
                    menu.con('Next');
                },
                next: {
                    '1': 'state1'
                }
            });
            menu.on('error', err => {
                expect(err).to.be.an('error');
                expect(err.message).to.equal('Cannot set route key');
                done();
            });

            menu.run(args);

        });

        it('should map incoming hubtel request to menu.args', function (done) {
            menu.sessionConfig(config);

            args.Message = '1';
            menu.startState({
                next: {
                    '1': 'state1'
                }
            });
            menu.state('state1', {
                run: function (state) {
                    expect(state.menu.args.phoneNumber).to.equal(`+${args.Mobile}`);
                    expect(state.menu.args.sessionId).to.equal(args.SessionId);
                    expect(state.menu.args.serviceCode).to.equal(args.ServiceCode);
                    expect(state.menu.args.text).to.equal(args.Message);
                    expect(state.val).to.equal(args.Message);
                    expect(menu.val).to.equal(args.Message);
                    done();
                }
            });

            menu.run(args);
        });
        it('should override message from Initiation call with empty string if no extra params', function(done) {
            menu.sessionConfig(config);
            const initArgs = Object.assign(args, {
                Sequence: 1,
                Message: '*713*4#',
                Type: 'Initiation',
            });

            menu.startState({
                run: function (state) {
                    expect(menu.val).to.equal('');
                    expect(state.menu.args.text).to.equal('');
                    done();
                }
            });

            menu.run(initArgs);
        });

        it('should map Message from Initiation call to a route if extra params', function(done) {
            menu.sessionConfig(config);
            const initArgs = Object.assign(args, {
                Sequence: 1,
                Message: '*713*4*3*5#',
                Type: 'Initiation',
            });

            menu.startState({
                run: function(state){
                    // Should not reach this function
                    expect(10).to.equal(20);
                },
                next: {
                    '3': 'state1'
                }
            });
            menu.state('state1', {
                run: function(state){
                    // Should not reach this function
                    expect(15).to.equal(5);
                },
                next: {
                    '5': 'state2'
                }
            });

            menu.state('state2', {
                run: function(state){
                    expect(state.menu.args.phoneNumber).to.equal(`+${args.Mobile}`);
                    expect(state.menu.args.sessionId).to.equal(args.SessionId);
                    expect(state.menu.args.serviceCode).to.equal(args.ServiceCode);
                    expect(state.menu.args.text).to.equal('3*5');
                    done();
                },
                next: {
                    // Should not reach here
                    '*\\d+': 'state1'
                }
            });

            menu.run(initArgs);
        });

        it('should return Response object from menu.con', function(done) {
            menu.sessionConfig(config);
            menu.startState({
                run: () => {
                    menu.con('Next');
                },
            });

            menu.run(args, res => {
                expect(res).to.be.an('object');
                expect(res.Message).to.equal('Next');
                expect(res.Type).to.equal('Response');
                done();
            });
        });

        it('should return Release object from menu.end', function (done) {
            menu.sessionConfig(config);
            menu.startState({
                run: () => {
                    menu.end('End');
                },
            });

            menu.run(args, res => {
                expect(res).to.be.an('object');
                expect(res.Message).to.equal('End');
                expect(res.Type).to.equal('Release');
                done();
            });
        });

        it('should be able to map first text to route', function (done) {
            menu.sessionConfig(config);

            const testResponse = 'state1 response';

            menu.startState({
                run: () => {
                    menu.con('Next');
                },
                next: {
                    '1': 'state1'
                }
            });
            menu.state('state1', {
                run: () => {
                    menu.con(testResponse);
                }
            });

            menu.run(args, () => {
                expect(session[args.SessionId].route).to.equal('');
                args.Message = '1';
                menu.run(args, res => {
                    process.nextTick(() => {
                        // expect session to be deleted
                        expect(res.Message).to.equal(testResponse);
                        expect(session[args.SessionId].route).to.equal(args.Message);
                        done();
                    });
                });
            });
        });
        it('should be able to map text after first sequence to route', function (done) {
            menu.sessionConfig(config);

            const initArgs = Object.assign({}, args, {
                Sequence: 1,
                Message: '*713*4#',
                Type: 'Initiation',
            });
            const firstResponseArgs = Object.assign({}, args, {
                Sequence: 2,
                Message: '1',
            });
            const secondResponseArgs = Object.assign({}, args, {
                Sequence: 3,
                Message: '3',
            });

            const testResponse = 'state1 response';
            const test2Response = 'state2 response';

            menu.startState({
                run: () => {
                    menu.con('Next');
                },
                next: {
                    '1': 'state1'
                }
            });
            menu.state('state1', {
                run: () => {
                    menu.con(testResponse);
                },
                next: {
                    '3': 'state2'
                }
            });

            menu.state('state2', {
                run: () => {
                    menu.end(test2Response);
                }
            });

            menu.run(initArgs, initResponse => {
                expect(session[args.SessionId].route).to.equal('');
                expect(initResponse.Message).to.equal('Next');
                expect(initResponse.Type).to.equal('Response');
                menu.run(firstResponseArgs, res => {
                    // expect session to be deleted
                    expect(res.Message).to.equal(testResponse);
                    expect(res.Type).to.equal('Response');
                    menu.run(secondResponseArgs, res2 => {
                        expect(res2.Message).to.equal(test2Response);
                        expect(res2.Type).to.equal('Release');
                        expect(session[args.SessionId].route).to.equal('1*3');
                        done();
                    });
                });
            });
        });

    });
    describe("Menu Run Returns Promise", function () {
        it('menu.run should return a resolvable promise', function (done) {
            let message = 'It works!';
            menu.startState({
                run: function () {
                    menu.end(message);
                }
            });
            menu.run(args).then(function (res) {
                expect(res).to.equal('END ' + message);
                done();
            });
        });

    });

});
Download .txt
gitextract_fkq589_g/

├── .eslintrc.json
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── index.d.ts
├── index.js
├── lib/
│   └── ussd-menu.js
├── package.json
└── test/
    └── ussd-menu.js
Download .txt
SYMBOL INDEX (29 symbols across 2 files)

FILE: index.d.ts
  class UssdState (line 11) | class UssdState {
  class UssdMenu (line 25) | class UssdMenu extends EventEmitter {
  type NextState (line 75) | interface NextState {
  type UssdGatewayArgs (line 79) | interface UssdGatewayArgs {
  type HubtelResponse (line 86) | interface HubtelResponse {
  type HubtelArgs (line 91) | interface HubtelArgs {
  type UssdMenuProvider (line 102) | type UssdMenuProvider = 'africasTalking' | 'hubtel';
  type UssdMenuOptions (line 103) | interface UssdMenuOptions {
  type UssdStateOptions (line 107) | interface UssdStateOptions {
  type UssdSessionConfig (line 113) | interface UssdSessionConfig {

FILE: lib/ussd-menu.js
  class UssdMenu (line 6) | class UssdMenu extends EventEmitter {
    method constructor (line 8) | constructor (opts = {}) {
    method callOnResult (line 24) | callOnResult () {
    method con (line 33) | con (text) {
    method end (line 45) | end (text) {
    method testLinkRule (line 60) | testLinkRule (rule, val) {
    method resolveRoute (line 74) | resolveRoute (route, callback) {
    method runState (line 175) | runState (state) {
    method go (line 182) | go (stateName) {
    method goStart (line 188) | goStart () {
    method sessionConfig (line 197) | sessionConfig (config) {
    method state (line 281) | state (name, options) {
    method startState (line 298) | startState (options) {
    method mapArgs (line 305) | mapArgs (args) {
    method parseHubtelInitiationText (line 324) | parseHubtelInitiationText(hubtelArgs) {
    method getRoute (line 340) | getRoute(args) {
    method run (line 367) | run (args, onResult) {
  class UssdState (line 403) | class UssdState {
    method constructor (line 405) | constructor (menu) {
Condensed preview — 10 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (91K chars).
[
  {
    "path": ".eslintrc.json",
    "chars": 381,
    "preview": "{\n    \"env\": {\n        \"es6\": true,\n        \"node\": true,\n        \"mocha\": true\n    },\n    \"extends\": \"eslint:recommende"
  },
  {
    "path": ".gitignore",
    "chars": 557,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n\n# Directory for instrumented libs generated by jscov"
  },
  {
    "path": ".travis.yml",
    "chars": 93,
    "preview": "language: node_js\nnode_js:\n - \"10\"\n - \"9\"\n - \"8\"\n - \"6\"\n - \"node\"\nscript: \"npm run coveralls\""
  },
  {
    "path": "LICENSE",
    "chars": 1085,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2016 Clément Habinshuti\n\nPermission is hereby granted, free of charge, to any perso"
  },
  {
    "path": "README.md",
    "chars": 17304,
    "preview": "# ussd-menu-builder\n\n[![Build Status](https://travis-ci.org/habbes/ussd-menu-builder.svg?branch=master)](https://travis-"
  },
  {
    "path": "index.d.ts",
    "chars": 2974,
    "preview": "/// <reference types=\"node\" />\n\n// Type definitions for ussd-menu-builder 1.0.0\n// Project: ussd-menu-builder\n// Definit"
  },
  {
    "path": "index.js",
    "chars": 45,
    "preview": "\nmodule.exports = require('./lib/ussd-menu');"
  },
  {
    "path": "lib/ussd-menu.js",
    "chars": 13465,
    "preview": "'use strict';\nconst async = require('async');\nconst EventEmitter = require('events');\n\n\nclass UssdMenu extends EventEmit"
  },
  {
    "path": "package.json",
    "chars": 1133,
    "preview": "{\n  \"name\": \"ussd-menu-builder\",\n  \"version\": \"1.2.0\",\n  \"description\": \"Easily compose USSD menus using states, compati"
  },
  {
    "path": "test/ussd-menu.js",
    "chars": 50090,
    "preview": "'use strict';\nconst expect = require('chai').expect;\nconst UssdMenu = require('../lib/ussd-menu');\n\ndescribe('UssdMenu',"
  }
]

About this extraction

This page contains the full source code of the habbes/ussd-menu-builder GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 10 files (85.1 KB), approximately 17.1k tokens, and a symbol index with 29 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!