ananse
Version:
Ananse is a lightweight NodeJs framework with batteries included for building efficient, scalable and maintainable USSD applications.
1 lines • 123 kB
Source Map (JSON)
{"version":3,"sources":["../src/types/request.ts","../src/core/app.core.ts","../src/helpers/constants.ts","../src/gateways/base.gateway.ts","../src/models/cache.model.ts","../src/models/ussd_state.model.ts","../src/gateways/wigal.gateway.ts","../src/sessions/base.session.ts","../src/sessions/redis.session.ts","../src/sessions/postgresql.session.ts","../src/sessions/memcache.session.ts","../src/sessions/mysql.session.ts","../src/types/config_options.type.ts","../src/gateways/emergent_technology.gateway.ts","../src/config.ts","../src/menus/action.menu.ts","../src/menus/base.menu.ts","../src/menus/dynamic_menu.menu.ts","../src/menus/index.ts","../src/helpers/menu.helper.ts","../src/helpers/index.ts","../src/core/form_handler.ts","../src/core/pagination_handler.ts","../src/core/request_handler.ts","../src/japa-plugin/index.ts"],"sourcesContent":["import { State } from \"@src/models\";\nimport { IncomingHttpHeaders, IncomingMessage, ServerResponse } from \"http\";\nimport { Url } from \"url\";\nimport { Session } from \".\";\n\nexport class Request {\n\tmethod:\n\t\t| \"GET\"\n\t\t| \"POST\"\n\t\t| \"PUT\"\n\t\t| \"DELETE\"\n\t\t| \"PATCH\"\n\t\t| \"HEAD\"\n\t\t| \"OPTIONS\"\n\t\t| \"CONNECT\"\n\t\t| \"TRACE\";\n\n\tpath?: string | null;\n\n\turl: string;\n\n\t/**\n\t * The msisdn of the user\n\t */\n\tmsisdn?: string;\n\n\t/**\n\t * USSD service short code. Set only for Emergent Technology USSD\n\t */\n\tserviceCode?: string;\n\n\t/**\n\t * The input from the user\n\t */\n\tinput?: string;\n\n\theaders: IncomingHttpHeaders;\n\n\tbody: any;\n\n\tquery?: Record<string, string>;\n\n\t/**\n\t * Current USSD state. Null until the request is processed by the middlewares\n\t */\n\tstate: State;\n\n\t/**\n\t * Current session. Null until the request is processed by the middlewares\n\t */\n\tsession: Session;\n\n\tconstructor(_url: Url, req: IncomingMessage) {\n\t\tthis.method = req.method as any;\n\t\tthis.path = _url.pathname;\n\t\tthis.url = _url.href;\n\t\tthis.headers = req.headers;\n\t\tthis.query = _url.query as Record<string, string>;\n\t}\n}\n\nexport class Response extends ServerResponse {\n\tdata: Record<string, any> | any;\n}\n","import { Request, type Response } from \"@src/types/request\";\nimport type { ServerResponse, IncomingMessage } from \"node:http\";\nimport { createServer } from \"node:http\";\nimport { parse } from \"node:url\";\nimport type { Menus } from \"@src/menus\";\nimport { Config, type ConfigOptions } from \"@src/config\";\nimport { RequestHandler } from \"./request_handler\";\n\n// @ts-ignore\nimport type {\n\tRequest as ExpressRequest,\n\tResponse as ExpressResponse,\n} from \"express\";\n\nexport class Ananse {\n\tprivate router: Menus;\n\n\tconfigure(opts: ConfigOptions) {\n\t\tconst instance = Config.getInstance();\n\t\tinstance.init(opts);\n\n\t\treturn this;\n\t}\n\n\tlisten(port?: number, hostname?: string, listeningListener?: () => void) {\n\t\treturn createServer((req, res) => this.requestListener(req, res)).listen(\n\t\t\tport,\n\t\t\thostname,\n\t\t\tlisteningListener,\n\t\t);\n\t}\n\n\tprivate async requestListener(req: IncomingMessage, res: ServerResponse) {\n\t\tconst request = new Request(parse(req.url!, true), req);\n\n\t\tif (req.method == \"POST\" || req.method == \"PUT\" || req.method == \"PATCH\") {\n\t\t\tlet data = \"\";\n\n\t\t\treq.on(\"data\", (chunk) => {\n\t\t\t\tdata += chunk;\n\t\t\t});\n\n\t\t\treq.on(\"end\", () => {\n\t\t\t\ttry {\n\t\t\t\t\tif (req.headers[\"content-type\"] == \"application/json\") {\n\t\t\t\t\t\trequest.body = JSON.parse(data);\n\t\t\t\t\t}\n\t\t\t\t\t// TODO: parse other content types\n\t\t\t\t} catch (error) {\n\t\t\t\t\tres.writeHead(400, { \"Content-Type\": \"application/json\" });\n\t\t\t\t\tres.end(\n\t\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\t\terror: \"Invalid JSON format in the request body\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\n\t\tconst handler = new RequestHandler(request, res as Response, this.router);\n\t\tawait handler.processRequest();\n\t}\n\n\tasync express(req: ExpressRequest, res: ExpressResponse) {\n\t\tconst request = new Request(parse(req.url!, true), req);\n\n\t\tif (req.method === \"POST\" || req.method === \"PUT\" || req.method === \"PATCH\") {\n\t\t\tconst data = req.body;\n\n\t\t\ttry {\n\t\t\t\tif (req.headers[\"content-type\"] != null && req.headers[\"content-type\"]?.indexOf(\"application/json\") > -1) {\n\t\t\t\t\trequest.body = data;\n\t\t\t\t}\n\t\t\t\t// TODO: parse other content types\n\t\t\t} catch (error) {\n\t\t\t\tres\n\t\t\t\t\t.status(400)\n\t\t\t\t\t.json({ error: \"Invalid JSON format in the request body\" });\n\t\t\t}\n\t\t}\n\n\t\tconst handler = new RequestHandler(\n\t\t\trequest,\n\t\t\tres as unknown as Response,\n\t\t\tthis.router,\n\t\t);\n\t\treturn await handler.processRequest();\n\t}\n}\n","/**\n * List of USSD gateways currently supported by default.\n */\nexport enum SupportedGateway {\n\twigal = \"wigal\",\n\temergent_technology = \"emergent_technology\",\n}\n\nexport const MAXIMUM_CHARACTERS = 182;\n","import { Request, Response } from \"@src/types/request\";\nimport { State } from \"@src/models\";\nimport { Config } from \"@src/config\";\nimport { BaseSession } from \"@src/sessions\";\n\nexport abstract class Gateway {\n\tconstructor(\n\t\tprotected readonly request: Request,\n\t\tprotected readonly response: Response,\n\t) {}\n\n\tget state(): Promise<State | undefined> {\n\t\treturn Config.getInstance().session!.getState(this.sessionId);\n\t}\n\n\tget session(): BaseSession {\n\t\treturn Config.getInstance().session!;\n\t}\n\n\tabstract get sessionId(): string;\n\n\t// TODO: add helper to load session/prev state from redis/cache\n\n\t// # extract ussd params from request body/parameters/json/form-data\n\t// # extract session from redis\n\tabstract handleRequest(\n\t\treq: Request,\n\t\tresp: Response,\n\t): Promise<State | undefined>;\n\n\tabstract handleResponse(req: Request, resp: Response): Promise<void>;\n\t// # pick data from session, eg. req.session\n\t// # AND\n\t// # return response based on the expected format of the ussd gateway\n}\n\n// TODO: add africstalking, etc\n","import { BaseMenu } from \"@src/menus/base.menu\";\nimport { DynamicMenu } from \"@src/menus/dynamic_menu.menu\";\nimport { Type } from \"@src/types\";\nimport { PaginationItem } from \"@src/types/pagination.type\";\n\nexport const MENU_CACHE: {\n\t[menuId: string]: { paginated: boolean; menu: Type<BaseMenu> | DynamicMenu };\n} = {};\n\n// export const PAGINATION_CACHE: {\n// \t[menuId: string]: { [page: number | string]: PaginationItem };\n// } = {};\n","import type { PaginationItem } from \"@src/types/pagination.type\";\nimport type { MenuAction } from \"../menus\";\n\nexport enum StateMode {\n\tstart = \"start\",\n\tmore = \"more\",\n\tend = \"end\",\n}\n\nexport class State {\n\tsessionId: string;\n\tmode: StateMode;\n\tmsisdn: string;\n\tuserData: string;\n\taction?: MenuAction | undefined;\n\tprevious?: State | undefined;\n\tform?:\n\t\t| {\n\t\t\t\tid: string;\n\t\t\t\t/**\n\t\t\t\t * Tracks submitted inputs. Key is the input name, and value must be `true`.\n\t\t\t\t * If an input is submitted, it is added to this object.\n\t\t\t\t * If the input is revisited, it is first removed from this object and\n\t\t\t\t * then added back when it is submitted again.\n\t\t\t\t *\n\t\t\t\t */\n\t\t\t\tsubmitted: Record<string, true>; // Can be array but a map for O(1) lookup\n\t\t\t\tnextInput: string | undefined;\n\t\t\t\t// TODO: track submitted inputs\n\t\t }\n\t\t| undefined;\n\n\t/**\n\t * Tracks visited menus/next to be visited menus.\n\t */\n\tmenu?:\n\t\t| {\n\t\t\t\t/**\n\t\t\t\t * Tracks visited menus.\n\t\t\t\t *\n\t\t\t\t * Key is the menu name, and value must be `true`.\n\t\t\t\t * If a menu is visited, it is added to this object. If the menu is to\n\t\t\t\t * be revisited, it is first removed from this object and then added back\n\t\t\t\t * after input validation.\n\t\t\t\t *\n\t\t\t\t */\n\t\t\t\tvisited: Record<string, true>; // Can be array but a map for O(1) lookup\n\t\t\t\tnextMenu: string | undefined;\n\t\t\t\t// TODO: track submitted inputs\n\t\t }\n\t\t| undefined;\n\n\tpagination: {\n\t\t[menuId: string]: {\n\t\t\tcurrentPage: PaginationItem | undefined;\n\t\t\tpages: PaginationItem[];\n\t\t};\n\t} = {};\n\n\tget isStart(): boolean {\n\t\treturn this.mode == StateMode.start;\n\t}\n\n\tget isEnd(): boolean {\n\t\treturn this.mode == StateMode.end;\n\t}\n\n\t/**\n\t * Sets mode to \"end\"\n\t */\n\tend(): void {\n\t\tthis.mode = StateMode.end;\n\t}\n\n\tstatic fromJSON(json: Record<string, any>): State {\n\t\treturn Object.assign(new State(), json);\n\t}\n\n\ttoJSON(): Record<string, any> {\n\t\treturn {\n\t\t\tsessionId: this.sessionId,\n\t\t\tmode: this.mode,\n\t\t\tmsisdn: this.msisdn,\n\t\t\tuserData: this.userData,\n\t\t\t// nextMenu: this.nextMenu,\n\t\t\tmenu: this.menu,\n\t\t\taction: this.action,\n\t\t\tprevious: this.previous?.toJSON(),\n\t\t\t// formInputId: this.formInputId,\n\t\t\tform: this.form,\n\t\t\tpagination: this.pagination,\n\t\t};\n\t}\n}\n","import { Request, Response } from \"@src/types/request\";\nimport { Gateway } from \"./base.gateway\";\nimport { State } from \"@src/models\";\n\nexport class WigalGateway extends Gateway {\n\tget sessionId(): string {\n\t\treturn this.request.query?.sessionid!;\n\t}\n\n\tasync handleRequest(): Promise<State | undefined> {\n\t\tlet _state = await this.state;\n\n\t\t_state ??= new State();\n\n\t\t_state.mode = this.request.query?.mode as any; //todo: validate\n\t\t_state.msisdn = this.request.query?.msisdn!;\n\t\t_state.sessionId = this.request.query?.sessionid!;\n\t\t_state.userData = this.request.query?.userdata!;\n\n\t\t// await this.session.setState(this.sessionId, _state);\n\t\tthis.request.state = _state;\n\t\tthis.request.input = this.request.query?.userdata!;\n\t\tthis.request.msisdn = _state.msisdn;\n\n\t\treturn _state;\n\t}\n\n\tasync handleResponse(_req: Request, res: Response): Promise<void> {\n\t\tres.writeHead(200, { \"Content-Type\": \"text/plain\" });\n\t\tres.end(await this.wigalResponse());\n\t\treturn;\n\t}\n\n\tprivate async wigalResponse(): Promise<string> {\n\t\tconst data = (await this.state)!;\n\t\treturn `${this.request.query?.network}|${data?.mode}|${data?.msisdn}|${\n\t\t\tdata?.sessionId\n\t\t}|${this.response.data?.replace(/\\n/g, \"^\") ?? \"\"}|${\n\t\t\tthis.request.query?.username\n\t\t}|${this.request.query?.trafficid}|${data?.menu?.nextMenu || \"\"}`;\n\t}\n}\n","import { State } from \"@src/models\";\nimport { SessionOptions } from \"@src/types\";\n\nexport abstract class BaseSession {\n protected readonly states: { [sessionId: string]: State } = {};\n protected readonly data: { [sessionId: string]: Record<string, any> } = {};\n\n // TODO: change this to a proper configuration based on the session type\n async configure(options?: SessionOptions): Promise<void> {\n // throw new Error(\"Method not implemented.\");\n }\n\n abstract setState(id: string, state: State): Promise<State>;\n\n abstract getState(id: string): Promise<State | undefined>;\n\n abstract clear(id: string): State;\n\n // TODO: add delete for data\n\n /**\n * Set a key value pair in the session\n *\n * @param {string} sessionId The session ID, must be unique for each user\n * @param {string} key The key to store the value\n * @param {any} value The value to store\n */\n abstract set(sessionId: string, key: string, value: any): Promise<void>;\n\n /**\n * Remove a key value pair from the session\n *\n * @param {string} sessionId The session ID, must be unique for each user\n * @param {string} key The key to remove along with its value from the session\n */\n abstract remove(sessionId: string, key: string): Promise<void>;\n\n abstract get<T>(\n sessionId: string,\n key: string,\n defaultValue?: T,\n ): Promise<T | undefined>;\n\n abstract getAll<T = unknown>(sessionId: string): Promise<T | undefined>;\n}\n","import { State } from \"@src/models\";\nimport { BaseSession } from \"./base.session\";\n\n// @ts-ignore\nimport { createClient, type RedisClientType } from \"redis\";\nimport type { RedisSessionOptions } from \"@src/types\";\n\nexport class RedisSession extends BaseSession {\n private static instance: RedisSession;\n\n private CLIENT: RedisClientType;\n private config: RedisSessionOptions;\n\n private constructor() {\n super();\n }\n\n public static getInstance(): RedisSession {\n if (!RedisSession.instance) {\n RedisSession.instance = new RedisSession();\n }\n\n return RedisSession.instance;\n }\n\n async configure(options?: RedisSessionOptions): Promise<void> {\n if (options == null) {\n throw new Error(\"Redis session configuration is required!\");\n }\n\n this.config = options!;\n this.config.keyPrefix = options?.keyPrefix || \"\";\n\n await this.redisClient();\n\n // TODO: How data is loaded from redis should be optimized. Idealy, the keys\n // should be the session ids, and the values should be the states and data\n // combined. This way, we can simply load the keys and values, and assign them\n // to the states and data properties respectively. ie. { sessionId: { state, data }}\n // instead of having to loop through the keys and values to get the states (VERY INEFFICIENT!!)\n //\n\n // Load all the states from redis\n // const keys = await this.CLIENT.keys(`${this.keyPrefix}*`);\n // const states = await this.CLIENT.mGet(keys);\n\n // states.forEach((state) => {\n // console.log(state);\n\n // const _state = JSON.parse(state!);\n // this.states[_state.sessionId] = _state;\n // });\n\n // // Load all the data from redis\n // const data = await this.CLIENT.mGet(keys);\n // data.forEach((data) => {\n // console.log(data);\n // const _data = JSON.parse(data!);\n // this.data[_data.key] = _data.value;\n // });\n }\n\n async setState(sessionId: string, state: State) {\n this.states[sessionId] = state;\n\n await this.redisClient().then((client) =>\n client.set(`${sessionId}:state`, JSON.stringify(state.toJSON())),\n );\n return state;\n }\n\n async getState(sessionId: string) {\n await this.redisClient();\n const val = await this.CLIENT.get(`${sessionId}:state`);\n\n return val == null ? undefined : State.fromJSON(JSON.parse(val));\n }\n\n clear(sessionId: string): State {\n const _state = this.states[sessionId];\n delete this.states[sessionId];\n delete this.data[sessionId];\n\n this.redisClient().then((client) => client.del(`${sessionId}:*`));\n\n return _state;\n }\n\n async set(sessionId: string, key: string, value: any): Promise<void> {\n await this.redisClient();\n const val = await this.CLIENT.get(`${sessionId}:data`);\n\n const data = JSON.parse(val || \"{}\");\n data[key] = value;\n\n await this.redisClient().then((client) =>\n client.set(`${sessionId}:data`, JSON.stringify(data)),\n );\n }\n\n async remove(sessionId: string, key: string): Promise<void> {\n await this.redisClient();\n const val = await this.CLIENT.get(`${sessionId}:data`);\n\n const data = JSON.parse(val || \"{}\");\n delete data[key]\n\n await this.redisClient().then((client) =>\n client.set(`${sessionId}:data`, JSON.stringify(data)),\n );\n }\n\n async get<T>(\n sessionId: string,\n key: string,\n defaultValue?: T,\n ): Promise<T | undefined> {\n await this.redisClient();\n const val = await this.CLIENT.get(`${sessionId}:data`);\n\n if (val == null) {\n return defaultValue;\n }\n\n return (JSON.parse(val)[key] || defaultValue) as T;\n }\n\n async getAll<T>(sessionId: string): Promise<T | undefined> {\n const val = await this.redisClient().then((client) =>\n client.get(`${sessionId}:data`),\n );\n\n if (val == null) {\n return undefined;\n }\n\n return JSON.parse(val) as T;\n }\n\n private async redisClient() {\n if (this.CLIENT == null) {\n if (this.config.url != null) {\n this.CLIENT = createClient({\n url: this.config.url,\n });\n } else {\n this.CLIENT = createClient({\n username: this.config.username!,\n socket: {\n host: this.config.host || \"localhost\",\n port: this.config.port || 6379,\n },\n database: this.config.database as number,\n password: this.config.password!,\n });\n }\n }\n if (!this.CLIENT?.isOpen) {\n await this.CLIENT.connect();\n }\n\n return this.CLIENT;\n }\n}\n","import { State } from \"@src/models\";\nimport { BaseSession } from \"./base.session\";\nimport type { SQLSessionOptions } from \"@src/types\";\n\n/**\n * PostgreSQL session manager\n * A session manager that uses postgres as the session store\n *\n * It is assumed that database has a session table with following schema:\n * ```sql\n * CREATE TABLE ussd_sessions (\n * id UUID PRIMARY KEY DEFAULT uuid_generate_v5(),\n * session_id VARCHAR(255),\n * state JSONB DEFAULT '{}',\n * data JSONB DEFAULT '{}',\n * created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n * updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n * deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL\n * );\n * -- comment this out (and use the next code) if you want to use soft delete\n * CREATE UNIQUE INDEX session_uniq_key ON ussd_sessions (session_id);\n *\n * -- use this index, if soft delete is enabled\n * -- CREATE UNIQUE INDEX session_uniq_key ON ussd_session (session_id, deleted_at);\n * ```\n */\nexport class PostgresSession extends BaseSession {\n\tprivate static instance: PostgresSession;\n\n\tprivate config: SQLSessionOptions;\n\tprivate db: any;\n\n\tprivate constructor() {\n\t\tsuper();\n\t}\n\n\tpublic static getInstance(): PostgresSession {\n\t\tif (!PostgresSession.instance) {\n\t\t\tPostgresSession.instance = new PostgresSession();\n\t\t}\n\n\t\treturn PostgresSession.instance;\n\t}\n\n\tasync configure(options: SQLSessionOptions): Promise<void> {\n\t\tif (options == null) {\n\t\t\tthrow new Error(\"Postgres session configuration is required!\");\n\t\t}\n\t\tthis.config = options;\n\t\tthis.config.tableName ??= \"ussd_sessions\";\n\t\tthis.config.schema ??= options?.schema ?? \"public\";\n\n\t\tlet pgPromise;\n\n\t\ttry {\n\t\t\tpgPromise = await import(\"pg-promise\");\n\t\t} catch (error) {\n\t\t\tthrow new Error(\n\t\t\t\t\"'pg-promise' module is required for postgres session. Please install it using 'npm install pg-promise' or 'yarn add pg-promise'\",\n\t\t\t);\n\t\t}\n\n\t\tconst pgp = pgPromise.default({\n\t\t\tcapSQL: true, // capitalize all generated SQL\n\t\t\tschema: [this.config.schema],\n\t\t});\n\n\t\tthis.db = pgp({\n\t\t\thost: options?.host || \"localhost\",\n\t\t\tport: options?.port || 5432,\n\t\t\tdatabase: options.database,\n\t\t\tuser: options.username || \"postgres\",\n\t\t\tpassword: options.password as any,\n\t\t});\n\t}\n\n\tprivate get softDeleteQuery() {\n\t\tif (this.config.softDelete == false || this.config.softDelete == null)\n\t\t\treturn \"\";\n\n\t\treturn \"AND deleted_at IS NULL\";\n\t}\n\n\tasync setState(sessionId: string, state: State) {\n\t\tthis.states[sessionId] = state;\n\n\t\t// Write postgres query to insert or update state\n\t\tawait this.db.none(\n\t\t\t`INSERT INTO $1~.$2~ (session_id, state, created_at, updated_at, deleted_at) VALUES ($3, $4::jsonb, $5, $5, NULL)\n ON CONFLICT (session_id) DO UPDATE SET state = $4::jsonb, updated_at = $5 WHERE $1~.$2~.session_id = $3 ${this.softDeleteQuery}`,\n\t\t\t[\n\t\t\t\tthis.config.schema,\n\t\t\t\tthis.config.tableName,\n\t\t\t\tsessionId,\n\t\t\t\tJSON.stringify(state.toJSON()),\n\t\t\t\tnew Date().toISOString(),\n\t\t\t],\n\t\t);\n\t\treturn state;\n\t}\n\n\tasync getState(sessionId: string) {\n\t\tconst [val] = await this.db.any(\n\t\t\t`SELECT state FROM $1~.$2~ WHERE session_id = $3 ${this.softDeleteQuery} LIMIT 1`,\n\t\t\t[this.config.schema, this.config.tableName, sessionId],\n\t\t);\n\n\t\tif (val == null) return undefined;\n\n\t\tif (typeof val === \"object\") return State.fromJSON(val.state);\n\n\t\treturn State.fromJSON(JSON.parse(val.state));\n\t}\n\n\tclear(sessionId: string): State {\n\t\tconst _state = this.states[sessionId];\n\t\tdelete this.states[sessionId];\n\t\tdelete this.data[sessionId];\n\n\t\tif (this.config.softDelete === false || this.config.softDelete == null) {\n\t\t\tthis.db\n\t\t\t\t.none(\"DELETE FROM $1~.$2~ WHERE session_id = $3\", [\n\t\t\t\t\tthis.config.schema,\n\t\t\t\t\tthis.config.tableName,\n\t\t\t\t\tsessionId,\n\t\t\t\t])\n\t\t\t\t.catch((error: Error) => {\n\t\t\t\t\tthrow error;\n\t\t\t\t});\n\t\t} else {\n\t\t\tthis.db\n\t\t\t\t.none(\n\t\t\t\t\t`UPDATE $1~.$2~ SET updated_at = $3, deleted_at = $3 WHERE session_id = $4 ${this.softDeleteQuery}`,\n\t\t\t\t\t[\n\t\t\t\t\t\tthis.config.schema,\n\t\t\t\t\t\tthis.config.tableName,\n\t\t\t\t\t\tnew Date().toISOString(),\n\t\t\t\t\t\tsessionId,\n\t\t\t\t\t],\n\t\t\t\t)\n\t\t\t\t.catch((error: Error) => {\n\t\t\t\t\tthrow error;\n\t\t\t\t});\n\t\t}\n\n\t\treturn _state;\n\t}\n\n\tasync set(sessionId: string, key: string, value: any): Promise<void> {\n\t\tconst val = await this.db.one(\n\t\t\t`UPDATE $1~.$2~ SET data = jsonb_set(data, '{$3~}', $4::jsonb), updated_at = $5 WHERE session_id = $6 ${this.softDeleteQuery} RETURNING *`,\n\t\t\t[\n\t\t\t\tthis.config.schema,\n\t\t\t\tthis.config.tableName,\n\t\t\t\tkey,\n\t\t\t\tJSON.stringify(value),\n\t\t\t\tnew Date().toISOString(),\n\t\t\t\tsessionId,\n\t\t\t],\n\t\t);\n\t\treturn val;\n\t}\n\n\tasync remove(sessionId: string, key: string): Promise<void> {\n\t\tconst val = await this.db.one(\n\t\t\t`UPDATE $1~.$2~ SET data = data - '{$3}', updated_at = $4 WHERE session_id = $5 ${this.softDeleteQuery} RETURNING *`,\n\t\t\t[\n\t\t\t\tthis.config.schema,\n\t\t\t\tthis.config.tableName,\n\t\t\t\tkey,\n\t\t\t\tnew Date().toISOString(),\n\t\t\t\tsessionId,\n\t\t\t],\n\t\t);\n\t\treturn val;\n\t}\n\n\tasync get<T>(\n\t\tsessionId: string,\n\t\tkey: string,\n\t\tdefaultValue?: T,\n\t): Promise<T | undefined> {\n\t\tconst [val] = await this.db.any(\n\t\t\t`SELECT data FROM $1~.$2~ WHERE session_id = $3 ${this.softDeleteQuery} LIMIT 1`,\n\t\t\t[this.config.schema, this.config.tableName, sessionId],\n\t\t);\n\n\t\tif (val == null) {\n\t\t\treturn defaultValue;\n\t\t}\n\n\t\tif (typeof val === \"object\") {\n\t\t\treturn val[key] || defaultValue;\n\t\t}\n\n\t\treturn (JSON.parse(val)[key] || defaultValue) as T;\n\t}\n\n\tasync getAll<T>(sessionId: string): Promise<T | undefined> {\n\t\tconst [val] = await this.db.one(\n\t\t\t`SELECT data FROM $1~.$2~ WHERE session_id = $3 ${this.softDeleteQuery} LIMIT 1`,\n\t\t\t[this.config.schema, this.config.tableName, sessionId],\n\t\t);\n\n\t\tif (val == null) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\treturn JSON.parse(val) as T;\n\t}\n}\n","import { State } from \"@src/models\";\nimport { BaseSession } from \"./base.session\";\n\nexport class MemcacheSession extends BaseSession {\n private static instance: MemcacheSession;\n\n private constructor() {\n super();\n }\n\n public static getInstance(): MemcacheSession {\n if (!MemcacheSession.instance) {\n MemcacheSession.instance = new MemcacheSession();\n }\n\n return MemcacheSession.instance;\n }\n\n async setState(id: string, state: State): Promise<State> {\n this.states[id] = state;\n return state;\n }\n\n async getState(id: string): Promise<State | undefined> {\n return this.states[id];\n }\n\n clear(id: string): State {\n const _state = this.states[id];\n delete this.states[id];\n delete this.data[id];\n return _state;\n }\n\n async set(sessionId: string, key: string, value: any) {\n if (this.data[sessionId] == null) {\n this.data[sessionId] = {};\n }\n\n this.data[sessionId][key] = value;\n }\n\n async remove(sessionId: string, key: string) {\n this.data[sessionId] ??= {};\n delete this.data[sessionId][key]\n }\n\n async get<T = unknown>(\n sessionId: string,\n key: string,\n defaultValue?: T,\n ): Promise<T | undefined> {\n if (this.data[sessionId] == null) {\n return defaultValue;\n }\n\n return (this.data[sessionId][key] || defaultValue) as T;\n }\n\n async getAll<T>(sessionId: string): Promise<T | undefined> {\n return this.data[sessionId] as T;\n }\n}\n","import { State } from \"@src/models\";\nimport { BaseSession } from \"./base.session\";\nimport { SQLSessionOptions } from \"@src/types\";\n\n/**\n * MySQL session manager\n * A session manager that uses mysql as the session store\n *\n * It is assumed that database has a session table with following schema:\n * ```sql\n * CREATE TABLE ussd_sessions (\n * id INTEGER PRIMARY KEY AUTO_INCREMENT,\n * session_id VARCHAR(255),\n * state LONGTEXT DEFAULT '{}',\n * data LONGTEXT DEFAULT '{}',\n * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n * updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n * deleted_at TIMESTAMP NULL DEFAULT NULL,\n * UNIQUE KEY session_uniq_key (session_id)\n * -- or uncomment the this for soft delete constraint\n * -- UNIQUE KEY session_uniq_key (session_id, deleted_at)\n * );\n * ```\n */\nexport class MySQLSession extends BaseSession {\n private static instance: MySQLSession;\n\n private config: SQLSessionOptions;\n private db: any;\n\n private constructor() {\n super();\n }\n\n public static getInstance(): MySQLSession {\n if (!MySQLSession.instance) {\n MySQLSession.instance = new MySQLSession();\n }\n\n return MySQLSession.instance;\n }\n\n async configure(options: SQLSessionOptions): Promise<void> {\n if (options == null) {\n throw new Error(\"Postgres session configuration is required!\");\n }\n this.config = options;\n this.config.tableName ??= \"ussd_sessions\";\n\n let mysql;\n\n try {\n mysql = await import(\"mysql2/promise\");\n } catch (error) {\n throw new Error(\n \"'mysql2/promise' module is required for postgres session. Please install it using 'npm install mysql2/promise' or 'yarn add mysql2/promise'\",\n );\n }\n\n this.db = await mysql.createConnection({\n host: this.config?.host || \"localhost\",\n user: this.config.username || \"root\",\n database: this.config.database,\n password: this.config.password as string,\n });\n }\n\n private get softDeleteQuery() {\n if (this.config.softDelete == false || this.config.softDelete == null)\n return \"\";\n\n return \"AND deleted_at IS NULL\";\n }\n\n private get tableName() {\n return this.config.tableName!;\n }\n\n async setState(sessionId: string, state: State) {\n this.states[sessionId] = state;\n\n // Write postgres query to insert or update state\n await this.db.query(\n `INSERT INTO ${this.tableName} (session_id, state, created_at, updated_at) VALUES (?, ?, NOW(), NOW())\n ON DUPLICATE KEY UPDATE state = ?`,\n [\n sessionId,\n JSON.stringify(state.toJSON()),\n JSON.stringify(state.toJSON()),\n sessionId,\n ],\n );\n return state;\n }\n\n async getState(sessionId: string) {\n const [resp, _fields] = await this.db.query(\n `SELECT state, data FROM ${this.tableName} WHERE session_id = ? ${this.softDeleteQuery} LIMIT 1`,\n [sessionId],\n );\n\n if (resp.length === 0) return undefined;\n\n const val = typeof resp[0] === \"string\" ? JSON.parse(resp[0]) : resp[0];\n this.data[sessionId] = val.data;\n return State.fromJSON(val.state);\n }\n\n clear(sessionId: string): State {\n const _state = this.states[sessionId];\n delete this.states[sessionId];\n delete this.data[sessionId];\n\n if (this.config.softDelete === false || this.config.softDelete == null) {\n this.db\n .query(`DELETE FROM ${this.tableName} WHERE session_id = ?`, [\n sessionId,\n ])\n .catch((error: Error) => {\n throw error;\n });\n } else {\n this.db\n .query(\n `UPDATE ${this.tableName} SET deleted_at = ? WHERE session_id = ? ${this.softDeleteQuery}`,\n [new Date().toISOString(), sessionId],\n )\n .catch((error: Error) => {\n throw error;\n });\n }\n\n return _state;\n }\n\n async set(sessionId: string, key: string, value: any): Promise<void> {\n this.data[sessionId] ??= {};\n this.data[sessionId][key] = value;\n\n await this.db.query(\n `UPDATE ${this.tableName} SET data = ? WHERE session_id = ? ${this.softDeleteQuery}`,\n [JSON.stringify(this.data[sessionId]), sessionId],\n );\n }\n\n async remove(sessionId: string, key: string): Promise<void> {\n this.data[sessionId] ??= {};\n delete this.data[sessionId][key];\n\n await this.db.query(\n `UPDATE ${this.tableName} SET data = ? WHERE session_id = ? ${this.softDeleteQuery}`,\n [JSON.stringify(this.data[sessionId]), sessionId],\n );\n }\n\n async get<T>(\n sessionId: string,\n key: string,\n defaultValue?: T,\n ): Promise<T | undefined> {\n const [resp, _fields] = await this.db.query(\n `SELECT data FROM ${this.tableName} WHERE session_id = ? ${this.softDeleteQuery} LIMIT 1`,\n [sessionId],\n );\n\n if (resp == null) {\n return defaultValue;\n }\n\n const val = typeof resp[0] === \"string\" ? JSON.parse(resp[0]) : resp[0];\n return ((val?.data || \"{}\")[key] || defaultValue) as T;\n }\n\n async getAll<T>(sessionId: string): Promise<T | undefined> {\n const [[val]] = await this.db.query(\n `SELECT data FROM ${this.tableName} WHERE session_id = ? ${this.softDeleteQuery}`,\n [sessionId],\n );\n\n if (val == null) {\n return undefined;\n }\n\n return JSON.parse(val.data || \"{}\") as T;\n }\n}\n","export type SessionOptions = RedisSessionOptions | SQLSessionOptions;\n\nexport interface BaseSessionOptions {\n\thost?: string | undefined;\n\tport?: number | undefined;\n\turl?: string;\n\tusername?: string | undefined;\n\tpassword?: string | undefined;\n\tdatabase?: string | number | undefined;\n}\n\nexport interface SQLSessionOptions extends BaseSessionOptions {\n\ttype: \"postgres\" | \"mysql\" | \"mssql\";\n\n\t/**\n\t * The name of the table to use for the session, default is `ussd_sessions`\n\t */\n\ttableName?: string;\n\n\t/**\n\t * The schema to use for the session table, default is `public`\n\t */\n\tschema?: string;\n\n\t/**\n\t * The name of the database to use\n\t */\n\tdatabase: string;\n\n\t/**\n\t * Whether to use soft delete or not, default is `false`.\n\t *\n\t * If set to `true`, the session will not be deleted from the database,\n\t * but will be marked as deleted by setting the `deleted_at` column to the current date and time.\n\t */\n\tsoftDelete?: boolean;\n}\n\nexport interface RedisSessionOptions extends BaseSessionOptions {\n\ttype: \"redis\";\n\tkeyPrefix?: string;\n}\n\nexport class PaginationOption {\n\t/**\n\t * Pagination is enabled by default if the content of the message to display is\n\t * more than the maximum 182 characters allowed.\n\t */\n\tenabled: boolean = true;\n\n\tnextPage: {\n\t\tdisplay: string;\n\t\tchoice: string;\n\t} = { display: \"*. More\", choice: \"*\" };\n\n\tpreviousPage: {\n\t\tdisplay: string;\n\t\tchoice: string;\n\t} = { display: \"#. Back\", choice: \"#\" };\n}\n","import { Request, Response } from \"@src/types/request\";\nimport { Gateway } from \"./base.gateway\";\nimport { State, StateMode } from \"@src/models\";\n\ninterface IEmergentRequest {\n\tType: \"initiation\" | \"response\" | \"release\" | \"timeout\";\n\tMobile: string;\n\tSessionId: string;\n\tServiceCode: string;\n\tMessage: string;\n\tOperator: string;\n}\n\nexport class EmergentTechnologyGateway extends Gateway {\n\tget sessionId(): string {\n\t\treturn (this.request.body as IEmergentRequest)?.SessionId!;\n\t}\n\n\tasync handleRequest(): Promise<State | undefined> {\n\t\tlet _state = await this.state;\n\n\t\t_state ??= new State();\n\n\t\tconst body = this.request.body as IEmergentRequest;\n\n\t\t_state.mode = this.getMode(body.Type.toLowerCase());\n\t\t_state.msisdn = body.Mobile;\n\t\t_state.sessionId = body.SessionId;\n\t\t_state.userData = body.Message;\n\n\t\tthis.request.state = _state;\n\t\tthis.request.msisdn = _state.msisdn;\n\t\tthis.request.serviceCode = body.ServiceCode;\n\n\t\t// The content of Message for session initiation is always the service short code value\n\t\t// We don't really need it, given that it is start of a session\n\t\tif (_state.mode === StateMode.start) {\n\t\t\tthis.request.input = \"\";\n\t\t} else {\n\t\t\tthis.request.input = body.Message;\n\t\t}\n\n\t\treturn _state;\n\t}\n\n\tasync handleResponse(req: Request, res: Response): Promise<void> {\n\t\tres.writeHead(200, { \"Content-Type\": \"application/json\" });\n\t\tres.end(\n\t\t\tJSON.stringify({\n\t\t\t\tMessage: this.response.data,\n\t\t\t\tType: req.state.mode == StateMode.more ? \"Response\" : \"Release\",\n\t\t\t}),\n\t\t);\n\t}\n\n\tprivate getMode(type: string): StateMode {\n\t\tswitch (type.toLowerCase()) {\n\t\t\tcase \"initiation\":\n\t\t\t\treturn StateMode.start;\n\t\t\tcase \"response\":\n\t\t\t\treturn StateMode.more;\n\t\t\tcase \"release\":\n\t\t\tcase \"timeout\":\n\t\t\t\treturn StateMode.end;\n\t\t\tdefault:\n\t\t\t\treturn StateMode.start;\n\t\t}\n\t}\n}\n","import { SupportedGateway } from \"./helpers/constants\";\nimport { Gateway } from \"./gateways/base.gateway\";\nimport { WigalGateway } from \"./gateways/wigal.gateway\";\nimport { BaseSession, PostgresSession } from \"./sessions\";\nimport { MemcacheSession } from \"./sessions/memcache.session\";\nimport { MySQLSession } from \"./sessions/mysql.session\";\nimport { RedisSession } from \"./sessions/redis.session\";\nimport { PaginationOption, SessionOptions, Type } from \"./types\";\nimport { EmergentTechnologyGateway } from \"./gateways/emergent_technology.gateway\";\n\nexport class Config {\n\tprivate static instance: Config;\n\n\t#gateway: Type<Gateway>;\n\t#options: ConfigOptions;\n\n\tprivate _session: BaseSession | undefined = undefined;\n\n\tprivate constructor() {}\n\n\tpublic static getInstance(): Config {\n\t\tif (!Config.instance) {\n\t\t\tConfig.instance = new Config();\n\t\t}\n\n\t\treturn Config.instance;\n\t}\n\n\tinit(options: ConfigOptions) {\n\t\tthis.#options = options;\n\n\t\tif (typeof options.gateway == \"string\") {\n\t\t\tswitch (options.gateway) {\n\t\t\t\tcase SupportedGateway.wigal:\n\t\t\t\t\tthis.#gateway = WigalGateway;\n\t\t\t\t\tbreak;\n\t\t\t\tcase SupportedGateway.emergent_technology:\n\t\t\t\t\tthis.#gateway = EmergentTechnologyGateway;\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t} else {\n\t\t\t// TODO: implement for custom gateway class\n\t\t}\n\n\t\t// Resolve session\n\t\tconst _session = options.session || \"memory\";\n\n\t\t// If session is already an instance of Session, then we are good to go\n\t\tif (this._session instanceof BaseSession) {\n\t\t\treturn this;\n\t\t}\n\n\t\tif (_session === \"memory\") {\n\t\t\tthis._session = MemcacheSession.getInstance();\n\t\t\treturn this;\n\t\t}\n\n\t\tif (typeof _session === \"object\") {\n\t\t\t// Configure is provided, so we need to create a new instance of the session\n\t\t\tif (_session?.type != null) {\n\t\t\t\tswitch (_session.type) {\n\t\t\t\t\tcase \"redis\":\n\t\t\t\t\t\tthis._session = RedisSession.getInstance();\n\t\t\t\t\t\tthis._session.configure(_session);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"postgres\":\n\t\t\t\t\t\tthis._session = PostgresSession.getInstance();\n\t\t\t\t\t\tthis._session.configure(_session);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"mssql\":\n\t\t\t\t\tcase \"mysql\":\n\t\t\t\t\t\tthis._session = MySQLSession.getInstance();\n\t\t\t\t\t\tthis._session.configure(_session);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t// case \"mongo\":\n\t\t\t\t\t// throw new Error(\"Mongo session not implemented yet\");\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tthrow new Error(\"Invalid session type\");\n\t\t\t\t}\n\t\t\t}\n\t\t\t// A session class is provided, so we need to create a new instance of the session\n\t\t\t// this._session = _session as unknown as Session;\n\t\t}\n\n\t\treturn this;\n\t}\n\n\tget gateway(): Type<Gateway> {\n\t\treturn this.#gateway;\n\t}\n\n\tget gatewayName(): SupportedGateway {\n\t\treturn this.#options.gateway as SupportedGateway;\n\t}\n\n\tget session(): BaseSession | undefined {\n\t\treturn this._session;\n\t}\n\n\tget options() {\n\t\treturn this.#options;\n\t}\n}\n\ntype CustomSession = Type<BaseSession>;\n\nexport class ConfigOptions {\n\tmiddlewares?: Type<Gateway>[];\n\tsession?: \"memory\" | SessionOptions | CustomSession;\n\tgateway: keyof typeof SupportedGateway;\n\tpagination?: PaginationOption = new PaginationOption();\n}\n","import { BaseSession } from \"@src/sessions\";\nimport { NextMenu, Session } from \"@src/types\";\nimport { Request, Response } from \"@src/types/request\";\n\nexport class MenuAction {\n\t// name: string; // FIXME: relevant? should be removed?\n\n\t/**\n\t * The choice that the user should enter to select this option\n\t * '*' is used to match any input. Useful for a catch-all option, must be the last option\n\t */\n\tchoice:\n\t\t| string\n\t\t| RegExp\n\t\t| ((\n\t\t\t\tinput: string | undefined,\n\t\t\t\treq: Request,\n\t\t\t\tres: Response,\n\t\t ) => Promise<string>); // TODO: or function\n\t//FIXME: remove this\n\t// route: string; // Route ID\n\t// TODO: change return type to response\n\t// TODO: or link to action class\n\t// action?: Type<BaseAction>;\n\tdisplay?:\n\t\t| string\n\t\t| ((req: Request, res: Response) => Promise<string> | string); // text to display. or function? text?\n\t// validation?: string | RegExp | ((req: Request) => boolean); //FIXME: move to action class\n\t// error_message?: string;\n\tnext_menu?: NextMenu; // TODO: links to next menu\n\n\t// TODO: validate that either route or action is provided\n\t// suggest a name for a method that will be called when this option is selected by the user\n\thandler?: (req: Request) => Promise<void>;\n\t// handler: (get: () => void, set: (val: any) => Promise<void>) => void = (\n\t// get,\n\t// set\n\t// ) => {\n\t// return;\n\t// };\n\n\t// get session(): Session {\n\t// return {\n\t// get: async <T>(key: string, defaultValue?: any) => {\n\t// return await Config.getInstance().session?.get<T>(\n\t// this.sessionId!,\n\t// key,\n\t// defaultValue\n\t// );\n\t// },\n\t// getAll: <T>() => {\n\t// return Config.getInstance().session?.getAll<T>(this.sessionId!);\n\t// },\n\t// set: (key: string, val: any) =>\n\t// Config.getInstance().session?.set(this.sessionId!, key, val),\n\t// };\n\t// }\n}\n","import { Config } from \"@src/config\";\nimport { FormInput, Session, ValidationResponse } from \"@src/types\";\nimport { MenuAction } from \"./action.menu\";\nimport { Request, Response } from \"@src/types/request\";\n\nexport abstract class BaseMenu {\n\tconstructor(\n\t\tprotected readonly request: Request,\n\t\tprotected readonly response: Response,\n\t) {}\n\n\tasync validate(_data?: string): Promise<ValidationResponse> {\n\t\treturn true;\n\t}\n\n\tpaginate(): Promise<boolean> | boolean {\n\t\treturn false;\n\t}\n\n\tabstract message(): Promise<string> | string | undefined;\n\n\tabstract nextMenu(): Promise<string | undefined> | string | undefined;\n\n\t/**\n\t * Terminate the current session\n\t *\n\t */\n\tend(): Promise<boolean> | boolean {\n\t\treturn false;\n\t}\n\n\tget sessionId(): string {\n\t\t// FIXME: this is not reliable, add to request object\n\t\treturn this.request.query?.sessionid!;\n\t}\n\n\tisStart(): Promise<boolean> | boolean {\n\t\treturn false;\n\t}\n\n\tget session(): Session {\n\t\treturn {\n\t\t\tget: async <T>(key: string, defaultValue?: any) => {\n\t\t\t\treturn await Config.getInstance().session?.get<T>(\n\t\t\t\t\tthis.sessionId!,\n\t\t\t\t\tkey,\n\t\t\t\t\tdefaultValue,\n\t\t\t\t);\n\t\t\t},\n\t\t\tgetAll: <T>() => {\n\t\t\t\treturn Config.getInstance().session?.getAll<T>(this.sessionId!);\n\t\t\t},\n\t\t\tset: (key: string, val: any) =>\n\t\t\t\tConfig.getInstance().session?.set(this.sessionId!, key, val),\n remove: (key: string) =>\n Config.getInstance().session?.remove(this.sessionId!, key),\n\t\t};\n\t}\n\n\t/**\n\t * Returns the current msisdn/phone number of the session.\n\t */\n\tget msisdn(): string {\n\t\treturn this.request.state.msisdn;\n\t}\n\n\tasync back(): Promise<string | undefined> {\n\t\treturn undefined;\n\t}\n\n\tabstract actions(): Promise<MenuAction[]> | MenuAction[];\n\n\tasync inputs(): Promise<FormInput[]> {\n\t\treturn [];\n\t}\n\n\tisForm(): boolean {\n\t\treturn false;\n\t}\n}\n","import type {\n FormInput,\n Null,\n Request,\n Response,\n Type,\n Validation,\n ValidationResponse,\n} from \"@src/types\";\nimport type { MenuAction } from \"./action.menu\";\nimport type { BaseMenu } from \"./base.menu\";\n\nexport class DynamicMenu {\n // TODO: Look for better class name\n\n #id: string;\n #formInputs: FormInput[] = [];\n #isForm = false;\n #end = false;\n #paginate = false;\n #message?:\n | string\n | Null\n | ((req: Request, res: Response) => Promise<string> | string) = undefined;\n #nextMenu?:\n | string\n | ((req: Request, res: Response) => Promise<string> | string) = undefined;\n\n private _validation?: Validation;\n private _actions: MenuAction[];\n private _isStart = false;\n private _currentOption?: MenuAction | undefined = undefined; // make private??\n private _action?: Type<BaseMenu> | undefined = undefined;\n\n constructor(id: string, action?: Type<BaseMenu>) {\n this.#id = id;\n this._action = action;\n }\n\n isForm(): DynamicMenu {\n this.#isForm = true;\n return this;\n }\n\n defaultNextMenu(\n menu: string | ((req: Request, res: Response) => Promise<string> | string),\n ): DynamicMenu {\n this.#nextMenu = menu;\n return this;\n }\n\n actions(items: MenuAction[]): DynamicMenu {\n if (this._action !== undefined) {\n throw new Error(\n \"Cannot set options for a menu with an action. Menu #${this._id} has an action defined\",\n );\n }\n\n this._actions = items;\n\n return this;\n }\n\n inputs(items: FormInput[]): DynamicMenu {\n this.#formInputs ??= [];\n this.#formInputs = [...this.#formInputs, ...items];\n\n if (this.#formInputs.length === 0) {\n throw new Error(`Form menu #${this.id} must have at least one input!`);\n }\n return this;\n }\n\n start(): DynamicMenu {\n // TODO: verify that only one start menu is defined. Move to Route class?\n this._isStart = true;\n\n return this;\n }\n\n validation(val: Validation) {\n this._validation = val;\n return this;\n }\n\n message(msg: string | Null | ((req: Request, res: Response) => Promise<string> | string)) {\n this.#message = msg;\n return this;\n }\n\n paginate() {\n this.#paginate = true;\n return this;\n }\n\n /**\n * Terminate the current session\n */\n end(): void {\n this.#end = true;\n }\n\n // TODO: rename to getactiona\n getActions(): MenuAction[] {\n return this._actions || [];\n }\n\n getInputs(): FormInput[] {\n return this.#formInputs || [];\n }\n\n async getMessage(req: Request, res: Response): Promise<string | Null> {\n if (typeof this.#message === \"function\") {\n return this.#message(req, res);\n }\n return this.#message;\n }\n\n async getDefaultNextMenu(\n req: Request,\n res: Response,\n ): Promise<string | undefined> {\n if (typeof this.#nextMenu === \"function\") {\n return this.#nextMenu(req, res);\n }\n return this.#nextMenu;\n }\n\n async validateInput(\n req: Request,\n res: Response,\n ): Promise<ValidationResponse> {\n if (this._validation == null) {\n return true;\n }\n\n if (typeof this._validation === \"function\") {\n return this._validation(req, res);\n }\n\n try {\n return this._validation.test(req.state.userData);\n } catch { }\n\n return false;\n }\n\n /**\n * Whether the current menu is a form menu or not.\n * Not to be confused with `isForm` which is used to set a menu as a form menu.\n *\n * **NOTE**: This is for internal use only!\n */\n get isFormMenu(): boolean {\n return this.#isForm;\n }\n\n get action() {\n return this._action;\n }\n\n get id(): string {\n return this.#id;\n }\n\n get isEnd(): boolean {\n return this.#end;\n }\n\n get isStart(): boolean {\n return this._isStart || false;\n }\n\n set currentOption(value: MenuAction | undefined) {\n this._currentOption = value;\n }\n\n get currentOption(): MenuAction | undefined {\n return this._currentOption;\n }\n\n get isPaginated(): boolean {\n return this.#paginate;\n }\n}\n","export { MenuAction } from \"./action.menu\";\nexport { BaseMenu } from \"./base.menu\";\nexport { DynamicMenu } from \"./dynamic_menu.menu\";\nexport { ValidationResponse } from \"@src/types\";\n\nimport { Request, Response } from \"@src/types/request\";\nimport { Type } from \"@src/types\";\nimport { BaseMenu } from \"@src/menus/base.menu\";\nimport { DynamicMenu } from \"@src/menus\";\nimport { MENU_CACHE } from \"@src/models\";\nimport { menuType } from \"..\";\n\nexport class Menus {\n private static instance: Menus;\n\n // private items: { [menuId: string]: Type<BaseMenu> | DynamicMenu } = {};\n\n private constructor() { }\n\n public static getInstance(): Menus {\n if (!Menus.instance) {\n Menus.instance = new Menus();\n }\n\n return Menus.instance;\n }\n\n add(cls: Type<BaseMenu>, name: string): void {\n MENU_CACHE[name] = { menu: cls, paginated: false };\n\t}\n\n menu(id: string): DynamicMenu {\n const _menu = new DynamicMenu(id);\n MENU_CACHE[id] = { menu: _menu, paginated: false };\n\n return _menu;\n }\n\n get menus() {\n return MENU_CACHE;\n }\n\n async getStartMenu(\n req: Request,\n res: Response,\n ): Promise<{ id: string; obj: DynamicMenu | Type<BaseMenu> }> {\n let startId: string | null = null;\n\n for (const id in MENU_CACHE) {\n let isStart = false;\n const menu = MENU_CACHE[id]?.menu;\n\n if (menuType(menu) === \"class\") {\n if (menu instanceof BaseMenu) {\n isStart = await menu.isStart();\n } else {\n // @ts-ignore\n isStart = await new menu(req, res).isStart();\n }\n } else {\n isStart = (menu as DynamicMenu).isStart;\n }\n\n if (isStart) {\n startId = id;\n break;\n }\n }\n\n // console.log(start, MENU_CACHE)\n if (startId == null) {\n throw new Error(\"No start menu defined. Please define a start menu\");\n }\n\n return { id: startId, obj: MENU_CACHE[startId]?.menu };\n }\n\n getMenu(id: string): DynamicMenu | Type<BaseMenu> {\n const menu = MENU_CACHE[id]?.menu;\n\n if (menu == undefined) {\n throw new Error(`Menu #${id} not found`);\n }\n\n return menu;\n }\n}\n\nexport type Menu = Type<BaseMenu> | DynamicMenu;\n\nexport const MenuRouter = Menus.getInstance();\n","import type { BaseMenu, DynamicMenu, Menu, MenuAction } from \"../menus\";\n\nexport function menuType(val: Menu): \"class\" | \"dynamic\" {\n\t// TODO: document why this special case is needed\n\tif (/^DynamicMenu$/i.test(val.constructor.name)) {\n\t\treturn \"dynamic\";\n\t}\n\treturn \"class\";\n}\n\nexport async function getMenuActions(menu: Menu): Promise<MenuAction[]> {\n\tif (menuType(menu) === \"class\") {\n\t\treturn (await (menu as unknown as BaseMenu).actions()) || [];\n\t}\n\treturn await (menu as DynamicMenu).getActions();\n}\n","import type {\n\tBaseMenu,\n\tDynamicMenu,\n\tMenu,\n\tMenuAction,\n\tValidationResponse,\n} from \"@src/menus\";\nimport type { State } from \"@src/models\";\nimport type { FormInput, Null } from \"@src/types\";\nimport type { Request, Response } from \"@src/types/request\";\nimport { SupportedGateway } from \"./constants\";\nimport { menuType } from \"./menu.helper\";\n\nexport async function validateInput(opts: {\n\tstate: State;\n\tmenu?: Menu;\n\tformInput?: FormInput;\n\trequest: Request;\n\tresponse: Response;\n}): Promise<{ error: string | undefined; valid: boolean }> {\n\tconst { state, menu, formInput: input, request, response } = opts;\n\n\tif (menu == null && input == null) {\n\t\tthrow new Error(\"Either menu or input must be defined\");\n\t}\n\n\tlet resp: { error: string | undefined; valid: boolean } = {\n\t\t\tvalid: true,\n\t\t\terror: undefined,\n\t\t};\n\tlet status: ValidationResponse = true;\n\n\tif (menu != null) {\n\t\tif (menuType(menu) == \"class\") {\n\t\t\tstatus = await (menu as unknown as BaseMenu).validate(state?.userData);\n\t\t} else {\n\t\t\tstatus = await (menu as DynamicMenu).validateInput(request, response);\n\t\t}\n\t}\n\n\tif (input != null) {\n\t\tif (input.validate == null) {\n\t\t\tstatus = true;\n\t\t} else if (typeof input.validate == \"function\") {\n\t\t\tstatus = await input.validate(request, response);\n\t\t} else {\n\t\t\ttry {\n\t\t\t\tstatus = (input.validate as RegExp).test(state?.userData);\n\t\t\t} catch (error) {}\n\t\t}\n\t}\n\n\tif (typeof status == \"string\") {\n\t\tresp = { valid: false, error: status };\n\t} else if (typeof status == \"boolean\" && status == false) {\n\t\tresp = { valid: false, error: undefined };\n\t}\n\n\treturn resp;\n}\n\nexport async function buildUserResponse(opts: {\n\tmenu: Menu | undefined;\n\tstate: State;\n\terrorMessage: string | undefined;\n\trequest: Request;\n\tresponse: Response;\n\tactions?: MenuAction[];\n}) {\n\tconst { menu, state, errorMessage, response, request } = opts;\n\n\tif (er