├── .gitignore ├── .npmignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── build.sh ├── package.json ├── src ├── index.ts └── test │ ├── test.ts │ └── topics.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | #Nothing in dist 31 | dist/* 32 | 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/* 2 | dist/test/* 3 | build.sh 4 | .jshintrc 5 | .gitignore 6 | .vscode 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug with mocha", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 12 | "stopOnEntry": false, 13 | "args": ["--no-timeouts", "dist/test/test.js"], 14 | "cwd": "${workspaceRoot}", 15 | "preLaunchTask": "build", 16 | "env": { 17 | "CHIMPANZEE_DEBUG": "yep" 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "label": "build", 9 | "script": "build", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 - Yak.ai 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software 5 | and associated documentation files (the "Software"), to deal in the Software without restriction, 6 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 16 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Don't use this!!! 2 | 3 | There are easier ways to do this. Ignore the following over-engineered project. 4 | 5 | # wild-yak 6 | 7 | Wild Yak is a state machine which can be used to make conversational bots. 8 | 9 | The state machine is organized as topics, with one topic active at a time. A topic is a class defining a method called handle() which receives user input and responds to it. The handle() method may also activate another topic as a result of an input. All inputs go the currently active topic. 10 | 11 | There are two special topics - RootTopic and DefaultTopic. The handle method of the RootTopic is invoked when the currently active topic chooses not to handle an input. The DefaultTopic is the first topic to be set as active after initialization. 12 | 13 | The full source for the examples below can be seen at: https://github.com/bigyak/wild-yak/blob/master/src/test 14 | Going through the tests will be the best way to learn how to use this library. 15 | 16 | Before doing anything, we need to define four data types: 17 | 18 | * IMessage defines the input or message format the topics will handle 19 | * ResultType is the response format from a topic's handler 20 | * IUserData defines user information which may be needed by the topics 21 | * IHost defines any external interfaces the bot might need 22 | 23 | ```typescript 24 | export interface IMessage { 25 | timestamp?: number; 26 | text: string; 27 | } 28 | 29 | export type ResultType = string | number | undefined; 30 | 31 | export interface IUserData { 32 | username: string; 33 | session: string; 34 | } 35 | 36 | export interface IHost { 37 | getUserDirectory(username: string): string; 38 | } 39 | ``` 40 | 41 | Now let's define a RootTopic. All Topics inherit from TopicBase and implement ITopic. This topic handles three specific messages ("do basic math", "help", "reset password") all of which activate other topics, and a generic response if it doesn't understand the input. 42 | 43 | ```typescript 44 | export class RootTopic extends TopicBase 45 | implements ITopic { 46 | async handle( 47 | state: IEvalState, 48 | message: IMessage, 49 | userData: IUserData, 50 | host: IHost 51 | ): Promise> { 52 | if (message.text === "do basic math") { 53 | this.enterTopic(state, new MathTopic()); 54 | return { handled: true, result: "You can type a math expression" }; 55 | } else if (message.text === "help") { 56 | this.enterTopic(state, new HelpTopic()); 57 | return { 58 | handled: true, 59 | result: "You're entering help mode. Type anything." 60 | }; 61 | } else if (message.text === "reset password") { 62 | this.enterTopic(state, new PasswordResetTopic()); 63 | return { 64 | handled: true, 65 | result: "Set your password." 66 | }; 67 | } else { 68 | return { 69 | handled: true, 70 | result: 71 | "Life is like riding a bicycle. To keep your balance you must keep moving." 72 | }; 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | Let's also define a DefaultTopic, which is the first topic to be loaded when the app starts. Its purpose in life is very simple - if it receives "hello world" it will respond with "greetings comrade!". 79 | 80 | ```typescript 81 | export class DefaultTopic 82 | extends TopicBase 83 | implements ITopic { 84 | async handle( 85 | state: IEvalState, 86 | message: IMessage, 87 | userData: IUserData, 88 | host: IHost 89 | ): Promise> { 90 | return message.text === "hello world" 91 | ? { handled: true, result: "greetings comrade!" } 92 | : { handled: false }; 93 | } 94 | 95 | isTopLevel() { 96 | return true; 97 | } 98 | } 99 | ``` 100 | 101 | As you can see, all Topic classes look similar. Let's go ahead and define another topic called HelpTopic - which you might have seen referred in the RootTopic. If the input is "help" the RootTopic will activate the HelpTopic. 102 | 103 | ```typescript 104 | export class HelpTopic extends TopicBase 105 | implements ITopic { 106 | async handle( 107 | state: IEvalState, 108 | message: IMessage, 109 | userData: IUserData, 110 | host: IHost 111 | ): Promise> { 112 | this.exitTopic(state); 113 | return { 114 | handled: true, 115 | result: "HELP: This is just a test suite. Nothing to see here, sorry." 116 | }; 117 | } 118 | 119 | isTopLevel() { 120 | return true; 121 | } 122 | } 123 | ``` 124 | 125 | Now that we have some Topics, let's call init(). This returns a handler to which you can pass inputs. The result of calling handler with the input message will contain the response from the topics. 126 | 127 | ```typescript 128 | async function run() { 129 | const otherTopics = [ 130 | MathTopic, 131 | AdvancedMathTopic, 132 | HelpTopic, 133 | PasswordResetTopic 134 | ]; 135 | const handler = init( 136 | RootTopic, 137 | DefaultTopic, 138 | otherTopics 139 | ); 140 | 141 | const message = { text: "hello world" }; 142 | const state = undefined; 143 | const userData = { username: "jeswin", session: "abcd" }; 144 | const host = { 145 | getUserDirectory(username: string) { 146 | return "/home/jeswin"; 147 | } 148 | }; 149 | const output = await handler(message, state, userData, host); 150 | } 151 | ``` 152 | 153 | We can continue the conversation by passing more messages the handler. But remember to send the most recent state along with the input. In the following example, notice that the second call passes the state retrieved from the previous response. This allows each topic to maintain internal state. 154 | 155 | Continuing from the last example: 156 | 157 | ```typescript 158 | async function run() { 159 | // omitted for brevity... 160 | const output = await handler(message, state, userData, host); 161 | 162 | const message2 = "help"; 163 | const output2 = await handler(message2, output.state, userData, host); 164 | } 165 | ``` 166 | 167 | As mentioned earlier, the best way to get started with the project is by going through the tests. 168 | 169 | Reach out to me if you have questions. @d2vneic0a0Y7OoRYvhXf+nCOBIV/lFQXHmOcHNr/3/I=.ed25519 on Secure ScuttleButt. -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | rm -rf dist 2 | tsc 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wild-yak", 3 | "version": "1.1.0", 4 | "author": "Yak", 5 | "types": "dist/index.d.ts", 6 | "scripts": { 7 | "build": "./build.sh", 8 | "test": "./build.sh && mocha dist/test/test.js" 9 | }, 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/node": "^10.0.6" 13 | }, 14 | "devDependencies": { 15 | "@types/mocha": "^5.2.0", 16 | "@types/should": "^13.0.0", 17 | "mocha": "5.1.1", 18 | "should": "^13.2.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export interface ITopicCtor { 2 | new (): T; 3 | } 4 | 5 | export interface IEvalState { 6 | topics: ITopic[]; 7 | rootTopic: ITopic; 8 | virgin: boolean; 9 | } 10 | 11 | export interface ISerializableTopic { 12 | ctor: string; 13 | props: { 14 | [key: string]: any; 15 | }; 16 | } 17 | 18 | export interface ISerializableEvalState { 19 | topics: ISerializableTopic[]; 20 | rootTopic: ISerializableTopic; 21 | virgin: boolean; 22 | used?: boolean; 23 | } 24 | 25 | export interface IHandlerResult { 26 | handled: boolean; 27 | result?: TResult; 28 | } 29 | 30 | export interface ITopic { 31 | handle( 32 | state: IEvalState, 33 | message: TMessage, 34 | userData: TUserData, 35 | host: THost 36 | ): Promise>; 37 | isTopLevel?(): boolean; 38 | } 39 | 40 | export abstract class TopicBase { 41 | enterTopic( 42 | evalState: IEvalState, 43 | topic: ITopic 44 | ) { 45 | const currentTopic = getActiveTopic(evalState); 46 | if ( 47 | evalState.rootTopic === (this as any) || 48 | currentTopic === (this as any) 49 | ) { 50 | enterTopic(evalState, topic); 51 | } else { 52 | throw new Error( 53 | `The caller is not the currently active topic. This is an error in code.` 54 | ); 55 | } 56 | } 57 | 58 | exitTopic(evalState: IEvalState) { 59 | const currentTopic = getActiveTopic(evalState); 60 | if ( 61 | evalState.rootTopic === (this as any) || 62 | currentTopic === (this as any) 63 | ) { 64 | exitTopic(evalState); 65 | } else { 66 | throw new Error( 67 | `The caller is not the currently active topic. This is an error in code.` 68 | ); 69 | } 70 | } 71 | } 72 | 73 | interface ITopicMap { 74 | [key: string]: ITopicCtor>; 75 | } 76 | 77 | function getActiveTopic( 78 | evalState: IEvalState 79 | ): ITopic | undefined { 80 | return evalState.topics.slice(-1)[0]; 81 | } 82 | 83 | async function processMessage( 84 | evalState: IEvalState, 85 | message: TMessage, 86 | userData: TUserData, 87 | host: THost, 88 | rootTopic: ITopic 89 | ) { 90 | const activeTopic = getActiveTopic( 91 | evalState 92 | ); 93 | 94 | const { handled, result } = activeTopic 95 | ? await activeTopic.handle(evalState, message, userData, host) 96 | : { handled: false, result: undefined }; 97 | 98 | if (handled) { 99 | return { handled, result }; 100 | } else { 101 | return await rootTopic.handle(evalState, message, userData, host); 102 | } 103 | } 104 | 105 | function toSerializable( 106 | state: IEvalState 107 | ) { 108 | return { 109 | rootTopic: { 110 | ctor: state.rootTopic.constructor.name, 111 | props: { ...state.rootTopic } 112 | }, 113 | topics: state.topics.map(c => ({ 114 | ctor: c.constructor.name, 115 | props: { ...c } 116 | })), 117 | virgin: state.virgin 118 | }; 119 | } 120 | 121 | function recreateTopicFromSerialized( 122 | ctor: ITopicCtor>, 123 | source: { [key: string]: any } 124 | ) { 125 | const recreatedTopic: any = new ctor(); 126 | const props = Object.keys(source); 127 | for (const prop of props) { 128 | recreatedTopic[prop] = source[prop]; 129 | } 130 | return recreatedTopic; 131 | } 132 | 133 | function recreateEvalState( 134 | serializable: ISerializableEvalState, 135 | topicMap: ITopicMap, 136 | rootTopicCtor: ITopicCtor> 137 | ): IEvalState { 138 | return { 139 | rootTopic: recreateTopicFromSerialized( 140 | rootTopicCtor, 141 | serializable.rootTopic 142 | ), 143 | topics: serializable.topics.map(c => 144 | recreateTopicFromSerialized(topicMap[c.ctor], c.props) 145 | ), 146 | virgin: serializable.virgin 147 | }; 148 | } 149 | 150 | function enterTopic( 151 | evalState: IEvalState, 152 | topic: ITopic 153 | ) { 154 | if (topic.isTopLevel && topic.isTopLevel()) { 155 | evalState.topics = [topic]; 156 | } else { 157 | evalState.topics.push(topic); 158 | } 159 | } 160 | 161 | function exitTopic( 162 | evalState: IEvalState 163 | ) { 164 | evalState.topics.pop(); 165 | } 166 | 167 | export type Handler = ( 168 | message: TMessage, 169 | stateSerializedByHost: ISerializableEvalState | undefined, 170 | userData: TUserData, 171 | host: THost, 172 | options?: { reuseState: boolean } 173 | ) => Promise<{ result: TResult | undefined; state: ISerializableEvalState }>; 174 | 175 | export function init( 176 | rootTopicCtor: ITopicCtor>, 177 | otherTopicCtors: ITopicCtor>[], 178 | defaultTopicCtor?: ITopicCtor> 179 | ): Handler { 180 | const rootTopic = new rootTopicCtor(); 181 | 182 | const topicMap: ITopicMap< 183 | TMessage, 184 | TResult, 185 | TUserData, 186 | THost 187 | > = (defaultTopicCtor ? [defaultTopicCtor] : []) 188 | .concat(otherTopicCtors) 189 | .reduce( 190 | ( 191 | acc: any, 192 | topicCtor: ITopicCtor> 193 | ) => { 194 | acc[topicCtor.name] = topicCtor; 195 | return acc; 196 | }, 197 | {} 198 | ); 199 | 200 | return async function handler( 201 | message: TMessage, 202 | stateSerializedByHost: ISerializableEvalState = { 203 | rootTopic: { 204 | ctor: rootTopic.constructor.name, 205 | props: rootTopic 206 | }, 207 | topics: [], 208 | virgin: true 209 | }, 210 | userData: TUserData, 211 | host: THost, 212 | options: { reuseState: boolean } = { reuseState: false } 213 | ): Promise<{ result: TResult | undefined; state: ISerializableEvalState }> { 214 | if (options.reuseState || !stateSerializedByHost.used) { 215 | stateSerializedByHost.used = true; 216 | 217 | const evalState = recreateEvalState( 218 | stateSerializedByHost, 219 | topicMap, 220 | rootTopicCtor 221 | ); 222 | 223 | if (evalState.virgin) { 224 | evalState.virgin = false; 225 | if (defaultTopicCtor) { 226 | enterTopic(evalState, new defaultTopicCtor()); 227 | } 228 | } 229 | 230 | const { handled, result } = await processMessage( 231 | evalState, 232 | message, 233 | userData, 234 | host, 235 | evalState.rootTopic 236 | ); 237 | 238 | return { 239 | result, 240 | state: toSerializable(evalState) 241 | }; 242 | } else { 243 | throw new Error("This evaluation state was previously used."); 244 | } 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /src/test/test.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import "should"; 3 | import { IEvalState, init, ISerializableEvalState } from "../"; 4 | 5 | import { 6 | AdvancedMathTopic, 7 | DefaultTopic, 8 | HelpTopic, 9 | IHost, 10 | IMessage, 11 | IUserData, 12 | MathTopic, 13 | PasswordResetTopic, 14 | ResultType, 15 | RootTopic 16 | } from "./topics"; 17 | 18 | const otherTopics = [ 19 | MathTopic, 20 | AdvancedMathTopic, 21 | HelpTopic, 22 | PasswordResetTopic 23 | ]; 24 | 25 | function getUserData(): IUserData { 26 | return { 27 | session: "666", 28 | username: "jeswin" 29 | }; 30 | } 31 | 32 | function getHost(): IHost { 33 | return { 34 | getUserDirectory(username: string) { 35 | return "/home/jeswin"; 36 | } 37 | }; 38 | } 39 | 40 | function getHandler(hasDefaultTopic = true) { 41 | return init( 42 | RootTopic, 43 | otherTopics, 44 | hasDefaultTopic ? DefaultTopic : undefined 45 | ); 46 | } 47 | 48 | function fakeSaveState(obj: any) { 49 | return JSON.stringify(obj); 50 | } 51 | 52 | function fakeRecreateState(serialized: string) { 53 | return JSON.parse(serialized); 54 | } 55 | 56 | async function assertFor( 57 | handler: ( 58 | message: IMessage, 59 | stateSerializedByHost: ISerializableEvalState | undefined, 60 | userData: IUserData, 61 | host: IHost, 62 | options?: { reuseState: boolean } 63 | ) => Promise<{ result: any; state: ISerializableEvalState }>, 64 | input: string, 65 | state: ISerializableEvalState | undefined, 66 | result: any, 67 | options?: { reuseState: boolean } 68 | ) { 69 | const realState = state ? fakeRecreateState(fakeSaveState(state)) : undefined; 70 | 71 | const message = { 72 | text: input 73 | }; 74 | 75 | const output = await handler( 76 | message, 77 | realState, 78 | getUserData(), 79 | getHost(), 80 | options 81 | ); 82 | 83 | output.result.should.equal(result); 84 | 85 | return output; 86 | } 87 | 88 | describe("Wild yak", () => { 89 | it("returns a handler on calling init()", async () => { 90 | const handler = getHandler(); 91 | handler.should.be.an.instanceOf(Function); 92 | }); 93 | 94 | it("enters the default topic while starting", async () => { 95 | const handler = getHandler(); 96 | const output = await assertFor( 97 | handler, 98 | "hello world", 99 | undefined, 100 | "greetings comrade!" 101 | ); 102 | output.state.topics.length.should.equal(1); 103 | }); 104 | 105 | it("handles a message with the root topic if nothing else matches", async () => { 106 | const handler = getHandler(); 107 | 108 | const output = await assertFor( 109 | handler, 110 | "something something!", 111 | undefined, 112 | "Life is like riding a bicycle. To keep your balance you must keep moving." 113 | ); 114 | output.state.topics.length.should.equal(1); 115 | }); 116 | 117 | it("handles a message with root topic if default topic is unspecified", async () => { 118 | const handler = getHandler(false); 119 | const output = await assertFor( 120 | handler, 121 | "hello world", 122 | undefined, 123 | "Life is like riding a bicycle. To keep your balance you must keep moving." 124 | ); 125 | output.state.topics.length.should.equal(0); 126 | }); 127 | 128 | it("enters a new topic", async () => { 129 | const handler = getHandler(); 130 | 131 | const output1 = await assertFor( 132 | handler, 133 | "do basic math", 134 | undefined, 135 | "You can type a math expression" 136 | ); 137 | 138 | const output2 = await assertFor(handler, "add 2 3", output1.state, 5); 139 | output2.state.topics.length.should.equal(1); 140 | 141 | const output3 = await assertFor(handler, "add 20 30", output2.state, 50); 142 | output3.state.topics.length.should.equal(1); 143 | }); 144 | 145 | it("enters a subtopic and exits", async () => { 146 | const handler = getHandler(); 147 | 148 | const output1 = await assertFor( 149 | handler, 150 | "do basic math", 151 | undefined, 152 | "You can type a math expression" 153 | ); 154 | 155 | const output2 = await assertFor(handler, "add 2 3", output1.state, 5); 156 | output2.state.topics.length.should.equal(1); 157 | 158 | const output3 = await assertFor( 159 | handler, 160 | "do advanced math", 161 | output2.state, 162 | "You can do advanced math now." 163 | ); 164 | output3.state.topics.length.should.equal(2); 165 | 166 | const output4 = await assertFor(handler, "exp 2 8", output3.state, 256); 167 | output4.state.topics.length.should.equal(2); 168 | 169 | const output5 = await assertFor( 170 | handler, 171 | "do basic math", 172 | output4.state, 173 | "Back to basic math." 174 | ); 175 | 176 | const output6 = await assertFor( 177 | handler, 178 | "exp 2 8", 179 | output5.state, 180 | "I don't know how to handle this." 181 | ); 182 | 183 | const output7 = await assertFor(handler, "add 2 8", output6.state, 10); 184 | }); 185 | 186 | it("enters a top level topic and exits", async () => { 187 | const handler = getHandler(); 188 | 189 | const output1 = await assertFor( 190 | handler, 191 | "help", 192 | undefined, 193 | "You're entering help mode. Type anything." 194 | ); 195 | 196 | const output2 = await assertFor( 197 | handler, 198 | "syntax", 199 | output1.state, 200 | "HELP: This is just a test suite. Nothing to see here, sorry." 201 | ); 202 | output2.state.topics.length.should.equal(0); 203 | }); 204 | 205 | it("throws when state is reused", async () => { 206 | try { 207 | const handler = getHandler(); 208 | 209 | const output1 = await assertFor( 210 | handler, 211 | "do basic math", 212 | undefined, 213 | "You can type a math expression" 214 | ); 215 | 216 | const output2 = await assertFor(handler, "add 2 3", output1.state, 5); 217 | output2.state.topics.length.should.equal(1); 218 | 219 | const output3 = await assertFor(handler, "add 20 30", output1.state, 50); 220 | } catch (ex) { 221 | ex.message.should.equal( 222 | "This evaluation state was previously used. Cannot reuse." 223 | ); 224 | } 225 | }); 226 | 227 | it("doesn't throw when the reuse flag is set", async () => { 228 | const handler = getHandler(); 229 | 230 | const output1 = await assertFor( 231 | handler, 232 | "do basic math", 233 | undefined, 234 | "You can type a math expression" 235 | ); 236 | 237 | const output2 = await assertFor(handler, "add 2 3", output1.state, 5); 238 | output2.state.topics.length.should.equal(1); 239 | 240 | const output3 = await assertFor(handler, "add 20 30", output1.state, 50, { 241 | reuseState: true 242 | }); 243 | output2.state.topics.length.should.equal(1); 244 | }); 245 | 246 | it("persists stateful topics", async () => { 247 | const handler = getHandler(); 248 | 249 | const output1 = await assertFor( 250 | handler, 251 | "reset password", 252 | undefined, 253 | "Set your password." 254 | ); 255 | 256 | const output2 = await assertFor( 257 | handler, 258 | "hello", 259 | output1.state, 260 | "Repeat your password." 261 | ); 262 | output2.state.topics.length.should.equal(1); 263 | 264 | const output3 = await assertFor( 265 | handler, 266 | "world", 267 | output2.state, 268 | "Password don't match. Reenter both passwords." 269 | ); 270 | output3.state.topics.length.should.equal(1); 271 | 272 | const output4 = await assertFor( 273 | handler, 274 | "hello", 275 | output3.state, 276 | "Repeat your password." 277 | ); 278 | output4.state.topics.length.should.equal(1); 279 | 280 | const output5 = await assertFor( 281 | handler, 282 | "hello", 283 | output4.state, 284 | "Password reset complete." 285 | ); 286 | output4.state.topics.length.should.equal(1); 287 | }); 288 | }); 289 | -------------------------------------------------------------------------------- /src/test/topics.ts: -------------------------------------------------------------------------------- 1 | import { IEvalState, IHandlerResult, ITopic, TopicBase } from ".."; 2 | 3 | export interface IMessage { 4 | timestamp?: number; 5 | text: string; 6 | } 7 | 8 | export interface IUserData { 9 | username: string; 10 | session: string; 11 | } 12 | 13 | export interface IHost { 14 | getUserDirectory(username: string): string; 15 | } 16 | 17 | export type ResultType = string | number | undefined; 18 | 19 | function basicMathOperators(text: string) { 20 | const [p1, p2, p3] = text.split(" "); 21 | const [operand1, operand2] = [parseInt(p2, 10), parseInt(p3, 10)]; 22 | return p1 === "add" 23 | ? operand1 + operand2 24 | : p1 === "substract" 25 | ? operand1 - operand2 26 | : p1 === "multiply" 27 | ? operand1 * operand2 28 | : p1 === "divide" 29 | ? operand1 / operand2 30 | : undefined; 31 | } 32 | 33 | function advancedMathOperators(text: string) { 34 | const result = basicMathOperators(text); 35 | return result === undefined 36 | ? (() => { 37 | const [p1, p2, p3] = text.split(" "); 38 | const [operand1, operand2] = [parseInt(p2, 10), parseInt(p3, 10)]; 39 | return p1 === "exp" ? operand1 ** operand2 : undefined; 40 | })() 41 | : result; 42 | } 43 | 44 | export class RootTopic extends TopicBase 45 | implements ITopic { 46 | async handle( 47 | state: IEvalState, 48 | message: IMessage, 49 | userData: IUserData, 50 | host: IHost 51 | ): Promise> { 52 | if (message.text === "do basic math") { 53 | this.enterTopic(state, new MathTopic()); 54 | return { handled: true, result: "You can type a math expression" }; 55 | } else if (message.text === "help") { 56 | this.enterTopic(state, new HelpTopic()); 57 | return { 58 | handled: true, 59 | result: "You're entering help mode. Type anything." 60 | }; 61 | } else if (message.text === "reset password") { 62 | this.enterTopic(state, new PasswordResetTopic()); 63 | return { 64 | handled: true, 65 | result: "Set your password." 66 | }; 67 | } else { 68 | return { 69 | handled: true, 70 | result: 71 | "Life is like riding a bicycle. To keep your balance you must keep moving." 72 | }; 73 | } 74 | } 75 | } 76 | 77 | export class DefaultTopic 78 | extends TopicBase 79 | implements ITopic { 80 | async handle( 81 | state: IEvalState, 82 | message: IMessage, 83 | userData: IUserData, 84 | host: IHost 85 | ): Promise> { 86 | return message.text === "hello world" 87 | ? { handled: true, result: "greetings comrade!" } 88 | : { handled: false }; 89 | } 90 | 91 | isTopLevel() { 92 | return true; 93 | } 94 | } 95 | 96 | export class MathTopic extends TopicBase 97 | implements ITopic { 98 | async handle( 99 | state: IEvalState, 100 | message: IMessage, 101 | userData: IUserData, 102 | host: IHost 103 | ): Promise> { 104 | if (message.text === "do advanced math") { 105 | this.enterTopic(state, new AdvancedMathTopic()); 106 | return { 107 | handled: true, 108 | result: "You can do advanced math now." 109 | }; 110 | } else { 111 | const result = basicMathOperators(message.text); 112 | return { 113 | handled: true, 114 | result: 115 | typeof result !== "undefined" 116 | ? result 117 | : "I don't know how to handle this." 118 | }; 119 | } 120 | } 121 | 122 | isTopLevel() { 123 | return true; 124 | } 125 | } 126 | 127 | export class AdvancedMathTopic 128 | extends TopicBase 129 | implements ITopic { 130 | async handle( 131 | state: IEvalState, 132 | message: IMessage, 133 | userData: IUserData, 134 | host: IHost 135 | ): Promise> { 136 | if (message.text === "do basic math") { 137 | this.exitTopic(state); 138 | return { 139 | handled: true, 140 | result: "Back to basic math." 141 | }; 142 | } else { 143 | const result = advancedMathOperators(message.text); 144 | return { 145 | handled: true, 146 | result: 147 | typeof result !== "undefined" 148 | ? result 149 | : "I don't know how to handle this." 150 | }; 151 | } 152 | } 153 | 154 | isTopLevel() { 155 | return false; 156 | } 157 | } 158 | 159 | export class HelpTopic extends TopicBase 160 | implements ITopic { 161 | async handle( 162 | state: IEvalState, 163 | message: IMessage, 164 | userData: IUserData, 165 | host: IHost 166 | ): Promise> { 167 | this.exitTopic(state); 168 | return { 169 | handled: true, 170 | result: "HELP: This is just a test suite. Nothing to see here, sorry." 171 | }; 172 | } 173 | 174 | isTopLevel() { 175 | return true; 176 | } 177 | } 178 | 179 | export class PasswordResetTopic 180 | extends TopicBase 181 | implements ITopic { 182 | password?: string; 183 | repeatPassword?: string; 184 | 185 | async handle( 186 | state: IEvalState, 187 | message: IMessage, 188 | userData: IUserData, 189 | host: IHost 190 | ): Promise> { 191 | if (!this.password) { 192 | this.password = message.text; 193 | return { 194 | handled: true, 195 | result: "Repeat your password." 196 | }; 197 | } else { 198 | if (message.text === this.password) { 199 | this.exitTopic(state); 200 | return { 201 | handled: true, 202 | result: "Password reset complete." 203 | }; 204 | } else { 205 | this.password = undefined; 206 | return { 207 | handled: true, 208 | result: "Password don't match. Reenter both passwords." 209 | }; 210 | } 211 | } 212 | } 213 | 214 | isTopLevel() { 215 | return true; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": false, 5 | "target": "es2015", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "lib": ["es2017", "esnext.asynciterable"], 10 | "allowSyntheticDefaultImports": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "types": ["node"] 14 | }, 15 | "include": ["./src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "arrow-parens": false, 7 | "trailing-comma": false, 8 | "no-empty": false, 9 | "member-access": false, 10 | "array-type": false, 11 | "no-trailing-whitespace": false, 12 | "max-classes-per-file": false, 13 | "no-bitwise": false, 14 | "no-var-requires": false 15 | }, 16 | "rulesDirectory": [] 17 | } 18 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/mocha@^5.2.0": 6 | version "5.2.0" 7 | resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.0.tgz#b3c8e69f038835db1a7fdc0b3d879fc50506e29e" 8 | 9 | "@types/node@^10.0.6": 10 | version "10.0.6" 11 | resolved "https://registry.yarnpkg.com/@types/node/-/node-10.0.6.tgz#c0bce8e539bf34c1b850c13ff46bead2fecc2e58" 12 | 13 | "@types/should@^13.0.0": 14 | version "13.0.0" 15 | resolved "https://registry.yarnpkg.com/@types/should/-/should-13.0.0.tgz#96c00117f1896177848fdecfa336313c230c879e" 16 | dependencies: 17 | should "*" 18 | 19 | balanced-match@^1.0.0: 20 | version "1.0.0" 21 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 22 | 23 | brace-expansion@^1.1.7: 24 | version "1.1.11" 25 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 26 | dependencies: 27 | balanced-match "^1.0.0" 28 | concat-map "0.0.1" 29 | 30 | browser-stdout@1.3.1: 31 | version "1.3.1" 32 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" 33 | 34 | commander@2.11.0: 35 | version "2.11.0" 36 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" 37 | 38 | concat-map@0.0.1: 39 | version "0.0.1" 40 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 41 | 42 | debug@3.1.0: 43 | version "3.1.0" 44 | resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" 45 | dependencies: 46 | ms "2.0.0" 47 | 48 | diff@3.5.0: 49 | version "3.5.0" 50 | resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" 51 | 52 | escape-string-regexp@1.0.5: 53 | version "1.0.5" 54 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 55 | 56 | fs.realpath@^1.0.0: 57 | version "1.0.0" 58 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 59 | 60 | glob@7.1.2: 61 | version "7.1.2" 62 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" 63 | dependencies: 64 | fs.realpath "^1.0.0" 65 | inflight "^1.0.4" 66 | inherits "2" 67 | minimatch "^3.0.4" 68 | once "^1.3.0" 69 | path-is-absolute "^1.0.0" 70 | 71 | growl@1.10.3: 72 | version "1.10.3" 73 | resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.3.tgz#1926ba90cf3edfe2adb4927f5880bc22c66c790f" 74 | 75 | has-flag@^2.0.0: 76 | version "2.0.0" 77 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" 78 | 79 | he@1.1.1: 80 | version "1.1.1" 81 | resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" 82 | 83 | inflight@^1.0.4: 84 | version "1.0.6" 85 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 86 | dependencies: 87 | once "^1.3.0" 88 | wrappy "1" 89 | 90 | inherits@2: 91 | version "2.0.3" 92 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 93 | 94 | minimatch@3.0.4, minimatch@^3.0.4: 95 | version "3.0.4" 96 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 97 | dependencies: 98 | brace-expansion "^1.1.7" 99 | 100 | minimist@0.0.8: 101 | version "0.0.8" 102 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 103 | 104 | mkdirp@0.5.1: 105 | version "0.5.1" 106 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" 107 | dependencies: 108 | minimist "0.0.8" 109 | 110 | mocha@5.1.1: 111 | version "5.1.1" 112 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.1.1.tgz#b774c75609dac05eb48f4d9ba1d827b97fde8a7b" 113 | dependencies: 114 | browser-stdout "1.3.1" 115 | commander "2.11.0" 116 | debug "3.1.0" 117 | diff "3.5.0" 118 | escape-string-regexp "1.0.5" 119 | glob "7.1.2" 120 | growl "1.10.3" 121 | he "1.1.1" 122 | minimatch "3.0.4" 123 | mkdirp "0.5.1" 124 | supports-color "4.4.0" 125 | 126 | ms@2.0.0: 127 | version "2.0.0" 128 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 129 | 130 | once@^1.3.0: 131 | version "1.4.0" 132 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 133 | dependencies: 134 | wrappy "1" 135 | 136 | path-is-absolute@^1.0.0: 137 | version "1.0.1" 138 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 139 | 140 | should-equal@^2.0.0: 141 | version "2.0.0" 142 | resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3" 143 | dependencies: 144 | should-type "^1.4.0" 145 | 146 | should-format@^3.0.3: 147 | version "3.0.3" 148 | resolved "https://registry.yarnpkg.com/should-format/-/should-format-3.0.3.tgz#9bfc8f74fa39205c53d38c34d717303e277124f1" 149 | dependencies: 150 | should-type "^1.3.0" 151 | should-type-adaptors "^1.0.1" 152 | 153 | should-type-adaptors@^1.0.1: 154 | version "1.1.0" 155 | resolved "https://registry.yarnpkg.com/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz#401e7f33b5533033944d5cd8bf2b65027792e27a" 156 | dependencies: 157 | should-type "^1.3.0" 158 | should-util "^1.0.0" 159 | 160 | should-type@^1.3.0, should-type@^1.4.0: 161 | version "1.4.0" 162 | resolved "https://registry.yarnpkg.com/should-type/-/should-type-1.4.0.tgz#0756d8ce846dfd09843a6947719dfa0d4cff5cf3" 163 | 164 | should-util@^1.0.0: 165 | version "1.0.0" 166 | resolved "https://registry.yarnpkg.com/should-util/-/should-util-1.0.0.tgz#c98cda374aa6b190df8ba87c9889c2b4db620063" 167 | 168 | should@*, should@^13.2.1: 169 | version "13.2.1" 170 | resolved "https://registry.yarnpkg.com/should/-/should-13.2.1.tgz#84e6ebfbb145c79e0ae42307b25b3f62dcaf574e" 171 | dependencies: 172 | should-equal "^2.0.0" 173 | should-format "^3.0.3" 174 | should-type "^1.4.0" 175 | should-type-adaptors "^1.0.1" 176 | should-util "^1.0.0" 177 | 178 | supports-color@4.4.0: 179 | version "4.4.0" 180 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" 181 | dependencies: 182 | has-flag "^2.0.0" 183 | 184 | wrappy@1: 185 | version "1.0.2" 186 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 187 | --------------------------------------------------------------------------------