@goonerlabs/ussd-router
v0.1.1
Published
A tiny, dependency-free state-machine router for Africa's Talking USSD apps.
Downloads
254
Maintainers
Readme
@goonerlabs/ussd-router
A tiny, dependency-free state-machine router for Africa's Talking USSD apps.
USSD is stateless: Africa's Talking POSTs { sessionId, serviceCode, phoneNumber, text } to your endpoint on every step, where text is the full *-joined trail of everything the user has entered so far (e.g. "1*2*500"). You reply with a plain-text body that starts with CON (keep the session open) or END (hang up).
This router lets you declare your app as a set of screens and replays that trail through them on each request — so navigation is deterministic and you don't need a session store.
npm install @goonerlabs/ussd-routerQuick start (Express)
const express = require('express');
const { createRouter, expressHandler } = require('@goonerlabs/ussd-router');
const router = createRouter({ start: 'main' });
router.screen('main', (ctx) =>
ctx.menu('Welcome to MyApp', [
{ label: 'Check balance', next: 'balance' },
{ label: 'Buy airtime', next: 'airtime' },
{ label: 'Help', next: (c) => c.end('Call 100 for help.') },
])
);
router.screen('balance', async (ctx) => {
const balance = await wallet.balanceOf(ctx.phoneNumber);
return ctx.end(`Your balance is ${balance}.`);
});
router.screen('airtime', (ctx) => ctx.prompt('Enter amount:', { next: 'airtime:confirm' }));
router.screen('airtime:confirm', (ctx) =>
ctx.end(`You're buying ${ctx.input} airtime. Thank you!`)
);
const app = express();
app.use(express.urlencoded({ extended: false })); // AT posts form-encoded
app.post('/ussd', expressHandler(router));
app.listen(3000);Point your Africa's Talking USSD channel at POST /ussd and dial in.
How it works
On every request the router starts at the entry screen and walks the input trail:
- a menu maps the user's numeric pick to the chosen item's
nextscreen; - a prompt sends the typed value to the
nextscreen asctx.input; - a terminal (
ctx.end) closes the session.
Because the trail is replayed from the start each time, your navigation screens (menus and prompts) should be side-effect-free; do the real work in terminal screens, which only run when reached.
API
createRouter({ start? })
Creates a router. start is the entry screen name (default "start").
router.screen(name, handler)
Registers a screen. handler(ctx) may be async and returns one of the ctx results below. Chainable.
router.handle(req) → Promise<string>
Takes an Africa's Talking request body and returns the raw CON /END string.
ctx
| Member | Description |
|---|---|
| ctx.sessionId / ctx.phoneNumber / ctx.serviceCode / ctx.text | Raw request fields |
| ctx.input | The value entered to reach this screen (menu pick or typed text) |
| ctx.history | Every input so far, in order |
| ctx.menu(title, items, opts?) | Numbered menu; items are { label, next }; next is a screen name or inline handler. opts.invalidText customises the bad-pick message. |
| ctx.prompt(text, { next }) | Ask for free input; the typed value becomes the next screen's ctx.input |
| ctx.con(text) | Keep the session open with raw text |
| ctx.end(text) | End the session |
expressHandler(router)
Returns an Express handler that reads req.body and replies text/plain.
Testing
Zero runtime dependencies; tests use the Node built-in runner:
npm test # node --testLicense
MIT © Owolabi Adeyemi (goonerlabs)
