diff --git a/Procfile b/Procfile index 9ebe8e8..06177a2 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ +web: npm run web worker: npm start diff --git a/adapter-repl.js b/adapter-repl.js new file mode 100644 index 0000000..91e56f0 --- /dev/null +++ b/adapter-repl.js @@ -0,0 +1,10 @@ +var conf = require('./config'); + +var SlackAdapter = require('./src/slack-adapter'); + +var SlackClient = require('slack-client'); + +var client = new SlackClient(conf.get('slackToken')); +var adapter = new SlackAdapter(client); + +module.exports = adapter; diff --git a/attributes/manness.json b/attributes/manness.json index 56e2d72..0a2b357 100644 --- a/attributes/manness.json +++ b/attributes/manness.json @@ -1,6 +1,24 @@ { "name": "manness", + "interviewQuestion": "Are you a man?", "values": [ + { + "value": "a man", + "matcherSets": [ + [{"matches": "true"}], + [{"matches": "man"}, {"doesNotMatch": "not"}, {"doesNotMatch": "woman"}], + [{"matches": "woman"}, {"matches": "not"}] + ], + "texts": { + "information": "you are a man", + "update": "Okay, we have noted that you are a man. ", + "wrong": "If I got it wrong, try saying “I am *not* a man!”", + "statistics": "men", + "table": "men", + "short": "M", + "interviewAnswer": "Yes" + } + }, { "value": "not a man", "matcherSets": [ @@ -10,26 +28,13 @@ ], "texts": { "information": "you are not a man", - "update": "Okay, we have noted that you are not a man. If I got it wrong, try saying “I am a man”.", + "update": "Okay, we have noted that you are not a man.", + "wrong": "If I got it wrong, try saying “I am a man”.", "statistics": "notMen", "table": "not-men", "terse": "not-men", - "short": "NM" - } - }, - { - "value": "a man", - "matcherSets": [ - [{"matches": "true"}], - [{"matches": "man"}, {"doesNotMatch": "not"}, {"doesNotMatch": "woman"}], - [{"matches": "woman"}, {"matches": "not"}] - ], - "texts": { - "information": "you are a man", - "update": "Okay, we have noted that you are a man. If I got it wrong, try saying “I am *not* a man!”", - "statistics": "men", - "table": "men", - "short": "M" + "short": "NM", + "interviewAnswer": "No" } }, { @@ -43,7 +48,8 @@ "update": "We have noted that it’s complicated whether you are a man. If I got it wrong, try saying “I am not a man.”", "statistics": "complicated", "table": "complicated", - "short": "‽" + "short": "‽", + "interviewAnswer": "It’s complicated" } }, { @@ -57,7 +63,8 @@ "update": "Okay, we have erased our record of whether you are a man.", "statistics": "unknown", "table": "unknown", - "short": "?" + "short": "?", + "interviewAnswer": "Decline" } } ], diff --git a/attributes/pocness.json b/attributes/pocness.json index ffba8fc..3292145 100644 --- a/attributes/pocness.json +++ b/attributes/pocness.json @@ -1,5 +1,6 @@ { "name": "pocness", + "interviewQuestion": "Are you a person of colour?", "values": [ { "value": "a PoC", @@ -9,11 +10,13 @@ ], "texts": { "information": "you are a person of colour", - "update": "We have noted that you are a person of colour. If I got it wrong, try saying “I am not a person of colour”", + "update": "We have noted that you are a person of colour.", + "wrong": "If I got it wrong, try saying “I am not a person of colour”", "statistics": "peopleOfColour", "table": "PoC", "terse": "people of colour", - "short": "PoC" + "short": "PoC", + "interviewAnswer": "Yes" } }, { @@ -24,10 +27,12 @@ ], "texts": { "information": "you are not a person of colour", - "update": "We have noted that you are not a person of colour. If I got it wrong, try saying “I am a person of colour”", + "update": "We have noted that you are not a person of colour.", + "wrong": "If I got it wrong, try saying “I am a person of colour”", "statistics": "nonPeopleOfColour", "table": "not-PoC", - "short": "N" + "short": "N", + "interviewAnswer": "No" } }, { @@ -38,10 +43,12 @@ ], "texts": { "information": "it’s complicated whether you are a person of colour", - "update": "We have noted that it’s complicated whether you are a person of colour. If I got it wrong, try saying “I am a person of colour”", + "update": "We have noted that it’s complicated whether you are a person of colour.", + "wrong": "If I got it wrong, try saying “I am a person of colour”", "statistics": "complicated", "table": "complicated", - "short": "‽" + "short": "‽", + "interviewAnswer": "It’s complicated" } }, { @@ -55,7 +62,8 @@ "update": "We have erased our record of whether you are a person of colour.", "statistics": "unknown", "table": "unknown", - "short": "?" + "short": "?", + "interviewAnswer": "Decline" } } ], diff --git a/package.json b/package.json index a5cb2ac..268c74d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "node --use-strict index.js", "debug": "node debug --use-strict index.js", - "test": "tap --strict test/*" + "test": "tap --strict test/*", + "web": "node --use-strict web.js" }, "repository": { "type": "git", @@ -26,14 +27,19 @@ "devDependencies": { "grunt": "^0.4.5", "grunt-tape": "0.0.2", + "nock": "^8.0.0", "require-subvert": "^0.1.0", "sinon": "^1.12.2", + "supertest-koa-agent": "^0.2.1", "tape": "^3.4.0" }, "dependencies": { "cli-table": "^0.3.1", "convict": "^0.6.1", "cron-parser": "^0.6.1", + "koa": "^1.2.0", + "koa-body": "^1.4.0", + "koa-router": "^5.4.0", "lodash.every": "^3.2.0", "lodash.find": "^3.2.0", "lodash.map": "^3.1.4", @@ -46,6 +52,7 @@ "sequelize": "^3.21.0", "sequelize-cli": "1.1.0", "slack-client": "^1.3.1", + "superagent": "^2.1.0", "tap": "^0.5.0", "validator": "^3.28.0" }, diff --git a/src/direct-message-handler.js b/src/direct-message-handler.js index 8466bbf..39729b3 100644 --- a/src/direct-message-handler.js +++ b/src/direct-message-handler.js @@ -1,12 +1,12 @@ // TODO this should probably be further decomposed // Also should maybe just return a reply which the bot actually sends? -var every = require('lodash.every'); var find = require('lodash.find'); var UpdateParser = require('./update-parser'); var attributeConfigurations = require('./attribute-configurations'); +var userInformation = require('./reports/user-information'); class DirectMessageHandler { constructor({userRepository, channelRepository, adapter}) { @@ -20,11 +20,39 @@ class DirectMessageHandler { handle(channel, message) { if (!message.text || message.subtype === 'bot_message') return; - var text = message.text.toLowerCase(); + var text = message.text.toLowerCase().trim(); var user = this.adapter.getUser(message.user); - if (text === 'info') { + // TODO replace the self-identification request with this + if (text === 'interview') { + channel.postMessage({ + text: DirectMessageHandler.INTERVIEW_INTRODUCTION, + attachments: [{ + title: 'Would you like to self-identify?', + callback_id: 'initial', + attachment_type: 'default', + actions: [ + { + name: 'yes', + text: 'Yes', + type: 'button', + value: 'yes' + }, { + name: 'no', + text: 'No', + type: 'button', + value: 'no' + }, { + name: 'more', + text: 'Tell me more', + type: 'button', + value: 'more' + } + ] + }] + }); + } else if (text === 'info') { this.handleInformationRequest(channel, message); } else if (text === 'help') { this.handleHelpRequest(channel, message); @@ -40,34 +68,11 @@ class DirectMessageHandler { } handleInformationRequest(channel, message) { - // FIXME fetch the entire user rather than run multiple queries - Promise.all(this.attributeConfigurations.map(function(configuration) { - return this.userRepository.retrieveAttribute(message.user, configuration.name); - }.bind(this))).then(function(values) { - var reply; - - if (every(values, function(value) {return value == null || value == undefined})) { - reply = `We don’t have you on record! ${DirectMessageHandler.HELP_MESSAGE}`; - } else { - reply = 'Our records indicate that:\n\n'; - - this.attributeConfigurations.forEach(function(attributeConfiguration, index) { - var value = values[index]; - - var valueConfiguration = find(attributeConfiguration.values, function(valueConfiguration) { - return valueConfiguration.value == value; - }); - - if (valueConfiguration) { - reply += `* ${valueConfiguration.texts.information}\n`; - } else { - reply += `* ${attributeConfiguration.unknownValue.texts.information}\n`; - } - }); - } - - channel.send(reply); - }.bind(this)); + userInformation(message.user, { + userRepository: this.userRepository, + helpMessage: DirectMessageHandler.HELP_MESSAGE, + attributeConfigurations: this.attributeConfigurations + }).then(reply => channel.send(reply)); } handleInformationUpdate(channel, message) { @@ -102,7 +107,7 @@ class DirectMessageHandler { }); if (matchingValue) { - reply = matchingValue.texts.update; + reply = `${matchingValue.texts.update} ${matchingValue.texts.wrong || ''}`; } channel.send(reply); @@ -179,6 +184,7 @@ class DirectMessageHandler { // TODO maybe messages should be collected somewhere central, and parameterised DirectMessageHandler.HELP_MESSAGE = 'You can let me know “I’m not a man”, “I am a person of colour”, “it’s complicated whether I am white” and other such variations, or ask for my current information on you with “info”. To erase what you’ve previously told me, say “It is unknown whether I am a man”, “it is unknown whether I am white”, and so on. View my source at https://github.com/backspace/slack-statsbot'; -DirectMessageHandler.VERBOSE_HELP_MESSAGE = `Hey, I’m a bot that collects statistics on who is taking up space in the channels I’m in. For now, I only track whether or not a participant is a man and/or a person of colour. ${DirectMessageHandler.HELP_MESSAGE}. Please note that, while I won’t directly reveal the answers you provide, it is sometimes possible to deduce them from the statistics I provide and channel logs.`; +DirectMessageHandler.INTERVIEW_INTRODUCTION = 'Hey, I’m a bot that collects statistics on who is taking up space in the channels I’m in. For now, I only track whether or not a participant is a man and/or a person of colour. If you feel comfortable answering questions about this, we can continue.'; +DirectMessageHandler.VERBOSE_HELP_MESSAGE = `${DirectMessageHandler.INTERVIEW_INTRODUCTION} ${DirectMessageHandler.HELP_MESSAGE}`; module.exports = DirectMessageHandler; diff --git a/src/message-buttons/question-for-attribute-configuration.js b/src/message-buttons/question-for-attribute-configuration.js new file mode 100644 index 0000000..7999539 --- /dev/null +++ b/src/message-buttons/question-for-attribute-configuration.js @@ -0,0 +1,16 @@ + +module.exports = function questionForAttributeConfiguration(attributeConfiguration) { + return { + title: attributeConfiguration.interviewQuestion, + callback_id: attributeConfiguration.name, + attachment_type: 'default', + actions: attributeConfiguration.values.map(value => { + return { + name: attributeConfiguration.name, + text: value.texts.interviewAnswer, + type: 'button', + value: value.value || 'decline' + }; + }) + }; +} diff --git a/src/message-buttons/server.js b/src/message-buttons/server.js new file mode 100644 index 0000000..663339c --- /dev/null +++ b/src/message-buttons/server.js @@ -0,0 +1,144 @@ +const koa = require('koa'); +const Router = require('koa-router'); + +const bodyParser = require('koa-body'); + +const request = require('superagent'); + +const userInformation = require('../reports/user-information'); +const DirectMessageHandler = require('../direct-message-handler'); + +module.exports = function({attributeConfigurations, questionForAttributeConfiguration, userRepository} = {}) { + const app = koa(); + app.use(bodyParser()); + + const router = new Router(); + + router.post('/slack/actions', function* (next) { + const payload = JSON.parse(this.request.body.payload); + + if (payload.token !== process.env.SLACK_VERIFICATION_TOKEN) { + this.status = 403; + yield next; + return; + } + + const attributeName = payload.callback_id; + const action = payload.actions[0]; + + if (attributeName === 'initial') { + if (action.value === 'yes') { + this.status = 200; + + request.post(payload.response_url).send({ + text: DirectMessageHandler.INTERVIEW_INTRODUCTION, + attachments: [{ + title: 'Would you like to self-identify?', + text: 'Yes' + }], + replace_original: true + }) + .end((err, res) => { + request.post(payload.response_url).send({ + attachments: [questionForAttributeConfiguration(attributeConfigurations[0])], + replace_original: false + }).end(); + }); + } else if (action.value === 'more') { + this.body = 'Here is more information.'; + } else { + this.body = 'Okay!'; + } + } else { + const attributeValueToFind = action.value === 'decline' ? null : action.value; + + const responseAttributeConfiguration = attributeConfigurations.find(configuration => configuration.name === attributeName); + const responseAttributeValue = responseAttributeConfiguration.values.find(attributeValue => attributeValue.value === attributeValueToFind); + + const userID = payload.user.id; + + userRepository.storeAttribute(userID, attributeName, attributeValueToFind); + + const nextAttributeConfiguration = getNextAttributeConfiguration(attributeConfigurations, attributeName); + + if (nextAttributeConfiguration) { + this.status = 200; + + request.post(payload.response_url).send({ + attachments: [{ + title: responseAttributeConfiguration.interviewQuestion, + text: responseAttributeValue.texts.interviewAnswer + }], + replace_original: true + }) + .end((err, res) => { + request.post(payload.response_url).send({ + attachments: [questionForAttributeConfiguration(nextAttributeConfiguration)], + replace_original: false + }).end(); + }); + } else { + yield userInformation(userID, { + userRepository: userRepository, + attributeConfigurations: attributeConfigurations + }).then(reply => { + this.status = 200; + + request.post(payload.response_url).send({ + attachments: [{ + title: responseAttributeConfiguration.interviewQuestion, + text: responseAttributeValue.texts.interviewAnswer + }], + replace_original: true + }) + .end((err, res) => { + request.post(payload.response_url).send({ + text: 'Thanks for participating. See you around the Slack!', + replace_original: false + }).end(); + }); + }); + } + } + + yield next; + }); + + router.get('/oauth', function* (next) { + if (this.request.query.code) { + const requestPromise = new Promise((resolve) => { + request + .post('https://slack.com/api/oauth.access') + .type('form') + .send({ + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + code: this.request.query.code + }) + .end((err, res) => { + if (res.body.ok) { + this.body = {bot_access_token: res.body.bot.bot_access_token}; + } else { + this.body = {error: res.body.error}; + } + + resolve(); + }); + }); + + yield requestPromise; + } else { + this.status = 422; + } + }); + + app.use(router.routes()); + app.use(router.allowedMethods()); + + return app; +}; + +function getNextAttributeConfiguration(attributeConfigurations, attributeName) { + const attributeConfigurationIndex = attributeConfigurations.findIndex(attributeConfiguration => attributeConfiguration.name === attributeName); + return attributeConfigurations[attributeConfigurationIndex + 1]; +} diff --git a/src/reports/user-information.js b/src/reports/user-information.js new file mode 100644 index 0000000..2f15c59 --- /dev/null +++ b/src/reports/user-information.js @@ -0,0 +1,35 @@ +// FIXME this seems like a bad name/position? Separate into fetching and generating? + +const every = require('lodash.every'); +const find = require('lodash.find'); + +module.exports = function userInformation(userID, {userRepository, attributeConfigurations, helpMessage}) { + // FIXME fetch the entire user rather than run multiple queries + return Promise.all(attributeConfigurations.map(function(configuration) { + return userRepository.retrieveAttribute(userID, configuration.name); + })).then(function(values) { + var reply; + + if (every(values, function(value) {return value == null || value == undefined})) { + reply = `We don’t have you on record! ${helpMessage}`; + } else { + reply = 'Our records indicate that:\n\n'; + + attributeConfigurations.forEach(function(attributeConfiguration, index) { + var value = values[index]; + + var valueConfiguration = find(attributeConfiguration.values, function(valueConfiguration) { + return valueConfiguration.value == value; + }); + + if (valueConfiguration) { + reply += `* ${valueConfiguration.texts.information}\n`; + } else { + reply += `* ${attributeConfiguration.unknownValue.texts.information}\n`; + } + }); + } + + return reply; + }); +} diff --git a/test/direct-message-handler.js b/test/direct-message-handler.js index af0795d..eb3b23f 100755 --- a/test/direct-message-handler.js +++ b/test/direct-message-handler.js @@ -156,8 +156,9 @@ test('DirectMessageHandler handles an information request', function(t) { retrieveAttributeStub.withArgs(person.id, 'manness').returns(Promise.resolve(person.manness)); retrieveAttributeStub.withArgs(person.id, 'pocness').returns(Promise.resolve(person.pocness)); + // The extra spaces are to verify trimming. handler.handle(personIDToChannel[person.id], { - text: 'info', + text: ' info ', user: person.id }); }); @@ -281,7 +282,7 @@ test('DirectMessageHandler updates channel options and reports them', function(t user: admin.id }); - setTimeout(() => { + setTimeout(() => { t.ok(adminDM.send.calledWithMatch(/<#menexplicitid> has no ignored attributes/), 'expected no ignored attributes to be listed'); t.end(); diff --git a/test/message-buttons/question-for-attribute-configuration.js b/test/message-buttons/question-for-attribute-configuration.js new file mode 100644 index 0000000..4a1da73 --- /dev/null +++ b/test/message-buttons/question-for-attribute-configuration.js @@ -0,0 +1,59 @@ +const test = require('tape'); +const questionForAttributeConfiguration = require('../../src/message-buttons/question-for-attribute-configuration'); + +const attributeConfiguration = { + name: 'jorts', + interviewQuestion: 'Do you wear jorts?', + values: [{ + value: 'wears jorts', + texts: { + interviewAnswer: 'Yes' + } + }, { + value: 'does not wear jorts', + texts: { + interviewAnswer: 'No' + } + }, { + value: 'sometimes wears jorts', + texts: { + interviewAnswer: 'Sometimes' + } + }, { + value: null, + texts: { + interviewAnswer: 'Decline' + } + }] +}; + +test('it translates an attribute configuration into a question', function(t) { + t.deepEqual(questionForAttributeConfiguration(attributeConfiguration), { + title: 'Do you wear jorts?', + callback_id: 'jorts', + attachment_type: 'default', + actions: [{ + name: 'jorts', + text: 'Yes', + type: 'button', + value: 'wears jorts' + }, { + name: 'jorts', + text: 'No', + type: 'button', + value: 'does not wear jorts' + }, { + name: 'jorts', + text: 'Sometimes', + type: 'button', + value: 'sometimes wears jorts' + }, { + name: 'jorts', + text: 'Decline', + type: 'button', + value: 'decline' + }] + }); + + t.end(); +}); diff --git a/test/message-buttons/server.js b/test/message-buttons/server.js new file mode 100644 index 0000000..01cc2e8 --- /dev/null +++ b/test/message-buttons/server.js @@ -0,0 +1,364 @@ +const startServer = require('../../src/message-buttons/server'); +const agent = require('supertest-koa-agent'); + +const test = require('tape'); +const sinon = require('sinon'); + +const DirectMessageHandler = require('../../src/direct-message-handler'); + +process.env.SLACK_VERIFICATION_TOKEN = 'a-verification-token'; + +const fakeUserRepository = { + storeAttribute() {}, + retrieveAttribute() {} +}; + +const firstAttributeConfiguration = { + name: 'jorts', + interviewQuestion: 'Do you wear jorts?', + values: [{ + value: 'wears jorts', + texts: { + interviewAnswer: 'Yes', + update: 'We have noted that you wear jorts.', + information: 'you wear jorts' + } + }, { + value: 'does not wear jorts', + texts: { + interviewAnswer: 'No' + } + }, { + value: 'sometimes wears jorts', + texts: { + interviewAnswer: 'Sometimes' + } + }, { + value: null, + texts: { + interviewAnswer: 'Decline' + } + }] +}; + +const secondAttributeConfiguration = { + name: 'jants', + interviewQuestion: 'Do you wear jants?', + values: [{ + value: 'wears jants', + texts: { + interviewAnswer: 'Yes' + } + }, { + value: 'does not wear jants', + texts: { + update: 'We have noted that you do not wear jants.', + information: 'you do not wear jants', + interviewAnswer: 'No' + } + }] +}; + +const attributeConfigurations = [ + firstAttributeConfiguration, + secondAttributeConfiguration +]; + +function questionForAttributeConfiguration(attributeConfiguration) { + return `${attributeConfiguration.name}Question`; +} + +test('it handles acceptance of the initial interview question by repeating the question and answer and responding with a question for the first attribute', function(t) { + const nock = require('nock'); + + const responses = []; + + nock('https://example.com') + .post('/a-response-url') + .times(2) + .reply((uri, requestBody) => { + responses.push(JSON.parse(requestBody)); + + if (responses.length === 2) { + const firstResponse = responses[0]; + const secondResponse = responses[1]; + + t.deepEqual(firstResponse, { + text: DirectMessageHandler.INTERVIEW_INTRODUCTION, + attachments: [{ + title: 'Would you like to self-identify?', + text: 'Yes' + }], + replace_original: true + }, 'should restate the question with the answer attached'); + + t.deepEqual(secondResponse, { + attachments: ['jortsQuestion'], + replace_original: false + }, 'should follow up with the first attribute question'); + + t.end(); + } + + return [200, 'hello']; + }); + + agent(startServer({attributeConfigurations, questionForAttributeConfiguration})) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'initial', + actions: [{ + name: 'yes', + value: 'yes' + }], + token: 'a-verification-token', + response_url: 'https://example.com/a-response-url' + })}) + .expect(200, () => {}); +}); + +test('it handles a response to the first attribute question by storing it and asking the second attribute question', function(t) { + const nock = require('nock'); + + const responses = []; + + nock('https://example.com') + .post('/a-response-url') + .times(2) + .reply((uri, requestBody) => { + responses.push(JSON.parse(requestBody)); + + if (responses.length === 2) { + const firstResponse = responses[0]; + const secondResponse = responses[1]; + + t.deepEqual(firstResponse, { + attachments: [{ + title: 'Do you wear jorts?', + text: 'Yes' + }], + replace_original: true + }, 'should restate the question with the answer attached'); + + t.deepEqual(secondResponse, { + attachments: ['jantsQuestion'], + replace_original: false + }, 'should follow up with the second attribute question'); + + t.end(); + } + + return [200, 'hello']; + }); + + const storeAttributeStub = sinon.stub(fakeUserRepository, 'storeAttribute'); + + agent(startServer({attributeConfigurations, questionForAttributeConfiguration, userRepository: fakeUserRepository})) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'jorts', + response_url: 'https://example.com/a-response-url', + user: { + id: 'userID' + }, + actions: [{ + name: 'yes', + value: 'wears jorts' + }], + token: 'a-verification-token' + })}) + .expect(200, () => { + t.ok(storeAttributeStub.calledWith('userID', 'jorts', 'wears jorts'), 'stores the attribute value'); + storeAttributeStub.restore(); + }); +}); + +test('it handles a decline response to the first attribute question by storing it as null', function(t) { + const storeAttributeStub = sinon.stub(fakeUserRepository, 'storeAttribute'); + + agent(startServer({attributeConfigurations, questionForAttributeConfiguration, userRepository: fakeUserRepository})) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'jorts', + response_url: 'https://example.com/a-response-url', + user: { + id: 'userID' + }, + actions: [{ + name: 'irrelevant', + value: 'decline' + }], + token: 'a-verification-token' + })}) + .expect(200, () => { + t.ok(storeAttributeStub.calledWith('userID', 'jorts', null), 'clears the attribute value'); + storeAttributeStub.restore(); + t.end(); + }); +}); + +test('it handles a response to the last attribute question by storing it, repeating the question and answer, and thanking', function(t) { + const nock = require('nock'); + + const responses = []; + + nock('https://example.com') + .post('/a-response-url') + .times(2) + .reply((uri, requestBody) => { + responses.push(JSON.parse(requestBody)); + + if (responses.length === 2) { + const firstResponse = responses[0]; + const secondResponse = responses[1]; + + t.deepEqual(firstResponse, { + attachments: [{ + title: 'Do you wear jants?', + text: 'No' + }], + replace_original: true + }, 'should restate the question with the answer attached'); + + t.deepEqual(secondResponse, { + text: 'Thanks for participating. See you around the Slack!', + replace_original: false + }, 'should end with a farewell'); + + t.end(); + } + + return [200, 'hello']; + }); + + const storeAttributeStub = sinon.stub(fakeUserRepository, 'storeAttribute'); + + const retrieveAttributeStub = sinon.stub(fakeUserRepository, 'retrieveAttribute'); + retrieveAttributeStub.withArgs('userID', 'jorts').returns(Promise.resolve('wears jorts')); + retrieveAttributeStub.withArgs('userID', 'jants').returns(Promise.resolve('does not wear jants')); + + agent(startServer({attributeConfigurations, questionForAttributeConfiguration, userRepository: fakeUserRepository})) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'jants', + response_url: 'https://example.com/a-response-url', + user: { + id: 'userID' + }, + actions: [{ + name: 'irrelevant', + value: 'does not wear jants' + }], + token: 'a-verification-token' + })}) + .expect(200, () => { + t.ok(storeAttributeStub.calledWith('userID', 'jants', 'does not wear jants'), 'stores the attribute value'); + storeAttributeStub.restore(); + }); +}); + +test('it handles a more information request from the initial interview question', function(t) { + agent(startServer()) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'initial', + actions: [{ + name: 'more', + value: 'more' + }], + token: 'a-verification-token' + })}) + .expect(200, 'Here is more information.', t.end); +}); + +test('it handles a rejection of the initial interview question', function(t) { + agent(startServer()) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + callback_id: 'initial', + actions: [{ + name: 'no', + value: 'no' + }], + token: 'a-verification-token' + })}) + .expect(200, 'Okay!', t.end); +}); + +test('it rejects an action without the correct verification token', function(t) { + agent(startServer()) + .post('/slack/actions') + .type('form') + .send({payload: JSON.stringify({ + token: 'an-invalid-token' + })}) + .expect(403, t.end); +}); + +test('it responds to an OAuth request with the bot access token', function(t) { + const nock = require('nock'); + + process.env.CLIENT_ID = 'client-id'; + process.env.CLIENT_SECRET = 'client-secret'; + + nock('https://slack.com') + .post('/api/oauth.access', 'client_id=client-id&client_secret=client-secret&code=a-code') + .reply(200, { + ok: true, + bot: { + bot_access_token: 'yesthisisthestring' + } + }); + + agent(startServer()) + .get('/oauth') + .type('form') + .query({ + code: 'a-code' + }) + .expect(200, (err, res) => { + t.notOk(err, 'expected no error'); + t.equal(res.body.bot_access_token, 'yesthisisthestring'); + + nock.cleanAll(); + t.end(); + }); +}); + +test('it responds to an OAuth request lacking a code with an error', function(t) { + agent(startServer()) + .get('/oauth') + .type('form') + .expect(422, (err, res) => { + t.end(); + }); +}); + +test('it handles an error from the Slack API', function(t) { + const nock = require('nock'); + + nock('https://slack.com') + .post('/api/oauth.access') + .reply(200, { + ok: false, + error: 'jorts_crisis' + }); + + agent(startServer()) + .get('/oauth') + .type('form') + .query({code: 'a-code'}) + .expect(422, (err, res) => { + t.ok(err, 'expected an error'); + t.equal(res.body.error, 'jorts_crisis'); + + nock.cleanAll(); + t.end(); + }); +}) diff --git a/web.js b/web.js new file mode 100644 index 0000000..51920ed --- /dev/null +++ b/web.js @@ -0,0 +1,10 @@ +const attributeConfigurations = require('./src/attribute-configurations'); +const questionForAttributeConfiguration = require('./src/message-buttons/question-for-attribute-configuration'); + +const UserRepository = require('./src/persistence/user-repository'); +const db = require('./models'); +const userRepository = new UserRepository(db.User); + +const server = require('./src/message-buttons/server')({attributeConfigurations, questionForAttributeConfiguration, userRepository}); + +server.listen(process.env.PORT || 3000);