├── index.js ├── screenshot.gif ├── screenshot2.png ├── example ├── jokeChatApp.js ├── test │ └── jokeChatAppTest.js └── jokeChatServer.js ├── .flowconfig ├── src ├── test │ └── BotBasicTest.js ├── ChatUtils.js ├── type.js ├── FBLocalChatRoutes.js └── index.js ├── .babelrc ├── localChatWeb ├── js │ ├── src │ │ ├── EventStore.js │ │ ├── LocalChatWebview.jsx │ │ ├── LocalChatMessagesContent.jsx │ │ ├── LocalChatOptin.jsx │ │ ├── LocalChatFooter.jsx │ │ ├── LocalChatMessagesQuickReply.jsx │ │ ├── LocalChatMessagePropType.js │ │ ├── LocalChatContainer.jsx │ │ ├── LocalChatStore.js │ │ ├── LocalChatMessage.jsx │ │ └── LocalChatPersistentMenuButton.jsx │ ├── main.js │ └── webpack.config.js ├── index.html └── css │ └── main.css ├── .gitignore ├── .eslintrc ├── LICENSE-MIT ├── package.json └── README.md /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build'); 2 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spchuang/fb-local-chat-bot/HEAD/screenshot.gif -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spchuang/fb-local-chat-bot/HEAD/screenshot2.png -------------------------------------------------------------------------------- /example/jokeChatApp.js: -------------------------------------------------------------------------------- 1 | const makeServer = require('./jokeChatServer'); 2 | 3 | makeServer(); 4 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/fbjs/.* 3 | .*/node_modules/config-chain/test/broken.json 4 | .*/node_modules/npmconf/test/.* 5 | -------------------------------------------------------------------------------- /src/test/BotBasicTest.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import Bot from '../'; 3 | 4 | describe('Basic Bot test', () => { 5 | it('Bot requires init', () => { 6 | let fn = () => Bot.sendText('123', 'text'); 7 | expect(fn).to.throw(Error); 8 | 9 | fn = () => Bot.router(); 10 | expect(fn).to.throw(Error); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "transform-flow-strip-types", 5 | "syntax-trailing-function-commas", 6 | "transform-class-properties", 7 | "syntax-async-functions", 8 | "transform-object-rest-spread", 9 | ["transform-async-to-module-method", { 10 | "module": "bluebird", 11 | "method": "coroutine" 12 | }] 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /localChatWeb/js/src/EventStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | import events from 'events'; 6 | 7 | const CHANGE_EVENT = 'change'; 8 | 9 | class EventStore extends events.EventEmitter { 10 | emitChange(): void { 11 | this.emit(CHANGE_EVENT); 12 | } 13 | 14 | addChangeListener(callback: Function): void { 15 | this.on(CHANGE_EVENT, callback); 16 | } 17 | 18 | removeChangeListener(callback: Function): void { 19 | this.removeListener(CHANGE_EVENT, callback); 20 | } 21 | } 22 | 23 | module.exports = EventStore; 24 | -------------------------------------------------------------------------------- /localChatWeb/js/main.js: -------------------------------------------------------------------------------- 1 | import LocalChatContainer from './src/LocalChatContainer.jsx'; 2 | import LocalChatStore from './src/LocalChatStore.js'; 3 | import ReactDOM from 'react-dom'; 4 | import React from 'react'; 5 | 6 | window.init = (baseURL: string) => { 7 | LocalChatStore.setBaseUrl(baseURL); 8 | LocalChatStore._getPersistentMenu(); 9 | LocalChatStore.startPolling(); 10 | 11 | let ID = null; 12 | while (ID === null) { 13 | ID = prompt("Please enter a User ID", "1234"); 14 | } 15 | 16 | ReactDOM.render(, document.getElementById('fb-local-chat-root')); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # local db 7 | *.sqlite 8 | memory 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | .DS_Store 41 | 42 | #image files created locally 43 | public/images/board*.jpg 44 | -------------------------------------------------------------------------------- /localChatWeb/js/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: __dirname + '/main.js', 6 | output: { path: __dirname, filename: 'dist/bundle.js' }, 7 | devtool: 'source-map', 8 | debug: true, 9 | module: { 10 | loaders: [ 11 | { 12 | test: /.jsx?$/, 13 | loader: 'babel-loader', 14 | exclude: /node_modules/, 15 | query: { 16 | presets: ['es2015', 'react'], 17 | plugins: [ 18 | "transform-flow-strip-types", 19 | "syntax-trailing-function-commas", 20 | "transform-class-properties" 21 | ], 22 | } 23 | }, 24 | { test: /\.json$/, loader: 'json-loader' } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.OldWatchingPlugin() 29 | ], 30 | node: { 31 | console: true, 32 | fs: 'empty', 33 | net: 'empty', 34 | tls: 'empty' 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | // in CommonJS 5 | "node": true 6 | }, 7 | "extends": [ 8 | "defaults/configurations/eslint", 9 | ], 10 | 11 | "rules": { 12 | // possible 13 | "comma-dangle": ["error", "only-multiline"], 14 | // best practices 15 | curly: "error", 16 | no-eval: "error", 17 | radix: "error", 18 | no-redeclare: "error", 19 | no-return-assign: "error", 20 | no-unmodified-loop-condition: "error", 21 | no-unused-expressions: ["error", {"allowShortCircuit": true}], 22 | no-unused-vars: ["error", { "varsIgnorePattern": "_" }], 23 | no-useless-escape: "error", 24 | eqeqeq: "error", 25 | no-multi-spaces: "error", 26 | block-scoped-var: "error", 27 | no-use-before-define: "error", 28 | 29 | // styles 30 | "quotes": [2, "single"], 31 | "eol-last": [0], 32 | "no-mixed-requires": [0], 33 | "no-underscore-dangle": [0], 34 | "no-console": ["error", {allow: ["log"]}] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Samping Chuang 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatWebview.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import LocalChatStore from './LocalChatStore.js'; 10 | import classNames from 'classNames'; 11 | 12 | const LocalChatWebview = React.createClass({ 13 | _webviewIframe: null, 14 | propTypes: { 15 | webViewURL: PropTypes.string.isRequired, 16 | webViewHeightRatio: PropTypes.oneOf(['tall', 'compact', 'full']).isRequired, 17 | }, 18 | 19 | render(): React.Element { 20 | 21 | const heightStyle = 'webview-' + this.props.webViewHeightRatio; 22 | return ( 23 |
24 |
25 |
26 | 27 |
28 | 32 |
33 |
34 | ); 35 | }, 36 | 37 | _handleCloseWebView(): void { 38 | LocalChatStore.closeWebView(); 39 | } 40 | }); 41 | 42 | module.exports = LocalChatWebview; 43 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatMessagesContent.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import LocalChatMessage from './LocalChatMessage.jsx'; 10 | import LocalChatMessagePropType from './LocalChatMessagePropType.js'; 11 | 12 | const LocalChatMessagesContent = React.createClass({ 13 | propTypes: { 14 | messages: PropTypes.arrayOf(LocalChatMessagePropType).isRequired, 15 | }, 16 | 17 | render(): React.Element { 18 | const messages = this.props.messages.map((message, index) => { 19 | if (!message.message) { 20 | return null; 21 | } 22 | return ( 23 | 28 | ); 29 | }); 30 | 31 | // check for sender action for last message 32 | const lastMessage = this.props.messages[this.props.messages.length - 1]; 33 | const senderActionType = lastMessage && lastMessage.sender_action; 34 | const senderTypingAction = senderActionType === 'typing_on' 35 | ?
36 | typing... 37 |
38 | : null; 39 | 40 | 41 | return ( 42 |
43 | {messages} 44 | {senderTypingAction} 45 |
46 | ); 47 | }, 48 | }); 49 | 50 | module.exports = LocalChatMessagesContent; 51 | -------------------------------------------------------------------------------- /example/test/jokeChatAppTest.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import {expect} from 'chai'; 3 | import Bot from '../../build'; 4 | import makeServer from '../jokeChatServer'; 5 | 6 | function userSendMessage(senderID, text) { 7 | Bot.emit( 8 | 'text', 9 | { 10 | sender: {id: senderID}, 11 | message: {text: text}, 12 | }, 13 | ); 14 | } 15 | 16 | function userSendPostBack(senderID, payload) { 17 | Bot.emit( 18 | 'postback', 19 | { 20 | sender: {id: senderID}, 21 | postback: {payload: payload}, 22 | }, 23 | ); 24 | } 25 | 26 | describe('Basic server test', function() { 27 | var server; 28 | var userID = 'testUser'; 29 | beforeEach(() => { 30 | server = makeServer(); 31 | }); 32 | 33 | afterEach(function (done) { 34 | Bot.clearLocalChatMessages(); 35 | server.close(done); 36 | }); 37 | 38 | it('Test chat', () => { 39 | userSendMessage(userID, 'hi'); 40 | let messages = Bot.getLocalChatMessagesForUser(userID); 41 | expect(messages.length).to.equal(1); 42 | 43 | userSendPostBack(userID, 'TELL_JOKE'); 44 | messages = Bot.getLocalChatMessagesForUser(userID); 45 | expect(messages.length).to.equal(3); 46 | 47 | userSendPostBack(userID, 'TELL_ANOTHER_JOKE'); 48 | messages = Bot.getLocalChatMessagesForUser(userID); 49 | expect(messages.length).to.equal(4); 50 | const lastMessage = messages[messages.length - 1]; 51 | expect(lastMessage.text).to.equal('Sorry, I only know one joke'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /localChatWeb/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Local test 5 | 6 | 7 | 8 | 9 | 10 | 28 | 29 |
30 |
31 |
32 |
33 |
34 | 35 | 36 | 40 | 41 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatOptin.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import LocalChatStore from './LocalChatStore.js'; 10 | 11 | const LocalChatOptin = React.createClass({ 12 | propTypes: { 13 | userID: PropTypes.string.isRequired, 14 | }, 15 | 16 | getInitialState(): Object { 17 | return { 18 | val: '', 19 | }; 20 | }, 21 | 22 | render(): React.Element { 23 | return ( 24 |
25 |
26 | 34 | 35 | 40 | 41 |
42 |
43 | ); 44 | }, 45 | 46 | _onChange(): void { 47 | this.setState({ 48 | val: this.refs.passThroughParam.value, 49 | }); 50 | }, 51 | 52 | _onAuthenticate(): void { 53 | const param = this.refs.passThroughParam.value; 54 | if (param === '') { 55 | return; 56 | } 57 | LocalChatStore.sendOptinForUser(this.props.userID, param); 58 | this.setState({val: ''}); 59 | }, 60 | }); 61 | 62 | module.exports = LocalChatOptin; 63 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatFooter.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import LocalChatPersistentMenuButton from './LocalChatPersistentMenuButton.jsx'; 10 | import LocalChatStore from './LocalChatStore.js'; 11 | 12 | const LocalChatFooter = React.createClass({ 13 | _menuButton: null, 14 | propTypes: { 15 | userID: PropTypes.string.isRequired, 16 | persistentMenu: PropTypes.arrayOf(PropTypes.object).isRequired, 17 | }, 18 | 19 | getInitialState(): Object { 20 | return { 21 | val: '', 22 | }; 23 | }, 24 | 25 | render(): React.Element { 26 | const menu = this.props.persistentMenu.length > 0 27 | ? 28 | 29 | 30 | : null; 31 | 32 | return ( 33 |
34 |
35 | {menu} 36 | 45 | 46 | 51 | 52 |
53 |
54 | ); 55 | }, 56 | 57 | _handleKeyPress(e: Object): void { 58 | if (e.key === 'Enter') { 59 | this._onSend(); 60 | } 61 | }, 62 | 63 | _onChange(): void { 64 | this.setState({ 65 | val: this.refs.messageInput.value, 66 | }); 67 | }, 68 | 69 | _onSend(): void { 70 | const text = this.refs.messageInput.value; 71 | if (text === '') { 72 | return; 73 | } 74 | LocalChatStore.sendMessageForUser(this.props.userID, text); 75 | this.setState({val: ''}); 76 | }, 77 | }); 78 | 79 | module.exports = LocalChatFooter; 80 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatMessagesQuickReply.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 'use strict'; 5 | 6 | import React, {PropTypes} from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import classNames from 'classNames'; 9 | import LocalChatMessagePropType from './LocalChatMessagePropType.js'; 10 | import LocalChatStore from './LocalChatStore.js'; 11 | 12 | const LocalChatMessagesQuickReply = React.createClass({ 13 | propTypes: { 14 | message: LocalChatMessagePropType, 15 | }, 16 | 17 | contextTypes: { 18 | userID: React.PropTypes.string, 19 | }, 20 | 21 | render(): ?React.Element { 22 | if (!this.props.message || !('quick_replies' in this.props.message)) { 23 | return null; 24 | } 25 | 26 | const quickReplyButtons = this.props.message.quick_replies 27 | .map((quickReplyButton) => { 28 | return ( 29 | 38 | ); 39 | }); 40 | return ( 41 |
42 | {quickReplyButtons} 43 |
44 | ); 45 | }, 46 | 47 | componentDidUpdate(prevProps: Object, prevState:Object): void { 48 | // if quick reply div overflows, change justify-content to flex-start 49 | let messagesQuickReplyDiv = document.getElementById('messages-quick-reply'); 50 | if (!messagesQuickReplyDiv) { 51 | return; 52 | } 53 | if (messagesQuickReplyDiv.scrollHeight > messagesQuickReplyDiv.clientHeight 54 | || messagesQuickReplyDiv.scrollWidth > messagesQuickReplyDiv.clientWidth) { 55 | messagesQuickReplyDiv.style.justifyContent = 'flex-start'; 56 | } 57 | }, 58 | 59 | _clickButton(quickReplyButton: Object): void { 60 | LocalChatStore.sendQuickReplyForUser( 61 | this.context.userID, 62 | quickReplyButton.title, 63 | quickReplyButton.payload, 64 | ); 65 | }, 66 | }); 67 | 68 | module.exports = LocalChatMessagesQuickReply; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fb-local-chat-bot", 3 | "version": "0.1.42", 4 | "main": "index.js", 5 | "description": "A ES6 Node client for Messenger Bot designed for easy local testing", 6 | "dependencies": { 7 | "dot": "1.0.3", 8 | "express": "4.13.4", 9 | "invariant": "2.2.1", 10 | "request-promise": "3.0.0" 11 | }, 12 | "scripts": { 13 | "flow": "flow; test $? -eq 0 -o $? -eq 2", 14 | "watch": "babel --watch=./src --out-dir=./build --source-maps inline --copy-files", 15 | "build": "babel ./src --out-dir=./build --source-maps inline --copy-files", 16 | "web_watch": "webpack --watch --watch-polling --config ./localChatWeb/js/webpack.config.js", 17 | "test": "mocha ./build/test", 18 | "lint": "eslint src --fix", 19 | "check": "npm run lint && npm run flow && npm run test", 20 | "s": "nodemon --harmony example/jokeChatApp.js --watch example", 21 | "joke": "node --harmony example/jokeChatApp.js", 22 | "test_joke": "mocha ./example/test --compilers js:babel-register" 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "6.10.1", 26 | "babel-eslint": "6.0.4", 27 | "babel-loader": "6.2.4", 28 | "babel-plugin-syntax-async-functions": "6.8.0", 29 | "babel-plugin-syntax-trailing-function-commas": "6.8.0", 30 | "babel-plugin-transform-async-to-module-method": "6.8.0", 31 | "babel-plugin-transform-class-properties": "6.10.2", 32 | "babel-plugin-transform-flow-strip-types": "6.8.0", 33 | "babel-plugin-transform-object-rest-spread": "6.23.0", 34 | "babel-preset-es2015": "6.9.0", 35 | "babel-preset-react": "6.11.1", 36 | "chai": "3.5.0", 37 | "classnames": "2.2.5", 38 | "eslint": "2.12.0", 39 | "eslint-config-defaults": "9.0.0", 40 | "flow": "0.2.3", 41 | "jquery": "3.0.0", 42 | "json-loader": "0.5.4", 43 | "mocha": "2.5.3", 44 | "nodemon": "1.9.2", 45 | "react": "15.1.0", 46 | "react-dom": "15.1.0", 47 | "supertest": "1.2.0", 48 | "supertest-as-promised": "3.1.0", 49 | "webpack": "1.13.1", 50 | "react-bootstrap": "0.30.8" 51 | }, 52 | "engines": { 53 | "node": "4.4.5", 54 | "npm": "2.15.5" 55 | }, 56 | "license": "MIT", 57 | "author": "Samping Chuang " 58 | } 59 | -------------------------------------------------------------------------------- /src/ChatUtils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | 'use strict'; 4 | 5 | import Promise from 'bluebird'; 6 | import rp from 'request-promise'; 7 | 8 | import type { 9 | PersistentMenu, 10 | } from './type'; 11 | 12 | let _userToMessagesMap = {}; 13 | let _persistentMenu = []; 14 | 15 | function _saveMessageToLocalChat( 16 | recipientID: string, 17 | messageData: Object, 18 | fromUser: boolean, 19 | ): void { 20 | if (!(recipientID in _userToMessagesMap)) { 21 | _userToMessagesMap[recipientID] = []; 22 | } 23 | // store a special flag to determine the source of message 24 | messageData.fromUser = fromUser; 25 | _userToMessagesMap[recipientID].push(messageData); 26 | } 27 | 28 | const ChatUtils = { 29 | storePersistentMenu(token: string): Promise { 30 | return rp({ 31 | uri: 'https://graph.facebook.com/v2.6/me/messenger_profile', 32 | qs: {access_token: token}, 33 | method: 'POST', 34 | body: { 35 | persistent_menu: _persistentMenu, 36 | }, 37 | json: true, 38 | }); 39 | }, 40 | 41 | send( 42 | recipientID: string, 43 | token: string, 44 | data: Object, 45 | useLocalChat: boolean, 46 | useMessenger: boolean, 47 | ): ?Promise { 48 | if (useLocalChat) { 49 | _saveMessageToLocalChat(recipientID, Object.assign({}, data), false /* fromUser */); 50 | } 51 | 52 | if (useMessenger) { 53 | return rp({ 54 | uri: 'https://graph.facebook.com/v2.6/me/messages', 55 | qs: {access_token:token}, 56 | method: 'POST', 57 | body: { 58 | recipient: {id: recipientID}, 59 | ...data, 60 | }, 61 | json: true, 62 | }, function(err, response) { 63 | if (err) { 64 | // TODO 65 | } else if (response.body.error) { 66 | // TODO 67 | } 68 | }); 69 | } 70 | return; 71 | }, 72 | 73 | getLocalChatMessages(): Object { 74 | return _userToMessagesMap; 75 | }, 76 | 77 | clearLocalChatMessages(): void { 78 | _userToMessagesMap = {}; 79 | }, 80 | 81 | saveSenderMessageToLocalChat(senderID: string, text: string): void { 82 | _saveMessageToLocalChat(senderID, {message: {text: text}}, true /* fromUser */); 83 | }, 84 | 85 | setPersistentMenu(persistentMenu: Array): void { 86 | _persistentMenu = persistentMenu; 87 | }, 88 | 89 | getPersistentMenu(): Array { 90 | return _persistentMenu; 91 | }, 92 | }; 93 | 94 | module.exports = ChatUtils; 95 | -------------------------------------------------------------------------------- /localChatWeb/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 50px; 3 | } 4 | 5 | .fb-local-chat-container { 6 | margin-top: 50px; 7 | } 8 | 9 | .fb-local-chat-container .chat-footer { 10 | position: relative; 11 | z-index: 0; 12 | } 13 | 14 | .fb-local-chat-container .webview-container { 15 | position: absolute; 16 | z-index: 2; 17 | width: 100%; 18 | height: 100%; 19 | bottom: 0; 20 | background: rgba(0,0,0,0.7); 21 | } 22 | 23 | .fb-local-chat-container .webview { 24 | position: absolute; 25 | width: 100%; 26 | bottom: 0; 27 | background: black; 28 | } 29 | 30 | .fb-local-chat-container .webview-compact { 31 | height: 50%; 32 | } 33 | 34 | .fb-local-chat-container .webview-tall { 35 | height: 75%; 36 | } 37 | 38 | .fb-local-chat-container .webview-full { 39 | height: 100%; 40 | } 41 | 42 | .fb-local-chat-container .webview-container iframe { 43 | width: 100%; 44 | height: 93%; 45 | } 46 | 47 | .fb-local-chat-container .webview-header { 48 | background: white; 49 | font-size: 20px; 50 | cursor: pointer; 51 | padding-left: 5px; 52 | height: 7%; 53 | } 54 | 55 | .fb-local-chat-container .chat-content-container { 56 | position: relative; 57 | } 58 | 59 | .fb-local-chat-container .messages-content { 60 | height: 450px; 61 | overflow-y: auto; 62 | padding: 15px; 63 | } 64 | 65 | .fb-local-chat-container .messages-content .message { 66 | margin-bottom: 10px; 67 | max-width: 70%; 68 | float: left; 69 | } 70 | 71 | .fb-local-chat-container .messages-content .message-bubble { 72 | background: #eceff1; 73 | color: black; 74 | padding: 10px 20px; 75 | border-radius: 30px; 76 | overflow-y: auto; 77 | } 78 | 79 | .fb-local-chat-container .messages-content .message-bubble.me { 80 | background: #0ebeff !important; 81 | color: white !important; 82 | } 83 | 84 | .fb-local-chat-container .messages-quick-reply { 85 | display: flex; 86 | justify-content: center; 87 | margin: 5px 0px 5px 0px; 88 | overflow: auto; 89 | } 90 | 91 | .fb-local-chat-container .messages-quick-reply .message-bubble { 92 | background: white; 93 | color: #3D98FD; 94 | padding: 5px 10px; 95 | border-radius: 30px; 96 | border-color: #3D98FD; 97 | border-style: solid; 98 | border-width: thin; 99 | margin: 0px 5px 0px 5px; 100 | white-space: nowrap; 101 | } 102 | 103 | .fb-local-chat-container .messages-content .message.me { 104 | float: right !important; 105 | } 106 | 107 | .fb-local-chat-container .messages-content .message img { 108 | max-width: 100%; 109 | } 110 | 111 | .fb-local-chat-container .messages-content .message .chat-button-list { 112 | text-align: center; 113 | margin: 0; 114 | margin-top: 5px; 115 | } 116 | 117 | .fb-local-chat-container .messages-content .message .chat-button-list .chat-button { 118 | text-align: center; 119 | } 120 | 121 | .fb-local-chat-container .messages-content .message .hscroll-wrapper { 122 | overflow-x: auto; 123 | } 124 | 125 | .fb-local-chat-container .messages-content .message .hscroll-element { 126 | float: left; 127 | margin-right: 10px; 128 | } 129 | 130 | .clear { 131 | clear: both; 132 | } 133 | 134 | .locale-dropdown hr { 135 | margin: 5px 0; 136 | } 137 | 138 | .locale-dropdown .dropdown-button { 139 | margin-left: 5px; 140 | } 141 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatMessagePropType.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import {PropTypes} from 'react'; 8 | 9 | // for reference: https://developers.facebook.com/docs/messenger-platform/send-api-reference 10 | /* 11 | * For now we only support 3 types of messages 12 | * 1. text Only 13 | * 2. image 14 | * 3. button template 15 | * 4. quick reply with text/image 16 | */ 17 | const TextMessagePropType = PropTypes.shape({ 18 | text: PropTypes.string.isRequired, 19 | }); 20 | 21 | const ImagePropType = PropTypes.shape({ 22 | type: PropTypes.oneOf(['image']).isRequired, 23 | payload: PropTypes.shape({ 24 | url: PropTypes.string.isRequired, 25 | }) 26 | }); 27 | 28 | const ImageMessagePropType = PropTypes.shape({ 29 | text: PropTypes.string, 30 | attachment: ImagePropType.isRequired, 31 | }); 32 | 33 | /* 34 | * Button Template 35 | * ref: https://developers.facebook.com/docs/messenger-platform/send-api-reference/buttons 36 | */ 37 | const URLButtonPropType = PropTypes.shape({ 38 | type: PropTypes.oneOf(['web_url']).isRequired, 39 | url: PropTypes.string.isRequired, 40 | title: PropTypes.string.isRequired, 41 | webview_height_ratio: PropTypes.oneOf(['compact', 'tall', 'full']), 42 | }); 43 | 44 | const PostbackButtonPropType = PropTypes.shape({ 45 | type: PropTypes.oneOf(['postback']).isRequired, 46 | title: PropTypes.string.isRequired, 47 | payload: PropTypes.string.isRequired, 48 | }); 49 | 50 | const CallButtonPropType = PropTypes.shape({ 51 | type: PropTypes.oneOf(['phone_number']).isRequired, 52 | title: PropTypes.string.isRequired, 53 | payload: PropTypes.string.isRequired, 54 | }); 55 | 56 | const ShareButtonPropType = PropTypes.shape({ 57 | type: PropTypes.oneOf(['element_share']).isRequired, 58 | }); 59 | 60 | const ButtonPropType = PropTypes.oneOfType([ 61 | URLButtonPropType, 62 | PostbackButtonPropType, 63 | CallButtonPropType, 64 | ShareButtonPropType, 65 | ]); 66 | 67 | const ButtonsTemplateMessagePropType = PropTypes.shape({ 68 | text: PropTypes.string, 69 | attachment: PropTypes.shape({ 70 | type: PropTypes.oneOf(['template']).isRequired, 71 | payload: PropTypes.shape({ 72 | "template_type": PropTypes.oneOf(['button']).isRequired, 73 | "text": PropTypes.string.isRequired, 74 | "buttons": PropTypes.arrayOf(ButtonPropType).isRequired, 75 | }).isRequired, 76 | }), 77 | }); 78 | 79 | const QuickRepliesPropType = PropTypes.shape({ 80 | content_type: PropTypes.oneOf(['text']).isRequired, 81 | title: PropTypes.string.isRequired, 82 | payload: PropTypes.string.isRequired, 83 | }); 84 | 85 | const QuickReplyMessageWithTextPropType = PropTypes.shape({ 86 | text: PropTypes.string.isRequired, 87 | quick_replies: PropTypes.arrayOf(QuickRepliesPropType), 88 | }); 89 | 90 | const QuickReplyMessageWithImagePropType = PropTypes.shape({ 91 | attachment: ImagePropType.isRequired, 92 | quick_replies: PropTypes.arrayOf(QuickRepliesPropType), 93 | }); 94 | 95 | const LocalChatMessagePropType = PropTypes.oneOfType([ 96 | TextMessagePropType, 97 | ImageMessagePropType, 98 | ButtonsTemplateMessagePropType, 99 | QuickReplyMessageWithTextPropType, 100 | QuickReplyMessageWithImagePropType, 101 | ]); 102 | 103 | module.exports = LocalChatMessagePropType; 104 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type Text = { 4 | text: string, 5 | }; 6 | 7 | type Payload = { 8 | url: string, 9 | is_reusable?: boolean, 10 | }; 11 | 12 | export type MultimediaAttachment = { 13 | type: 'audio' | 'file' | 'image' | 'video', 14 | payload: Payload, 15 | }; 16 | 17 | export type SendActionType = 18 | 'mark_seen' | 'typing_on' | 'typing_off'; 19 | 20 | export type PersistentMenu = { 21 | locale: string, 22 | composer_input_disabled: boolean, 23 | call_to_actions: Array, 24 | }; 25 | 26 | export type NestedPersistentMenuItem = { 27 | title: string, 28 | type: 'nested', 29 | call_to_actions: Array, 30 | }; 31 | 32 | export type PersistenMenuItem = 33 | PostbackButton | 34 | URLButton | 35 | NestedPersistentMenuItem; 36 | 37 | export type ButtonTemplateAttachment = { 38 | type: 'template', 39 | payload: { 40 | template_type: 'button', 41 | text: string, 42 | buttons: Array 62 | : null; 63 | 64 | return ( 65 |
66 |
67 |
68 | Local FB chat test (user ID: {this.state.userID}) 69 | 70 |
71 |
72 | {webView} 73 | 74 | 75 |
76 | 80 |
81 |
82 |
83 | {saveMenuButton} 84 |
85 | ); 86 | }, 87 | 88 | _onChange(): void { 89 | const newMessages = LocalChatStore.getMessagesForUser(this.state.userID); 90 | if (newMessages.length !== this.state.messages.length) { 91 | this.setState({ 92 | messages: newMessages, 93 | }); 94 | } 95 | 96 | // set webview config 97 | this.setState(LocalChatStore.getWebViewState()); 98 | 99 | const menu = LocalChatStore.getPersistentMenu(); 100 | if (menu.length !== this.state.persistentMenu.length) { 101 | this.setState({ 102 | persistentMenu: menu, 103 | }); 104 | } 105 | }, 106 | 107 | _loadWebview(url: string): void { 108 | this.setState( { 109 | webviewURL: url, 110 | hideWebview: false 111 | }); 112 | }, 113 | 114 | _closeWebview(): void { 115 | this.setState( { 116 | webviewURL: '', 117 | hideWebview: true, 118 | }); 119 | }, 120 | 121 | _storePersistentMenu(): void { 122 | LocalChatStore.storePersistentMenu(); 123 | }, 124 | }); 125 | 126 | module.exports = LocalChatContainer; 127 | -------------------------------------------------------------------------------- /src/FBLocalChatRoutes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | 'use strict'; 4 | 5 | import ChatUtils from './ChatUtils'; 6 | import {Router} from 'express'; 7 | import invariant from 'invariant'; 8 | import fs from 'fs'; 9 | import dot from 'dot'; 10 | import path from 'path'; 11 | 12 | const FBLocalChatRoutes = (router: Router, Bot: Object): Router => { 13 | router.get('/localChat/getMessages', (req, res) => { 14 | res.json(ChatUtils.getLocalChatMessages()); 15 | }); 16 | 17 | router.get('/localChat/persistentMenu', (req, res) => { 18 | res.json(ChatUtils.getPersistentMenu()); 19 | }); 20 | 21 | router.post('/localChat/sendMessage', (req, res) => { 22 | const senderID = req.body.senderID; 23 | const message = req.body.message; 24 | invariant(senderID && message, 'both senderID and message are required'); 25 | 26 | ChatUtils.saveSenderMessageToLocalChat(senderID, message); 27 | const event = { 28 | sender: {id: senderID}, 29 | recipient: {id: 'pageID'}, 30 | timestamp: Math.floor(new Date() / 1000), 31 | message: { 32 | text: message, 33 | }, 34 | }; 35 | Bot.emit('text', event); 36 | res.sendStatus(200); 37 | }); 38 | 39 | router.post('/localChat/storePersistentMenuWithFacebook', (req, res) => { 40 | 41 | Bot.storePersistentMenu() 42 | .then((data: Object) => { 43 | res.sendStatus(200); 44 | }) 45 | .catch((data: Object) => { 46 | res.status(data.statusCode).send(data.message); 47 | }); 48 | }); 49 | 50 | router.post('/localChat/optin/', (req, res) => { 51 | const senderID = req.body.senderID; 52 | const ref = req.body.ref; 53 | 54 | invariant(senderID && ref, 'both senderID and payload are required'); 55 | const event = { 56 | sender: {id: senderID}, 57 | recipient: {id: 'pageID'}, 58 | timestamp: Math.floor(new Date() / 1000), 59 | optin: { 60 | ref: ref, 61 | }, 62 | }; 63 | Bot.emit('optin', event); 64 | res.sendStatus(200); 65 | }); 66 | 67 | router.post('/localChat/postback/', (req, res) => { 68 | const senderID = req.body.senderID; 69 | const payload = req.body.payload; 70 | 71 | invariant(senderID && payload, 'both senderID and payload are required'); 72 | const event = { 73 | sender: {id: senderID}, 74 | recipient: {id: 'pageID'}, 75 | timestamp: Math.floor(new Date() / 1000), 76 | postback: { 77 | payload: payload, 78 | }, 79 | }; 80 | Bot.emit('postback', event); 81 | res.sendStatus(200); 82 | }); 83 | 84 | router.post('/localChat/quickReply/', (req, res) => { 85 | const senderID = req.body.senderID; 86 | const payload = req.body.payload; 87 | const text = req.body.text 88 | 89 | invariant(senderID && payload, 'both senderID and payload are required'); 90 | const event = { 91 | sender: {id: senderID}, 92 | recipient: {id: 'pageID'}, 93 | timestamp: Math.floor(new Date() / 1000), 94 | message: { 95 | text: text, 96 | quick_reply: { 97 | payload: payload 98 | } 99 | }, 100 | }; 101 | Bot.emit('quick_reply', event); 102 | res.sendStatus(200); 103 | }); 104 | 105 | router.get('/localChat/*', (req, res) => { 106 | const dir = path.join(path.dirname(__filename), '..', 'localChatWeb'); 107 | var filePath = req.url.replace('/localChat', ''); 108 | if (filePath !== '/') { 109 | res.sendFile(filePath, {root: dir}); 110 | return 111 | } 112 | const baseURL = req.baseUrl; 113 | 114 | // return html 115 | fs.readFile(dir + '/index.html', 'utf8', (err, data) => { 116 | console.log(err); 117 | if (err) { 118 | res.send('ERROR'); 119 | return; 120 | } 121 | var tempFn = dot.template(data); 122 | res.send(tempFn({baseURL})); 123 | }); 124 | }); 125 | 126 | return router; 127 | } 128 | 129 | module.exports = FBLocalChatRoutes; 130 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatStore.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | import $ from 'jquery'; 6 | import EventStore from './EventStore.js'; 7 | 8 | const POLLING_INTERVAL = 1500; 9 | const BASE_URL = ''; 10 | 11 | type WebViewHeightRatio = 'compact' | 'tall' | 'full'; 12 | 13 | class LocalChatStore extends EventStore { 14 | _userIDToMessagesMap: Object; 15 | _baseURL: string; 16 | _webViewURL: string; 17 | _openWebView: boolean; 18 | _webViewHeightRatio: WebViewHeightRatio; 19 | _persistentMenu: Array; 20 | 21 | constructor() { 22 | super(); 23 | this._userIDToMessagesMap = {}; 24 | this._webViewURL = ''; 25 | this._openWebView = false; 26 | this._webViewHeightRatio = 'compact'; 27 | this._persistentMenu = []; 28 | } 29 | 30 | _getPersistentMenu(): void { 31 | const url = this._baseURL + '/localChat/persistentMenu'; 32 | $.get(url) 33 | .done((res: Array) => { 34 | this._persistentMenu = res; 35 | this.emitChange(); 36 | }) 37 | .fail((res: Object) => { 38 | console.log(res); 39 | }); 40 | } 41 | 42 | setBaseUrl(baseURL: string) { 43 | this._baseURL = baseURL; 44 | } 45 | 46 | startPolling(): void { 47 | // get all the local messages 48 | const url = this._baseURL + '/localChat/getMessages'; 49 | $.get(url) 50 | .done((res: Object) => { 51 | this._userIDToMessagesMap = res; 52 | this.emitChange(); 53 | setTimeout(this.startPolling.bind(this), POLLING_INTERVAL) 54 | }) 55 | .fail((res: Object) => { 56 | console.log(res); 57 | setTimeout(this.startPolling.bind(this), POLLING_INTERVAL) 58 | }); 59 | } 60 | 61 | openWebView(url: string, heightRatio: WebViewHeightRatio): void { 62 | this._webViewURL = url; 63 | this._webViewHeightRatio = heightRatio; 64 | this._openWebView = true; 65 | this.emitChange(); 66 | } 67 | 68 | closeWebView(): void { 69 | this._openWebView = false; 70 | this.emitChange(); 71 | } 72 | 73 | sendOptinForUser(senderID: string, param: string): void { 74 | const url = this._baseURL + '/localChat/optin'; 75 | $.post(url, {senderID: senderID, ref: param}) 76 | .done((res: Object) => { 77 | }) 78 | .fail((res: Object) => { 79 | console.log(res); 80 | }); 81 | } 82 | 83 | getWebViewState(): Object { 84 | return { 85 | openWebView: this._openWebView, 86 | webViewHeightRatio: this._webViewHeightRatio, 87 | webViewURL: this._webViewURL, 88 | }; 89 | } 90 | 91 | getPersistentMenu(): Array { 92 | return this._persistentMenu; 93 | } 94 | 95 | getMessagesForUser(userID: string): Array { 96 | if (userID in this._userIDToMessagesMap) { 97 | return this._userIDToMessagesMap[userID]; 98 | } 99 | return []; 100 | } 101 | 102 | sendMessageForUser(senderID: string, message: string): void { 103 | const url = this._baseURL + '/localChat/sendMessage'; 104 | $.post(url, {senderID: senderID, message: message}) 105 | .fail((res: Object) => { 106 | console.log(res); 107 | }); 108 | } 109 | 110 | sendPostbackForUser(senderID: string, payload: string): void { 111 | const url = this._baseURL + '/localChat/postback'; 112 | $.post(url, {senderID: senderID, payload: payload}) 113 | .fail((res: Object) => { 114 | console.log(res); 115 | }); 116 | } 117 | 118 | sendQuickReplyForUser(senderID: string, text: string, payload: string): void { 119 | const url = this._baseURL + '/localChat/quickReply'; 120 | $.post(url, {senderID: senderID, text: text, payload: payload}) 121 | .fail((res: Object) => { 122 | console.log(res); 123 | }); 124 | } 125 | 126 | storePersistentMenu(): void { 127 | const url = this._baseURL + '/localChat/storePersistentMenuWithFacebook'; 128 | $.post(url) 129 | .done((res: Object) => { 130 | alert("Successfully stored the menu!"); 131 | }) 132 | .fail((res: Object) => { 133 | alert("Failed to store the menu: " + res.responseText); 134 | console.log(res); 135 | }); 136 | } 137 | } 138 | 139 | const store = new LocalChatStore(); 140 | 141 | module.exports = store; 142 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatMessage.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import ReactDOM from 'react-dom'; 9 | import classNames from 'classNames'; 10 | import LocalChatStore from './LocalChatStore.js'; 11 | import LocalChatMessagePropType from './LocalChatMessagePropType.js'; 12 | 13 | import invariant from 'invariant'; 14 | 15 | const HSCROLL_ELEMENT_WIDHT = 300; 16 | 17 | const LocalChatMessage = React.createClass({ 18 | propTypes: { 19 | message: LocalChatMessagePropType.isRequired, 20 | fromUser: PropTypes.bool.isRequired, 21 | }, 22 | 23 | contextTypes: { 24 | userID: React.PropTypes.string, 25 | }, 26 | 27 | render(): React.Element { 28 | const message = this.props.message; 29 | 30 | let bubble = null; 31 | let useBubble = true; 32 | if (!message.attachment) { 33 | bubble = this._renderText(); 34 | } else if (message.attachment.type === 'image') { 35 | bubble = this._renderImage(); 36 | useBubble = false; 37 | } else if ( 38 | message.attachment.type === 'template' && 39 | message.attachment.payload.template_type === 'generic' 40 | ) { 41 | bubble = this._renderGenericTemplate(); 42 | } else if ( 43 | message.attachment.type === 'template' && 44 | message.attachment.payload.template_type === 'button' 45 | ) { 46 | bubble = this._renderButtonTemplate(); 47 | } else { 48 | bubble = 49 |
Error: This message is corrupted or unsupported
50 | } 51 | 52 | return ( 53 |
54 | 59 | {bubble} 60 | 61 |
62 |
63 | ); 64 | }, 65 | 66 | _renderText(): React.Element { 67 | const message = this.props.message; 68 | return ( 69 | {message.text} 70 | ); 71 | }, 72 | 73 | _renderGenericTemplate(): React.Element { 74 | const message = this.props.message; 75 | const elements = message.attachment.payload.elements.map((element, index) => { 76 | const buttons = element.buttons.map((button, index) => { 77 | return this._renderButton(button, index); 78 | }); 79 | return ( 80 |
81 | 82 |

{element.title}

83 |

{element.subtitle}

84 | {buttons} 85 |
86 | ) 87 | }); 88 | return ( 89 |
90 | {elements} 91 |
92 | ) 93 | }, 94 | 95 | _renderButtonTemplate(): React.Element { 96 | const message = this.props.message; 97 | const buttons = message.attachment.payload.buttons.map((button, index) => { 98 | return this._renderButton(button, index); 99 | }); 100 | return ( 101 | 102 | {message.attachment.payload.text} 103 |
104 | {buttons} 105 |
106 |
107 | ); 108 | }, 109 | 110 | _renderButton(button: Object, index: number): React.Element { 111 | if (button.type === 'element_share') { 112 | button.title = 'Share'; 113 | } 114 | return ( 115 | 121 | ); 122 | }, 123 | 124 | _renderImage(): React.Element { 125 | const message = this.props.message; 126 | return ( 127 | 128 | ); 129 | }, 130 | 131 | _clickButton(button: Object): void { 132 | switch (button.type) { 133 | case 'web_url': 134 | LocalChatStore.openWebView(button.url, button.webview_height_ratio); 135 | break; 136 | case 'postback': 137 | LocalChatStore.sendPostbackForUser(this.context.userID, button.payload); 138 | break; 139 | case 'phone_number': 140 | alert('calling number for: ' + button.payload); 141 | break; 142 | case 'element_share': 143 | alert('Share!'); 144 | break; 145 | } 146 | }, 147 | }); 148 | 149 | module.exports = LocalChatMessage; 150 | -------------------------------------------------------------------------------- /localChatWeb/js/src/LocalChatPersistentMenuButton.jsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @flow 3 | */ 4 | 5 | 'use strict'; 6 | 7 | import React, {PropTypes} from 'react'; 8 | import {ButtonToolbar, DropdownButton, MenuItem, OverlayTrigger, Popover} from 'react-bootstrap'; 9 | import ReactDOM from 'react-dom'; 10 | import LocalChatStore from './LocalChatStore.js'; 11 | 12 | const LocalChatPersistentMenuButton = React.createClass({ 13 | propTypes: { 14 | persistentMenu: PropTypes.arrayOf(PropTypes.object).isRequired, 15 | }, 16 | 17 | contextTypes: { 18 | userID: React.PropTypes.string, 19 | }, 20 | 21 | getInitialState(): Object { 22 | return { 23 | menuIndex: 0, 24 | levels: [], 25 | }; 26 | }, 27 | 28 | render(): React.Element { 29 | return ( 30 | 31 | 32 | 35 | 36 | 37 | ); 38 | }, 39 | 40 | getMenuItem(): Array { 41 | let menuItems = this.props.persistentMenu[this.state.menuIndex].call_to_actions; 42 | this.state.levels.forEach((level) => { 43 | menuItems = menuItems[level].call_to_actions; 44 | }); 45 | return menuItems; 46 | }, 47 | 48 | getTitle(): string { 49 | const numLevels = this.state.levels.length; 50 | let menuItems = this.props.persistentMenu[this.state.menuIndex].call_to_actions; 51 | 52 | for (let i = 0; i < numLevels; i++) { 53 | const level = this.state.levels[i]; 54 | // stop at the level before to get the title 55 | if (i === numLevels - 1) { 56 | return menuItems[level].title; 57 | } 58 | menuItems = menuItems[level].call_to_actions; 59 | } 60 | 61 | return 'Menu'; 62 | }, 63 | 64 | renderMenuItem(item: Object, index: number): React.Element { 65 | const glyph = item.type === 'nested' 66 | ? 67 | 68 | 69 | : null; 70 | 71 | return ( 72 | this._handleClickItem(item, index)}> 77 | {item.title} 78 | {glyph} 79 | 80 | ); 81 | }, 82 | 83 | getLocaleDropDown(): ?React.Element { 84 | const persistentMenu = this.props.persistentMenu; 85 | 86 | if (persistentMenu.length === 1) { 87 | return null; 88 | } 89 | 90 | const items = persistentMenu.map((menu, index) => { 91 | return ( 92 | this.setState({menuIndex: index})}> 95 | {menu.locale} 96 | 97 | ); 98 | }); 99 | return ( 100 |
101 | Locale: 102 | 106 | {items} 107 | 108 |
109 |
110 | ); 111 | }, 112 | 113 | renderMenu(): React.Element { 114 | const menuItems = this.getMenuItem().map((item, index) => { 115 | return this.renderMenuItem(item, index); 116 | }); 117 | 118 | 119 | const backButton = this.state.levels.length > 0 120 | ? 121 | 122 | back 123 | 124 | : null; 125 | 126 | return ( 127 | 128 | {this.getLocaleDropDown()} 129 | {backButton} 130 |
131 | {menuItems} 132 |
133 |
134 | ); 135 | }, 136 | 137 | _handleClickItem(item: Object, index: number): void { 138 | switch (item.type) { 139 | case 'web_url': 140 | LocalChatStore.openWebView(item.url, item.webview_height_ratio); 141 | break; 142 | case 'postback': 143 | LocalChatStore.sendPostbackForUser(this.context.userID, item.payload); 144 | break; 145 | case 'nested': 146 | const levels = this.state.levels; 147 | levels.push(index); 148 | this.setState({levels: levels}); 149 | break; 150 | } 151 | }, 152 | 153 | _goBackOneLevel(): void { 154 | const levels = this.state.levels; 155 | levels.pop(); 156 | this.setState({levels: levels}) 157 | }, 158 | }); 159 | 160 | module.exports = LocalChatPersistentMenuButton; 161 | -------------------------------------------------------------------------------- /example/jokeChatServer.js: -------------------------------------------------------------------------------- 1 | const Bot = require('../build'); 2 | const express = require('express'); 3 | const bodyParser = require('body-parser') 4 | const Promise = require('bluebird'); 5 | 6 | const JOKE = "Did you know photons had mass? I didn't even know they were Catholic."; 7 | const RiddleImageUrl ="http://tinyurl.com/he9tsph"; 8 | const PostBackTypes = { 9 | TELL_JOKE: 'TELL_JOKE', 10 | TELL_ANOTHER_JOKE: 'TELL_ANOTHER_JOKE', 11 | LIST_JOKES: 'LIST_JOKES', 12 | TELL_RIDDLE: 'TELL_RIDDLE', 13 | MORE_BUTTONS: 'MORE_BUTTONS', 14 | SHOW_HSCROLL: 'SHOW_HSCROLL', 15 | SEND_SENDER_ACTION_TYPING_ON: 'SEND_SENDER_ACTION_TYPING_ON', 16 | }; 17 | const QuickReplyTypes = { 18 | TELL_JOKE: 'TELL_JOKE', 19 | WRONG_ANSWER: 'WRONG_ANSWER', 20 | CORRECT_ANSWER: 'CORRECT_ANSWER', 21 | } 22 | const catImage = 'http://www.rd.com/wp-content/uploads/sites/2/2016/04/01-cat-wants-to-tell-you-laptop.jpg'; 23 | // Create 10 Quick Replies, Joke 1, Joke 2, ... 24 | const JokeQuickReplies = [...Array(10).keys()].map(x => Bot.createQuickReply(`Joke ${x+1}`, 'TELL_JOKE')); 25 | 26 | function makeServer() { 27 | // initialize Bot and define event handlers 28 | Bot.init('', '', true /*useLocalChat*/, false /*useMessenger*/); 29 | 30 | Bot.on('text', (event) => { 31 | const senderID = event.sender.id; 32 | Bot.sendButtons( 33 | senderID, 34 | 'Hello, how are you?', 35 | [ 36 | Bot.createPostbackButton('Tell me a joke', PostBackTypes.TELL_JOKE), 37 | Bot.createPostbackButton('Show list of jokes', PostBackTypes.LIST_JOKES), 38 | Bot.createPostbackButton('Solve a riddle', PostBackTypes.TELL_RIDDLE), 39 | Bot.createURLButton('Open website for jokes', 'http://jokes.cc.com/', 'tall'), 40 | Bot.createPostbackButton('More buttons', PostBackTypes.MORE_BUTTONS), 41 | Bot.createPostbackButton('Show hscroll', PostBackTypes.SHOW_HSCROLL), 42 | Bot.createPostbackButton('Show typing on action', PostBackTypes.SEND_SENDER_ACTION_TYPING_ON), 43 | ] 44 | ); 45 | }); 46 | 47 | const menuItems = [ 48 | Bot.createPostbackButton('Tell me a joke', PostBackTypes.TELL_JOKE), 49 | Bot.createPostbackButton('Show list of jokes', PostBackTypes.LIST_JOKES), 50 | Bot.createURLButton('Open website for jokes', 'http://jokes.cc.com/', 'tall'), 51 | Bot.createNestedMenuItem('more', [ 52 | Bot.createPostbackButton('More buttons', PostBackTypes.MORE_BUTTONS), 53 | Bot.createPostbackButton('Show hscroll', PostBackTypes.SHOW_HSCROLL), 54 | Bot.createNestedMenuItem('Even more stuff', [ 55 | Bot.createPostbackButton('More buttons', PostBackTypes.MORE_BUTTONS), 56 | Bot.createPostbackButton('Show hscroll', PostBackTypes.SHOW_HSCROLL), 57 | Bot.createPostbackButton('Show typing on action', PostBackTypes.SEND_SENDER_ACTION_TYPING_ON), 58 | ]), 59 | ]), 60 | ]; 61 | 62 | Bot.setPersistentMenu( 63 | [ 64 | Bot.createPersistentMenu('default', false, menuItems), 65 | Bot.createPersistentMenu('cn', false, menuItems), 66 | Bot.createPersistentMenu('jp', false, menuItems), 67 | ] 68 | ); 69 | 70 | Bot.on('postback', event => { 71 | const senderID = event.sender.id; 72 | switch(event.postback.payload) { 73 | case PostBackTypes.TELL_JOKE: 74 | Bot.sendText(senderID, JOKE); 75 | Bot.sendButtons( 76 | senderID, 77 | 'Ha. Ha. Ha. What else may I do for you?', 78 | [Bot.createPostbackButton('Tell me another joke', PostBackTypes.TELL_ANOTHER_JOKE)] 79 | ); 80 | break; 81 | case PostBackTypes.TELL_ANOTHER_JOKE: 82 | Bot.sendText(senderID, 'Sorry, I only know one joke'); 83 | break; 84 | case PostBackTypes.LIST_JOKES: 85 | Bot.sendQuickReplyWithText( 86 | senderID, 87 | 'Select a joke', 88 | JokeQuickReplies 89 | ); 90 | break; 91 | case PostBackTypes.TELL_RIDDLE: 92 | Bot.sendQuickReplyWithAttachment( 93 | senderID, 94 | Bot.createImageAttachment(RiddleImageUrl), 95 | [ 96 | Bot.createQuickReply('97', QuickReplyTypes.WRONG_ANSWER), 97 | Bot.createQuickReply('87', QuickReplyTypes.CORRECT_ANSWER), 98 | Bot.createQuickReply('89', QuickReplyTypes.WRONG_ANSWER), 99 | Bot.createQuickReply('91', QuickReplyTypes.WRONG_ANSWER), 100 | ] 101 | ) 102 | break; 103 | case PostBackTypes.MORE_BUTTONS: 104 | Bot.sendButtons( 105 | senderID, 106 | 'More buttons?', 107 | [ 108 | Bot.createCallButton('Call Sam', '+12345678'), 109 | Bot.createShareButton(), 110 | ] 111 | ); 112 | break; 113 | case PostBackTypes.SHOW_HSCROLL: 114 | const elements = [ 115 | Bot.createGenericTemplateElement( 116 | 'This is a cool hscroll 1', 117 | null, 118 | null, 119 | catImage, 120 | null, 121 | [ 122 | Bot.createPostbackButton('button1', ''), 123 | Bot.createPostbackButton('button2', ''), 124 | ] 125 | ), 126 | Bot.createGenericTemplateElement( 127 | 'This is a cool hscroll 2', 128 | null, 129 | null, 130 | catImage, 131 | null, 132 | [ 133 | Bot.createPostbackButton('button1', ''), 134 | Bot.createPostbackButton('button2', ''), 135 | ] 136 | ), 137 | ]; 138 | 139 | Bot.sendGenericTemplate( 140 | senderID, 141 | elements 142 | ); 143 | break; 144 | case PostBackTypes.SEND_SENDER_ACTION_TYPING_ON: 145 | Bot.sendSenderAction(senderID, 'typing_on'); 146 | break; 147 | } 148 | }); 149 | 150 | Bot.on('quick_reply', event => { 151 | const senderID = event.sender.id; 152 | switch(event.message.quick_reply.payload) { 153 | case QuickReplyTypes.TELL_JOKE: 154 | Bot.sendText(senderID, 'You asked for ' + event.message.text); 155 | break; 156 | case QuickReplyTypes.WRONG_ANSWER: 157 | Bot.sendText(senderID, 'Wrong answer... :('); 158 | break; 159 | case QuickReplyTypes.CORRECT_ANSWER: 160 | Bot.sendText(senderID, 'Correct answer! :)'); 161 | break; 162 | } 163 | 164 | }); 165 | 166 | const app = express(); 167 | 168 | app.use(bodyParser.json()); 169 | app.use(bodyParser.urlencoded({extended: true})); 170 | 171 | app.use('/chat', Bot.router()); 172 | 173 | var server = app.listen(5000); 174 | return server; 175 | } 176 | 177 | module.exports = makeServer; 178 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | 'use strict'; 4 | 5 | import ChatUtils from './ChatUtils'; 6 | import {EventEmitter} from 'events' 7 | import FBLocalChatRoutes from './FBLocalChatRoutes'; 8 | import {Router} from 'express'; 9 | import Promise from 'bluebird'; 10 | import invariant from 'invariant'; 11 | import rp from 'request-promise'; 12 | 13 | import type { 14 | MultimediaAttachment, 15 | ButtonTemplateAttachment, 16 | GenericTemplateAttachmentElement, 17 | GenericTemplateAttachment, 18 | ListTemplateAttachmentElement, 19 | ListTemplateAttachment, 20 | QuickReply, 21 | URLButton, 22 | PostbackButton, 23 | CallButton, 24 | ShareButton, 25 | TextQuickReply, 26 | LocationQuickReply, 27 | Attachment, 28 | Button, 29 | Message, 30 | WebviewHeightRatio, 31 | SendActionType, 32 | PersistentMenu, 33 | PersistenMenuItem, 34 | NestedPersistentMenuItem, 35 | } from './type'; 36 | 37 | class Bot extends EventEmitter { 38 | _token: string; 39 | _verifyToken: string; 40 | _useLocalChat: boolean; 41 | _useMessenger: boolean; 42 | _init: boolean; 43 | 44 | _verifyInitOrThrow(): void { 45 | invariant(this._init, 'Please initialize the Bot first'); 46 | } 47 | 48 | _verifyInLocalChatOrThrow(): void { 49 | invariant(this._useLocalChat, 'Not in local chat mode'); 50 | } 51 | 52 | constructor() { 53 | super(); 54 | this._init = false; 55 | } 56 | 57 | init( 58 | token: string, 59 | verfyToken: string, 60 | useLocalChat: boolean = false, 61 | useMessenger: boolean = true, 62 | ): void { 63 | this._token = token; 64 | this._verifyToken = verfyToken; 65 | this._useLocalChat = useLocalChat; 66 | this._useMessenger = useMessenger; 67 | this._init = true; 68 | } 69 | 70 | router(): Router { 71 | this._verifyInitOrThrow(); 72 | 73 | let router = Router(); 74 | router.get('/', (req, res) => { 75 | if (req.query['hub.verify_token'] === this._verifyToken) { 76 | res.send(req.query['hub.challenge']); 77 | } 78 | res.send('Error, wrong validation token'); 79 | }); 80 | 81 | router.post('/', (req, res) => { 82 | this.handleMessage(req.body); 83 | res.sendStatus(200); 84 | }); 85 | 86 | // attach local chat routes 87 | if (this._useLocalChat) { 88 | router = FBLocalChatRoutes(router, this); 89 | } 90 | 91 | return router; 92 | } 93 | 94 | getUserProfile(id: string): Promise { 95 | this._verifyInitOrThrow(); 96 | return rp({ 97 | uri: 'https://graph.facebook.com/v2.6/' + id, 98 | qs: { 99 | access_token: this._token, 100 | fields: 'first_name,last_name,profile_pic,locale,timezone,gender', 101 | }, 102 | json: true, 103 | }); 104 | } 105 | 106 | handleMessage(data: Object): void { 107 | if (data.object !== 'page') { 108 | return; 109 | } 110 | 111 | data.entry.forEach((entry) => { 112 | entry.messaging.forEach((event) => { 113 | // handle messages 114 | if (event.message) { 115 | // Since a message containing a quick_reply can also contain text 116 | // and attachment, check for quick_reply first 117 | if (event.message.quick_reply) { 118 | this.emit('quick_reply', event); 119 | return; 120 | } 121 | if (event.message.text) { 122 | this.emit('text', event); 123 | } else if (event.message.attachments) { 124 | this.emit('attachments', event); 125 | } 126 | } 127 | 128 | // handle postback 129 | if (event.postback && event.postback.payload) { 130 | this.emit('postback', event); 131 | } 132 | // Handle authentication 133 | if (event.optin && event.optin.ref) { 134 | this.emit('optin', event); 135 | } 136 | // TODO: handle message delivery 137 | }) 138 | }); 139 | } 140 | 141 | /** 142 | * send APIs 143 | */ 144 | send(recipientID: string, messageData: Message): Promise { 145 | this._verifyInitOrThrow(); 146 | return ChatUtils.send( 147 | recipientID, 148 | this._token, 149 | {message: messageData}, 150 | this._useLocalChat, 151 | this._useMessenger, 152 | ); 153 | } 154 | 155 | sendSenderAction(recipientID: string, action: SendActionType): Promise { 156 | return ChatUtils.send( 157 | recipientID, 158 | this._token, 159 | {sender_action: action}, 160 | this._useLocalChat, 161 | this._useMessenger, 162 | ); 163 | } 164 | 165 | setPersistentMenu(menuDefinition: Array): void { 166 | ChatUtils.setPersistentMenu(menuDefinition); 167 | } 168 | 169 | storePersistentMenu(): Promise { 170 | return ChatUtils.storePersistentMenu(this._token); 171 | } 172 | 173 | sendText(recipientID: string, text: string): Promise { 174 | return this.send(recipientID, {text: text}); 175 | } 176 | 177 | sendAttachment(recipientID: string, attachment: Attachment): Promise { 178 | return this.send(recipientID, {'attachment': attachment}); 179 | } 180 | 181 | sendImage(recipientID: string, url: string): Promise { 182 | return this.sendAttachment(recipientID,this.createImageAttachment(url)); 183 | } 184 | 185 | sendVideo(recipientID: string, url: string): Promise { 186 | return this.sendAttachment(recipientID, this.createVideoAttachment(url)); 187 | } 188 | 189 | sendFile(recipientID: string, url: string): Promise { 190 | return this.sendAttachment(recipientID, this.createFileAttachment(url)); 191 | } 192 | 193 | sendAudio(recipientID: string, url: string): Promise { 194 | return this.sendAttachment(recipientID, this.createAudioAttachment(url)); 195 | } 196 | 197 | sendButtons(recipientID: string, text: string, buttons: Array