diff --git a/readme.md b/readme.md index a6487a0..1e51009 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,24 @@ Convo separates your working application's code completely from all DialogFlow d `npm i @tmtek/convo` +### Topics: + +* ConvoApp +* DialogFlow Fulfillments + * About Class Mappings +* Responding with Convo + * Responding to an Intent + * Composing Responses + * Async Operations +* Development And Testing + * Simulating Conversations +* API Reference +* Advanced Topics + * Using Storage + * Handling Lists + + + ## Convo App Convo applications require you to encapsulate your DialogFlow intent callbacks in a class wrapper that facilitates their execution outside of the DialogFlow scaffolding: @@ -34,7 +52,7 @@ new MyApplication() The advantage here is that I can run and test my application by simply executing this application with Node. -### DialogFlow Fulfillment +## DialogFlow Fulfillments The application shown above can be published into a DialogFlow fulfillment by simply requiring the ConvoApp subclass you created in the fulfillment, and then binding it to the DialogFlow app as shown: @@ -55,6 +73,53 @@ exports.dialogflowFirebaseFulfillment = functions.https.onRequest( ); ``` +#### About Class Mappings + +Convo keeps all DialogFlow related scaffolding and dependencies out of your dev and test enviornment. One of the things it needs to keep out, is the classes DialogFlow uses to wrap responses. Convo offers methods you can use build rich responses, but they are mapped just in time to their DialogFlow counterparts with the help of the method `ConvoApp.setClassMappings()`: + +```javascript +const { + dialogflow, + SignIn, + SimpleResponse, + BasicCard, + List, + Button, + Image, + NewSurface +} = require('actions-on-google'); +const functions = require('firebase-functions'); +const {MyApplication} = require('myapplication'); + +exports.dialogflowFirebaseFulfillment = functions.https.onRequest( + new MyApplication() + .setClassMappings({ + SignIn: obj => new SignIn(obj), + SimpleResponse: obj => new SimpleResponse(obj), + NewSurface: obj => new NewSurface(obj), + BasicCard: obj => new BasicCard(obj), + List: obj => new List(obj), + Button: obj => new Button(obj), + Image: obj => new Image(obj) + }) + .bind(dialogflow({debug: true})) +); + +``` +In the above fulfillment example, Your application is mapping all of the rich response classes just before binding to the DialogFlow application. + +**You only need to bind the classes you are actually using, and SimpleResponse must always be bound.** + +These mappings are expressed in your application when you utilize the methods fir Rich responses like so: + +```javascript +Convo.ask(Convo.SignIn()); +Convo.ask(Convo.BasicCard{title:"My card"}); +//etc + +``` + + ## Responding with Convo The core of the Convo library is the `Convo` class used to compose responses to the user's intents. Convo wraps DialogFlow's `conv` object, simulates all of it's capabilities outside of the DialogFlow scaffolding, and also provides simpler methods to compose complex responses. @@ -94,6 +159,39 @@ this.registerIntent('welcome', (convo, params, option, debug) => { A response must ALWAYS return the result of either `Convo.ask(convo)` or `Convo.close(convo)`, but you are free to decorate your convo instance in whatever way you need to. +### Composing responses: + +Responses to the end user are composed using 3 main methods. All of the following methods return a self reference to facilitate chaining for ease of use: + +#### convo.speak(message, alsoWrite=true) + +This method produces a spoken response to the end user, and by default will also write the response if a screen is available on the device your app is running on. If `alsoWrite` is set to false, the written response will not be produced. + +You may call `.speak()` as many times as you need to to compose your response. Convo will compile all of your calls together into one block of text when it submits it to DialogFlow. + +#### convo.write(message) + +This method will write text on the screen if one is available on the device your app is running on, otherwise it is ignored. This text is never spoken. + +#### convo.present(media, requiredCapabilities, send) + +This method allows you to display rich responses to the end user, as well as other special user interactions facilitated by DialogFlow: + +##### Supported Media Types: + +* Convo.SignIn({}) +* Convo.SimpleResponse({}) +* Convo.NewSurface({}) +* Convo.BasicCard({}) +* Convo.List({}) +* Convo.Button({}) +* Convo.Image({}) + +##### Sending Rich Responses + +If your application is runnign on a device that does not support the capabilities required to render the media, you can use `send` to request that the media be pushed to another device that can render it if the user has another device that supports it. + + ### Async Operations for a Convo Response: Convo instances have a `.promise()` method that allow you to perform async work and apply that result to the existing convo object: @@ -158,7 +256,64 @@ new MyApplication() ``` We use`then()` of the resulting promises returned from `intent()` to simulate the multiple conversation steps. Notice how for each intent call we create a new instance of Convo derived from the previous: `new Convo(convo)`. This allows us to create a new response for each intent, but still carry over the context and storage data to simulate how things work in DialogFlow with a standard `conv` object. -### Using Storage + + +## API Reference + +### ConvoApp + +* ConvoApp.setClassMappings(mappings); +* onRegisterIntents(); +* registerIntent(intent, intentHandler); +* intent(convo, intent, params, option, debugOptions); +* bind(dialogflowapp); + +### Convo + +* new Convo(convo) +* new Convo(conv) +* Convo.ask(convo, debugOptions) +* Convo.close(convo, debugOptions) +* write(text) +* speak(text, alsoWrite = true) +* present(media, capabilities, send) +* promise(convo => {}) +* clear() +* setAccessToken(token) +* setConext(contextName, lifespan, value) +* getContext(contextName) + +#### Convo Storage +* getStorage() +* setStorage(data) +* setToStorage(name, data) +* getFromStorage(name) +* isInStorage(name, predicate) + +#### Convo Lists +* setList(type, list, paging = { start: 0, count: -1 }) +* forListPage(({convo, page,list}) => {}) +* updateListPaging(paging = { start: 0, count: -1 }) +* nextListPage(count = -1) +* prevListPage(count = -1) + +#### Convo Rich Responses +* Convo.SimpleResponse() +* Convo.BasicCard() +* Convo.List() +* Convo.Image() +* Convo.Button() +* Convo.NewSurface() +* Convo.SignIn() + +#### ConvoStorage +* new ConvoStorage(filename) +* .load(convo => {}) + + +# Advanced Topics + +## Using Storage Convo allows you to simulate DialogFlow's storage capabilities. Storage lets you store arbitrary data for the user that is accessible across sessions of usage. Convo offers methods to simply interaction with storage, but to also simulate it in the dev/test environment. @@ -191,43 +346,79 @@ new ConvoStorage("storage.json").load(storageConvo => { The ConvoStorage class allows you to specify a json file to load your data from. Then you call `load(convo=> {})` to get a Convo object generated for that storage data. If your application uses `setToStorage()` thereafter, the data will be automatically saved to that json file. +## Handling Lists: +Lists are challenging to manage in a conversational application. Long lists cannot be presented to a user in their entireity because you risk overwhelming the user with too much information and too many options. -## API Reference +A great conversational list experience allows the user to step through a list in easy to digest pages, and select items out of those pages. The application must persist the list and the cursor state through context so that the user can interact with the list n a series of requests and responses. -### ConvoApp +Convo offers a toolkit to simplify presenting lists to the end user, offering features such as context-based persistence, list paging, and list item selection. -* ConvoApp.setClassMappings(mappings); -* onRegisterIntents(); -* registerIntent(intent, intentHandler); -* intent(convo, intent, params, option, debugOptions); -* bind(dialogflowapp); +When managing lists with Convo there are a few assumptions the framework makes in the spirit of user experience and usability: -### Convo +* Only one list is being presented to the user at a time. +* List items will be presented in digestible pages to avoid overwhelming the user with information and options. +* User will be able to make selections from any page presented to them, and that selection options will always be in relation to the current page. -* Convo.ask(convo, debugOptions) -* Convo.close(convo, debugOptions) -* write(text) -* speak(text, alsoWrite = true) -* present(media, capabilities, send) -* promise(convo => {}) -* clear() -* setAccessToken(token) -* setConext(contextName, lifespan, value) -* getContext(contextName) -* getStorage() -* setStorage(data) -* setToStorage(name, data) -* getFromStorage(name) -* isInStorage(name, predicate) +```javascript +let list = [ + 'list item 0', + 'list item 1', + 'list item 2', + 'list item 3', + 'list item 4' +]; -#### Convo Rich Responses -* Convo.SimpleResponse() -* Convo.BasicCard() -* Convo.List() -* Convo.Image() -* Convo.Button() -* Convo.NewSurface() -* Convo.SignIn() +Convo.ask(new Convo() + .speak("Here's your list:") + .setList(list, { start: 0, count: 3 }) + .forListPage(({ convo, page }) => { + page.forEach(item => { + convo.speak(item); + }); + }); +); + +``` + +### setList() + +`convo.setList()` can be called at any time to have convo start managing a list for presentation. The full signature of the method is: + +`setList(items, paging = { start:0, count:5 }, listType = 'defaultlist')` + +**items**: An array of objects that you want to start managing as the list to be presented to the user. + +**paging**: set the paging rules by default for this list. The paging object has a `start` value that specifies what index to start the paging at. `count` is the amount of items in each page. + +**listType**: an optional value you can set to distinguish different types of lists your application might handle. This value is used when rendering pages of a list using `forListPage()`. + +### forListPage() + +`convo.forListPage()` can be used at any time to render the state of the list into a Convo response. You must supply a function to this method that receives a listState object, and allows you to handle the result like so: + +```javascript + +.forListPage(({ convo, page, list, type}) => { + convo.speak(`Rendering page from list of type ${type}:`); + page.forEach(item => { + convo.speak(`${item},`); + }); + convo.speak(` and ${list.length - page.length} other items.`); +}); + +``` + +### List control methods: + +There are other methods you can use to control the state of the list: + +* `updateListPaging(paging = { start: 0, count: -1 })` : allows you to specifically reset the start index and page count for the list. -1 for count signifies all items. +* `nextListPage(count = -1)`: Increments the current list page based on it's default paging values. Passing a number as the first argument will change the page count. +* `prevListPage(count = -1)` : Decrements the current list page based on it's default paging values. Passing a number as the first argument will change the page count. +* `clearList()`: Removes the current list from the conversational context. +* `hasList(type = 'defaultlist')`: returns true if a list is being managed by this Convo instance. you may optionally specify the list type. + +### Selecting Items from a List: diff --git a/src/convo.js b/src/convo.js index dfc1800..33d81f9 100644 --- a/src/convo.js +++ b/src/convo.js @@ -232,7 +232,7 @@ class Convo { setToStorage(name, value) { if (this.conv && this.conv.user && this.conv.user.storage) { this.conv.user.storage[name] = value; - if(this._onStorageUpdated) {this._onStorageUpdated(this.conv.user.storage)}; + if (this._onStorageUpdated) {this._onStorageUpdated(this.conv.user.storage);} } return this; } @@ -257,6 +257,148 @@ class Convo { (!predicate || predicate(this.conv.user.storage[name])); } + setList(type, list, paging = { start: 0, count: -1 }){ + this.setContext('list', 1, { + type, + list, + paging, + selectedIndex: -1 + }); + return this; + } + + clearList() { + this.setContext('list', 0, null); + return this; + } + + hasList() { + return this.getContext('list') && this.getContext('list').list; + } + + updateListPaging(paging = { start: 0, count: -1 }){ + if (this.hasList()){ + this.getContext('list').paging = paging; + } + return this; + } + + nextListPage(count = -1){ + let listContext = this.getContext('list'); + let newCount = count === -1 ? listContext.paging.count : count; + let newIndex = listContext.paging.start + listContext.paging.count; + if (newIndex >= listContext.list.length || newIndex < 0) { + newIndex = 0; + } + this.updateListPaging({ + start: newIndex, + count: newCount + }); + return this; + } + + prevListPage(count = -1){ + let listContext = this.getContext('list'); + let newCount = count === -1 ? listContext.paging.count : count; + let newIndex = 0; + if (listContext.paging.start <= 0) { + newIndex = listContext.list.length - newCount; + } + else { + newIndex = listContext.paging.start - listContext.paging.count; + if (newIndex < 0) { + newIndex = 0; + } + } + this.updateListPaging({ + start: newIndex, + count: newCount + }); + return this; + } + + forListPage(func) { + if (this.hasList()){ + let listContext = this.getContext('list'); + let paging = listContext.paging; + let count = paging.count < 0 ? listContext.list.length : paging.count; + let page = listContext.list.slice(paging.start, Math.min(paging.start + count, listContext.list.length)); + func({ convo: this, page, list: listContext.list, type: listContext.type }); + } + else { + func({ convo: this }); + } + return this; + } + + selectFromList(index = 0){ + this.getContext('list').selectedIndex = index; + return this; + } + + selectFromListByQuery(query, findTextFunc = (item) => item){ + return this.forList(({ list }) => { + let testedItems = list.map(item => { + let test = new RegExp(query.toLowerCase()).test(findTextFunc(item).toLowerCase()); + return test; + }); + for (let i = 0; i< testedItems.length; i++) { + if (testedItems[i]) { + this.selectFromList(i); + break; + } + } + }); + } + + forList(func){ + if (this.hasList()){ + let listContext = this.getContext('list'); + func({ convo: this, list: listContext.list, type: listContext.type }); + } + else { + func({ convo: this }); + } + return this; + } + + clearListSelected() { + this.getContext('list').selectedIndex = -1; + return this; + } + + selectFromListPage(index = 0){ + let listContext = this.getContext('list'); + listContext.selectedIndex = listContext.paging.start + index; + return this; + } + + hasListSelected() { + let listContext = this.getContext('list'); + return listContext && listContext.list && listContext.selectedIndex > -1; + } + + forListSelected(func) { + let listContext = this.getContext('list'); + let item = listContext.list[listContext.selectedIndex]; + func({ convo: this, item, type: listContext.type }); + return this; + } + + selectNextFromList(){ + let listContext = this.getContext('list'); + listContext.selectedIndex = listContext.selectedIndex + 1 >= listContext.list.length ? + 0 : listContext.selectedIndex + 1; + return this; + } + + selectPrevFromList(){ + let listContext = this.getContext('list'); + listContext.selectedIndex =listContext.selectedIndex -1 < 0 ? + listContext.list.length -1 : listContext.selectedIndex -1; + return this; + } + } function isPromise(obj) { diff --git a/test.js b/test.js index 0ea3175..64361cd 100644 --- a/test.js +++ b/test.js @@ -1,5 +1,111 @@ const { Convo, ConvoApp } = require(`./index`); + +class MyApplication extends ConvoApp { + + onRegisterIntents() { + + let responseToList = listData => { + if (!listData.list) { + return listData.convo.speak('The list is empty.'); + } + listData.convo.speak(listData.page.map(item => item.display_name).join(',')); + if (listData.page.length < listData.list.length){ + listData.convo.speak(`and ${listData.list.length - listData.page.length} others.`); + } + }; + + let responseToItem = ({ convo, item }) => { + convo.speak(`Selected item: ${item.display_name}`); + }; + + this.registerIntent('welcome', (convo, params, option, debug) => { + let list = [ + { display_name: 'KingGothalion' }, + { display_name: 'Ninja' }, + { display_name: 'professorbroman' }, + { display_name: 'tmtek' } + ]; + return Convo.ask( + convo.speak("Here's your list:") + .setList('general', list, { start: 0, count: 3 }) + .forListPage(responseToList), + debug + ); + }); + + this.registerIntent('list_clear', (convo, params, option, debug) => Convo.ask( + convo + .clearList() + .speak('Cleared the list.'), + debug + )); + + this.registerIntent('list_next', (convo, params, option, debug) => Convo.ask( + convo + .nextListPage(params ? params.count : -1) + .forListPage(responseToList), + debug + )); + + this.registerIntent('list_prev', (convo, params, option, debug) => Convo.ask( + convo + .prevListPage(params ? params.count : -1) + .forListPage(responseToList), + debug + )); + + this.registerIntent('list_all', (convo, params, option, debug) => Convo.ask(convo + .updateListPaging({ start: 0, count: -1 }) + .forListPage(responseToList),debug)); + + this.registerIntent('list_select', (convo, { index }, option, debug) => Convo.ask(convo + .selectFromListPage(index) + .forListSelected(responseToItem), debug + )); + + this.registerIntent('list_find', (convo, { query }, option, debug) => Convo.ask(convo + .selectFromListByQuery(query, (item) => item.display_name) + .forListSelected(responseToItem), debug + )); + + this.registerIntent('list_select_next', (convo, params, option, debug) => Convo.ask( + convo + .selectNextFromList() + .forListSelected(responseToItem), + debug) + ); + + this.registerIntent('list_select_prev', (convo, params, option, debug) => Convo.ask( + convo + .selectPrevFromList() + .forListSelected(responseToItem), + debug)); + } +} + +new MyApplication() + .intent(new Convo(), 'welcome', null, null, { log: true }) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_find', { query: 'bro' }, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_select_next', null, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_select_next', null, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_select_next', null, null, { log: true })); + +/* + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_next', null, null, { log: true })) + .then(({ app, convo }) => app.intent(new Convo(convo), 'list_select', { index: 0 }, null, { log: true })) + .then(({ app, convo }) => app.intent(new Convo(convo), 'list_select_next', null, null, { log: true })); + */ + +/* + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_all', null, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_next', { count: 2 }, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_next', { count: 2 }, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_clear', null, null, { log: true })) + .then(({ app,convo }) => app.intent(new Convo(convo), 'list_all', null, null, { log: true })); + */ + +/* class MyApplication extends ConvoApp { onRegisterIntents() { @@ -19,18 +125,31 @@ class MyApplication extends ConvoApp { )); } } +*/ - +/* new MyApplication() //User starts your application, and you respond: - .intent(new Convo(), 'welcome', null, null, { log: true }) +.intent(new Convo(), 'welcome', null, null, { log: true }) //User asks for your favorite color: - .then(({ app, convo }) => - app.intent(new Convo(convo), 'my_fav_color', null, null, { log: true }) - ) +.then(({ app, convo }) => + app.intent(new Convo(convo), 'my_fav_color', null, null, { log: true }) +) //User responds with "blue": - .then(({ app, convo }) => - app.intent(new Convo(convo), 'your_fav_color', { color: 'green' }, null, { log: true }) - ); +.then(({ app, convo }) => + app.intent(new Convo(convo), 'your_fav_color', { color: 'green' }, null, { log: true }) +); +*/ + +/* +let convo = new Convo() + .onStorageUpdated(storage => {console.log(storage)}) //fires after setToStorage call. + .setStorage({}) //populates storage with data (doesn't trigger onStorageUpdated). + .setToStorage("list", ["one","two","three"]); //Add value to storage. + +convo.isInStorage("list", list => list.length > 0); //returns true +convo.getFromStorage("list"); //returns ["one","two","three"] + +*/