Redux bot sample (#21)
This commit is contained in:
Родитель
2ed63a0cb4
Коммит
ca22ab35af
|
@ -0,0 +1,59 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2018 Matt Shim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,103 @@
|
|||
# Redux-Bot
|
||||
![redux-abs-logo][redux-abs-logo]
|
||||
|
||||
BotBuilder v3 Node.js bot with Redux state management
|
||||
|
||||
# Overview
|
||||
|
||||
This sample is to showcase the flexibility of the Azure Bot Service, by demonstrating that you can author rich conversational experiences using whatever technologies/libraries you'd like. This sample bot uses [Redux](https://redux.js.org/), a popular JavaScript framework for application state management, and [Redux-Saga](https://redux-saga.js.org/) to recreate an existing [city search bot sample](https://github.com/Microsoft/BotBuilder-Samples/tree/master/Node/core-CustomState).
|
||||
|
||||
![city-search-bot][city-search-bot]
|
||||
|
||||
# To run this sample
|
||||
|
||||
Clone BotFramework-Samples repo, and CD into this folder (Blog-Redux-Bot). From your CLI, install the node_module dependencies:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
## Emulator
|
||||
|
||||
This bot runs by default on **localhost:3978**, which you can run directly using the [Bot Framework Emulator](https://github.com/Microsoft/BotFramework-Emulator).
|
||||
|
||||
```
|
||||
node app.js
|
||||
```
|
||||
|
||||
## UI Redux Store Render
|
||||
|
||||
![ui-state][ui-state]
|
||||
|
||||
Included in **public/index.html** is a simple web app using a custom web chat instance which communicates with the Azure Bot Service over the DirectLine channel.
|
||||
|
||||
[Official ngrok page](https://ngrok.com/)
|
||||
|
||||
![ngrok][ngrok]
|
||||
|
||||
On Azure, in bot channels registration, copy and paste the ngrok port forwarding address to the messaging endpoint in settings--> configuration as shown. **Ensure that this address ends in /api/messages.**
|
||||
|
||||
![channel-reg][channel-registration]
|
||||
|
||||
In your CLI, navigate to the redux bot root folder and run:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
This will run the custom web application on **localhost:3000**.
|
||||
Lastly, provision the [DirectLine Secret](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-channel-connect-directline) in the URL header of your browser. This will allow the Azure Bot Service to communicate with your local hosted custom web chat instance.
|
||||
|
||||
![dl-secret][dl-secret]
|
||||
|
||||
Now, when you interact with the bot, any state changes to the Redux Store will be rendered on the web page.
|
||||
|
||||
|
||||
# To-do
|
||||
|
||||
* [ ] More tests for Redux
|
||||
* [X] Cleaner code inside `app.js`
|
||||
* [ ] Investigate possibility to use ChatConnector directly
|
||||
|
||||
# Hiccups
|
||||
|
||||
## Turning `conversationUpdate` event into an action
|
||||
|
||||
It would be great if a new member joined, we sent a welcome message right away.
|
||||
|
||||
* `bot.on('conversationUpdate')` event handler does not associate with `session` object
|
||||
* `session` object is required to create a Redux store
|
||||
|
||||
Looking at other sample code on the same scenario, instead of sending the greeting thru `session.send`, it must be sent thru `bot.send` with an addressed message. Thus, it further proves that `conversationUpdate` is not associated with any `session` object.
|
||||
|
||||
Because our Redux store design requires `session` object, thus, `conversationUpdate` cannot be turn into an action.
|
||||
|
||||
## Multi-turn dialog
|
||||
|
||||
It is intuitive to write code in `redux-saga` like this:
|
||||
|
||||
```js
|
||||
takeEvery(RECEIVE_MESSAGE, function* (action) {
|
||||
yield put(promptText('What is your name?'));
|
||||
|
||||
action = yield take(RECEIVE_RESULT);
|
||||
|
||||
yield put(sendMessage(`Hello, ${ action.payload.response }`);
|
||||
});
|
||||
```
|
||||
|
||||
But this would require a dialog to resume in the middle of a saga (resume at the `yield take` line). Due to the nature of serverless functions, it is difficult to implement a saga that works this way.
|
||||
|
||||
# References
|
||||
* [Redux](https://github.com/reactjs/redux)
|
||||
* [Redux-Saga](https://github.com/redux-saga/redux-saga)
|
||||
* [BotBuilder SDK](https://github.com/Microsoft/BotBuilder)
|
||||
* [ngrok](https://ngrok.com/)
|
||||
|
||||
[redux-abs-logo]: ../../images/redux-abs-logo.png
|
||||
[ngrok]: ../../images/ngrok-forward.png
|
||||
[city-search-bot]: ../../images/redux-bot-02.png
|
||||
[channel-registration]: ../../images/bot-channels-ngrok.png
|
||||
[bot-webchat]: ../../images/redux-bot-01.png
|
||||
[ui-state]: ../../images/redux-store-02.png
|
||||
[dl-secret]: ../../images/direct-line-secret-url.png
|
|
@ -0,0 +1,43 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const builder = require('botbuilder');
|
||||
const restify = require('restify');
|
||||
|
||||
const inMemoryStorage = new builder.MemoryBotStorage();
|
||||
const connector = new builder.ChatConnector({
|
||||
appId: process.env.MICROSOFT_APP_ID,
|
||||
appPassword: process.env.MICROSOFT_APP_PASSWORD
|
||||
})
|
||||
|
||||
const bot = new builder.UniversalBot(connector).set('storage', inMemoryStorage); // Register in memory storage
|
||||
|
||||
// Redux
|
||||
const loadStore = require('./redux/loadStore');
|
||||
const DialogActions = require('./redux/dialogActions');
|
||||
|
||||
// Create server
|
||||
const server = restify.createServer();
|
||||
|
||||
server.listen(process.env.port || process.env.PORT || 3978, function () {
|
||||
console.log(`${ server.name } listening to ${ server.url }`);
|
||||
});
|
||||
|
||||
server.post('/api/messages', connector.listen());
|
||||
|
||||
//=========================================================
|
||||
// Bot Recognizers
|
||||
//=========================================================
|
||||
|
||||
// const LuisAppID = process.env.LUIS_APP_ID; // Your-LUIS-App-ID
|
||||
// const LuisKey = process.env.LUIS_APP_KEY; // Your-LUIS-Key
|
||||
// const LuisModel = `https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/${ LuisAppID }?subscription-key=${ LuisKey }`;
|
||||
// const recognizer = new builder.LuisRecognizer(LuisModel);
|
||||
|
||||
bot.dialog('/', new builder.SimpleDialog((session, result) => {
|
||||
const store = loadStore(session);
|
||||
const { attachments, text } = session.message || {};
|
||||
|
||||
if (attachments || result || text) {
|
||||
store.dispatch(DialogActions.receiveMessage(text, attachments, result));
|
||||
}
|
||||
}));
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "redux-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "npx concurrently --names \"bot,web\" --kill-others \"npm run start:bot\" \"npm run start:web\"",
|
||||
"start:bot": "npx node-dev .",
|
||||
"start:web": "npx serve -p 3000 public",
|
||||
"test": "jest"
|
||||
},
|
||||
"private": true,
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"botbuilder": "^3.14.0",
|
||||
"dotenv": "^5.0.1",
|
||||
"redux": "^3.7.2",
|
||||
"redux-saga": "^0.16.0",
|
||||
"restify": "^6.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^22.4.3"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Bot Chat</title>
|
||||
|
||||
<link href="https://unpkg.com/botframework-webchat/botchat.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#container > div.state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
#container > div.state > pre {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#container > #botChat {
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, .2);
|
||||
display: flex;
|
||||
flex-basis: 460px;
|
||||
flex-shrink: 0;
|
||||
width: 460px;
|
||||
}
|
||||
|
||||
#container > #botChat .wc-app {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#container > #botChat .wc-chatview-panel {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="botChat"></div>
|
||||
<div class="state">
|
||||
<h1>Redux server store</h1>
|
||||
<pre id="store">Nothing to see here</pre>
|
||||
<ul id="actions"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://unpkg.com/botframework-webchat/botchat.js"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
window['botchatDebug'] = params.get('debug') && params.get('debug') === 'true';
|
||||
|
||||
const botConnection = new BotChat.DirectLine({
|
||||
domain: params.get('domain'),
|
||||
secret: params.get('s'),
|
||||
token: params.get('t'),
|
||||
webSocket: params.get('webSocket') && params.get('webSocket') === 'true' // defaults to true
|
||||
});
|
||||
|
||||
BotChat.App({
|
||||
bot: {
|
||||
id: params.get('botid') || 'botid',
|
||||
name: params.get('botname') || 'botname'
|
||||
},
|
||||
botConnection: botConnection,
|
||||
showUploadButton: true,
|
||||
user: {
|
||||
id: params.get('userid') || 'userid',
|
||||
name: params.get('username') || 'username'
|
||||
}
|
||||
}, document.getElementById('botChat'));
|
||||
|
||||
botConnection.activity$
|
||||
.filter(activity => activity.type === 'event' && activity.name === 'store')
|
||||
.subscribe(activity => {
|
||||
document.querySelector('pre#store').innerText = JSON.stringify(activity.value, null, 2);
|
||||
});
|
||||
|
||||
botConnection.activity$
|
||||
.filter(activity => activity.type === 'event' && activity.name === 'action')
|
||||
.subscribe(activity => {
|
||||
const li = document.createElement('li');
|
||||
const pre = document.createElement('pre');
|
||||
|
||||
pre.innerText = JSON.stringify(activity.value).substr(0, 150);
|
||||
li.appendChild(pre);
|
||||
document.querySelector('ul#actions').appendChild(li);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
document.querySelector('.wc-shellinput').focus();
|
||||
}, 300);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,25 @@
|
|||
const RESET = 'RESET';
|
||||
const SET_CITY = 'SET_CITY';
|
||||
const SET_USERNAME = 'SET_USERNAME';
|
||||
|
||||
function reset() {
|
||||
return { type: RESET };
|
||||
}
|
||||
|
||||
function setCity(city) {
|
||||
return { type: SET_CITY, payload: { city } };
|
||||
}
|
||||
|
||||
function setUsername(username) {
|
||||
return { type: SET_USERNAME, payload: { username } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RESET,
|
||||
SET_CITY,
|
||||
SET_USERNAME,
|
||||
|
||||
reset,
|
||||
setCity,
|
||||
setUsername
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
const PROMPT_TEXT = 'DIALOG/PROMPT_TEXT';
|
||||
const RECEIVE_MESSAGE = 'DIALOG/RECEIVE_MESSAGE';
|
||||
const SEND_EVENT = 'DIALOG/SEND_EVENT';
|
||||
const SEND_MESSAGE = 'DIALOG/SEND_MESSAGE';
|
||||
const END_CONVERSATION = 'DIALOG/END_CONVERSATION';
|
||||
|
||||
function promptText(text) {
|
||||
return { type: PROMPT_TEXT, payload: { text } };
|
||||
}
|
||||
|
||||
function receiveMessage(text, attachments, result) {
|
||||
return {
|
||||
type: RECEIVE_MESSAGE,
|
||||
payload: { attachments, result, text }
|
||||
};
|
||||
}
|
||||
|
||||
function sendEvent(name, value) {
|
||||
return {
|
||||
type: SEND_EVENT,
|
||||
payload: { name, value }
|
||||
};
|
||||
}
|
||||
|
||||
function sendMessage(text, attachments) {
|
||||
return {
|
||||
type: SEND_MESSAGE,
|
||||
payload: { attachments, text }
|
||||
};
|
||||
}
|
||||
|
||||
function endConversation(text, attachments, result){
|
||||
return {
|
||||
type: END_CONVERSATION,
|
||||
payload: { attachments, result, text }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROMPT_TEXT,
|
||||
RECEIVE_MESSAGE,
|
||||
SEND_EVENT,
|
||||
SEND_MESSAGE,
|
||||
END_CONVERSATION,
|
||||
|
||||
promptText,
|
||||
receiveMessage,
|
||||
sendEvent,
|
||||
sendMessage,
|
||||
endConversation
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
const { applyMiddleware, createStore } = require('redux');
|
||||
const { default: createSagaMiddleware } = require('redux-saga');
|
||||
|
||||
const createDefaultSaga = require('./sagas/default');
|
||||
const createDialogSagas = require('./sagas/dialog');
|
||||
const reducer = require('./reducer');
|
||||
|
||||
module.exports = function loadStore(session) {
|
||||
const saga = createSagaMiddleware();
|
||||
const store = createStore(
|
||||
reducer,
|
||||
|
||||
// Restore the store from conversationData
|
||||
session.conversationData,
|
||||
|
||||
applyMiddleware(
|
||||
saga,
|
||||
store => next => action => {
|
||||
// Send action to web page for debugging
|
||||
session.send({
|
||||
type: 'event',
|
||||
name: 'action',
|
||||
value: action
|
||||
});
|
||||
|
||||
return next(action);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
store.subscribe(() => {
|
||||
// Save the store to conversationData
|
||||
session.conversationData = store.getState();
|
||||
session.save();
|
||||
|
||||
// Send store state to web page for debugging
|
||||
session.send({
|
||||
type: 'event',
|
||||
name: 'store',
|
||||
value: store.getState()
|
||||
});
|
||||
});
|
||||
|
||||
saga.run(function* () {
|
||||
yield* createDialogSagas(session);
|
||||
yield* createDefaultSaga(session);
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
const { RESET, SET_CITY, SET_USERNAME } = require('./conversationActions');
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
city: null,
|
||||
username: null
|
||||
};
|
||||
|
||||
function conversationReducer(state = DEFAULT_STATE, action) {
|
||||
switch (action.type) {
|
||||
case RESET:
|
||||
state = DEFAULT_STATE;
|
||||
break;
|
||||
|
||||
case SET_CITY:
|
||||
state = { ...state, city: action.payload.city };
|
||||
break;
|
||||
|
||||
case SET_USERNAME:
|
||||
state = { ...state, username: action.payload.username };
|
||||
break;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
module.exports = conversationReducer;
|
|
@ -0,0 +1,44 @@
|
|||
const { put, select, takeEvery } = require('redux-saga/effects');
|
||||
const { reset, setCity, setUsername } = require('../conversationActions');
|
||||
const { promptText, RECEIVE_MESSAGE, sendMessage, endConversation } = require('../dialogActions');
|
||||
|
||||
module.exports = function* (session) {
|
||||
yield takeEvery(RECEIVE_MESSAGE, function* (action) {
|
||||
const { text } = action.payload;
|
||||
const changeCityMatch = /^change city to (.*)/i.exec(text);
|
||||
const currentCityMatch = /^current city/i.exec(text);
|
||||
const resetMatch = /^reset/i.exec(text);
|
||||
const endConversationMatch = /^end conversation/i.exec(text);
|
||||
let { city, username } = yield select();
|
||||
|
||||
if (!city) {
|
||||
city = 'Seattle';
|
||||
|
||||
yield put(setCity(city));
|
||||
yield put(sendMessage(`Welcome to the Search City bot. I\'m currently configured to search for things in ${ city }`));
|
||||
yield put(promptText('Before get started, please tell me your name?'));
|
||||
} else if (!username) {
|
||||
yield put(setUsername(text));
|
||||
yield put(sendMessage(`Welcome ${ text }!\n * If you want to know which city I'm using for my searches type 'current city'. \n * Want to change the current city? Type 'change city to cityName'. \n * Want to change it just for your searches? Type 'change my city to cityName'`));
|
||||
} else if (changeCityMatch) {
|
||||
const newCity = changeCityMatch[1];
|
||||
|
||||
yield put(setCity(newCity));
|
||||
yield put(sendMessage(`All set ${ username }. From now on, all my searches will be for things in ${ newCity }.`));
|
||||
} else if (currentCityMatch) {
|
||||
yield put(sendMessage(`Hey ${ username }, I\'m currently configured to search for things in ${ city }.`));
|
||||
} else if (resetMatch) {
|
||||
yield put(reset());
|
||||
yield put(sendMessage('Oops... I\'m suffering from a memory loss...'));
|
||||
} else if (endConversationMatch){
|
||||
yield put(endConversation());
|
||||
yield put(sendMessage('Ending Conversation...'));
|
||||
} else {
|
||||
const { city, username } = yield select();
|
||||
const messageText = action.payload.text.trim();
|
||||
|
||||
yield put(sendMessage(`${ username }, wait a few seconds. Searching for \'${ messageText }\' in \'${ city }\'...`));
|
||||
yield put(sendMessage(`https://www.bing.com/search?q=${ encodeURIComponent(`${ messageText } in ${ city }`) }`));
|
||||
}
|
||||
});
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
const builder = require('botbuilder');
|
||||
const { takeEvery } = require('redux-saga/effects');
|
||||
const DialogActions = require('../dialogActions');
|
||||
|
||||
module.exports = function* (session) {
|
||||
yield takeEvery(DialogActions.PROMPT_TEXT, function* (action) {
|
||||
builder.Prompts.text(session, action.payload.text);
|
||||
});
|
||||
|
||||
yield takeEvery(DialogActions.END_CONVERSATION, function* (action){
|
||||
|
||||
session.endConversation('Bye!');
|
||||
});
|
||||
|
||||
yield takeEvery(DialogActions.SEND_EVENT, function* (action) {
|
||||
const { name, value } = action.payload;
|
||||
|
||||
session.send({ type: 'event', name, value });
|
||||
});
|
||||
|
||||
yield takeEvery(DialogActions.SEND_MESSAGE, function* (action) {
|
||||
const { attachments, text } = action.payload;
|
||||
|
||||
message = new builder.Message(session);
|
||||
|
||||
text && message.text(text);
|
||||
attachments && message.attachments(attachments);
|
||||
|
||||
session.send(message);
|
||||
});
|
||||
};
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 39 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 3.7 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 17 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 26 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 39 KiB |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 97 KiB |
Загрузка…
Ссылка в новой задаче