ussd-interpreter-json
v0.1.1
Published
A JSON-driven USSD interpreter with async-correct replay, fingerprint-based API deduplication, and a pluggable store
Maintainers
Readme
ussd-interpreter-json
A JSON-driven USSD interpreter with async-correct replay, fingerprint-based API deduplication, and a pluggable store. Define your flow in JSON. Wire your SDKs in a function registry. The library handles the rest.
npm install ussd-interpreter-jsonRequires Node.js >= 20.
Quick start
import { Flow, Interpreter, Registry, MemoryStore } from 'ussd-interpreter-json'
// 1. Register your async behaviour
const registry = new Registry()
// Registry functions own SDK clients and API keys. The interpreter never
// makes network calls — it calls registry.call(name, ctx) and awaits the result.
registry.register('lookupCustomer', async (ctx) => {
const customer = await bankSdk.getCustomer(ctx.session.accountNumber)
return { customerName: customer.name, customerType: customer.type }
})
registry.register('computeFee', (ctx) => {
return { fee: Math.min(ctx.session.amount * 0.01, 100) }
})
registry.register('fetchBalance', async (ctx) => {
const balance = await bankSdk.getBalance(ctx.gateway.phoneNumber)
return { balance }
})
registry.register('transferFunds', async (ctx) => {
const res = await mpesa.stkPush({
phone: ctx.gateway.phoneNumber,
amount: ctx.session.amount,
reference: ctx.session.accountNumber
})
return { ref: res.checkoutRequestId }
})
// 2. Define your flow in JSON
const flow = Flow.fromJSON({
states: {
main: {
type: 'navigation',
render: 'Welcome\n1. Transfer\n2. Balance',
transitions: { 1: 'enter_account', 2: 'show_balance' }
},
enter_account: {
type: 'data',
render: 'Enter account number:',
capture: 'accountNumber',
validate: { pattern: '^\\d{10}$', message: 'Must be 10 digits.' },
next: 'enter_amount',
onArrive: {
id: 'lookup',
fn: 'lookupCustomer',
dependencies: ['accountNumber'],
timeout: 3000
}
},
enter_amount: {
type: 'data',
render: 'Enter amount (KES):',
capture: 'amount',
validate: { type: 'number', min: 1, max: 150000, message: '1 - 150,000.' },
transform: 'toNumber',
next: 'confirm',
onArrive: {
id: 'calc_fee',
fn: 'computeFee',
dependencies: ['amount', 'customerType'],
timeout: 1000
}
},
confirm: {
type: 'navigation',
render: 'Send KES {{amount}} to {{accountNumber}}?\nFee: KES {{fee}}\n1. Confirm\n2. Cancel',
transitions: { 1: 'success', 2: 'cancelled' },
label: 'Reviewed transfer.'
},
success: {
type: 'terminal',
render: 'Sent. Ref: {{ref}}',
onArrive: {
id: 'execute',
fn: 'transferFunds',
sideEffect: true,
dedupSideEffect: true,
timeout: 10000
}
},
show_balance: {
type: 'terminal',
render: 'Balance: KES {{balance ?? "--"}}',
onArrive: {
id: 'fetch_bal',
fn: 'fetchBalance',
dependencies: ['gateway.phoneNumber'],
timeout: 3000
}
},
cancelled: {
type: 'terminal',
render: 'Cancelled.'
}
}
}, { registry })
// 3. Create the interpreter
const interpreter = new Interpreter(flow, {
store: new MemoryStore({ ttl: 120 }),
key: (gateway) => gateway.phoneNumber
})
// 4. Handle a request
// The handler is three lines. Resumption, consent, and dedup are automatic.
async function handleRequest (req) {
const session = await interpreter.resolveAsync(req.body.text, req.body)
return respond(session)
}State types
| Type | What it does |
|------|-------------|
| navigation | Renders a menu. Maps input tokens to next states via transitions. Supports backToken (default 0) and rootToken (default 00). |
| data | Captures free-form input into a named variable. Validates and transforms before proceeding to next. "0" is raw data — never back. |
| terminal | Ends the session. All further input is rejected. onArrive on a terminal defaults to sideEffect: true. |
| branch | Invisible decision node. Calls a registered function that returns a state ID. Auto-advances — the user never sees it. |
| pagination | Splits {{content}} across multiple screens. Handles next, back, accept, and cancel tokens. On the last page with no accept configured, the session ends. |
Branch — root routing
registry.register('routeRoot', async (ctx) => {
const meta = await ctx.store.getPhoneMeta(ctx.gateway.phoneNumber)
if (!meta.registered) return 'unregistered_menu'
return 'registered_menu'
})
const flow = Flow.fromJSON({
states: {
_root: { type: 'branch', fn: 'routeRoot', dependencies: ['gateway.phoneNumber'] },
unregistered_menu: { type: 'navigation', render: 'Welcome\n1. Sign Up', transitions: { 1: 'signup' } },
registered_menu: { type: 'navigation', render: 'Welcome back\n1. Transfer', transitions: { 1: 'transfer' } }
}
}, { registry })Template syntax
All render, content, and label fields support interpolation:
| Syntax | Example | Output with { name: "Ann", bal: 0 } |
|--------|---------|--------------------------------------|
| {{key}} | Hello {{name}} | Hello Ann |
| {{nested.key}} | {{user.name}} | value at path |
| {{key ?? "fallback"}} | Bal: {{bal ?? "--"}} | Bal: -- |
Unclosed {{ tokens are left as literal text. Null intermediates don't throw.
Validation
Data states validate captured input before proceeding.
Regex:
{ "pattern": "^\\d{10}$", "message": "Must be 10 digits." }Number range:
{ "type": "number", "min": 1, "max": 150000, "message": "1 - 150,000." }Function-based:
{ "fn": "verifyPin", "message": "Invalid PIN." }The registered function receives the raw token and returns true for valid, or a string for the error message.
Composite (all must pass, cheap checks first):
{
"all": [
{ "pattern": "^\\d+$", "message": "Digits only." },
{ "fn": "verifyCode", "message": "Code used." }
]
}Transforms convert captured values before storing: "toNumber", "trim".
onArrive hooks
{
"onArrive": {
"id": "lookup",
"fn": "lookupCustomer",
"dependencies": ["accountNumber"],
"timeout": 3000
}
}| Field | Purpose |
|-------|---------|
| fn | Registered function name (required) |
| id | Unique ID for fingerprint tracking (defaults to fn) |
| dependencies | Keys that trigger re-execution when their values change |
| timeout | Milliseconds before the call is aborted |
| sideEffect | Set to true for operations that change external state |
| dedupSideEffect | With sideEffect: true, stores the result and skips on replay |
| onFail | "continue" — silence the error and proceed. Default: throw. |
Data state hooks fire after capture — the captured value is available as a dependency. Navigation and terminal hooks fire when the state is entered.
Side-effect deduplication
When sideEffect: true + dedupSideEffect: true, the result is stored in the
session. On replay, the same state with the same dependencies skips execution —
the stored result is used. The user pays once regardless of how many requests
they take.
registry.register('transferFunds', async (ctx) => {
const res = await mpesa.stkPush({ ... })
return { ref: res.checkoutRequestId }
})Session stories — buildStory()
const session = await interpreter.resolveAsync('1*0724000001*500*1', gateway)
const story = session.buildStory({ redact: ['accountNumber'] })
// {
// outcome: 'completed',
// exitState: 'success',
// path: ['main', 'enter_account', 'enter_amount', 'confirm', 'success'],
// steps: [
// { state: 'enter_account', label: 'Entered account [REDACTED].' },
// ...
// ],
// narrative: '... Entered account [REDACTED]. ... Transfer completed. Ref: TXN001.'
// }States without a label appear in path but not in steps or narrative.
Branch states are always invisible. redact: true on a data state auto-redacts
its captured key.
enrich — external data injection
Background jobs can update session data between requests. Useful for KYC approval, profile updates, or any out-of-band state change.
const result = await interpreter.enrich(phoneNumber, {
kycVerified: true,
tier: 'premium'
})
// → { sessionId, data: { ..., kycVerified: true } } or null if no active sessionNo fingerprint invalidation needed — the next replay detects changed
dependency values and re-runs affected onArrive hooks automatically.
Configuration
const interpreter = new Interpreter(flow, {
store: new MemoryStore({ ttl: 120 }),
key: (gateway) => gateway.phoneNumber,
separator: '*', // default
maxTokens: 20, // guard against overlong input
maxRetries: 3, // validation retries before kill
consumeBudget: 200, // max consume() calls per replay
onArriveTimeout: 5000, // per-hook timeout (ms)
resumption: {
enabled: true, // track sessions across dials
consent: true, // prompt before resuming
resumeToken: '1', // user picks resume
discardToken: '2', // user picks start fresh
render: 'You have an active session.\n1. Resume\n2. Start fresh'
},
// Lifecycle events (fire-and-forget — errors are caught)
onDuplicate: (existingSessionId, gateway) => { ... },
onResume: (sessionId, lastInput) => { ... },
onSessionEnd: (sessionId, story) => { ... }
})Custom store adapter
Swap MemoryStore for any storage backend. Implement these methods:
class RedisStore {
async getSession (sessionId) { ... } // → entry or undefined
async setSession (sessionId, entry) { ... }
async getPhoneSessions (phoneKey) { ... } // → Set of sessionIds
async addPhoneSession (phoneKey, sessionId) { ... }
async removePhoneSession (phoneKey, sessionId) { ... }
async getPhoneMeta (phoneKey) { ... } // → object (cross-session)
async setPhoneMeta (phoneKey, meta) { ... }
}MemoryStore is built in. Everything else is your adapter.
License
MIT
