├── .dockerignore ├── .ebextensions ├── files.config └── papertrail.config ├── .gitignore ├── .sequelizerc ├── Dockerfile ├── LICENSE.md ├── README.md ├── buzzbot.js ├── client ├── .gitignore ├── css │ └── base.css ├── index.html └── js │ ├── CreateForm.js │ ├── Dashboard.js │ ├── Footer.js │ ├── Layout.js │ ├── Message.js │ ├── MessageForm.js │ ├── Nav.js │ ├── PollView.js │ ├── Response.js │ ├── ResponseList.js │ ├── SendForm.js │ ├── TriggerForm.js │ ├── formatting.js │ └── index.js ├── config.js ├── db ├── .gitignore ├── controller.js ├── fixtures │ ├── example-menu-commands.js │ ├── example-messages.js │ ├── example-tags.js │ └── example-triggers.js ├── index.js ├── init.js ├── models │ ├── menu-command.js │ ├── message-event.js │ ├── message.js │ ├── responses.js │ ├── tag.js │ ├── trigger.js │ └── users.js ├── nuke.js ├── scripts │ ├── init-seq-ids.sql │ └── init-triggers.sql └── sequelize_config.js ├── docs ├── architecture.md ├── assets │ ├── buzzbot-cover.png │ ├── buzzbot-messages-back.jpg │ ├── create-message-view.png │ ├── dashboard-view.png │ ├── open-lab-logo.png │ ├── scan-fb-page-code.jpg │ ├── search-for-fb-page.png │ ├── send-message-to-buzzbot.jpg │ └── send-message-view.png ├── database-structure.md ├── installation.md └── overview.md ├── etc └── init │ └── buzzbot.conf ├── package.json ├── src ├── commands.js └── messenger-interface.js ├── test ├── .gitignore ├── README.md ├── load-test.json └── test-server.js └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Elastic Beanstalk Files 2 | .elasticbeanstalk/* 3 | .git 4 | .gitignore -------------------------------------------------------------------------------- /.ebextensions/files.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/etc/nginx/conf.d/websocketupgrade.conf" : 3 | mode: "000755" 4 | owner: root 5 | group: root 6 | content: | 7 | proxy_set_header Upgrade $http_upgrade; 8 | proxy_set_header Connection "upgrade"; -------------------------------------------------------------------------------- /.ebextensions/papertrail.config: -------------------------------------------------------------------------------- 1 | # See http://help.papertrailapp.com/kb/hosting-services/aws-elastic-beanstalk/ 2 | # Usage: 3 | # - replace with the version of remote_syslog2 you want to use. Example: .../download/v0.14/remote_syslog_linux_amd64.tar.gz 4 | # - replace with the files you want to monitor for new log lines. Example: - /var/log/httpd/access_log 5 | # - replace and with the values shown under log destinations: https://papertrailapp.com/account/destinations 6 | 7 | sources: 8 | /home/ec2-user: https://github.com/papertrail/remote_syslog2/releases/download/v0.17/remote_syslog_linux_amd64.tar.gz 9 | 10 | files: 11 | "/etc/log_files.yml": 12 | mode: "00644" 13 | owner: root 14 | group: root 15 | encoding: plain 16 | content: | 17 | files: 18 | - /var/log/eb-docker/containers/eb-current-app/*.log 19 | - /var/log/nginx/*.log 20 | hostname: ##WILL_BE_REPLACED## 21 | destination: 22 | host: logs4.papertrailapp.com 23 | port: 17050 24 | protocol: tls 25 | 26 | "/tmp/set-logger-hostname.sh": 27 | mode: "00555" 28 | owner: root 29 | group: root 30 | encoding: plain 31 | content: | 32 | #!/bin/bash 33 | logger_config="/etc/log_files.yml" 34 | appname=`{ "Ref" : "AWSEBEnvironmentName" }` 35 | instid=`wget -q -O - http://169.254.169.254/latest/meta-data/instance-id` 36 | myhostname=${appname}_${instid} 37 | 38 | if [ -f $logger_config ]; then 39 | # Sub the hostname 40 | sed "s/hostname:.*/hostname: $myhostname/" -i $logger_config 41 | fi 42 | 43 | "/etc/init.d/remote_syslog": 44 | mode: "00555" 45 | owner: root 46 | group: root 47 | encoding: plain 48 | content: | 49 | #!/bin/bash 50 | # 51 | # remote_syslog This shell script takes care of starting and stopping 52 | # remote_syslog daemon 53 | # 54 | # chkconfig: - 58 74 55 | # description: papertrail/remote_syslog \ 56 | # https://github.com/papertrail/remote_syslog2/blob/master/examples/remote_syslog.init.d 57 | 58 | ### BEGIN INIT INFO 59 | # Provides: remote_syslog 60 | # Required-Start: $network $local_fs $remote_fs 61 | # Required-Stop: $network $local_fs $remote_fs 62 | # Should-Start: $syslog $named ntpdate 63 | # Should-Stop: $syslog $named 64 | # Short-Description: start and stop remote_errolog 65 | # Description: papertrail/remote_syslog2 66 | # https://github.com/papertrail/remote_syslog2/blob/master/examples/remote_syslog.init.d 67 | ### END INIT INFO 68 | 69 | # Source function library. 70 | . /etc/init.d/functions 71 | 72 | # Source networking configuration. 73 | . /etc/sysconfig/network 74 | 75 | prog="/usr/local/bin/remote_syslog" 76 | config="/etc/log_files.yml" 77 | pid_dir="/var/run" 78 | 79 | EXTRAOPTIONS="--poll=true" 80 | 81 | pid_file="$pid_dir/remote_syslog.pid" 82 | 83 | PATH=/sbin:/bin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin 84 | 85 | RETVAL=0 86 | 87 | is_running(){ 88 | # Do we have PID-file? 89 | if [ -f "$pid_file" ]; then 90 | # Check if proc is running 91 | pid=`cat "$pid_file" 2> /dev/null` 92 | if [[ $pid != "" ]]; then 93 | exepath=`readlink /proc/"$pid"/exe 2> /dev/null` 94 | exe=`basename "$exepath"` 95 | if [[ $exe == "remote_syslog" ]]; then 96 | # Process is running 97 | return 0 98 | fi 99 | fi 100 | fi 101 | return 1 102 | } 103 | 104 | start(){ 105 | echo -n $"Starting $prog: " 106 | unset HOME MAIL USER USERNAME 107 | $prog -c $config --pid-file=$pid_file $EXTRAOPTIONS 108 | RETVAL=$? 109 | echo 110 | return $RETVAL 111 | } 112 | 113 | stop(){ 114 | echo -n $"Stopping $prog: " 115 | if (is_running); then 116 | kill `cat $pid_file` 117 | RETVAL=$? 118 | echo 119 | return $RETVAL 120 | else 121 | echo "$pid_file not found" 122 | fi 123 | } 124 | 125 | status(){ 126 | echo -n $"Checking for $pid_file: " 127 | 128 | if (is_running); then 129 | echo "found" 130 | else 131 | echo "not found" 132 | fi 133 | } 134 | 135 | reload(){ 136 | restart 137 | } 138 | 139 | restart(){ 140 | stop 141 | start 142 | } 143 | 144 | condrestart(){ 145 | is_running && restart 146 | return 0 147 | } 148 | 149 | # See how we were called. 150 | case "$1" in 151 | start) 152 | start 153 | ;; 154 | stop) 155 | stop 156 | ;; 157 | status) 158 | status 159 | ;; 160 | restart) 161 | restart 162 | ;; 163 | reload) 164 | reload 165 | ;; 166 | condrestart) 167 | condrestart 168 | ;; 169 | *) 170 | echo $"Usage: $0 {start|stop|status|restart|condrestart|reload}" 171 | RETVAL=1 172 | esac 173 | 174 | exit $RETVAL 175 | 176 | container_commands: 177 | 00_stop_service: 178 | command: "/sbin/service remote_syslog stop" 179 | ignoreErrors: true 180 | 181 | 01_set_logger_hostname: 182 | command: ". /tmp/set-logger-hostname.sh" 183 | 184 | 02_install_remote_syslog_binary: 185 | command: "cp /home/ec2-user/remote_syslog/remote_syslog /usr/local/bin" 186 | 187 | 03_enable_service: 188 | command: "/sbin/chkconfig remote_syslog on" 189 | 190 | 04_start_service: 191 | command: "/sbin/service remote_syslog restart" 192 | ignoreErrors: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | node_modules 4 | npm-debug.log* 5 | 6 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | config: './db/sequelize_config.js', 3 | migrationsPath: './db/migrations', 4 | modelsPath: './db/models' 5 | }; 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:argon 2 | 3 | 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | COPY package.json /usr/src/app/ 8 | RUN npm install 9 | 10 | COPY . /usr/src/app 11 | 12 | EXPOSE 8080 13 | 14 | CMD ["npm", "run", "init"] 15 | CMD [ "npm", "start" ] 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Buzzfeed Open Lab and [contributors](https://github.com/buzzfeed-openlab/buzzbot/graphs/contributors) 4 | 5 | ``` 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | ``` 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | BuzzBot 3 |
4 | 5 | # BuzzBot 6 | 7 | BuzzBot is an experiment, designed to help journalists connect with people on the ground at events. It acts as a Facebook Messenger bot plus a dashboard that journalists can use to send messages to groups of users and view aggregated responses. 8 | 9 | BuzzFeed News and Open Lab deployed BuzzBot at the 2016 conventions. 10 | 11 | ### Jump to: 12 | 13 | - [Overview](./docs/overview.md) 14 | - [Architecture](./docs/architecture.md) 15 | - [Installation](./docs/installation.md) 16 | 17 | ======= 18 | 19 | #### Written by [Westley Argentum Hennigh-Palermo](mailto:WestleyArgentum@gmail.com) at the [Open Lab](https://www.buzzfeed.com/openlab) 20 | 21 | The Open Lab for Journalism, Technology, and the Arts is a workshop in BuzzFeed’s San Francisco bureau. We offer fellowships to artists, programmers, and storytellers to spend a year making new work in a collaborative environment. 22 | 23 |
24 | OpenLab 25 |
26 | -------------------------------------------------------------------------------- /buzzbot.js: -------------------------------------------------------------------------------- 1 | 2 | import path from 'path'; 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | import request from 'request'; 6 | import webpack from 'webpack'; 7 | import webpackConfig from './webpack.config.js'; 8 | 9 | import db, { Controller, pg, User, Tag } from './db'; 10 | import Commands from './src/commands'; 11 | import { 12 | sendMessage, 13 | sendMessagesSequentially, 14 | fetchUserInfo, 15 | markSeen, 16 | updatePersistentMenu, 17 | turnOnGetStartedButton, 18 | } from './src/messenger-interface'; 19 | 20 | import config from './config.js'; 21 | 22 | // clear user table 23 | // User.destroy({ where: {} }).then(() => { 24 | // console.log('CLEARED USER TABLE'); 25 | // }); 26 | 27 | updatePersistentMenu(config.pageToken); 28 | turnOnGetStartedButton(config.pageToken); 29 | 30 | var app = express(); 31 | 32 | // Middleware ------- 33 | 34 | // enable webpack hot reloading in development 35 | if (config.env === 'development') { 36 | const compiler = webpack(webpackConfig); 37 | app.use(require('webpack-dev-middleware')(compiler, { 38 | noInfo: true, 39 | publicPath: webpackConfig.output.publicPath 40 | })); 41 | 42 | app.use(require('webpack-hot-middleware')(compiler)); 43 | } 44 | 45 | // serve up the admin page 46 | app.use('/admin', express.static(path.join(__dirname, 'client'))); 47 | 48 | // body parsing 49 | app.use(bodyParser.json()); 50 | app.use(bodyParser.urlencoded({ extended: false })); 51 | 52 | // error handling 53 | app.use(function (error, req, res, next) { 54 | if (error) { 55 | console.log('ERROR: ', error); 56 | } else { 57 | next(); 58 | } 59 | }); 60 | 61 | // ------- 62 | 63 | // Handler functions ------- 64 | 65 | function setContainsAll(set, items) { 66 | for (var i = 0; i < items.length; ++i) { 67 | if (!set.has(items[i])) { 68 | return false; 69 | } 70 | } 71 | 72 | return true; 73 | } 74 | 75 | function parseTag(tag) { 76 | var tagData = tag.split(':'); 77 | return { mid: tagData[0], tag: tagData[1] }; 78 | } 79 | 80 | function normalizeText(text) { 81 | return String(text).toUpperCase(); 82 | } 83 | 84 | function handleIncomingMessage(token, event) { 85 | const userId = event.sender.id; 86 | 87 | Controller.getOrCreateUser(userId).spread((user, createdUser) => { 88 | const props = { 89 | text: event.message.text, 90 | attachments: event.message.attachments 91 | }; 92 | 93 | !props.text && !props.attachments && console.log('WARNING, unknown event:', event, 'from user:', userId); 94 | 95 | if (createdUser) { 96 | startInitialConversation(token, userId); 97 | 98 | return Controller.createResponse(userId, props).then(() => { console.log('PERSISTED GREETING'); }); 99 | } 100 | 101 | Controller.getMessageEventsForUser(userId).then((messageEvents) => { 102 | // look for messages expecting unstructured replies 103 | var message; 104 | for (var i = 0; i < messageEvents.length; ++i) { 105 | if (messageEvents[i].Message.unstructuredReply) { 106 | message = messageEvents[i].Message; 107 | props.messageId = message.id; 108 | break; 109 | } 110 | } 111 | 112 | const normalizedText = normalizeText(props.text); 113 | 114 | // check for special types of responses (commands, polls) 115 | if (Commands[normalizedText]) { 116 | Commands[normalizedText](token, event, user); 117 | 118 | } else if (message && message.poll && props.text) { 119 | Controller.getUserResponsesToMessage(userId, message.id).then((responses) => { 120 | // don't let users vote more than once 121 | if (responses.length) { 122 | return; 123 | } 124 | 125 | var pollData = JSON.parse(message.poll); 126 | 127 | pollData[props.text] = (pollData[props.text] || 0) + 1; 128 | message.update({ poll: JSON.stringify(pollData) }); 129 | }); 130 | } 131 | 132 | // persist the response 133 | Controller.createResponse(userId, props).then(() => { 134 | console.log('PERSISTED RESPONSE'); 135 | }); 136 | 137 | markSeen(token, userId); 138 | 139 | // trigger additional messages 140 | if (message) { 141 | Controller.getMessagesForTriggerFromMessage(message).then((triggeredMessages) => { 142 | sendMessagesSequentially(token, userId, triggeredMessages); 143 | }); 144 | } 145 | }); 146 | }); 147 | } 148 | 149 | function handlePostBack(token, event) { 150 | var userId = event.sender.id, 151 | payload = event.postback.payload; 152 | 153 | Controller.getOrCreateUser(userId).spread((user, createdUser) => { 154 | var tagData = parseTag(payload); 155 | 156 | // check for new users, start conversation and ignore initial postback 157 | if (createdUser) { 158 | startInitialConversation(token, userId); 159 | 160 | return Controller.createResponse(userId, { 161 | text: tagData.tag 162 | }); 163 | } 164 | 165 | // check for paused, until the user resumes postbacks should not be recorded 166 | if (user.state === 'paused') { 167 | return; 168 | } 169 | 170 | // check for command postback 171 | if (tagData.mid === 'command') { 172 | const normalizedCommand = normalizeText(tagData.tag); 173 | 174 | if (Commands[normalizedCommand]) { 175 | Commands[normalizedCommand](token, event, user); 176 | 177 | Controller.createResponse(userId, { 178 | text: tagData.tag 179 | }); 180 | } 181 | 182 | return; 183 | } 184 | 185 | // otherwise, handle message postback 186 | Controller.getTag({ messageId: tagData.mid, tag: tagData.tag }).then((tag) => { 187 | user.addTag(tag); 188 | 189 | Controller.createResponse(userId, { 190 | text: tagData.tag, 191 | messageId: tagData.mid, 192 | tagId: tag.id 193 | }); 194 | 195 | markSeen(token, userId); 196 | 197 | // trigger additional messages 198 | Controller.getMessagesForTriggerFromTag(tag).then((triggeredMessages) => { 199 | sendMessagesSequentially(token, userId, triggeredMessages); 200 | }); 201 | }); 202 | }); 203 | } 204 | 205 | function startInitialConversation(token, userId) { 206 | console.log('starting initial conversation with user:', userId); 207 | 208 | fetchUserInfo(token, userId); 209 | 210 | Controller.getInitialMessages().then((messages) => { 211 | sendMessagesSequentially(token, userId, messages); 212 | }); 213 | } 214 | 215 | // ------- 216 | 217 | // Routes ------- 218 | 219 | app.get('/health', function (req, res) { 220 | db.Message.findOne({ where: {} }).then((user) => { 221 | res.status(200).json({ 222 | api: 200, 223 | db: 200 224 | }); 225 | }).catch((err) => { 226 | res.status(503).json({ 227 | api: 200, 228 | db:503 229 | }); 230 | }); 231 | }); 232 | 233 | app.get('/hook/', function (req, res) { 234 | if (req.query['hub.verify_token'] === config.verifyToken) { 235 | console.log('success, verified hook!'); 236 | return res.send(req.query['hub.challenge']); 237 | } 238 | 239 | console.log('ERROR: failed to verify hook...'); 240 | return res.send('Error, wrong validation token'); 241 | }); 242 | 243 | app.post('/hook/', function (req, res) { 244 | var status = 200; 245 | 246 | const entries = req.body.entry; 247 | if (!entries) { 248 | return res.sendStatus(400); 249 | } 250 | 251 | for (var e = 0; e < entries.length; ++e) { 252 | var messaging_events = entries[e].messaging; 253 | 254 | for (var i = 0; i < messaging_events.length; i++) { 255 | var event = messaging_events[i]; 256 | 257 | if (event.message) { 258 | handleIncomingMessage(config.pageToken, event); 259 | } else if (event.postback) { 260 | handlePostBack(config.pageToken, event); 261 | } else { 262 | console.log('unknown event type: ', event); 263 | } 264 | } 265 | } 266 | 267 | res.sendStatus(status); 268 | }); 269 | 270 | app.post('/messages/', function (req, res) { 271 | if (!req.body.message) { 272 | return res.sendStatus(400); 273 | } 274 | 275 | const messageData = req.body.message; 276 | const metadata = req.body.metadata; 277 | var unstructuredReply = req.body.unstructuredReply || false; 278 | var poll = req.body.poll ? '{}' : undefined; 279 | var surpriseMe = req.body.surpriseMe || false; 280 | 281 | if ((poll && !unstructuredReply) || (surpriseMe && !unstructuredReply)) { 282 | console.log('WARNING: received request to create poll or "surprise me" message with unstructuredReply == false, setting it to true...'); 283 | unstructuredReply = true; 284 | } 285 | 286 | Controller.createMessageAndTags(messageData, unstructuredReply, poll, surpriseMe, metadata).then((message) => { 287 | console.log('CREATED MESSAGE:', message.get({plain: true})); 288 | res.status(200).json(message.get({ plain: true })); 289 | }).catch((err) => { 290 | console.log('ERROR creating message: ', err); 291 | res.status(500).json(err); 292 | }); 293 | }); 294 | 295 | app.post('/send/', function (req, res) { 296 | if (!req.body.messageId) { 297 | return res.status(400).json({ message: '`messageId` must be specified in request' }); 298 | } 299 | 300 | const requiredTags = req.body.tagIds.map((t) => +t); 301 | 302 | Controller.getAllActiveUserIds().then((users) => { 303 | Controller.getMessage(req.body.messageId).then((message) => { 304 | for (var i = 0; i < users.length; ++i) { 305 | const user = users[i]; 306 | user.getTags().then((tags) => { 307 | if (requiredTags && !setContainsAll(new Set(tags.map((t) => t.id)), requiredTags)) { 308 | return; 309 | } 310 | 311 | sendMessage(config.pageToken, user.id, message); 312 | }); 313 | } 314 | 315 | res.sendStatus(200); 316 | }); 317 | }).catch((err) => { 318 | console.log('ERROR sending message: ', err); 319 | res.status(400).json(err); 320 | }); 321 | }); 322 | 323 | app.post('/triggers/', function (req, res) { 324 | 325 | const triggerTagId = req.body.triggerTagId, 326 | triggerTag = req.body.triggerTag, 327 | triggerMessageId = req.body.triggerMessageId, 328 | messages = req.body.messages; 329 | 330 | if (!(triggerTagId || triggerMessageId || (triggerTag && triggerMessageId)) || !messages) { 331 | return res.status(400).json({ message: '`triggerTagId` or `triggerMessageId` or `triggerTag` + `triggerMessageId` must be specified, along with `messages`' }); 332 | } 333 | 334 | if (triggerTagId || triggerTag) { 335 | const tagData = { 336 | id: req.body.triggerTagId, 337 | messageId: req.body.triggerMessageId, 338 | tag: req.body.triggerTag 339 | } 340 | 341 | Controller.getTag(tagData).then((tag) => { 342 | Controller.getOrCreateTriggerWithTag(tag.id, messages).then((trigger) => { 343 | console.log('CREATED TRIGGER: ', trigger.get({plain: true})); 344 | res.sendStatus(200); 345 | }); 346 | }).catch((err) => { 347 | console.log('ERROR creating trigger with tag: ', err); 348 | res.status(500).json(err); 349 | }); 350 | } else if (triggerMessageId) { 351 | Controller.getOrCreateTriggerWithMessage(triggerMessageId, messages).then((trigger) => { 352 | console.log('CREATED TRIGGER: ', trigger.get({plain: true})); 353 | res.sendStatus(200); 354 | }).catch((err) => { 355 | console.log('ERROR creating trigger with message', err); 356 | res.status(500).json(err); 357 | }); 358 | } 359 | }); 360 | 361 | // ------- 362 | 363 | // Websockets ------- 364 | 365 | const server = require('http').Server(app); 366 | const io = require('socket.io')(server); 367 | 368 | io.on('connection', function (socket) { 369 | socket.on('get-responses', (options) => { 370 | Controller.getResponses({ 371 | where: {}, 372 | limit: options.limit || 300, 373 | order: '"updatedAt" DESC' 374 | }).then((responses) => { 375 | socket.emit('responses', responses.map((r) => r.get({ plain: true }))); 376 | }); 377 | }); 378 | 379 | socket.on('get-messages', (options = {}) => { 380 | Controller.getMessages(options.messageIds).then((messages) => { 381 | socket.emit('messages', messages.map((m) => m.get({ plain: true }))); 382 | }); 383 | }); 384 | 385 | socket.on('get-tags', (options) => { 386 | Controller.getTags(options).then((tags) => { 387 | socket.emit('tags', tags.map((t) => t.get({ plain: true }))); 388 | }); 389 | }); 390 | 391 | socket.on('get-users', (options = {}) => { 392 | Controller.getUsers(options.userIds).then((users) => { 393 | socket.emit('users', users.map((u) => u.get({ plain: true }))); 394 | }); 395 | }); 396 | 397 | socket.on('error', (err) => { 398 | console.log('SOCKET ERROR: ', err); 399 | }); 400 | }); 401 | 402 | pg.connect(function(err) { 403 | if(err) { 404 | console.log('ERROR connecting to database with pg: ', err); 405 | } 406 | pg.on('notification', function(msg) { 407 | const payloadData = msg.payload.split(','); 408 | 409 | if (msg.channel == 'responses') { 410 | Controller.getResponse(payloadData[2]).then((response) => { 411 | io.emit('new-response', response.get({ plain: true })); 412 | }); 413 | } else if (msg.channel == 'users') { 414 | Controller.getUser(payloadData[2]).then((user) => { 415 | io.emit('users', [ user.get({ plain: true }) ]); 416 | }); 417 | } else { 418 | return console.log('UNKNOWN DB EVENT: ', msg); 419 | } 420 | }); 421 | var responsesQuery = pg.query("LISTEN responses"); 422 | var usersQuery = pg.query("LISTEN users"); 423 | }); 424 | 425 | // ------- 426 | 427 | console.log('STARTING SERVER 🎉'); 428 | console.log('env: ', config.env); 429 | console.log('port: ', config.port); 430 | console.log('-------'); 431 | 432 | 433 | server.listen(config.port); 434 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | index.min.js -------------------------------------------------------------------------------- /client/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #fff; 3 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | font-size: 15px; 5 | line-height: 1.7; 6 | margin: 0; 7 | padding: 30px; 8 | } 9 | 10 | a { 11 | color: #4183c4; 12 | text-decoration: none; 13 | } 14 | 15 | a:hover { 16 | text-decoration: underline; 17 | } 18 | 19 | code { 20 | background-color: #f8f8f8; 21 | border: 1px solid #ddd; 22 | border-radius: 3px; 23 | font-family: "Bitstream Vera Sans Mono", Consolas, Courier, monospace; 24 | font-size: 12px; 25 | margin: 0 2px; 26 | padding: 0 5px; 27 | } 28 | 29 | h1, h2, h3, h4 { 30 | font-weight: bold; 31 | margin: 0 0 15px; 32 | padding: 0; 33 | } 34 | 35 | h1 { 36 | border-bottom: 1px solid #ddd; 37 | font-size: 2.5em; 38 | font-weight: bold; 39 | margin: 0 0 15px; 40 | padding: 0; 41 | } 42 | 43 | h2 { 44 | border-bottom: 1px solid #eee; 45 | font-size: 2em; 46 | } 47 | 48 | h3 { 49 | font-size: 1.5em; 50 | } 51 | 52 | h4 { 53 | font-size: 1.2em; 54 | } 55 | 56 | p, ul { 57 | margin: 15px 0; 58 | } 59 | 60 | ul { 61 | padding-left: 30px; 62 | } 63 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | BuzzBot 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /client/js/CreateForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactBootstrap, { Row, Col } from 'react-bootstrap'; 3 | 4 | import MessageForm from './MessageForm'; 5 | import TriggerForm from './TriggerForm'; 6 | 7 | export default class CreateForm extends React.Component { 8 | constructor() { 9 | super(); 10 | } 11 | 12 | render() { 13 | return ( 14 |
15 | 16 | 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/js/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactBootstrap, { Row, Col } from 'react-bootstrap'; 3 | import update from 'react-addons-update'; 4 | 5 | import ResponseList from './ResponseList'; 6 | import PollView from './PollView'; 7 | 8 | export default class Dashboard extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | responses: {}, 14 | responsesQueue: [], 15 | messages: {}, 16 | users: {}, 17 | usersQueue: [], 18 | renderCounter: 1 19 | } 20 | 21 | this.handleResponses = this.handleResponses.bind(this); 22 | this.handleMessages = this.handleMessages.bind(this); 23 | this.queueResponse = this.queueResponse.bind(this); 24 | this.queueUsers = this.queueUsers.bind(this); 25 | } 26 | 27 | componentWillMount() { 28 | const socket = this.props.route.socket; 29 | 30 | socket.on('responses', this.handleResponses); 31 | socket.on('new-response', this.queueResponse); 32 | socket.on('messages', this.handleMessages); 33 | socket.on('users', this.queueUsers); 34 | 35 | socket.emit('get-responses', { limit: 1000 }); 36 | 37 | this.intervals = [ 38 | setInterval(this.processResponseAndUserQueues.bind(this), 10000) 39 | ]; 40 | } 41 | 42 | componentWillUnmount() { 43 | const socket = this.props.route.socket; 44 | 45 | socket.removeListener('responses', this.handleResponses); 46 | socket.removeListener('new-response', this.queueResponse); 47 | socket.removeListener('messages', this.handleMessages); 48 | socket.removeListener('users', this.queueUsers); 49 | 50 | this.intervals.forEach(clearInterval); 51 | } 52 | 53 | shouldComponentUpdate(nextProps, nextState) { 54 | if (this.state.renderCounter == nextState.renderCounter) { 55 | return false; 56 | } 57 | 58 | return true; 59 | } 60 | 61 | render() { 62 | console.log('RENDER DASHBOARD'); 63 | 64 | const responseLists = Object.keys(this.state.responses).map((listKey) => { 65 | const message = this.state.messages[listKey]; 66 | 67 | if (message && message.poll) { 68 | return ( 69 | 70 | 71 | 72 | ); 73 | } 74 | 75 | return ( 76 | 77 | 82 | 83 | ); 84 | }); 85 | 86 | // bump the unassociated column up to the top 87 | for (var i = 0; i < responseLists.length; ++i) { 88 | if (responseLists[i].key == 'none') { 89 | const noneList = responseLists.splice(i, 1); 90 | responseLists.splice(0, 0, ...noneList); 91 | break; 92 | } 93 | } 94 | 95 | return ( 96 |
97 | 98 | {responseLists} 99 | 100 |
101 | ); 102 | } 103 | 104 | handleResponses(responses) { 105 | const responseState = {}, 106 | mesageIds = [], 107 | userIds = []; 108 | 109 | for (var i = 0; i < responses.length; ++i) { 110 | const response = responses[i]; 111 | const messageId = response.messageId || 'none'; 112 | 113 | if (!responseState[messageId]) { 114 | responseState[messageId] = []; 115 | } 116 | 117 | if (messageId != 'none') { 118 | mesageIds.push(messageId); 119 | } 120 | 121 | userIds.push(response.userId); 122 | 123 | responseState[messageId].push(response); 124 | } 125 | 126 | // fetch the message data relevant to these responses 127 | if (mesageIds.length) { 128 | this.props.route.socket.emit('get-messages', { 129 | messageIds: [ ...new Set(mesageIds) ] 130 | }); 131 | } 132 | 133 | // fetch the user data relevant to these responses 134 | if (userIds.length) { 135 | this.props.route.socket.emit('get-users', { 136 | userIds: [ ...new Set(userIds) ] 137 | }); 138 | } 139 | 140 | this.setState(update(this.state, { 141 | responses: { 142 | $set: responseState 143 | }, 144 | renderCounter: { 145 | $set: this.state.renderCounter + 1 146 | } 147 | })); 148 | } 149 | 150 | handleMessages(messages) { 151 | const messageState = {}; 152 | 153 | for (var i = 0; i < messages.length; ++i) { 154 | const message = messages[i]; 155 | 156 | messageState[message.id] = { 157 | $set: message 158 | } 159 | } 160 | 161 | this.setState(update(this.state, { 162 | messages: messageState, 163 | renderCounter: { 164 | $set: this.state.renderCounter + 1 165 | } 166 | })); 167 | } 168 | 169 | queueUsers(users) { 170 | const newState = update(this.state, { 171 | usersQueue: { 172 | $push: users 173 | } 174 | }); 175 | 176 | this.setState(newState); 177 | } 178 | 179 | queueResponse(response) { 180 | // if we don't have the data for this message, request it 181 | if (response.messageId && !this.state.messages[response.messageId]) { 182 | this.props.route.socket.emit('get-messages', { 183 | messageIds: [ response.messageId ] 184 | }); 185 | } 186 | 187 | // queue the response 188 | const newState = update(this.state, { 189 | responsesQueue: { 190 | $push: [response] 191 | } 192 | }); 193 | 194 | this.setState(newState); 195 | } 196 | 197 | processResponseAndUserQueues() { 198 | const responseState = {}, 199 | userState = {}, 200 | messagesToUpdate = []; 201 | 202 | console.log(this.state.responsesQueue.length, 'NEW RESPONSES'); 203 | console.log(this.state.usersQueue.length, 'NEW USERS'); 204 | 205 | for (var i = 0; i < this.state.responsesQueue.length; ++i) { 206 | const response = this.state.responsesQueue[i]; 207 | const messageId = response.messageId || 'none'; 208 | 209 | var updateKey = this.state.responses[messageId] ? '$unshift' : '$set'; 210 | if (!responseState[messageId]) { 211 | responseState[messageId] = { 212 | [updateKey]: [] 213 | }; 214 | } 215 | 216 | responseState[messageId][updateKey].unshift(response); 217 | 218 | // If this response was to a poll message, our message data is 219 | // out of date. Re request it! Done while processing to avoid 220 | // constant re-rendering. 221 | if (this.state.messages[messageId] && 222 | this.state.messages[messageId].poll) { 223 | 224 | messagesToUpdate.push(messageId); 225 | } 226 | } 227 | 228 | for (var i = 0; i < this.state.usersQueue.length; ++i) { 229 | const user = this.state.usersQueue[i]; 230 | 231 | userState[user.id] = { 232 | $set: user 233 | } 234 | } 235 | 236 | // update messages 237 | if (messagesToUpdate.length) { 238 | this.props.route.socket.emit('get-messages', { 239 | messageIds: [ ...new Set(messagesToUpdate) ] 240 | }); 241 | } 242 | 243 | this.setState(update(this.state, { 244 | responses: responseState, 245 | users: userState, 246 | responsesQueue: { $set: [] }, 247 | usersQueue: { $set: [] }, 248 | renderCounter: { 249 | $set: this.state.renderCounter + 1 250 | } 251 | })); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /client/js/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactBootstrap, { Row, Col } from 'react-bootstrap'; 3 | 4 | 5 | export default class Footer extends React.Component { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | render() { 11 | return ( 12 | 13 | 14 |

This is an Open Lab project -- email WestleyArgentum@gmail.com with any questions / feedback

15 | 16 |
17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/js/Layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import ReactBootstrap, { Row, Col } from 'react-bootstrap'; 4 | 5 | import Footer from "./Footer"; 6 | import Nav from "./Nav"; 7 | 8 | export default class Layout extends React.Component { 9 | render() { 10 | const { location } = this.props; 11 | const containerStyle = { 12 | marginTop: "60px" 13 | }; 14 | 15 | return ( 16 |
17 | 18 |
30 | 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/js/Message.js: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import ReactBootstrap, { 4 | Row, 5 | Col, 6 | ListGroup, 7 | ListGroupItem, 8 | } from 'react-bootstrap'; 9 | 10 | export default class Message extends React.Component { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | render() { 16 | if (!this.props.message) { 17 | return ( 18 |
19 | 20 | 21 |

No Associated Message

22 | 23 |
24 | 25 | 26 |
27 | The responses below are unprompted. People may be reaching out with something to share, or just saying hi. 28 |
29 | 30 |
31 |
32 | ); 33 | } 34 | 35 | const messageData = JSON.parse(this.props.message.data); 36 | var messageText, buttonList; 37 | 38 | if (messageData.attachment && messageData.attachment.payload) { 39 | const data = messageData.attachment.payload; 40 | 41 | messageText = data.text; 42 | 43 | if (data.buttons) { 44 | buttonList = ( 45 |
46 | {data.buttons.map((b) => b.title).join(' | ')} 47 |
48 | ); 49 | } 50 | 51 | } else if (messageData.text) { 52 | messageText = messageData.text; 53 | } else { 54 | console.log('WARNING unknown message type: ', messageData); 55 | } 56 | 57 | if (this.props.message.metadata) { 58 | messageText += ` [metadata: ${this.props.message.metadata}]` 59 | } 60 | 61 | return ( 62 |
63 | 64 | 65 | 66 | 67 |

{messageText}

68 | 69 |
70 | 71 | 72 | {buttonList} 73 | 74 | 75 |
76 | Message Id: {this.props.message.id} 77 |
78 | 79 |
80 | 81 |
82 |
83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client/js/MessageForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import update from 'react-addons-update'; 3 | import ReactBootstrap, { 4 | Row, 5 | Col, 6 | FormGroup, 7 | ControlLabel, 8 | FormControl, 9 | HelpBlock, 10 | Form, 11 | Checkbox, 12 | Button, 13 | Radio, 14 | InputGroup 15 | } from 'react-bootstrap'; 16 | 17 | import request from 'axios'; 18 | 19 | 20 | export default class MessageForm extends React.Component { 21 | constructor() { 22 | super(); 23 | 24 | this.submitMessage = this.submitMessage.bind(this); 25 | 26 | this.shouldDisableButtons = this.shouldDisableButtons.bind(this); 27 | this.validateMessageText = this.validateMessageText.bind(this); 28 | this.validateButton = this.validateButton.bind(this); 29 | this.validateAll = this.validateAll.bind(this); 30 | 31 | this.handleMessageTextChange = this.handleMessageTextChange.bind(this); 32 | this.handleMetadataTextChange = this.handleMetadataTextChange.bind(this); 33 | this.handleButtonChange = this.handleButtonChange.bind(this); 34 | this.handleUnstructuredChange = this.handleUnstructuredChange.bind(this); 35 | this.handlePollChange = this.handlePollChange.bind(this); 36 | this.handleSurpriseMeChange = this.handleSurpriseMeChange.bind(this); 37 | 38 | this.state = { 39 | messageText: '', 40 | mediaType: 'text', 41 | unstructuredReply: false, 42 | poll: false, 43 | surpriseMe: false, 44 | buttons: [ 45 | { text: '', tag: '' }, 46 | { text: '', tag: '' }, 47 | { text: '', tag: '' }, 48 | ], 49 | }; 50 | } 51 | 52 | render() { 53 | return ( 54 | 55 | 56 |
57 |

Create a Message

58 | 62 | Message text (or media url): 63 | 69 | 70 | 71 | 72 | 78 | Text 79 | 80 | {' '} 81 | 87 | Image URL 88 | 89 | {' '} 90 | 96 | Video URL 97 | 98 | 99 | 100 | 101 | Metadata (only visible in amdin interface): 102 | 108 | 109 | 113 | Expect unstructured reply   (users will be able to respond with arbitray text or media) 114 | 115 | 116 | 120 | Poll question   (must expect unstructured reply) 121 | 122 | 123 | 127 | "Surprise Me" Message   (must expect unstructured reply) 128 | 129 | 130 | 131 | 132 | Button 1: 133 | {' '} 134 | 141 | 142 | {' '} 143 | 150 | 151 | 152 | 153 |
154 | 155 | Button 2: 156 | {' '} 157 | 164 | 165 | {' '} 166 | 173 | 174 | 175 |
176 |
177 | 178 | Button 3: 179 | {' '} 180 | 187 | 188 | {' '} 189 | 196 | 197 | 198 |
199 | 205 | 206 | 207 |
208 | ); 209 | } 210 | 211 | submitMessage() { 212 | var buttonData = []; 213 | for (var i = 0; i < this.state.buttons.length; ++i) { 214 | if (this.state.buttons[i].text) { 215 | const button = this.state.buttons[i]; 216 | buttonData.push({ 217 | "title": button.text, 218 | "type": "postback", 219 | "payload": button.tag 220 | }); 221 | } 222 | } 223 | 224 | var messageBody; 225 | if (this.state.unstructuredReply || this.state.poll || !buttonData.length) { 226 | const mediaType = this.state.mediaType; 227 | if (mediaType == 'text') { 228 | messageBody = { 229 | "message": { 230 | "text": this.state.messageText 231 | }, 232 | } 233 | } else if (mediaType == 'image') { 234 | messageBody = { 235 | "message": { 236 | "attachment": { 237 | "type":"image", 238 | "payload": { 239 | "url": this.state.messageText 240 | } 241 | } 242 | } 243 | } 244 | } else if (mediaType == 'video') { 245 | messageBody = { 246 | "message": { 247 | "attachment": { 248 | "type":"video", 249 | "payload": { 250 | "url": this.state.messageText 251 | } 252 | } 253 | } 254 | } 255 | } 256 | 257 | messageBody.unstructuredReply = this.state.unstructuredReply; 258 | messageBody.poll = this.state.poll; 259 | messageBody.surpriseMe = this.state.surpriseMe; 260 | 261 | } else if (buttonData.length) { 262 | messageBody = { 263 | "message": { 264 | "attachment": { 265 | "type":"template", 266 | "payload": { 267 | "template_type": "button", 268 | "text": this.state.messageText, 269 | "buttons": buttonData 270 | } 271 | } 272 | } 273 | } 274 | } else { 275 | console.log('ERROR BUILDING MESSAGE DATA'); 276 | } 277 | 278 | if (this.state.metadata) { 279 | messageBody.metadata = this.state.metadata; 280 | } 281 | 282 | request.post('/messages', messageBody).then((response) => { 283 | window.location.reload(); 284 | }).catch((response) => { 285 | console.log('ERROR POSTING NEW MESSAGE: ', response); 286 | }); 287 | } 288 | 289 | shouldDisableButtons() { 290 | return this.state.unstructuredReply || this.state.poll || this.state.surpriseMe; 291 | } 292 | 293 | validateMessageText() { 294 | if (!this.state.messageText || !this.state.messageText.length) { 295 | return 'error'; 296 | } 297 | 298 | return 'success'; 299 | } 300 | 301 | validateButton(index) { 302 | const buttonState = this.state.buttons[index]; 303 | 304 | if (!buttonState) { 305 | return 'success'; 306 | } 307 | 308 | // if button text is set but tag isn't, 309 | // or if button text is too long 310 | if ((buttonState.text && !buttonState.tag) || 311 | (buttonState.text.length > 20)) { 312 | return 'error'; 313 | } 314 | 315 | // if button tag is set but text isn't, 316 | // or if button tag is too long 317 | if ((buttonState.tag && !buttonState.text) || 318 | (buttonState.tag.length > 20)) { 319 | return 'error'; 320 | } 321 | 322 | // if anything is set while expecting an unstructured reply 323 | if ((buttonState.tag || buttonState.text) && this.state.unstructuredReply) { 324 | return 'error'; 325 | } 326 | 327 | return 'success'; 328 | } 329 | 330 | validateAll() { 331 | if (this.validateMessageText() == 'error') { 332 | return 'error'; 333 | } 334 | 335 | if (!this.state.unstructuredReply && 336 | (this.validateButton(0) == 'error' || 337 | this.validateButton(1) == 'error' || 338 | this.validateButton(2) == 'error')) { 339 | 340 | return 'error'; 341 | } 342 | 343 | return 'success'; 344 | } 345 | 346 | handleMessageTextChange(e) { 347 | const newState = update(this.state, { 348 | messageText: { 349 | $set: e.target.value 350 | } 351 | }); 352 | 353 | this.setState(newState); 354 | } 355 | 356 | handleMediaTypeChange(type, e) { 357 | const newState = update(this.state, { 358 | mediaType: { 359 | $set: type 360 | } 361 | }); 362 | 363 | this.setState(newState); 364 | } 365 | 366 | handleMetadataTextChange(e) { 367 | const newState = update(this.state, { 368 | metadata: { 369 | $set: e.target.value 370 | } 371 | }); 372 | 373 | this.setState(newState); 374 | } 375 | 376 | handleButtonChange(index, field, e) { 377 | const newState = update(this.state, { 378 | buttons: { 379 | [index]: { 380 | [field]: { 381 | $set: e.target.value 382 | } 383 | } 384 | } 385 | }); 386 | 387 | this.setState(newState); 388 | } 389 | 390 | handleUnstructuredChange(e) { 391 | const newState = update(this.state, { 392 | unstructuredReply: { 393 | $set: e.target.checked 394 | } 395 | }); 396 | 397 | this.setState(newState); 398 | } 399 | 400 | handlePollChange(e) { 401 | const newState = update(this.state, { 402 | poll: { 403 | $set: e.target.checked 404 | }, 405 | unstructuredReply: { 406 | $set: e.target.checked 407 | } 408 | }); 409 | 410 | this.setState(newState); 411 | } 412 | 413 | handleSurpriseMeChange(e) { 414 | const newState = update(this.state, { 415 | surpriseMe: { 416 | $set: e.target.checked 417 | }, 418 | unstructuredReply: { 419 | $set: e.target.checked 420 | } 421 | }); 422 | 423 | this.setState(newState); 424 | } 425 | } -------------------------------------------------------------------------------- /client/js/Nav.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IndexLink, Link } from "react-router"; 3 | 4 | export default class Nav extends React.Component { 5 | constructor() { 6 | super() 7 | this.state = { 8 | collapsed: true, 9 | }; 10 | } 11 | 12 | toggleCollapse() { 13 | const collapsed = !this.state.collapsed; 14 | this.setState({collapsed}); 15 | } 16 | 17 | render() { 18 | const { location } = this.props; 19 | const { collapsed } = this.state; 20 | const dashboardClass = location.pathname === "/" ? "active" : ""; 21 | const messageFormClass = location.pathname.match(/^\/create-messages/) ? "active" : ""; 22 | const sendFormClass = location.pathname.match(/^\/send-messages/) ? "active" : ""; 23 | const navClass = collapsed ? "collapse" : ""; 24 | 25 | return ( 26 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/js/PollView.js: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import ReactList from 'react-list'; 4 | import update from 'react-addons-update'; 5 | import ReactBootstrap, { 6 | Row, 7 | Col, 8 | ListGroup, 9 | ListGroupItem, 10 | } from 'react-bootstrap'; 11 | 12 | import Message from './Message'; 13 | 14 | export default class PollView extends React.Component { 15 | constructor() { 16 | super(); 17 | 18 | this.state = { 19 | pollResults: [] 20 | } 21 | 22 | this.renderPollResults = this.renderPollResults.bind(this); 23 | } 24 | 25 | componentWillMount() { 26 | const pollData = JSON.parse(this.props.message.poll); 27 | this.updatePollResults(pollData); 28 | } 29 | 30 | componentWillReceiveProps(props) { 31 | const pollData = JSON.parse(props.message.poll); 32 | this.updatePollResults(pollData); 33 | } 34 | 35 | render() { 36 | const messageBoxStyle = { 37 | minHeight: 100, 38 | }; 39 | 40 | const listBoxStyle = { 41 | overflow: 'auto', 42 | maxHeight: 512, 43 | }; 44 | 45 | return ( 46 |
47 | 48 | 49 | 55 | 56 |
57 | ); 58 | } 59 | 60 | updatePollResults(pollData) { 61 | const pollResults = []; 62 | 63 | for (var result in pollData) { 64 | pollResults.push({ result: result, count: pollData[result] }); 65 | } 66 | 67 | pollResults.sort((result1, result2) => { 68 | return result2.count - result1.count; 69 | }); 70 | 71 | const newState = update(this.state, { 72 | pollResults: { 73 | $set: pollResults 74 | } 75 | }); 76 | 77 | this.setState(newState); 78 | } 79 | 80 | renderPollResults(i, key) { 81 | const result = this.state.pollResults[i]; 82 | 83 | return ( 84 | 85 | 86 | 87 |

{result.result}

88 | 89 | 90 |

{result.count}

91 | 92 |
93 |
94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/js/Response.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { 4 | default as Video, 5 | Controls, 6 | Play, 7 | Mute, 8 | Seek, 9 | Fullscreen, 10 | Time, 11 | Overlay 12 | } from 'react-html5video'; 13 | 14 | import ReactBootstrap, { Row, Col } from 'react-bootstrap'; 15 | 16 | 17 | export default class Response extends React.Component { 18 | constructor() { 19 | super(); 20 | } 21 | 22 | render() { 23 | var textRow, attachmentRow; 24 | 25 | if (this.props.response.text) { 26 | textRow = ( 27 | 28 | 29 |

{this.props.response.text}

30 | 31 |
32 | ); 33 | } 34 | 35 | if (this.props.response.attachments) { 36 | const attachmentViews = this.props.response.attachments.map((attachment) => { 37 | if (attachment.type == 'image') { 38 | return ( 39 | 40 | 44 | 45 | ); 46 | } else if (attachment.type == 'video') { 47 | return ( 48 | 49 | 59 | 60 | ); 61 | } 62 | }); 63 | attachmentRow = ( 64 | 65 | {attachmentViews} 66 | 67 | ); 68 | } 69 | 70 | var userLabel = this.props.response.userId; 71 | if (this.props.user) { 72 | userLabel = this.props.user.firstName + ' ' + this.props.user.lastName; 73 | } 74 | 75 | return ( 76 |
77 | 78 | 79 | Response id: {this.props.response.id} 80 | 81 | 82 | User: {userLabel} 83 | 84 | 85 | {textRow} 86 | {attachmentRow} 87 | 88 | 89 | {this.props.response.createdAt} 90 | 91 | 92 |
93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /client/js/ResponseList.js: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import ReactList from 'react-list'; 4 | import ReactBootstrap, { 5 | Row, 6 | Col, 7 | ListGroup, 8 | ListGroupItem, 9 | } from 'react-bootstrap'; 10 | 11 | import Response from './Response'; 12 | import Message from './Message'; 13 | 14 | export default class ResponseList extends React.Component { 15 | constructor() { 16 | super(); 17 | 18 | this.renderResponse = this.renderResponse.bind(this); 19 | } 20 | 21 | render() { 22 | const messageBoxStyle = { 23 | minHeight: 100, 24 | }; 25 | 26 | const listBoxStyle = { 27 | overflow: 'auto', 28 | maxHeight: 512, 29 | }; 30 | 31 | return ( 32 |
33 | 34 | 35 | 41 | 42 |
43 | ); 44 | } 45 | 46 | renderResponse(i, key) { 47 | const response = this.props.responses[i]; 48 | 49 | return ( 50 | 51 | 55 | 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/js/SendForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import update from 'react-addons-update'; 3 | import request from 'axios'; 4 | import Select from 'react-select'; 5 | import ReactBootstrap, { 6 | Row, 7 | Col, 8 | FormGroup, 9 | ControlLabel, 10 | FormControl, 11 | HelpBlock, 12 | Form, 13 | Checkbox, 14 | Button, 15 | } from 'react-bootstrap'; 16 | 17 | import { 18 | formatMessageInfo, 19 | getMessageText 20 | } from './formatting'; 21 | 22 | 23 | export default class SendForm extends React.Component { 24 | constructor() { 25 | super(); 26 | 27 | this.state = { 28 | messages: {}, 29 | tags: {}, 30 | selectedMessage: undefined, 31 | selectedTags: undefined 32 | }; 33 | 34 | this.sendMessage = this.sendMessage.bind(this); 35 | this.handleMessages = this.handleMessages.bind(this); 36 | this.handleTags = this.handleTags.bind(this); 37 | this.handleMessageChange = this.handleMessageChange.bind(this); 38 | this.handleTagChange = this.handleTagChange.bind(this); 39 | this.validateAll = this.validateAll.bind(this); 40 | } 41 | 42 | componentWillMount() { 43 | const socket = this.props.route.socket; 44 | 45 | socket.on('messages', this.handleMessages); 46 | socket.on('tags', this.handleTags); 47 | 48 | socket.emit('get-messages'); 49 | socket.emit('get-tags'); 50 | } 51 | 52 | componentWillUnmount() { 53 | const socket = this.props.route.socket; 54 | 55 | socket.removeListener('messages', this.handleMessages); 56 | socket.removeListener('tags', this.handleTags); 57 | } 58 | 59 | render() { 60 | const midToText = {}; 61 | const midToMetadata = {}; 62 | for (var mid in this.state.messages) { 63 | const message = this.state.messages[mid]; 64 | const messageText = getMessageText(message); 65 | 66 | midToText[mid] = messageText; 67 | midToMetadata[mid] = message.metadata; 68 | } 69 | 70 | const messageList = Object.keys(midToText).map((mid) => { 71 | return { 72 | value: mid, 73 | label: formatMessageInfo(mid, midToText[mid], midToMetadata[mid]) 74 | } 75 | }); 76 | 77 | const tagList = Object.keys(this.state.tags).map((tagid) => { 78 | const tag = this.state.tags[tagid]; 79 | return { 80 | value: tagid, 81 | label: formatMessageInfo(tag.messageId, midToText[tag.messageId], midToMetadata[tag.messageId], tag.tag) 82 | }; 83 | }); 84 | 85 | return ( 86 |
87 | 88 | 89 |
90 |

Send a Message

91 | 92 | Select Message to send 93 | 109 | 110 | 116 |
117 | 118 |
119 |
120 | ); 121 | } 122 | 123 | sendMessage() { 124 | var tagIds = []; 125 | if (this.state.selectedTags) { 126 | tagIds = this.state.selectedTags.map((t) => t.value) 127 | } 128 | 129 | request.post('/send', { 130 | messageId: this.state.selectedMessage.value, 131 | tagIds: tagIds 132 | }).then((response) => { 133 | window.location.reload(); 134 | }).catch((response) => { 135 | console.log('ERROR SENDING OUT MESSAGE: ', response); 136 | }); 137 | } 138 | 139 | handleMessages(messages) { 140 | const messageState = {}; 141 | 142 | for (var i = 0; i < messages.length; ++i) { 143 | const message = messages[i]; 144 | message.data = JSON.parse(message.data); 145 | 146 | messageState[message.id] = { 147 | $set: message 148 | } 149 | } 150 | 151 | const newState = update(this.state, { 152 | messages: messageState 153 | }); 154 | 155 | this.setState(newState); 156 | } 157 | 158 | handleTags(tags) { 159 | const tagState = {}; 160 | 161 | for (var i = 0; i < tags.length; ++i) { 162 | const tag = tags[i]; 163 | 164 | tagState[tag.id] = { 165 | $set: tag 166 | } 167 | } 168 | 169 | const newState = update(this.state, { 170 | tags: tagState 171 | }); 172 | 173 | this.setState(newState); 174 | } 175 | 176 | handleMessageChange(message) { 177 | const newState = update(this.state, { 178 | selectedMessage: { 179 | $set: message 180 | } 181 | }); 182 | 183 | this.setState(newState); 184 | } 185 | 186 | handleTagChange(tags) { 187 | const newState = update(this.state, { 188 | selectedTags: { 189 | $set: tags 190 | } 191 | }); 192 | this.setState(newState); 193 | } 194 | 195 | validateAll() { 196 | if (this.state.selectedMessage) { 197 | return 'success'; 198 | } 199 | 200 | return 'error'; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /client/js/TriggerForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import update from 'react-addons-update'; 3 | import request from 'axios'; 4 | import Select from 'react-select'; 5 | import ReactBootstrap, { 6 | Row, 7 | Col, 8 | FormGroup, 9 | ControlLabel, 10 | FormControl, 11 | HelpBlock, 12 | Form, 13 | Checkbox, 14 | Button, 15 | } from 'react-bootstrap'; 16 | 17 | import { 18 | formatMessageInfo, 19 | getMessageText 20 | } from './formatting'; 21 | 22 | 23 | export default class TriggerForm extends React.Component { 24 | constructor() { 25 | super(); 26 | 27 | this.state = { 28 | messages: {}, 29 | tags: {}, 30 | triggerTag: undefined, 31 | triggerMessage: undefined, 32 | triggeredMessage: undefined, 33 | }; 34 | 35 | this.createTrigger = this.createTrigger.bind(this); 36 | this.handleTags = this.handleTags.bind(this); 37 | this.handleMessages = this.handleMessages.bind(this); 38 | this.handleTagChange = this.handleTagChange.bind(this); 39 | this.handleMessageChange = this.handleMessageChange.bind(this); 40 | this.handleTriggerMessageChange = this.handleTriggerMessageChange.bind(this); 41 | this.validateAll = this.validateAll.bind(this); 42 | } 43 | 44 | componentWillMount() { 45 | const socket = this.props.socket; 46 | 47 | socket.on('tags', this.handleTags); 48 | socket.on('messages', this.handleMessages); 49 | 50 | socket.emit('get-tags'); 51 | socket.emit('get-messages'); 52 | } 53 | 54 | componentWillUnmount() { 55 | const socket = this.props.socket; 56 | 57 | socket.removeListener('tags', this.handleTags); 58 | socket.removeListener('messages', this.handleMessages); 59 | } 60 | 61 | render() { 62 | const messages = this.state.messages; 63 | 64 | // message text can mean different things based on the type 65 | // of the message -- resolve the text once for use below 66 | const midToText = {}; 67 | const midToMetadata = {}; 68 | const triggerMids = []; 69 | for (var mid in messages) { 70 | const message = messages[mid]; 71 | const messageText = getMessageText(message); 72 | 73 | midToText[mid] = messageText; 74 | midToMetadata[mid] = message.metadata; 75 | 76 | // if the message expects an unstructured reply, it can be 77 | // a trigger message 78 | if (message.unstructuredReply) { 79 | triggerMids.push(mid); 80 | } 81 | } 82 | 83 | const tagList = Object.keys(this.state.tags).map((tagid) => { 84 | const tag = this.state.tags[tagid]; 85 | 86 | return { 87 | value: tagid, 88 | label: formatMessageInfo(tag.messageId, midToText[tag.messageId], midToMetadata[tag.messageId], tag.tag) 89 | } 90 | }); 91 | 92 | const messageList = Object.keys(midToText).map((mid) => { 93 | return { 94 | value: mid, 95 | label: formatMessageInfo(mid, midToText[mid], midToMetadata[mid]) 96 | } 97 | }); 98 | 99 | const triggerMessageList = triggerMids.map((mid) => { 100 | return { 101 | value: mid, 102 | label: formatMessageInfo(mid, midToText[mid], midToMetadata[mid]) 103 | } 104 | }); 105 | 106 | return ( 107 | 108 | 109 |
110 | 111 | 112 |

Create a Trigger

113 |
When users respond to something, trigger a new message to be sent.
114 | 115 |
116 | 117 | 118 | 119 | Select a trigger Tag 120 | 133 | 134 | 135 | 136 | 137 | Select a Message to trigger 138 |