├── .cfignore ├── .gitignore ├── .travis.yml ├── test ├── unit │ ├── eslint.js │ ├── slackbot.js │ ├── call_manager.js │ └── phonebot.js └── integration │ └── phonebot.js ├── app.js ├── .eslintrc ├── manifest.yml ├── LICENSE ├── package.json ├── routes └── index.js ├── admin.js ├── lib ├── slackbot.js ├── translate.js ├── call_manager.js └── phonebot.js └── README.md /.cfignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | -------------------------------------------------------------------------------- /test/unit/eslint.js: -------------------------------------------------------------------------------- 1 | var lint = require('mocha-eslint'); 2 | 3 | // Array of paths to lint 4 | // Note: a seperate Mocha test will be run for each path and each file which 5 | // matches a glob pattern 6 | var paths = [ 7 | 'lib', 8 | 'routes', 9 | 'app.js' 10 | ]; 11 | 12 | // Specify style of output 13 | var options = {}; 14 | 15 | // Run the tests 16 | lint(paths, options); 17 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var express = require('express'), 4 | cfenv = require('cfenv'), 5 | log = require('loglevel') 6 | 7 | log.setLevel(process.env.LOG_LEVEL || 'info') 8 | 9 | var app = express() 10 | require('./routes')(app) 11 | 12 | var server = app.listen(cfenv.getAppEnv().port, function () { 13 | var host = server.address().address 14 | var port = server.address().port 15 | 16 | log.info('Phonebot now listening at http://%s:%s', host, port) 17 | }) 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "camelcase": 0, 4 | "curly": 0, 5 | "indent": [ 6 | 2, 7 | 2 8 | ], 9 | "quotes": [ 10 | 2, 11 | "single" 12 | ], 13 | "linebreak-style": [ 14 | 2, 15 | "unix" 16 | ], 17 | "semi": [ 18 | 2, 19 | "never" 20 | ] 21 | }, 22 | "env": { 23 | "node": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | declared-services: 3 | twilio: 4 | label: Twilio 5 | plan: 'user-provided' 6 | slack_webhooks: 7 | label: slack_webhooks 8 | plan: 'user-provided' 9 | speech_to_text: 10 | label: speech_to_text 11 | plan: free 12 | applications: 13 | - name: phonebot 14 | memory: 256M 15 | command: node app.js 16 | buildpack: https://github.com/jthomas/nodejs-buildpack.git 17 | services: 18 | - twilio 19 | - speech_to_text 20 | - slack_webhooks 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 James Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phonebot", 3 | "version": "1.0.0", 4 | "description": "Slackbot to make phonecalls using Twilio and IBM Watson", 5 | "main": "app.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "async": "^0.9.0", 11 | "cfenv": "^1.0.0", 12 | "loglevel": "^1.2.0", 13 | "request": "^2.55.0", 14 | "express-xml-bodyparser": "^0.0.7", 15 | "promise": "^7.0.1", 16 | "body-parser": "^1.12.3", 17 | "express": "^4.12.3", 18 | "twilio": "^2.2.0", 19 | "watson-developer-cloud": "^0.9.9", 20 | "sox": "^0.1.0", 21 | "tmp": "^0.0.25", 22 | "commander": "^2.6.0", 23 | "http-post": "^0.1.1" 24 | }, 25 | "devDependencies": { 26 | "mocha": "^2.2.5", 27 | "mocha-eslint": "^0.1.7", 28 | "mockery": "^1.4.0", 29 | "requestbin": "^1.1.1" 30 | }, 31 | "scripts": { 32 | "test": "mocha test/unit", 33 | "integration-test": "mocha test/integration", 34 | "install": "node admin.js track", 35 | "start": "node app.js" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/IBM-Bluemix/phonebot.git" 40 | }, 41 | "author": "James Thomas", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/IBM-Bluemix/phonebot/issues" 45 | }, 46 | "homepage": "https://github.com/IBM-Bluemix/phonebot" 47 | } 48 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var xmlparser = require('express-xml-bodyparser'), 4 | bodyParser = require('body-parser'), 5 | twilio = require('twilio'), 6 | watson = require('watson-developer-cloud'), 7 | phonebot = require('../lib/phonebot.js'), 8 | cfenv = require('cfenv'), 9 | log = require('loglevel') 10 | 11 | var service_credentials = function (name) { 12 | var service = cfenv.getAppEnv().getService(name) 13 | if (!service) { 14 | throw new Error('FATAL: Missing service credentials: ' + name) 15 | } 16 | 17 | return service.credentials 18 | } 19 | 20 | var twilio_account = service_credentials('twilio'), 21 | host = twilio_account.url.replace(/http(s)?:\/\//, ''), 22 | client = twilio(twilio_account.accountSID, twilio_account.authToken, {host: host}) 23 | 24 | var s2t = service_credentials('speech_to_text') 25 | var speech_to_text = watson.speech_to_text({ 26 | username: s2t.username, 27 | password: s2t.password, 28 | url: s2t.url, 29 | version: 'v1' 30 | }) 31 | 32 | var bot = phonebot(client, speech_to_text, service_credentials('slack_webhooks'), cfenv.getAppEnv().url + '/recording') 33 | 34 | module.exports = function (app) { 35 | app.use(bodyParser.urlencoded({ extended: true })) 36 | app.use(xmlparser()) 37 | 38 | app.post('/recording/:channel', function (req, res) { 39 | log.debug('HTTP POST /recording/' + req.params.channel + '@ ' + (new Date()).toISOString()) 40 | log.trace(JSON.stringify(req.body)) 41 | 42 | res.send(bot.phone_message(req.params.channel, req)) 43 | }) 44 | 45 | /*eslint-disable no-unused-vars*/ 46 | app.post('/slackbot', function (req, res) { 47 | /*eslint-enable no-unused-vars*/ 48 | log.debug('HTTP POST /slackbot/ (#' + req.body.channel_name + ')' + ' @ ' + (new Date()).toISOString()) 49 | log.trace(req.body) 50 | 51 | bot.slack_message(req.body.channel_name, req.body) 52 | res.sendStatus(200) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /admin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Licensed under the Apache 2.0 License. See footer for details. 3 | 4 | var express = require("express"), 5 | http = require("http"), 6 | path = require("path"), 7 | program = require("commander"), 8 | pkg = require(path.join(__dirname, "package.json")); 9 | 10 | http.post = require("http-post"); 11 | 12 | 13 | var app = express(); 14 | 15 | if (process.env.VCAP_APPLICATION) { 16 | var vcapApplication = JSON.parse(process.env.VCAP_APPLICATION); 17 | app.set("vcapApplication", vcapApplication); 18 | } 19 | 20 | 21 | 22 | program 23 | .command("track") 24 | .description("Track application deployments") 25 | .action(function(options) { 26 | var vcapApplication = app.get("vcapApplication"); 27 | if (vcapApplication) { 28 | var event = { 29 | date_sent: new Date().toJSON() 30 | }; 31 | if (pkg.version) { 32 | event.code_version = pkg.version; 33 | } 34 | if (pkg.repository && pkg.repository.url) { 35 | event.repository_url = pkg.repository.url; 36 | } 37 | if (vcapApplication.application_name) { 38 | event.application_name = vcapApplication.application_name; 39 | } 40 | if (vcapApplication.space_id) { 41 | event.space_id = vcapApplication.space_id; 42 | } 43 | if (vcapApplication.application_version) { 44 | event.application_version = vcapApplication.application_version; 45 | } 46 | if (vcapApplication.application_uris) { 47 | event.application_uris = vcapApplication.application_uris; 48 | } 49 | // TODO: Make this work over HTTPS 50 | http.post("http://deployment-tracker.mybluemix.net/", event); 51 | } 52 | }).on("--help", function() { 53 | console.log(" Examples:"); 54 | console.log(); 55 | console.log(" $ track"); 56 | console.log(); 57 | }); 58 | 59 | program.parse(process.argv); 60 | 61 | //------------------------------------------------------------------------------- 62 | // Copyright IBM Corp. 2015 63 | // 64 | // Licensed under the Apache License, Version 2.0 (the "License"); 65 | // you may not use this file except in compliance with the License. 66 | // You may obtain a copy of the License at 67 | // 68 | // http://www.apache.org/licenses/LICENSE-2.0 69 | // 70 | // Unless required by applicable law or agreed to in writing, software 71 | // distributed under the License is distributed on an "AS IS" BASIS, 72 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 73 | // See the License for the specific language governing permissions and 74 | // limitations under the License. 75 | //------------------------------------------------------------------------------- 76 | -------------------------------------------------------------------------------- /lib/slackbot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var request = require('request'), 4 | util = require('util'), 5 | EventEmitter = require('events').EventEmitter, 6 | log = require('loglevel') 7 | 8 | function Slackbot (outgoing) { 9 | this.COMMANDS = { 10 | 'help': 'Hello! This is Phonebot. You can make telephone calls using the \'call\' command.', 11 | 'call': this.call, 12 | 'say': this.say, 13 | 'duration': this.duration, 14 | 'verbose': this.verbose, 15 | 'hangup': this.emit.bind(this, 'hangup') 16 | } 17 | 18 | this.outgoing = outgoing 19 | } 20 | 21 | util.inherits(Slackbot, EventEmitter) 22 | 23 | Slackbot.prototype.post = function (text) { 24 | log.info('Slackbot.post (' + this.outgoing + '): ' + text) 25 | request.post({ 26 | url: this.outgoing, 27 | body: {text: text, username: 'phonebot', icon_emoji: ':phone:'}, 28 | json: true 29 | }) 30 | } 31 | 32 | Slackbot.prototype.call = function (text) { 33 | var message = 'Phonebot Command: call PHONE_NUMBER <-- Dials the phone number.' 34 | var numbers = text.match(/\d+/g) 35 | 36 | if (numbers) { 37 | message = ':phone: Let me try to put you through...' 38 | this.emit('call', numbers.join('')) 39 | } 40 | return message 41 | } 42 | 43 | Slackbot.prototype.say = function (text) { 44 | var message = 'Phonebot Command: say TEXT...<-- Sends text as speech to the call.' 45 | if (text.length) { 46 | this.emit('say', text) 47 | message = '' 48 | } 49 | return message 50 | } 51 | 52 | Slackbot.prototype.duration = function (text) { 53 | var message = 'Phonebot Command: duration NUMBER<-- Modify audio recording duration' + 54 | 'for translation. Smaller durations mean faster translations but greater audio gaps.' 55 | 56 | var number = text.match(/\d+/) 57 | if (number) { 58 | message = '' 59 | this.emit('duration', parseInt(number[0], 10)) 60 | } 61 | 62 | return message 63 | } 64 | 65 | Slackbot.prototype.verbose = function (text) { 66 | var message = 'Phonebot Command: verbose {on|off}<-- Enable/disable verbose mode. ' + 67 | 'When enabled, channel messages sent to notify users when call audio has been received but not translated.' 68 | 69 | var toggle = text.split(' ')[0] 70 | 71 | if (toggle === 'on' || toggle === 'off') { 72 | message = '' 73 | this.emit('verbose', toggle === 'on') 74 | } 75 | 76 | return message 77 | } 78 | 79 | Slackbot.prototype.channel_message = function (message) { 80 | var response = 'What\'s up?' 81 | 82 | var words = message.text.split(' '), 83 | command = this.COMMANDS[words[1]] 84 | 85 | if (typeof command === 'string') { 86 | response = command 87 | } else if (typeof command === 'function') { 88 | response = command.call(this, words.slice(2).join(' ')) 89 | } 90 | 91 | if (typeof response === 'string') this.post(response) 92 | } 93 | 94 | module.exports = function (outgoing) { 95 | return new Slackbot(outgoing) 96 | } 97 | -------------------------------------------------------------------------------- /lib/translate.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var tmp = require('tmp'), 4 | fs = require('fs'), 5 | util = require('util'), 6 | Promise = require('promise'), 7 | sox = require('sox'), 8 | request = require('request'), 9 | log = require('loglevel') 10 | 11 | var EventEmitter = require('events').EventEmitter 12 | 13 | var temp_wav_file = Promise.denodeify(tmp.file) 14 | 15 | var Translate = function (speech_to_text, location) { 16 | this.transcript = null 17 | this.location = location 18 | this.speech_to_text = speech_to_text 19 | } 20 | 21 | util.inherits(Translate, EventEmitter) 22 | 23 | Translate.prototype.error = function (message) { 24 | if (message) log.error(message) 25 | this.failed = true 26 | this.emit('failed') 27 | } 28 | 29 | Translate.prototype.transcode_to_16k = function () { 30 | var that = this 31 | var promise = new Promise(function (resolve, reject) { 32 | var job = sox.transcode(that.source, that.upsample, { 33 | sampleRate: 16000, 34 | format: 'wav', 35 | channelCount: 1 36 | }) 37 | job.on('error', function (err) { 38 | that.error(err) 39 | reject(err) 40 | }) 41 | job.on('end', function () { 42 | log.debug('Translation Audio Converted (' + that.source + '): ' + that.upsample) 43 | resolve() 44 | }) 45 | job.start() 46 | }) 47 | 48 | return promise 49 | } 50 | 51 | Translate.prototype.download_source = function () { 52 | log.debug('Retrieving Audio Recording Source: ' + this.source) 53 | var that = this 54 | 55 | var promise = new Promise(function (resolve, reject) { 56 | var source_stream = fs.createWriteStream(that.source) 57 | source_stream.on('finish', resolve) 58 | 59 | request(that.location) 60 | .on('error', reject) 61 | .pipe(source_stream) 62 | }) 63 | 64 | return promise 65 | } 66 | 67 | Translate.prototype.translate_to_text = function () { 68 | log.debug('Translating audio recording (upsampled): ' + this.upsample) 69 | var that = this 70 | 71 | var params = { 72 | audio: fs.createReadStream(this.upsample), 73 | content_type: 'audio/l16; rate=16000' 74 | } 75 | 76 | this.speech_to_text.recognize(params, function (err, res) { 77 | log.trace('Watson Service Response: ' + JSON.stringify(res)) 78 | if (err) { 79 | that.error(err) 80 | return 81 | } 82 | 83 | var result = res.results[res.result_index] 84 | if (result) { 85 | that.transcript = result.alternatives[0].transcript 86 | that.emit('available') 87 | } else { 88 | that.error('Missing speech recognition result.') 89 | } 90 | }) 91 | } 92 | 93 | Translate.prototype.tmp_files = function () { 94 | var that = this 95 | var temp_files = [temp_wav_file({postfix: '.wav'}), temp_wav_file({postfix: '.wav'})] 96 | 97 | return Promise.all(temp_files).then(function (results) { 98 | that.source = results[0] 99 | that.upsample = results[1] 100 | }, this.error.bind(this)) 101 | } 102 | 103 | Translate.prototype.start = function () { 104 | var err = this.error.bind(this) 105 | this.tmp_files() 106 | .then(this.download_source.bind(this), err) 107 | .then(this.transcode_to_16k.bind(this), err) 108 | .then(this.translate_to_text.bind(this), err) 109 | } 110 | 111 | module.exports = function (speech_to_text, location) { 112 | return new Translate(speech_to_text, location) 113 | } 114 | -------------------------------------------------------------------------------- /lib/call_manager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var twilio = require('twilio'), 4 | util = require('util'), 5 | EventEmitter = require('events').EventEmitter, 6 | log = require('loglevel') 7 | 8 | function CallManager (client, channel) { 9 | this.client = client 10 | this.channel = channel 11 | this.active_call = null 12 | this.outgoing = [] 13 | 14 | this.defaults = { 15 | duration: 5, 16 | verbose: true 17 | } 18 | 19 | // Find first account number to use as outgoing number 20 | var that = this 21 | client.incomingPhoneNumbers.list(function (err, data) { 22 | var err_message = 'ERROR: Unable to find outgoing phone number from Twilio' 23 | if (err) throw new Error(err_message + '\n' + JSON.stringify(err)) 24 | 25 | var first = data.incoming_phone_numbers[0] 26 | if (!first) throw new Error(err_message) 27 | 28 | that.from = first.phone_number 29 | }) 30 | } 31 | 32 | util.inherits(CallManager, EventEmitter) 33 | 34 | CallManager.prototype.default_greeting = 'Hello this is Phonebot' 35 | 36 | CallManager.prototype.update_call_status = function (details) { 37 | // NodeJS Twilio clients wraps TwiML responses with different 38 | // parameter names than XML POSTs. Handle both types of message name. 39 | var status = details.CallStatus || details.status, 40 | sid = details.CallSid || details.sid, 41 | number = details.To || details.to 42 | 43 | // Ensure message sid matches active call sid 44 | if (!this.is_message_valid(status, sid)) { 45 | log.error('ERROR: Invalid message received for call (' + this.active_call.sid + '): ' + details) 46 | return null 47 | } 48 | 49 | switch (status) { 50 | // Store new call reference when outbound call is queued. 51 | case 'queued': 52 | this.active_call = {sid: sid, status: status, number: number} 53 | this.emit(status) 54 | break 55 | case 'ringing': 56 | case 'in-progress': 57 | var old = this.active_call.status 58 | this.active_call.status = status 59 | // Don't emit the same event multiple times 60 | if (old !== status) this.emit(status) 61 | break 62 | // When the phone call ends, for any reason, remove 63 | // the active call reference so we can make another. 64 | case 'completed': 65 | case 'busy': 66 | case 'failed': 67 | case 'no-answer': 68 | case 'canceled': 69 | this.active_call = null 70 | this.emit(status) 71 | break 72 | default: 73 | log.error('ERROR: Unknown call state encountered (' + status + '): ' + JSON.stringify(details)) 74 | status = null 75 | } 76 | 77 | return status 78 | } 79 | 80 | CallManager.prototype.call = function (number, route) { 81 | // We can't make a call until the line is free... 82 | if (this.call_active()) return 83 | 84 | var that = this 85 | this.outgoing = [this.default_greeting] 86 | this.active_call = {sid: null, status: null, number: number} 87 | 88 | this.client.makeCall({ 89 | to: number, 90 | from: this.from, 91 | url: route 92 | }, function (err, responseData) { 93 | if (err) { 94 | that.request_fail('Failed To Start Call: ' + number + '(' + route + ') ', err) 95 | return 96 | } 97 | 98 | that.request_success('New Call Started: ' + number + ' (' + route + '): ' + responseData.sid, responseData) 99 | }) 100 | } 101 | 102 | CallManager.prototype.hangup = function () { 103 | var that = this 104 | 105 | if (!this.call_active()) return 106 | 107 | this.client.calls(this.active_call.sid).update({ 108 | status: 'completed' 109 | }, function (err, call) { 110 | if (err) { 111 | that.request_fail('Failed To Hangup Call (' + that.active_call.sid + ')', err) 112 | return 113 | } 114 | 115 | that.request_success('Call Terminated: ' + that.active_call, call) 116 | }) 117 | } 118 | 119 | CallManager.prototype.say = function (text) { 120 | // If we're waiting for the outgoing call to connect, 121 | // let user override the default greeting. 122 | if (this.outgoing[0] === this.default_greeting) { 123 | this.outgoing[0] = text 124 | return 125 | } 126 | 127 | this.outgoing.push(text) 128 | } 129 | 130 | CallManager.prototype.options = function (opts) { 131 | if (typeof opts.duration === 'number') { 132 | this.defaults.duration = opts.duration 133 | } 134 | if (opts.hasOwnProperty('verbose')) { 135 | this.defaults.verbose = opts.verbose 136 | } 137 | } 138 | 139 | CallManager.prototype.call_active = function () { 140 | return !!this.active_call 141 | } 142 | 143 | CallManager.prototype.stats = function () { 144 | } 145 | 146 | CallManager.prototype.process = function (req) { 147 | var twiml = '' 148 | 149 | var current_status = this.update_call_status(req.body) 150 | if (current_status) { 151 | if (current_status === 'in-progress') { 152 | twiml = new twilio.TwimlResponse() 153 | 154 | // Do we have text to send down the active call? 155 | if (this.outgoing.length) { 156 | var user_speech = this.outgoing.join(' ') 157 | this.outgoing = [] 158 | twiml.say(user_speech) 159 | } 160 | 161 | twiml.record({playBeep: false, trim: 'do-not-trim', maxLength: this.defaults.duration, timeout: 60}) 162 | } 163 | 164 | if (req.body && req.body.RecordingUrl) { 165 | this.emit('recording', req.body.RecordingUrl) 166 | log.info('New Recording Available: ' + req.body.RecordingUrl) 167 | } 168 | } 169 | 170 | return twiml 171 | } 172 | 173 | CallManager.prototype.request_fail = function (message, err) { 174 | log.error(message) 175 | log.error(err) 176 | this.update_call_status({CallStatus: 'failed', sid: this.active_call.sid}) 177 | } 178 | 179 | CallManager.prototype.request_success = function (message, call) { 180 | log.info(message) 181 | log.trace(call) 182 | this.update_call_status(call) 183 | } 184 | 185 | CallManager.prototype.is_message_valid = function (status, sid) { 186 | return (status === 'queued' || sid === this.active_call.sid) 187 | } 188 | 189 | module.exports = function (client, channel) { 190 | return new CallManager(client, channel) 191 | } 192 | -------------------------------------------------------------------------------- /test/unit/slackbot.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var mockery = require('mockery') 3 | 4 | var log = require('loglevel') 5 | log.disableAll() 6 | 7 | var param 8 | var request = function (req) { 9 | param = req 10 | } 11 | request.post = function (req) { 12 | param = req 13 | } 14 | 15 | describe('Slackbot', function(){ 16 | before(function() { 17 | mockery.enable({useCleanCache: true}); // Enable mockery at the start of your test suite 18 | mockery.registerMock('request', request); 19 | mockery.registerAllowables(['loglevel', 'events', '../../lib/slackbot.js', 'util']); 20 | slackbot = require('../../lib/slackbot.js') 21 | }) 22 | 23 | after(function() { 24 | mockery.disable(); // Disable Mockery after tests are completed 25 | }) 26 | 27 | describe('#post', function(){ 28 | it('should send text as HTTP POST to url parameter', function(){ 29 | var url = "some_url", 30 | text = "testing 1 2 3", 31 | bot = slackbot(url) 32 | 33 | bot.post(text) 34 | assert.equal(param.url, url) 35 | assert.equal(param.json, true) 36 | assert.deepEqual(param.body, {text: text, username: 'phonebot', icon_emoji: ':phone:'}) 37 | }) 38 | }) 39 | 40 | describe('#call', function(){ 41 | it('should notify listeners of new call requests', function(done){ 42 | var bot = slackbot() 43 | 44 | bot.on('call', function(number){ 45 | assert.equal('111', number) 46 | done() 47 | }) 48 | bot.call('111') 49 | }) 50 | it('should parse all digits into a phone number', function(done){ 51 | var bot = slackbot() 52 | 53 | bot.on('call', function(number){ 54 | assert.equal('111222333', number) 55 | done() 56 | }) 57 | 58 | bot.call('111 222 333') 59 | }) 60 | it('should return channel message about new phone call', function(){ 61 | var bot = slackbot() 62 | var message = bot.call('111') 63 | assert.equal(message, ':phone: Let me try to put you through...') 64 | }) 65 | it('should show help text for command without number', function(){ 66 | var bot = slackbot() 67 | var message = bot.call('') 68 | assert.ok(message.indexOf('Phonebot Command') === 0) 69 | }) 70 | }) 71 | 72 | describe('#say', function(){ 73 | it('should notify listeners of new speech replies', function(done){ 74 | var bot = slackbot() 75 | 76 | bot.on('say', function(text){ 77 | assert.equal('111', text) 78 | done() 79 | }) 80 | var response = bot.say('111') 81 | assert.equal(response, '') 82 | }) 83 | it('should show help text for command without number', function(){ 84 | var bot = slackbot() 85 | var message = bot.say('') 86 | assert.ok(message.indexOf('Phonebot Command') === 0) 87 | }) 88 | }) 89 | 90 | describe('#duration', function(){ 91 | it('should notify listeners of duration change', function(done){ 92 | var bot = slackbot() 93 | 94 | bot.on('duration', function(_){ 95 | assert.equal('111', _) 96 | done() 97 | }) 98 | bot.duration('111') 99 | }) 100 | it('should use first number as duration', function(done){ 101 | var bot = slackbot() 102 | 103 | bot.on('duration', function(_){ 104 | assert.equal('111', _) 105 | done() 106 | }) 107 | bot.duration('111 222') 108 | }) 109 | it('should show help text for command without duration', function(){ 110 | var bot = slackbot() 111 | var message = bot.duration('') 112 | assert.ok(message.indexOf('Phonebot Command') === 0) 113 | }) 114 | }) 115 | 116 | describe('#verbose', function(){ 117 | it('should ignore invalid settings', function(){ 118 | var bot = slackbot() 119 | 120 | bot.on('verbose', function(_){ 121 | assert.ok(false) 122 | }) 123 | 124 | bot.duration('random text') 125 | }) 126 | it('should turn verbose on', function(done){ 127 | var bot = slackbot() 128 | 129 | bot.on('verbose', function(_){ 130 | assert.equal(true, _) 131 | done() 132 | }) 133 | 134 | bot.verbose('on') 135 | }) 136 | it('should turn verbose off', function(done){ 137 | var bot = slackbot() 138 | 139 | bot.on('verbose', function(_){ 140 | assert.equal(false, _) 141 | done() 142 | }) 143 | 144 | bot.verbose('off') 145 | }) 146 | it('should show help text for command without duration', function(){ 147 | var bot = slackbot() 148 | var message = bot.verbose('') 149 | assert.ok(message.indexOf('Phonebot Command') === 0) 150 | }) 151 | }) 152 | 153 | describe('#channel_message', function(){ 154 | it('should ignore commands not registered', function(){ 155 | var bot = slackbot(), 156 | response 157 | bot.post = function (_) { 158 | response = _ 159 | } 160 | bot.channel_message({text:'@slackbot hello'}) 161 | assert.equal(response, 'What\'s up?') 162 | }) 163 | it('should return string commands as channel message', function(){ 164 | var bot = slackbot(), 165 | response 166 | bot.post = function (_) { 167 | response = _ 168 | } 169 | bot.COMMANDS.hello = "testing" 170 | bot.channel_message({text:'@slackbot hello'}) 171 | assert.equal(response, bot.COMMANDS.hello) 172 | }) 173 | it('should execute and return function commands as message', function(){ 174 | var bot = slackbot(), 175 | response, arg 176 | bot.post = function (_) { 177 | response = _ 178 | } 179 | bot.COMMANDS.hello = function (_) { 180 | arg = _ 181 | return 'testing' 182 | } 183 | bot.channel_message({text:'@slackbot hello 1 2 3'}) 184 | assert.equal(response, 'testing') 185 | assert.equal(arg, '1 2 3') 186 | 187 | bot.COMMANDS.hello = function (_) { 188 | arg = _ 189 | } 190 | 191 | response = null 192 | bot.channel_message({text:'@slackbot hello 1 2 3'}) 193 | assert.equal(response, null) 194 | }) 195 | }) 196 | }) 197 | -------------------------------------------------------------------------------- /lib/phonebot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var slackbot = require('./slackbot.js'), 4 | call_manager = require('./call_manager.js'), 5 | translate = require('./translate.js'), 6 | async = require('async'), 7 | log = require('loglevel') 8 | 9 | var PhoneBot = function (client, watson, channels, base_url) { 10 | this.channels = {} 11 | this.base_url = base_url 12 | this.watson = watson 13 | 14 | for (var key in channels) { 15 | this.channels[key] = { 16 | bot: this.create_channel_bot(channels[key], key), 17 | phone: this.create_call_manager(client, key), 18 | queue: this.create_translation_queue(key) 19 | } 20 | log.info('Registering #' + key + ' (' + channels[key] + ')') 21 | } 22 | } 23 | 24 | PhoneBot.prototype.create_channel_bot = function (webhook, channel) { 25 | var bot = slackbot(webhook) 26 | var that = this 27 | 28 | bot.on('call', function (number) { 29 | var phone = that.channels[channel].phone 30 | 31 | if (phone.call_active()) { 32 | bot.post('The line is busy, you have to hang up first...!') 33 | return 34 | } 35 | 36 | phone.call(number, that.base_url + '/' + channel) 37 | log.info('#' + channel + ': call ' + number) 38 | }) 39 | 40 | bot.on('say', function (text) { 41 | var phone = that.channels[channel].phone 42 | phone.say(text) 43 | log.info('#' + channel + ': say ' + text) 44 | }) 45 | 46 | bot.on('duration', function (duration) { 47 | var phone = that.channels[channel].phone 48 | phone.options({duration: duration}) 49 | log.info('#' + channel + ': duration ' + duration) 50 | }) 51 | 52 | bot.on('verbose', function (enabled) { 53 | var phone = that.channels[channel].phone 54 | phone.options({verbose: enabled}) 55 | log.info('#' + channel + ': verbose ' + enabled) 56 | }) 57 | 58 | bot.on('hangup', function () { 59 | var phone = that.channels[channel].phone 60 | 61 | if (!phone.call_active()) { 62 | bot.post('There isn\'t a phone call to hang up...') 63 | return 64 | } 65 | 66 | phone.hangup() 67 | log.info('#' + channel + ': hangup') 68 | }) 69 | 70 | bot.post('_Phonebot is here!_') 71 | 72 | return bot 73 | } 74 | 75 | PhoneBot.prototype.create_call_manager = function (client, channel) { 76 | var phone = call_manager(client, channel) 77 | var that = this 78 | 79 | phone.on('recording', function (location) { 80 | if (phone.defaults.verbose) { 81 | that.channels[channel].bot.post(':speech_balloon: _waiting for translation_') 82 | } 83 | var req = translate(that.watson, location) 84 | req.start() 85 | that.channels[channel].queue.push(req) 86 | }) 87 | 88 | phone.on('queued', function () { 89 | that.channels[channel].bot.post(':phone: Connecting to ' + phone.active_call.number) 90 | }) 91 | 92 | phone.on('ringing', function () { 93 | that.channels[channel].bot.post(':phone: Still ringing...') 94 | }) 95 | 96 | phone.on('in-progress', function () { 97 | that.channels[channel].bot.post(':phone: You\'re connected! :+1:') 98 | }) 99 | 100 | phone.on('completed', function () { 101 | // If we have translation tasks outstanding, wait until we have finished 102 | // before posting the call finished message 103 | if (!that.channels[channel].queue.idle()) { 104 | that.channels[channel].queue.drain = function () { 105 | setTimeout(function () { 106 | that.channels[channel].bot.post(':phone: That\'s it, call over!') 107 | }, 1000) 108 | that.channels[channel].queue.drain = null 109 | } 110 | } else { 111 | that.channels[channel].bot.post(':phone: That\'s it, call over!') 112 | } 113 | }) 114 | 115 | phone.on('canceled', function () { 116 | that.channels[channel].bot.post(':phone: That\'s it, call over!') 117 | }) 118 | 119 | phone.on('busy', function () { 120 | that.channels[channel].bot.post(':phone: They were busy, sorry :unamused:') 121 | }) 122 | 123 | phone.on('no-answer', function () { 124 | that.channels[channel].bot.post(':phone: Oh no, they didn\'t answer :sleeping:') 125 | }) 126 | 127 | phone.on('failed', function () { 128 | that.channels[channel].bot.post(':phone: Whoops, something failed. My bad. Try again? :see_no_evil:') 129 | }) 130 | 131 | return phone 132 | } 133 | 134 | PhoneBot.prototype.create_translation_queue = function (channel) { 135 | var that = this 136 | 137 | return async.queue(function (task, callback) { 138 | var done = function (message) { 139 | if (message) that.channels[channel].bot.post(':speech_balloon: ' + message) 140 | callback() 141 | return true 142 | } 143 | 144 | var process = function () { 145 | log.info('Transcription Task Result (' + channel + '): ' + task.location) 146 | log.info('Transcription Task Result (' + channel + '): ' + task.transcript) 147 | return done(task.transcript) 148 | } 149 | 150 | var failed = function () { 151 | log.error('Transcription Task Failed(' + channel + '): ' + task.location) 152 | return done(that.channels[channel].phone.defaults.verbose ? '_unable to recognise speech_' : '') 153 | } 154 | 155 | if (task.transcript && process()) return 156 | if (task.failed && failed()) return 157 | 158 | log.info('Transcription Task Queued(' + channel + '): ' + task.location) 159 | task.on('available', process) 160 | task.on('failed', failed) 161 | }, 1) 162 | } 163 | 164 | // Need to handle unknown channel messages. 165 | PhoneBot.prototype.phone_message = function (channel, message) { 166 | var response = null, 167 | lookup = this.channels[channel] 168 | 169 | if (lookup) { 170 | response = lookup.phone.process(message).toString() 171 | log.trace(response) 172 | } else { 173 | log.error('Phone message received for unknown channel: ' + channel) 174 | } 175 | return response 176 | } 177 | 178 | PhoneBot.prototype.slack_message = function (channel, message) { 179 | var lookup = this.channels[channel] 180 | 181 | if (lookup) { 182 | lookup.bot.channel_message(message) 183 | } else { 184 | log.error('Slack message received for unknown channel: ' + channel) 185 | } 186 | } 187 | 188 | module.exports = function (client, watson, channels, base_url) { 189 | return new PhoneBot(client, watson, channels, base_url) 190 | } 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phonebot 2 | 3 | Slackbot that lets users make phone calls within a Slack channel. 4 | Users can dial a phone number, with the phone call audio converted to text and sent to the channel. 5 | Channel message replies are converted to speech and sent over the phone call. 6 | 7 | Twilio is used to make phone calls and capture call audio. 8 | IBM Watson's "Speech To Text" service is used to translate the audio into text. 9 | NodeJS web application handles incoming messages from Slack, IBM Watson and Twilio. 10 | 11 | 12 | Bluemix button 13 | 14 | 15 | [![Build Status](https://api.travis-ci.org/IBM-Bluemix/phonebot.svg?branch=master)](https://api.travis-ci.org/IBM-Bluemix/phonebot.svg?branch=master) 16 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard) 17 | 18 | ![Phonebot](http://jamesthom.as/images/Phonebot.png) 19 | 20 | ## Usage 21 | 22 | Once the Phonebot application is registered for a Slack channel, the following 23 | commands are available: 24 | 25 | ``` 26 | @phonebot call PHONE_NUMBER <-- Dials the phone number 27 | @phonebot say TEXT <-- Sends text as speech to the call 28 | @phonebot hangup <-- Ends the active call 29 | @phonebot verbose {on|off}<-- Toggle verbose mode 30 | @phonebot duration NUMBER <-- Set recording duration 31 | @phonebot help <-- Show all commands usage information 32 | ``` 33 | 34 | ## Deployment Instructions 35 | 36 | Phonebot is a NodeJS application designed for deployment to IBM Bluemix, a Cloud Foundry 37 | Platform-as-a-Service instance. 38 | 39 | Before deploying the application, we need to... 40 | 41 | * Register [Slack webhooks](https://api.slack.com/) to allow the bot to send and receive channel messages. 42 | * Register for [Twilio](http://twilio.com) and [IBM Watson](http://www.ibm.com/smarterplanet/us/en/ibmwatson/developercloud/) API credentials. 43 | 44 | ### Slack Webhooks 45 | 46 | Phonebot can be registered on multiple channels. 47 | 48 | Every channel you want Phonebot to join needs both an outgoing incoming and outgoing webhook. 49 | 50 | #### Set up Outgoing Slack Webhooks 51 | 52 | [Outgoing Webhooks](https://api.slack.com/outgoing-webhooks) are used to notify 53 | Phonebot when messages for the bot (_@phonebot command_) are sent to the channel. 54 | Visiting the [Service Integration](https://my.slack.com/services/new/outgoing-webhook) page will allow 55 | you to create a new webhooks that posts all messages starting with a keyword to 56 | an external URL. 57 | 58 | The following parameters must be configured: 59 | * **Channel** - Channel for Phonebot to listen on 60 | * **Trigger Words** - @phonebot 61 | * **URL** - http://.mybluemix.net/slackbot 62 | 63 | ![Outgoing Webhooks](http://jamesthom.as/images/outgoing_wh.png) 64 | 65 | #### Set up Incoming Slack Webhooks 66 | 67 | [Incoming Webhooks](https://api.slack.com/incoming-webhooks) are used by 68 | Phonebot to post the translated call audio as channel messages. We need an incoming webhook for each channel 69 | with a registered outgoing webhook. Visiting the [Service Integration](https://my.slack.com/services/new/incoming-webhook) page will allow 70 | you to create a new incoming webhook URL to post channel messages. 71 | 72 | ![Incoming Webhooks](http://jamesthom.as/images/incoming_wh.png) 73 | 74 | Copy the generated URL that exposes the webhook, you will need to pass this into 75 | the application as explained in the section below. 76 | 77 | ### Deploy to IBM Bluemix 78 | 79 | Before we can deploy the application, we need to create the service 80 | credentials the application relies on within IBM Bluemix. 81 | 82 | These credentials will be bound to the application at runtime. 83 | 84 | #### Twilio 85 | 86 | Register for a developer account at [Twilio](https://www.twilio.com/try-twilio). 87 | 88 | Connect an [external phone number](https://www.twilio.com/user/account/phone-numbers/incoming) to 89 | the account, this will be used as the caller id when making phone calls. 90 | 91 | Authentication credentials for Phonebot will be available [here](https://www.twilio.com/user/account/settings). 92 | 93 | Run the following CF CLI command to expose your developer account credentials to 94 | the platform. Replace the ACCOUNT_SID and TOKEN values with credentials from 95 | your [account settings page](https://www.twilio.com/user/account/settings). 96 | 97 | ``` 98 | $ cf cups twilio -p '{"accountSID":"ACCOUNT_SID","authToken":"TOKEN", "url": "https://api.twilio.com"}' 99 | ``` 100 | 101 | *Note: Phonebot will work with a Twilio trial account, however outgoing calls are 102 | only allowed to verified numbers. See [here](https://www.twilio.com/user/account/phone-numbers/verified) for more details.* 103 | 104 | #### IBM Watson 105 | 106 | This ["Speech To Text"](http://www.ibm.com/smarterplanet/us/en/ibmwatson/developercloud/speech-to-text.html) 107 | service will be automatically created using the _"Free Plan"_ when the 108 | application is deployed. To change the service instance the application uses, 109 | modify the [manifest.yml](http://docs.cloudfoundry.org/devguide/deploy-apps/manifest.html). 110 | 111 | #### Slack channels 112 | 113 | Run the following [CF CLI](http://docs.cloudfoundry.org/devguide/installcf/) 114 | command to register your Slack channel webhooks within Phonebot. 115 | 116 | ``` 117 | $ cf cups slack_webhooks -p '{"channel_name":"incoming_webhook_url",...}' 118 | ``` 119 | 120 | Replace the *channel_name* and *incoming_webhook_url* with the actual values for the channel name and 121 | incoming webhooks found at your [Slack integrations page](https://myslack.slack.com/services). 122 | 123 | You have to register each channel as a separate property. 124 | 125 | #### Deploy! 126 | 127 | Phew, you're now got everything configured! Deploying the application is easy, 128 | just run this command: 129 | 130 | ``` 131 | $ cf push --name your_random_identifier 132 | ``` 133 | 134 | Modify *your_random_identifier* to a name relevant for your Phonebot instance. 135 | 136 | Once the application has finished deploying, Phonebot will be listening to 137 | requests at http://your_random_identifier.mybluemix.net. 138 | 139 | *This address must match the URL registered with the outgoing webhooks.* 140 | 141 | **_... and you're done! Phonebot should post a message to each registered channel 142 | to confirm it's ready for action._** 143 | 144 | ### Development Mode 145 | 146 | Running Phonebot on your development machine is possible provided you follow 147 | these steps... 148 | 149 | * Copy VCAP_SERVICES and VCAP_APPLICATION to local development environment. 150 | * Run *node app.js* to start application 151 | * Ensure application running on localhost is available at external URL, e.g. ngrok 152 | * Modify Slack outgoing webhooks to point to new URL 153 | -------------------------------------------------------------------------------- /test/integration/phonebot.js: -------------------------------------------------------------------------------- 1 | var request = require('request'), 2 | assert = require('assert') 3 | 4 | var stub_server = process.env.STUB_SERVER 5 | if (!stub_server) throw new Error('Unable to find stub server from process.env') 6 | 7 | var test_server = process.env.TEST_SERVER 8 | if (!test_server) throw new Error('Unable to find test server from process.env') 9 | 10 | describe('PhoneBot', function(){ 11 | after(function(done){ 12 | request.get({url: stub_server + '/requests/clear'}, function () { 13 | done() 14 | }) 15 | }) 16 | describe('#startup', function(){ 17 | var requests 18 | before(function (done) { 19 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 20 | if (err) assert.ok(false) 21 | if (!body) assert.ok(false) 22 | requests = body.requests 23 | done() 24 | }) 25 | }) 26 | it('should post new channel message to each registered channel', function(){ 27 | var channel_message = requests[0] 28 | 29 | assert.deepEqual({ 30 | text: '_Phonebot is here!_', 31 | username: 'phonebot', 32 | icon_emoji: ':phone:' 33 | }, channel_message.body) 34 | assert.equal('/slack/testing', channel_message.url) 35 | assert.equal('POST', channel_message.method) 36 | }) 37 | it('should request incoming list of account phone numbers', function(){ 38 | var channel_message = requests[1] 39 | 40 | assert.equal('/2010-04-01/Accounts/xxx/IncomingPhoneNumbers.json', channel_message.url) 41 | assert.equal('GET', channel_message.method) 42 | }) 43 | }) 44 | 45 | describe('#slackbot', function(){ 46 | it('should respond to channel messages', function(done){ 47 | this.timeout(5000) 48 | request.post({url: test_server + '/slackbot', form: {channel_name: 'testing', text: '@phonebot hello'}}, function (err, resp, body) { 49 | if (err) { 50 | console.log(err) 51 | assert.ok(false) 52 | } 53 | 54 | setTimeout(function () { 55 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 56 | if (err) assert.ok(false) 57 | 58 | message = body.requests.pop() 59 | 60 | assert.deepEqual({ 61 | text: 'What\'s up?', 62 | username: 'phonebot', 63 | icon_emoji: ':phone:' 64 | }, message.body) 65 | assert.equal('/slack/testing', message.url) 66 | assert.equal('POST', message.method) 67 | 68 | done() 69 | }) 70 | }, 2000) 71 | }) 72 | }) 73 | 74 | it('should make a call from user command', function(done){ 75 | this.timeout(10000); 76 | request.post({url: test_server + '/slackbot', form: {channel_name: 'testing', text: '@phonebot call 1234567890'}}, function (err) { 77 | if (err) assert.ok(false) 78 | 79 | setTimeout(function () { 80 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 81 | if (err) assert.ok(false) 82 | 83 | var slack_message = body.requests.pop(), 84 | twilio_message = body.requests.pop() 85 | 86 | assert.deepEqual({ 87 | text: ':phone: Connecting to 1234567890', 88 | username: 'phonebot', 89 | icon_emoji: ':phone:' 90 | }, slack_message.body) 91 | assert.equal('/slack/testing', slack_message.url) 92 | assert.equal('POST', slack_message.method) 93 | 94 | assert.deepEqual({ 95 | To: '1234567890', 96 | From: '+440123456789', 97 | Url: test_server.replace('http', 'https') + '/recording/testing' 98 | }, twilio_message.body) 99 | assert.equal('/2010-04-01/Accounts/xxx/Calls.json', twilio_message.url) 100 | assert.equal('POST', twilio_message.method) 101 | 102 | done() 103 | }) 104 | }, 5000) 105 | }) 106 | }) 107 | 108 | it('should ignore new calls for active line', function(done){ 109 | this.timeout(10000); 110 | request.post({url: test_server + '/slackbot', form: {channel_name: 'testing', text: '@phonebot call 1234567890'}}, function (err) { 111 | if (err) assert.ok(false) 112 | 113 | setTimeout(function () { 114 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 115 | if (err) assert.ok(false) 116 | 117 | var slack_message = body.requests.pop() 118 | 119 | assert.deepEqual({ 120 | text: 'The line is busy, you have to hang up first...!', 121 | username: 'phonebot', 122 | icon_emoji: ':phone:' 123 | }, slack_message.body) 124 | assert.equal('/slack/testing', slack_message.url) 125 | assert.equal('POST', slack_message.method) 126 | 127 | done() 128 | }) 129 | }, 5000) 130 | }) 131 | }) 132 | 133 | it('should hang up active calls', function(done){ 134 | this.timeout(10000); 135 | request.post({url: test_server + '/slackbot', form: {channel_name: 'testing', text: '@phonebot hangup'}}, function (err) { 136 | if (err) assert.ok(false) 137 | 138 | setTimeout(function () { 139 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 140 | if (err) assert.ok(false) 141 | 142 | var slack_message = body.requests.pop() 143 | 144 | assert.deepEqual({ 145 | text: ':phone: That\'s it, call over!', 146 | username: 'phonebot', 147 | icon_emoji: ':phone:' 148 | }, slack_message.body) 149 | assert.equal('/slack/testing', slack_message.url) 150 | assert.equal('POST', slack_message.method) 151 | 152 | done() 153 | }) 154 | }, 5000) 155 | }) 156 | }) 157 | }) 158 | 159 | describe('#call transcribing', function(){ 160 | it('should send audio descriptions back to channels', function(done){ 161 | this.timeout(10000); 162 | // start the call... 163 | request.post({url: test_server + '/slackbot', form: {channel_name: 'testing', text: '@phonebot call 1234567890'}}, function (err) { 164 | if (err) assert.ok(false) 165 | 166 | setTimeout(function () { 167 | request.post({url: test_server + '/recording/testing', form: {sid: 'testing_sid', status: 'in-progress', RecordingUrl: stub_server + '/audio.wav'}}, function (err, resp, body) { 168 | 169 | setTimeout(function () { 170 | request.get({url: stub_server + '/requests', json: true}, function (err, resp, body) { 171 | if (err) assert.ok(false) 172 | 173 | message = body.requests.pop() 174 | 175 | assert.deepEqual({ 176 | text: ':speech_balloon: Hello World', 177 | username: 'phonebot', 178 | icon_emoji: ':phone:' 179 | }, message.body) 180 | assert.equal('/slack/testing', message.url) 181 | assert.equal('POST', message.method) 182 | done() 183 | }) 184 | }, 5000) 185 | }) 186 | }, 2000) 187 | }) 188 | }) 189 | }) 190 | // AFTER CLEAR DOWN TEST RESULTS 191 | }) 192 | -------------------------------------------------------------------------------- /test/unit/call_manager.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var CallManager = require('../../lib/call_manager.js') 3 | var twilio = require('twilio') 4 | var log = require('loglevel') 5 | log.disableAll() 6 | 7 | var args = null 8 | var cb_arg = {} 9 | var client = { 10 | makeCall: function (options, cb) { 11 | args = options 12 | cb(null, cb_arg) 13 | }, 14 | incomingPhoneNumbers: { list: function (cb) { 15 | cb(null, {incoming_phone_numbers: [{phone_number: 1234}]}) 16 | }} 17 | } 18 | 19 | var failing = { 20 | makeCall: function (options, cb) { 21 | cb('Error') 22 | }, 23 | incomingPhoneNumbers: { list: function () {}}, 24 | calls: function () { 25 | return { 26 | update: function (opts, cb) { 27 | cb('Error', null) 28 | } 29 | } 30 | } 31 | } 32 | 33 | describe('CallManager', function(){ 34 | describe('#constructor', function(){ 35 | it('should retrieve active phone number for outgoing call', function(){ 36 | var cm = CallManager({incomingPhoneNumbers: { 37 | list: function (cb) { 38 | cb(null, {incoming_phone_numbers: [{phone_number: 1234}]}) 39 | } 40 | }}) 41 | assert.ok(1234, cm.from) 42 | }) 43 | it('should throw an error if we cannot access outbound number', function(){ 44 | var called = false 45 | try { 46 | var cm = CallManager({incomingPhoneNumbers: { 47 | list: function (cb) { 48 | cb(true, {incoming_phone_numbers: [{phone_number: 1234}]}) 49 | } 50 | }}) 51 | } catch (e) { 52 | called = true 53 | } 54 | assert.ok(called) 55 | }) 56 | }) 57 | describe('#call', function(){ 58 | it('should not make a call if there is an active call', function(){ 59 | var channel = 'some_url', 60 | number = '111 222 333' 61 | cm = CallManager({ 62 | makeCall: function () { 63 | assert.ok(false) 64 | }, 65 | incomingPhoneNumbers: { 66 | list: function (){} 67 | } 68 | }, channel) 69 | 70 | var route = 'localhost' 71 | cm.active_call = {sid: null, status: null} 72 | cm.call(number, route) 73 | }) 74 | it('should make a call and save the sid as the active call reference', function(){ 75 | var channel = 'some_url', 76 | number = '111 222 333' 77 | cm = CallManager(client, channel) 78 | 79 | cb_arg = {sid: 111, CallStatus: 'queued'} 80 | var route = 'localhost' 81 | cm.call(number, route) 82 | assert.equal(args.to, number) 83 | assert.equal(args.from, 1234) 84 | assert.equal(args.url, route) 85 | assert.equal(cm.active_call.sid, 111) 86 | assert.equal(cm.active_call.status, 'queued') 87 | assert.deepEqual(cm.outgoing, [cm.default_greeting]) 88 | }) 89 | it('should remove active call when initial request fails', function(done){ 90 | var channel = 'some_url', 91 | number = '111 222 333' 92 | cm = CallManager(failing, channel) 93 | 94 | cm.on('failed', function (arg) { 95 | done() 96 | }) 97 | var route = 'localhost' 98 | cm.call(number, route) 99 | assert.equal(cm.active_call, null) 100 | }) 101 | }) 102 | describe('#hangup', function(){ 103 | it('should not hang up without an active call', function(){ 104 | var channel = 'some_url', 105 | number = '111 222 333' 106 | 107 | cm = CallManager({ 108 | makeCall: function () { 109 | assert.ok(false) 110 | }, 111 | incomingPhoneNumbers: { 112 | list: function (){} 113 | } 114 | }, channel) 115 | 116 | cm.hangup() 117 | }) 118 | it('should hang up the active call', function(done){ 119 | var channel = 'some_url', 120 | number = '111 222 333' 121 | 122 | var called = false 123 | cm = CallManager({calls: function () { 124 | called = true 125 | return {update: function (opts, cb) { cb(null, {CallStatus: 'completed', CallSid: 111})} } 126 | }, incomingPhoneNumbers: {list: function () {}}}, channel) 127 | 128 | cm.on('completed', function (arg) { 129 | done() 130 | }) 131 | cm.active_call = {sid: 111, status: null} 132 | cm.hangup() 133 | assert.ok(called) 134 | assert.equal(null, cm.active_call) 135 | }) 136 | it('should handle request failure when hanging up the active call', function(done){ 137 | var channel = 'some_url', 138 | number = '111 222 333' 139 | 140 | cm = CallManager(failing, channel) 141 | 142 | cm.on('failed', function (arg) { 143 | done() 144 | }) 145 | 146 | cm.active_call = {sid: null, status: null} 147 | cm.hangup() 148 | assert.equal(null, cm.active_call) 149 | }) 150 | }) 151 | describe('#say', function(){ 152 | it('should push user text into queue of outgoing messages', function(){ 153 | var channel = 'some_url', 154 | number = '111 222 333' 155 | 156 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}, channel) 157 | cm.outgoing = [] 158 | cm.say('Hello world') 159 | cm.say('Hello world') 160 | cm.say('Hello world') 161 | assert.deepEqual(cm.outgoing, ['Hello world', 'Hello world', 'Hello world']) 162 | }) 163 | it('should let user override the default greeting', function(){ 164 | var channel = 'some_url', 165 | number = '111 222 333' 166 | 167 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}, channel) 168 | cm.outgoing = [cm.default_greeting] 169 | cm.say('Hello world') 170 | assert.deepEqual(cm.outgoing, ['Hello world']) 171 | }) 172 | }) 173 | describe('#options', function(){ 174 | it('should let the user change the call duration', function(){ 175 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 176 | cm.options({duration: 100}) 177 | assert.equal(cm.defaults.duration, 100) 178 | }) 179 | }) 180 | describe('#process', function(){ 181 | it('should record calls when they reach in-progress state', function(){ 182 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 183 | cm.outgoing = [] 184 | cm.active_call = {sid: 111} 185 | var response = cm.process({body: {CallStatus: 'in-progress', CallSid: 111}}) 186 | var twiml = new twilio.TwimlResponse() 187 | twiml.record({playBeep: false, trim: 'do-not-trim', maxLength: cm.defaults.duration, timeout: 60}) 188 | 189 | assert.equal(response.toString(), twiml.toString()) 190 | }) 191 | it('should add all outgoing speech text to response', function(){ 192 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 193 | cm.outgoing = ['Hello', 'World', '!'] 194 | cm.active_call = {sid: 111} 195 | var response = cm.process({body: {CallStatus: 'in-progress', CallSid: 111}}) 196 | var twiml = new twilio.TwimlResponse() 197 | twiml.say('Hello World !') 198 | twiml.record({playBeep: false, trim: 'do-not-trim', maxLength: cm.defaults.duration, timeout: 60}) 199 | 200 | assert.equal(response.toString(), twiml.toString()) 201 | }) 202 | it('should notify listeners recording is available', function(done){ 203 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 204 | cm.outgoing = [] 205 | cm.active_call = {sid: 111} 206 | cm.on('recording', function (arg) { 207 | assert.ok(arg) 208 | done() 209 | }) 210 | var response = cm.process({body: {RecordingUrl: 'testing', CallStatus: 'in-progress', CallSid: 111}}) 211 | }) 212 | }) 213 | describe('#is_message_valid', function(){ 214 | it('should ignore queued messages', function(){ 215 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 216 | assert.ok(cm.is_message_valid('queued', null)) 217 | }) 218 | it('should match sid against current sid', function(){ 219 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 220 | cm.active_call = {sid: 1} 221 | assert.ok(cm.is_message_valid(null, 1)) 222 | assert.ok(!cm.is_message_valid(null, 2)) 223 | }) 224 | }) 225 | describe('#update_call_status', function(){ 226 | it('should stop processing when invalid sid encountered', function(){ 227 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 228 | var msg = {'CallStatus': 'in-progress', sid: 2222} 229 | cm.active_call = {sid: 1111} 230 | assert.equal(null, cm.update_call_status(msg)) 231 | }) 232 | it('should create the new active call reference when call is started', function(done){ 233 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 234 | var msg = {'CallStatus': 'queued', sid: 'sid'} 235 | cm.on('queued', function () { 236 | done() 237 | }) 238 | cm.update_call_status(msg) 239 | 240 | assert.equal(cm.active_call.sid, 'sid') 241 | assert.equal(cm.active_call.status, 'queued') 242 | }) 243 | it('should notify listeners when call starts ringing', function(done){ 244 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 245 | var msg = {'CallStatus': 'ringing'} 246 | cm.on('ringing', function () { 247 | done() 248 | }) 249 | cm.active_call = {} 250 | cm.update_call_status(msg) 251 | 252 | assert.equal(cm.active_call.status, 'ringing') 253 | }) 254 | it('should notify listeners when call is connected', function(done){ 255 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 256 | var msg = {'CallStatus': 'in-progress'} 257 | cm.on('in-progress', function () { 258 | done() 259 | }) 260 | cm.active_call = {} 261 | 262 | cm.update_call_status(msg) 263 | assert.equal(cm.active_call.status, 'in-progress') 264 | }) 265 | it('should not notify listeners unless status changes', function(done){ 266 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 267 | var msg = {'CallStatus': 'in-progress'} 268 | cm.on('in-progress', function () { 269 | done() 270 | }) 271 | cm.active_call = {} 272 | 273 | cm.update_call_status(msg) 274 | cm.update_call_status(msg) 275 | assert.equal(cm.active_call.status, 'in-progress') 276 | }) 277 | it('should notify listeners when call is completed normally', function(done){ 278 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 279 | var msg = {'CallStatus': 'completed'} 280 | cm.on('completed', function () { 281 | done() 282 | }) 283 | cm.active_call = {} 284 | 285 | cm.update_call_status(msg) 286 | assert.equal(null, cm.active_call) 287 | }) 288 | it('should notify listeners when call could not connect', function(done){ 289 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 290 | var msg = {'CallStatus': 'failed'} 291 | cm.on('failed', function () { 292 | done() 293 | }) 294 | cm.active_call = {} 295 | 296 | cm.update_call_status(msg) 297 | assert.equal(cm.active_call, null) 298 | }) 299 | it('should notify listeners when call was not answered', function(done){ 300 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 301 | var msg = {'CallStatus': 'no-answer', 'CallSid': 111} 302 | cm.active_call = {sid: 111} 303 | cm.on('no-answer', function () { 304 | done() 305 | }) 306 | 307 | cm.update_call_status(msg) 308 | assert.equal(cm.active_call, null) 309 | }) 310 | it('should notify listeners when we cancel the call', function(done){ 311 | cm = CallManager({incomingPhoneNumbers: {list: function () {}}}) 312 | var msg = {'CallStatus': 'canceled', sid: 111} 313 | cm.active_call = {sid: 111} 314 | cm.on('canceled', function () { 315 | done() 316 | }) 317 | 318 | cm.update_call_status(msg) 319 | assert.equal(cm.active_call, null) 320 | }) 321 | }) 322 | }) 323 | -------------------------------------------------------------------------------- /test/unit/phonebot.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var mockery = require('mockery') 3 | var async = require('async') 4 | 5 | var log = require('loglevel') 6 | log.disableAll() 7 | 8 | var location 9 | var cbs = { 10 | available: [], 11 | failed: [] 12 | } 13 | 14 | var client = { 15 | incomingPhoneNumbers: { list: function () {}} 16 | } 17 | 18 | var ret = { 19 | start: function () { 20 | }, 21 | transcript: 'Sample', 22 | on: function (id, cb) { 23 | cbs[id].push(cb) 24 | } 25 | } 26 | var translate = function (_) { 27 | location = _ 28 | return ret 29 | } 30 | 31 | describe('PhoneBot', function(){ 32 | before(function() { 33 | mockery.enable(); // Enable mockery at the start of your test suite 34 | mockery.warnOnUnregistered(false); 35 | mockery.registerMock('./translate.js', translate); 36 | mockery.registerMock('request', { 37 | post: function () {} 38 | }) 39 | PhoneBot = require('../../lib/phonebot.js') 40 | }) 41 | 42 | after(function() { 43 | mockery.deregisterMock('request'); 44 | mockery.disable(); // Disable Mockery after tests are completed 45 | }) 46 | 47 | describe('#constructor', function(){ 48 | it('should create slackbots and phone clients for each channel', function(){ 49 | var channels = { 50 | 'one': 'hook_one', 51 | 'two': 'hook_two', 52 | 'three': 'hook_three' 53 | } 54 | 55 | var pb = PhoneBot(client, null, channels) 56 | var keys = Object.keys(pb.channels) 57 | assert.deepEqual(Object.keys(channels), keys) 58 | keys.forEach(function (key) { 59 | assert.equal(pb.channels[key].bot.outgoing, channels[key]) 60 | assert.equal(pb.channels[key].phone.channel, key) 61 | }) 62 | }) 63 | it('should not make a call if phone line is busy', function(done){ 64 | var channels = { 65 | 'one': 'hook_one' 66 | } 67 | 68 | var pb = PhoneBot(client, null, channels), 69 | bot = pb.channels.one.bot, 70 | phone = pb.channels.one.phone 71 | 72 | phone.active_call = true 73 | bot.post = function () { 74 | done() 75 | } 76 | 77 | bot.emit('call', '123456789') 78 | }) 79 | it('should make a call if phone line is free', function(done){ 80 | var channels = { 81 | 'one': 'hook_one' 82 | } 83 | 84 | var pb = PhoneBot(client, null, channels, 'http://sample.com'), 85 | bot = pb.channels.one.bot, 86 | phone = pb.channels.one.phone 87 | 88 | phone.active_call = false 89 | phone.call = function (number, location) { 90 | assert.equal(number, '123456789') 91 | assert.equal(location, 'http://sample.com/one') 92 | done() 93 | } 94 | 95 | bot.emit('call', '123456789') 96 | }) 97 | it('should sent channel messages to phone line', function(done){ 98 | var channels = { 99 | 'one': 'hook_one' 100 | } 101 | 102 | var pb = PhoneBot(client, null, channels), 103 | bot = pb.channels.one.bot, 104 | phone = pb.channels.one.phone 105 | 106 | phone.active_call = true 107 | phone.say = function (text) { 108 | assert.equal(text, '123456789') 109 | done() 110 | } 111 | 112 | bot.emit('say', '123456789') 113 | }) 114 | it('should sent allow user to change recording duration', function(done){ 115 | var channels = { 116 | 'one': 'hook_one' 117 | } 118 | 119 | var pb = PhoneBot(client, null, channels), 120 | bot = pb.channels.one.bot, 121 | phone = pb.channels.one.phone 122 | 123 | phone.options = function (options) { 124 | assert.equal(options.duration, 100) 125 | done() 126 | } 127 | 128 | bot.emit('duration', 100) 129 | }) 130 | it('should sent user to enable verbose mode', function(done){ 131 | var channels = { 132 | 'one': 'hook_one' 133 | } 134 | 135 | var pb = PhoneBot(client, null, channels), 136 | bot = pb.channels.one.bot, 137 | phone = pb.channels.one.phone 138 | 139 | phone.options = function (options) { 140 | assert.equal(options.verbose, true) 141 | done() 142 | } 143 | 144 | bot.emit('verbose', true) 145 | }) 146 | 147 | it('should not attempt to hang up phone call when line is not active', function(done){ 148 | var channels = { 149 | 'one': 'hook_one' 150 | } 151 | 152 | var pb = PhoneBot(client, null, channels), 153 | bot = pb.channels.one.bot, 154 | phone = pb.channels.one.phone 155 | 156 | phone.active_call = false 157 | bot.post = function () { 158 | done() 159 | } 160 | bot.emit('hangup') 161 | }) 162 | it('should hang up phone call when line is active', function(done){ 163 | var channels = { 164 | 'one': 'hook_one' 165 | } 166 | 167 | var pb = PhoneBot(client, null, channels), 168 | bot = pb.channels.one.bot, 169 | phone = pb.channels.one.phone 170 | 171 | phone.active_call = true 172 | phone.hangup = function () { 173 | done() 174 | } 175 | bot.emit('hangup') 176 | }) 177 | it('should notify channel when phone call is queued', function(done){ 178 | var channels = { 179 | 'one': 'hook_one' 180 | } 181 | 182 | var pb = PhoneBot(client, null, channels), 183 | bot = pb.channels.one.bot, 184 | phone = pb.channels.one.phone 185 | 186 | phone.active_call = {number: '1234'} 187 | bot.post = function (text) { 188 | assert.equal(text, ':phone: Connecting to 1234') 189 | done() 190 | } 191 | phone.emit('queued') 192 | }) 193 | it('should notify channel when phone call is ringing', function(done){ 194 | var channels = { 195 | 'one': 'hook_one' 196 | } 197 | 198 | var pb = PhoneBot(client, null, channels), 199 | bot = pb.channels.one.bot, 200 | phone = pb.channels.one.phone 201 | 202 | bot.post = function (text) { 203 | assert.equal(text, ':phone: Still ringing...') 204 | done() 205 | } 206 | phone.emit('ringing') 207 | }) 208 | it('should notify channel when phone call is connected', function(done){ 209 | var channels = { 210 | 'one': 'hook_one' 211 | } 212 | 213 | var pb = PhoneBot(client, null, channels), 214 | bot = pb.channels.one.bot, 215 | phone = pb.channels.one.phone 216 | 217 | bot.post = function (text) { 218 | assert.equal(text, ':phone: You\'re connected! :+1:') 219 | done() 220 | } 221 | phone.emit('in-progress') 222 | }) 223 | it('should notify channel when phone call is canceled', function(done){ 224 | var channels = { 225 | 'one': 'hook_one' 226 | } 227 | 228 | var pb = PhoneBot(client, null, channels), 229 | bot = pb.channels.one.bot, 230 | phone = pb.channels.one.phone 231 | 232 | bot.post = function (text) { 233 | assert.equal(text, ':phone: That\'s it, call over!') 234 | done() 235 | } 236 | phone.emit('canceled') 237 | }) 238 | it('should notify channel when phone call is busy', function(done){ 239 | var channels = { 240 | 'one': 'hook_one' 241 | } 242 | 243 | var pb = PhoneBot(client, null, channels), 244 | bot = pb.channels.one.bot, 245 | phone = pb.channels.one.phone 246 | 247 | bot.post = function (text) { 248 | assert.equal(text, ':phone: They were busy, sorry :unamused:') 249 | done() 250 | } 251 | phone.emit('busy') 252 | }) 253 | it('should notify channel when phone call is not answered', function(done){ 254 | var channels = { 255 | 'one': 'hook_one' 256 | } 257 | 258 | var pb = PhoneBot(client, null, channels), 259 | bot = pb.channels.one.bot, 260 | phone = pb.channels.one.phone 261 | 262 | bot.post = function (text) { 263 | assert.equal(text, ':phone: Oh no, they didn\'t answer :sleeping:') 264 | done() 265 | } 266 | phone.emit('no-answer') 267 | }) 268 | it('should notify channel when phone call fails', function(done){ 269 | var channels = { 270 | 'one': 'hook_one' 271 | } 272 | 273 | var pb = PhoneBot(client, null, channels), 274 | bot = pb.channels.one.bot, 275 | phone = pb.channels.one.phone 276 | 277 | bot.post = function (text) { 278 | assert.equal(text, ':phone: Whoops, something failed. My bad. Try again? :see_no_evil:') 279 | done() 280 | } 281 | phone.emit('failed') 282 | }) 283 | 284 | it('should post message when phone call ends once queue empties', function(done){ 285 | var channels = { 286 | 'one': 'hook_one' 287 | } 288 | 289 | var pb = PhoneBot(client, null, channels), 290 | bot = pb.channels.one.bot, 291 | phone = pb.channels.one.phone 292 | 293 | pb.channels.one.queue = async.queue(function (task, callback) { 294 | setTimeout(function () { 295 | callback() 296 | }, 100) 297 | }) 298 | 299 | pb.channels.one.queue.push({}) 300 | pb.channels.one.queue.push({}) 301 | pb.channels.one.queue.push({}) 302 | 303 | bot.post = function (text) { 304 | assert.ok(pb.channels.one.queue.idle()) 305 | assert.equal(text, ':phone: That\'s it, call over!') 306 | done() 307 | } 308 | 309 | phone.emit('completed') 310 | }) 311 | it('should post message when phone call ends and the queue is empty', function(done){ 312 | var channels = { 313 | 'one': 'hook_one' 314 | } 315 | 316 | var pb = PhoneBot(client, null, channels), 317 | bot = pb.channels.one.bot, 318 | phone = pb.channels.one.phone 319 | 320 | bot.post = function (text) { 321 | assert.equal(text, ':phone: That\'s it, call over!') 322 | done() 323 | } 324 | phone.emit('completed') 325 | }) 326 | it('should schedule translation when recording is available', function(done){ 327 | var channels = { 328 | 'one': 'hook_one' 329 | } 330 | 331 | var pb = PhoneBot(client, null, channels), 332 | bot = pb.channels.one.bot, 333 | phone = pb.channels.one.phone 334 | 335 | var messages = [':speech_balloon: Sample', ':speech_balloon: _waiting for translation_'] 336 | bot.post = function (text) { 337 | assert.equal(text, messages[messages.length-1]) 338 | messages.pop() 339 | if (messages.length === 0) done() 340 | } 341 | // Need to mock out translate 342 | phone.emit('recording', 'location') 343 | }) 344 | it('should post translation to channel when async task finishes', function(done){ 345 | var channels = { 346 | 'one': 'hook_one' 347 | } 348 | 349 | var pb = PhoneBot(client, null, channels), 350 | bot = pb.channels.one.bot, 351 | phone = pb.channels.one.phone 352 | 353 | ret.transcript = null 354 | cbs.available = [] 355 | cbs.failed = [] 356 | 357 | var messages = [':speech_balloon: Sample', ':speech_balloon: _waiting for translation_'] 358 | bot.post = function (text) { 359 | assert.equal(text, messages[messages.length-1]) 360 | messages.pop() 361 | if (messages.length === 0) done() 362 | } 363 | 364 | // Need to mock out translate 365 | phone.emit('recording', 'location') 366 | setTimeout(function () { 367 | ret.transcript = "Sample" 368 | cbs.available[0]() 369 | }, 10) 370 | }) 371 | it('should handle multiple translation tasks being queued', function(done){ 372 | var channels = { 373 | 'one': 'hook_one' 374 | } 375 | 376 | var pb = PhoneBot(client, null, channels), 377 | bot = pb.channels.one.bot, 378 | phone = pb.channels.one.phone 379 | 380 | ret.transcript = null 381 | cbs.available = [] 382 | cbs.failed = [] 383 | 384 | var count = 0 385 | 386 | bot.post = function (text) { 387 | if (text === ':speech_balloon: _waiting for translation_') return 388 | assert.equal(text, ':speech_balloon: transcript') 389 | if (++count === 2) done() 390 | } 391 | // Need to mock out translate 392 | phone.emit('recording', 'location') 393 | phone.emit('recording', 'location') 394 | phone.emit('recording', 'location') 395 | 396 | setTimeout(function () { 397 | ret.transcript = "transcript" 398 | cbs.available[0]() 399 | }, 50) 400 | }) 401 | it('should handle failed translation tasks', function(done){ 402 | var channels = { 403 | 'one': 'hook_one' 404 | } 405 | 406 | var pb = PhoneBot(client, null, channels), 407 | bot = pb.channels.one.bot, 408 | phone = pb.channels.one.phone 409 | 410 | ret.transcript = null 411 | cbs.available = [] 412 | cbs.failed = [] 413 | 414 | bot.post = function (text) { 415 | if (text === ':speech_balloon: _waiting for translation_') return 416 | assert.equal(text, ':speech_balloon: _unable to recognise speech_') 417 | done() 418 | } 419 | // Need to mock out translate 420 | phone.emit('recording', 'location') 421 | phone.emit('recording', 'location') 422 | 423 | setTimeout(function () { 424 | ret.transcript = "transcript" 425 | cbs.failed[0]() 426 | }, 50) 427 | }) 428 | }) 429 | describe('#phone_message', function(){ 430 | it('should ignore messages for channels not registered', function(){ 431 | var channels = { 432 | 'one': 'hook_one' 433 | } 434 | 435 | var pb = PhoneBot(client, null, channels), 436 | phone = pb.channels.one.phone 437 | 438 | assert.equal(null, pb.phone_message('two', null)) 439 | }) 440 | it('should process messages for registered channels', function(){ 441 | var channels = { 442 | 'one': 'hook_one' 443 | } 444 | 445 | var pb = PhoneBot(client, null, channels), 446 | phone = pb.channels.one.phone 447 | 448 | phone.process = function () { 449 | return "testing" 450 | } 451 | assert.equal('testing', pb.phone_message('one', null)) 452 | }) 453 | }) 454 | describe('#slack_message', function(){ 455 | it('should ignore messages for channels not registered', function(){ 456 | var channels = { 457 | 'one': 'hook_one' 458 | } 459 | 460 | var pb = PhoneBot(client, null, channels), 461 | phone = pb.channels.one.phone 462 | 463 | assert.equal(null, pb.slack_message('two', null)) 464 | }) 465 | it('should process messages for registered channels', function(){ 466 | var channels = { 467 | 'one': 'hook_one' 468 | } 469 | 470 | var pb = PhoneBot(client, null, channels), 471 | bot = pb.channels.one.bot 472 | 473 | var called = false 474 | bot.channel_message = function () { 475 | called = true 476 | } 477 | pb.slack_message('one', null) 478 | assert.equal(true, called) 479 | }) 480 | }) 481 | }) 482 | --------------------------------------------------------------------------------