diff --git a/README.md b/README.md index 2aeb9e2..b1f80d3 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ Start direct messages with people incoming-webhook Post messages to specific channels in Slack + +users: read +View people in the workspace ``` 3. Install the app: [https://api.slack.com/start/building/bolt#install](https://api.slack.com/start/building/bolt#install) diff --git a/config.js b/config.js index df47d19..4ebc9fe 100644 --- a/config.js +++ b/config.js @@ -43,6 +43,7 @@ const config = { path.join('plugins', 'system.js'), path.join('plugins', 'wolframalpha.js'), path.join('plugins', 'spellcheck.js'), + path.join('plugins', 'sentiment.js'), path.join('plugins', 'wikipedia.js'), path.join('plugins', 'lira.js') ] diff --git a/docs/sentiment_analysis.md b/docs/sentiment_analysis.md index 1d3d894..2469e44 100644 --- a/docs/sentiment_analysis.md +++ b/docs/sentiment_analysis.md @@ -1,6 +1,15 @@ # sentiment_analysis -Provides sentiment analysis over a user's last N-messages. +Provides sentiment analysis over a user's last N-messages. The plugin uses +[DatumBox's Sentiment Analysis API](http://www.datumbox.com/api-sandbox/#!/Document-Classification/SentimentAnalysis_post_0) + and you must provide the API key in your `secret.json`: + +```json +{ + ... + "datumbox": "api-token-here" +} +``` ### Example Usage @@ -9,4 +18,5 @@ analyse omar #> Output omar has recently been positive -``` \ No newline at end of file +``` + diff --git a/package-lock.json b/package-lock.json index 18a8d97..92524c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1595,6 +1595,18 @@ "is-stream": "^1.1.0", "p-queue": "^2.4.2", "p-retry": "^4.0.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } } }, "@types/babel__core": { @@ -3895,16 +3907,6 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, - "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", diff --git a/plugins/sentiment.js b/plugins/sentiment.js index 5b5e09b..df4c34f 100644 --- a/plugins/sentiment.js +++ b/plugins/sentiment.js @@ -1,97 +1,118 @@ -const request = require('request') const winston = require('winston') +const fetch = require('node-fetch') +const { URLSearchParams } = require('url') +const match = require('@menadevs/objectron') +const { pre } = require('../utils.js') +const secret = require('../secret.json') -const Plugin = require('../utils.js').Plugin +const verbose = ` +How to use this pluginL -const META = { - name: 'sentiment', - short: 'provides a sentiment analysis on the last 10 messages of a user', - examples: [ - 'analyse jordan' - ] -} + analyse jordan +` -function findUser (bot, name) { - return new Promise((resolve, reject) => { - const members = bot.users.filter(m => m.name === name) +/** + * find the user based on the passed name and return the first one found + * returns undefined if no user was found + * @param {*} users + * @param {*} name + */ +async function findUser (users, name) { + const { members } = await users.list() - if (members.length !== 1) { - reject(`I don't know of a ${name}`) - } else { - resolve(members[0]) - } - }) + return members.filter(member => member.profile.display_name.toLowerCase() === name.toLowerCase())[0] } -function pickTarget (bot, channel) { - return [...bot.channels, ...bot.groups].filter(c => c.id === channel)[0] -} +/** + * find the channel or group in the list of workspace channels/groups + * returns undefined if the channel/group didn't match + * @param {*} conversations (could be a channel, a group or an im) + * @param {*} channel + */ +async function getTargetChannel (conversations, channel) { + const { channels } = await conversations.list() -function loadRecentMessages (options, channel, user) { - return new Promise((resolve, reject) => { - const target = pickTarget(options.bot, channel) - let source = options.web.channels - - if (target.is_group) { - source = options.web.groups - } - - source.history(channel, { count: 1000 }, (error, response) => { - if (error) { - reject(error) - } else { - let messages = response.messages.filter(m => m.user === user.id) + return channels.filter(c => c.id === channel && c.is_archived === false)[0] +} - if (messages.length > options.config.plugins.sentiment.recent) { - messages = messages.slice(1, options.config.plugins.sentiment.recent) - } +/** + * get the user messages in the last 100 messages in the provided channel + * @param {*} channel + * @param {*} user + */ +async function getUserMessagesInChannel (conversations, channel, user) { + const { messages } = await conversations.history({ channel: channel.id, limit: 1000 }) + // fetching first 10 messages from the list of messages + const lastTenMessagesByUser = messages.filter(message => message.user === user.id).slice(0, 10) - if (messages.length === 0) { - reject('User has not spoken recently') - } else { - const text = messages.map(m => m.text).join('\n') - resolve(text) - } - } - }) - }) + return lastTenMessagesByUser } -function analyseSentiment (secret, messages) { - return new Promise((resolve, reject) => { - request.post({ - url: 'http://api.datumbox.com/1.0/SentimentAnalysis.json', - form: { - api_key: secret.datumbox, - text: messages - }, - headers: { - 'User-Agent': 'request' - } - }, (error, response, body) => { - if (error) { - reject(error) - } else { - resolve(JSON.parse(body)) - } - }) - }) +/** + * send the messages to the api and return response + * @param {*} secret + * @param {*} messages + */ +async function analyseSentiment (messages) { + const params = new URLSearchParams() + params.set('api_key', secret.datumbox) + params.append('text', messages) + const response = await fetch('http://api.datumbox.com/1.0/SentimentAnalysis.json', { method: 'POST', body: params }) + const jsonResult = await response.json() + return jsonResult } -function analyse (options, message, target) { - findUser(options.bot, target) - .then(user => loadRecentMessages(options, message.channel, user)) - .then(messages => analyseSentiment(options.secret, messages)) - .then(sentiment => message.reply_thread(`${target} has recently been ${sentiment.output.result}`)) - .catch(error => winston.error(`${META.name} - Error: ${error}`)) +/** + * analyse the user messages in a channel or group + * @param {*} options + * @param {*} message + * @param {*} target + */ +async function analyse (options, message, target) { + try { + const user = await findUser(options.web.users, target) + if (!user) { + message.reply_thread(`I don't know of a ${target}. Please validate you entered the correct person's name.`) + return + } + + const targetChannel = await getTargetChannel(options.web.conversations, message.channel) + if (!targetChannel) { + message.reply_thread('Are you in a channel or group? sentiment doesn\'t work in a direct message.') + return + } + + const messages = await getUserMessagesInChannel(options.web.conversations, targetChannel, user) + if (messages.length !== 0) { + const response = await analyseSentiment(messages.map(m => m.text).join('\n')) + message.reply_thread(`${target} has recently been ${response.output.result}.`) + } else { + message.reply_thread(`User ${target} has not spoken recently.`) + } + } catch (error) { + message.reply_thread(`Something went wrong! this has nothing to do with the sentiments of ${target}. Please check the logs.`) + options.logger.error(`${module.exports.name} - something went wrong. Here's the error: ${pre(error)}`) + } } -function register (bot, rtm, web, config, secret) { - const plugin = new Plugin({ bot, rtm, web, config, secret }) - plugin.route(/^analyse (.+)/, analyse, {}) +const events = { + message: (options, message) => { + match(message, { + type: 'message', + text: /^analyse (?.+)/ + }, result => analyse(options, message, result.groups.name)) + } } module.exports = { - register, - META + name: 'sentiment', + help: 'provides a sentiment analysis on the last 10 messages of a user', + verbose, + events, + findUser, + getTargetChannel, + getUserMessagesInChannel, + analyseSentiment, + analyse } + diff --git a/plugins/spellcheck.js b/plugins/spellcheck.js index 5b8e9f7..d2ae862 100644 --- a/plugins/spellcheck.js +++ b/plugins/spellcheck.js @@ -32,7 +32,7 @@ const events = { module.exports = { name: 'spellcheck', - help: 'see if the bot is alive, or ask it to ping others', + help: 'spell checks any word in a sentence', verbose, events, spell diff --git a/plugins/system.js b/plugins/system.js index 6c5410e..d0dfd18 100644 --- a/plugins/system.js +++ b/plugins/system.js @@ -1,6 +1,6 @@ const cp = require('child_process') const config = require('../config') -const pre = require('../utils.js').pre +const { pre } = require('../utils.js') const storage = require('node-persist') const match = require('@menadevs/objectron') diff --git a/test/sentiment.test.js b/test/sentiment.test.js new file mode 100644 index 0000000..ee243c4 --- /dev/null +++ b/test/sentiment.test.js @@ -0,0 +1,380 @@ +const sentiment = require('../plugins/sentiment') + +jest.mock('node-fetch') + +afterEach(() => { + jest.clearAllMocks() +}) + +describe('findUser function', () => { + const list = jest.fn().mockReturnValue({ + members: [ + { + profile: { + display_name: 'bosta' + } + }, + { + profile: { + display_name: 'John' + } + } + ] + }) + + const users = { list } + it('should call the list function on the passed users object', async () => { + const returnedUser = await sentiment.findUser(users, 'John') + expect(list).toHaveBeenCalled() + expect(returnedUser).toEqual({ + profile: { + display_name: 'John' + } + }) + }) + it('should return undefined when the user is not found', async () => { + const returnedUser = await sentiment.findUser(users, 'Jane') + expect(list).toHaveBeenCalled() + expect(returnedUser).toBe(undefined) + }) +}) + +describe('getTrargetChannel function', () => { + const list = jest.fn().mockReturnValue({ + channels: [ + { + id: '1234', + is_archived: false + }, + { + id: '5677', + is_archived: false + }, + { + id: '9999', + is_archived: true + } + ] + }) + + const conversations = { list } + it('should call the list function on the passed conversations object', async () => { + const returnedChannel = await sentiment.getTargetChannel(conversations, '5677') + expect(list).toHaveBeenCalled() + expect(returnedChannel).toEqual({ + id: '5677', + is_archived: false + }) + }) + it('should return undefined when searching for an archived channel', async () => { + const returnedChannel = await sentiment.getTargetChannel(conversations, '9999') + expect(list).toHaveBeenCalled() + expect(returnedChannel).toBe(undefined) + }) + it('should return undefined when no channel is found', async () => { + const returnedChannel = await sentiment.getTargetChannel(conversations, '3333') + expect(list).toHaveBeenCalled() + expect(returnedChannel).toBe(undefined) + }) +}) + +describe('getUserMessagesInChannel function', () => { + const history = jest.fn().mockReturnValue({ + messages: [ + { + user: '1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + } + ] + }) + + const conversations = { history } + + it('should call the history function on the passed on the conversations object', async () => { + const returnedMessages = await sentiment.getUserMessagesInChannel(conversations, { id: '1234' }, { id: 'user1' }) + expect(history).toHaveBeenCalled() + expect(returnedMessages).toEqual([ + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + } + ]) + }) + it('should return empty array when no messages are found for the user', async () => { + const returnedMessages = await sentiment.getUserMessagesInChannel(conversations, { id: '1234' }, { id: 'user2' }) + expect(history).toHaveBeenCalled() + expect(returnedMessages).toEqual([]) + }) +}) + +describe('analyseSentiment function', () => { + const json = jest.fn() + const fetch = require('node-fetch').mockImplementation( + () => { + return { + json + } + } + ) + it('should call the fetch and json functions', async () => { + const returnedMessages = await sentiment.analyseSentiment('message 1 \n message 2') + expect(fetch).toHaveBeenCalled() + expect(json).toHaveBeenCalled() + }) +}) + +describe('analyse function', () => { + const reply_thread = jest.fn() + const message = { + reply_thread, + channel: '1234' + } + + it('should reply that the name passed does not belong to a user', async () => { + const list = jest.fn().mockReturnValue({ + members: [] + }) + + const options = { + web : { + users: { list } + } + } + + const analyseResult = await sentiment.analyse(options, message, 'John') + expect(reply_thread).toHaveBeenCalledWith(`I don't know of a John. Please validate you entered the correct person's name.`) + }) + + it('should reply that the channel or group is invalid', async () => { + const listUsers = jest.fn().mockReturnValue({ + members: [ + { + profile: { + display_name: 'bosta' + } + }, + { + profile: { + display_name: 'John' + } + } + ] + }) + + const listChannels = jest.fn().mockReturnValue({ + channels: [] + }) + + + const options = { + web : { + users: { list: listUsers }, + conversations: { list: listChannels } + } + } + + const analyseResult = await sentiment.analyse(options, message, 'John') + expect(reply_thread).toHaveBeenCalledWith(`Are you in a channel or group? sentiment doesn\'t work in a direct message.`) + }) + + it('should reply that the person has not spoken recently', async () => { + const listUsers = jest.fn().mockReturnValue({ + members: [ + { + profile: { + display_name: 'bosta' + } + }, + { + profile: { + display_name: 'John' + } + } + ] + }) + + const listChannels = jest.fn().mockReturnValue({ + channels: [ + { + id: '1234', + is_archived: false + }, + { + id: '5677', + is_archived: false + }, + { + id: '9999', + is_archived: true + } + ] + }) + + const history = jest.fn().mockReturnValue({ + messages: [] + }) + + const options = { + web : { + users: { list: listUsers }, + conversations: { list: listChannels, history } + } + } + + const analyseResult = await sentiment.analyse(options, message, 'John') + expect(reply_thread).toHaveBeenCalledWith(`User John has not spoken recently.`) + }) + + it('should call the sentiments api and return a proper sentiment result', async () => { + const listUsers = jest.fn().mockReturnValue({ + members: [ + { + profile: { + display_name: 'bosta' + } + }, + { + id: 'user1', + profile: { + display_name: 'John', + } + } + ] + }) + + const listChannels = jest.fn().mockReturnValue({ + channels: [ + { + id: '1234', + is_archived: false + }, + { + id: '5677', + is_archived: false + }, + { + id: '9999', + is_archived: true + } + ] + }) + + const history = jest.fn().mockReturnValue({ + messages: [ + { + user: '1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + }, + { + user: 'user1' + } + ] + }) + + const options = { + web : { + users: { list: listUsers }, + conversations: { list: listChannels, history } + } + } + + const json = jest.fn().mockReturnValue({output: {result: 'positive'}}) + const fetch = require('node-fetch').mockImplementation( + () => { + return { + json + } + } + ) + + const analyseResult = await sentiment.analyse(options, message, 'John') + expect(reply_thread).toHaveBeenCalledWith(`John has recently been positive.`) + }) +}) diff --git a/test/spellcheck.test.js b/test/spellcheck.test.js index 6ec461e..7151d61 100644 --- a/test/spellcheck.test.js +++ b/test/spellcheck.test.js @@ -1,13 +1,13 @@ -const {spell} = require('../plugins/spellcheck') +const { spell } = require('../plugins/spellcheck') const { isMisspelled } = require('spellchecker') jest.mock('spellchecker') -afterEach(() => { - jest.clearAllMocks(); +afterEach(() => { + jest.clearAllMocks() }) describe('spellcheck spell function', () => { - const replyThreadMock = jest.fn(); + const replyThreadMock = jest.fn() it('should call isMisspelled, getCorrectionsForMisspelling with the passed word, and return that the value is spelled correctly', () => { const isMisspelledMock = jest.fn().mockReturnValue(false) const getCorrectionsForMisspellingMock = jest.fn() @@ -17,7 +17,7 @@ describe('spellcheck spell function', () => { const message = { type: 'message', text: 'daylight(sp?) savings', - reply_thread: replyThreadMock, + reply_thread: replyThreadMock } const word = 'daylight' spell(message, word) @@ -35,11 +35,11 @@ describe('spellcheck spell function', () => { const message = { type: 'message', text: 'daylight(sp?) savings', - reply_thread: replyThreadMock, + reply_thread: replyThreadMock } const word = 'daylightblabla' spell(message, word) - expect(replyThreadMock).toHaveBeenCalledWith(`I don't know how to fix daylightblabla`) + expect(replyThreadMock).toHaveBeenCalledWith('I don\'t know how to fix daylightblabla') }) it('should return possible spelling for word', () => { @@ -51,10 +51,10 @@ describe('spellcheck spell function', () => { const message = { type: 'message', text: 'daylight(sp?) savings', - reply_thread: replyThreadMock, + reply_thread: replyThreadMock } const word = 'daylightblabla' spell(message, word) - expect(replyThreadMock).toHaveBeenCalledWith(`possible spelling for daylightblabla: daylight, day-light`) + expect(replyThreadMock).toHaveBeenCalledWith('possible spelling for daylightblabla: daylight, day-light') }) })