Giter VIP home page Giter VIP logo

ussd-menu-builder's Introduction

ussd-menu-builder

Build Status Coverage Status

Easily compose USSD menus in Node.js, compatible with Africastalking API or Hubtel API.

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

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.

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:

  • 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:

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 framework:

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:

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:

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:

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__'.

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

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

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:

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.

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

menu.state('thisState', {
    ...
    next: {
        'input': 'nextState'
    }
})

Mapping to a synchronous function

menu.state('thisState', {
    ...
    next: {
        'input': function(){
            if(test){
                return 'nextState';
            } else {
                return 'otherState';
            }
        }
    }
});

Mapping to an async function with callback

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

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.

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

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:

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:

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:

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").

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.

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

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);
    });
})

ussd-menu-builder's People

Contributors

habbes avatar kechkibet avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

ussd-menu-builder's Issues

Failed to get route: typeError: Cannot read properties of undefined (reading 'split') at UssdMenu.resolveRoute

I am getting this error whenever I try to my app. if you have bypassed this kindly assist.

Failed to get route: TypeError: Cannot read properties of undefined (reading 'split')
at UssdMenu.resolveRoute (/var/task/node_modules/ussd-menu-builder/lib/ussd-menu.js:76:46)
at /var/task/node_modules/ussd-menu-builder/lib/ussd-menu.js:357:22
/var/task/node_modules/ussd-menu-builder/lib/ussd-menu.js:365
return this.emit('error', new Error(err));
^

Error: TypeError: Cannot read properties of undefined (reading 'split')
at /var/task/node_modules/ussd-menu-builder/lib/ussd-menu.js:365:43
Emitted 'error' event on UssdMenu instance at:
at /var/task/node_modules/ussd-menu-builder/lib/ussd-menu.js:365:29

Node.js v18.18.2

How to integrate with Express?

Please can someone provide an example with express Js integration?

I followed the example but I kept getting this error

UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'phoneNumber' of undefined

Press Zero to Go Back

Kindly explain how you would implement the ability to demote a session level like press zero to go back

How can I handle return to the previous menu?

Please I'm doing an app in ussd with your menu builder and I want to know how I can implement a return to previous by taping "0" on every screen to go to the previous one and also when there been also able to work around

Match state in a different file

I'm having a hard time keeping different states into modules, the exact problem is to match a state when it's in a different file.

main.js

menu.state('welcome', {  
  run: () => {    
    menu.con('1. Hospital services')
  },
  next: {
    '1': 'hospital.js' // How can I do this?
  }
}

hospital.js

menu.state('register', {
run : () => {
    ...
}

Can't set headers after they are sent.

I use your lib in production and I noticed that when there's many USSD requests, they will end up without being resolved throwing this error

app[web.1]: Error: Can't set headers after they are sent.
app[web.1]:     at validateHeader (_http_outgoing.js:491:11)
app[web.1]:     at ServerResponse.setHeader (_http_outgoing.js:498:3)
app[web.1]:     at ServerResponse.header (/app/node_modules/express/lib/response.js:767:10)
app[web.1]:     at ServerResponse.send (/app/node_modules/express/lib/response.js:170:12)
app[web.1]:     at UssdMenu.menu.run.ussdResult [as onResult] (/app/src/ussd/index.ts:33:11)
app[web.1]:     at UssdMenu.callOnResult (/app/node_modules/ussd-menu-builder/lib/ussd-menu.js:20:18)
app[web.1]:     at UssdMenu.con (/app/node_modules/ussd-menu-builder/lib/ussd-menu.js:26:14)
app[web.1]:     at menuOps (/app/src/ussd/registration.ts:222:17)
app[web.1]:     at <anonymous>
app[web.1]: (node:27) UnhandledPromiseRejectionWarning: Error: Can't set headers after they are sent.
app[web.1]:     at validateHeader (_http_outgoing.js:491:11)
app[web.1]:     at ServerResponse.setHeader (_http_outgoing.js:498:3)
app[web.1]:     at ServerResponse.header (/app/node_modules/express/lib/response.js:767:10)
app[web.1]:     at ServerResponse.send (/app/node_modules/express/lib/response.js:170:12)
app[web.1]:     at UssdMenu.menu.run.ussdResult [as onResult] (/app/src/ussd/index.ts:33:11)
app[web.1]:     at UssdMenu.callOnResult (/app/node_modules/ussd-menu-builder/lib/ussd-menu.js:20:18)
app[web.1]:     at UssdMenu.end (/app/node_modules/ussd-menu-builder/lib/ussd-menu.js:31:14)
app[web.1]:     at endMenu (/app/src/ussd/registration.ts:232:8)
app[web.1]:     at menuOps (/app/src/ussd/registration.ts:226:5)
app[web.1]:     at <anonymous>

It's the same request resolved twice or more. I'm working on a fix but I have so far hit a dead end.

Getting the User Network

There is an Extra Field which contain the telco of the phoneNumber interacting with your ussd application which need to be map to the menu.args
Africastalking API uses "networkCode" as the parameter while
Hubtel API uses "Operator" as the parameter

It would help if the programmer needs to know the mobile network the user is using to interact with it.

Question: Possible to add run & transition events?

Would it be possible to emit an event when a state is run as well as when it transitions to the next state?

It would be useful for understanding user flow

run events would simple output which node was hit
transition events would emit something to the effect of state1 => state2

push to npm?

@habbes maybe push this package to npm and also a PR on the at-node.js README on how to use this?

Session Management

Presently the module is using system memory to keep track of each section, which might not scale well when ran on clusters or if the process restarts. Please, can we have an option to add centralized in-memory storage like Redis?

States aren't respected when declared in state.next

I have a set of three options set up in menu.startState, that are all respected just fine.

However, when I set up further states with a regex, or otherwise, those rules aren't respected. The only rules that are used are the initial rules declared in startState:

menu.startState({
	run: function() {
		menu.con('Please Choose:' + 
			'\n1. Sign Up' + 
			'\n2. View Orders' + 
			'\n3. View Produce'
		);
	},
	next: {
		'1': 'register.start',
		'2': 'viewOrders',
		'3': 'viewProduce'
	}
});

// this function is encountered, but it never accepts the next route 
menu.state('register.start', {
	run: function() {
		menu.con('Enter your name');
	},
	next: {
		'*^[a-zA-Z\\s]*$': 'register.address'
	}
});

// this is never encountered
menu.state('register.address', {
	run: function() {
		console.log(menu.val);
		menu.con('Please enter your address:')
	},
	next: {
		'*^[a-zA-Z0-9\\s\\,]*$': 'register.produce'
	}
});

Admittedly I'm using a simulator (this one) but I'm not confident this is where the issue lies.

Unable to use SessionConfig

When i use provided code to config session callbacks

i get this error:
UnhandledPromiseRejectionWarning: ReferenceError: sessions is not defined

@habbes how can i resolve this?

fully qualitfied USSD command

How do I handle a user sending through a full ussd command on session initialisation?

"*1234*1*1*9876#"

where 1234 is the shortcode and 119876 the preconfigured responses?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.