From 2351df0fb95930747e243e3f9f692f9eac88c114 Mon Sep 17 00:00:00 2001 From: Stefan Natter Date: Sat, 27 Jun 2020 11:33:20 +0200 Subject: [PATCH] feat: added dnd.setSnooze and endSnooze (config: dndNumMinutes) (#6) --- CHANGELOG.md | 10 +- README.md | 24 +++- package.json | 4 +- slack-status-config-example.js | 9 ++ src/__tests__/slack.test.js | 231 +++++++++++++++++++++------------ src/config.js | 7 +- src/slack.js | 86 ++++++++++-- yarn.lock | 5 + 8 files changed, 267 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f3b65..647361f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Features -- Resolves [#4](https://github.com/natterstefan/zoom-slack-status-updater/issues/4)), - by adding new `zoomVerificationToken` option to config. -- Only the slack workspace matching the request's `headers.authorization` will - be updated, not all anymore +- `zoomVerificationToken` ensures hook can only be triggered by valid Zoom app + (resolve [#4](https://github.com/natterstefan/zoom-slack-status-updater/issues/4)) +- Only slack workspaces with a `zoomVerificationToken` matching the request's + `headers.authorization` will be updated +- set do not disturb when joining Zoom meetings with the new `dndNumMinutes` + setting ## 0.2.0 (2020-05-24) diff --git a/README.md b/README.md index e44c0df..45a79b6 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ cp now-example.json now.json ### Step 2 - Setup Slack 1. Create a [Slack App](https://api.slack.com/apps) for your workspace(s) -2. Grant each app the `users.profile:write` privilege in `User Token Scopes` - in the `OAuth Tokens & Redirect URLs` view, before clicking on the "Install - App" button. +2. Grant each app the `users.profile:write` **and** `dnd:write` privilege in + `User Token Scopes` in the `OAuth & Permissions` view, before + clicking on the "Install App" button. 3. Copy and paste each `OAuth Access Token` into the configuration file created in the subsequent step. @@ -154,6 +154,15 @@ module.exports = [ * @see https://marketplace.zoom.us/docs/api-reference/webhook-reference#headers */ zoomVerificationToken: 'Vivamusultricies', + /** + * Slack DnD Status + * + * Turns on Do Not Disturb mode for the current user. Number of minutes, + * from now, to snooze until. + * + * @see https://api.slack.com/methods/dnd.setSnooze + */ + dndNumMinutes: 60, meetingStatus: { text: "I'm in a meeting", emoji: ':warning:', // emoji code @@ -210,9 +219,9 @@ now ls ## Other solutions -- https://github.com/cmmarslender/zoom-status -- https://github.com/mivok/slack_status_updater with [hammerspoon](http://macappstore.org/hammerspoon/) -- https://github.com/chrisscott/ZoomSlack +- +- with [hammerspoon](http://macappstore.org/hammerspoon/) +- - [How to automatically update your Slack status with Zapier](https://zapier.com/blog/automate-slack-status/) ## References @@ -247,4 +256,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) +specification. Contributions of any kind welcome! diff --git a/package.json b/package.json index 056ee5d..50c2375 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "main": "./src/index.js", "scripts": { + "coverage": "jest --coverage", "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls", "postinstall": "node scripts/copyfile.js", "start": "node .", @@ -24,7 +25,8 @@ "body-parser": "^1.19.0", "dotenv": "^8.2.0", "express": "^4.17.1", - "lodash.get": "^4.4.2" + "lodash.get": "^4.4.2", + "qs": "^6.9.4" }, "devDependencies": { "@types/jest": "^26.0.3", diff --git a/slack-status-config-example.js b/slack-status-config-example.js index 4e0c864..16c63fc 100644 --- a/slack-status-config-example.js +++ b/slack-status-config-example.js @@ -26,6 +26,15 @@ module.exports = [ * @see https://marketplace.zoom.us/docs/api-reference/webhook-reference#headers */ zoomVerificationToken: 'Vivamusultricies', + /** + * Slack DnD Status + * + * Turns on Do Not Disturb mode for the current user. Number of minutes, + * from now, to snooze until. + * + * @see https://api.slack.com/methods/dnd.setSnooze + */ + dndNumMinutes: 60, meetingStatus: { text: "I'm in a meeting", emoji: ':warning:', // emoji code diff --git a/src/__tests__/slack.test.js b/src/__tests__/slack.test.js index b528092..87a1441 100644 --- a/src/__tests__/slack.test.js +++ b/src/__tests__/slack.test.js @@ -6,7 +6,10 @@ const updateSlack = require('../slack') jest.mock('../logger') jest.mock('../../slack-status-config', () => mockExampleConfig) -const baseOptions = { verificationToken: 'Vivamusultricies' } +const baseOptions = { + verificationToken: 'Vivamusultricies', + presenceStatus: 'Do_Not_Disturb', +} describe('updateSlack', () => { const DEFAULT_SLACK_RESPONSE = { @@ -35,8 +38,12 @@ describe('updateSlack', () => { it('invokes slack api for workspace with matching verificationToken', async () => { moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) + moxios.respondAllWith( + // updateSlack + DEFAULT_SLACK_RESPONSE, + // updateSlackDndStatus + DEFAULT_SLACK_RESPONSE, + ) }) const result = await updateSlack(baseOptions) @@ -46,14 +53,12 @@ describe('updateSlack', () => { it('invokes slack api for multiple workspaces', async () => { moxios.wait(() => { moxios.respondAllWith( - { - status: 200, - response: {}, - }, - { - status: 200, - response: {}, - }, + // workspace 1 + DEFAULT_SLACK_RESPONSE, + DEFAULT_SLACK_RESPONSE, + // workspace 2 + DEFAULT_SLACK_RESPONSE, + DEFAULT_SLACK_RESPONSE, ) }) @@ -64,83 +69,165 @@ describe('updateSlack', () => { expect(result).toBeTruthy() }) - it('invokes slack api with proper request config', async () => { + it('invokes both updateSlackStatus and updateSlackDndStatus', async () => { moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) }) const result = await updateSlack(baseOptions) - - expect(result.request.config.headers.Authorization).toStrictEqual( - 'Bearer xoxp-xxx-xxx', - ) - expect(result.request.config.url).toStrictEqual( - 'https://slack.com/api/users.profile.set', - ) + expect(result).toHaveLength(2) }) - it('invokes slack api with proper request data when user is in meeting', async () => { + it('invokes only updateSlackStatus when dndNumMinutes is not configured for workspace', async () => { moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE) }) const result = await updateSlack({ ...baseOptions, - presenceStatus: 'Do_Not_Disturb', + workspaces: [{ ...mockExampleConfig[0], dndNumMinutes: 0 }], }) - - expect(result.request.config.data).toStrictEqual( - '{"profile":{"status_text":"I\'m in a meeting","status_emoji":":warning:","status_expiration":0}}', - ) + expect(result).toHaveLength(1) }) - it('invokes slack api with proper request data when user is not in a meeting', async () => { - moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) + describe('updateSlackStatus', () => { + it('invokes slack api with proper request header', async () => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) + + const result = await updateSlack(baseOptions) + + expect(result[0].request.config.headers.Authorization).toStrictEqual( + 'Bearer xoxp-xxx-xxx', + ) }) - const result = await updateSlack({ - ...baseOptions, - presenceStatus: 'Available', + it('sets proper slack status when user is in Do_Not_Disturb mode', async () => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) + + const result = await updateSlack(baseOptions) + + expect(result[0].request.config.data).toStrictEqual( + '{"profile":{"status_text":"I\'m in a meeting","status_emoji":":warning:","status_expiration":0}}', + ) }) - expect(result.request.config.data).toStrictEqual( - '{"profile":{"status_text":"","status_emoji":"","status_expiration":0}}', - ) - }) + it('sets proper slack status when user is is not in Do_Not_Disturb mode', async () => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) - it('invokes slack api with proper request data when user is in meeting and mail matches', async () => { - moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) + const result = await updateSlack({ + ...baseOptions, + presenceStatus: 'Available', + }) + + expect(result[0].request.config.data).toStrictEqual( + '{"profile":{"status_text":"","status_emoji":"","status_expiration":0}}', + ) }) - const result = await updateSlack({ - ...baseOptions, - presenceStatus: 'Do_Not_Disturb', - workspaces: [ - { - ...mockExampleConfig[0], - email: 'your-address@mail.com', + it('sets proper slack status when user is is in Do_Not_Disturb mode and mail matches', async () => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) + + const result = await updateSlack({ + ...baseOptions, + workspaces: [ + { + ...mockExampleConfig[0], + email: 'your-address@mail.com', + }, + ], + email: 'your-address@mail.com', + }) + + expect(result[0].request.config.data).toStrictEqual( + '{"profile":{"status_text":"I\'m in a meeting","status_emoji":":warning:","status_expiration":0}}', + ) + }) + + it.each([ + { + status: 200, + response: { + error: 'some error occured', }, - ], - email: 'your-address@mail.com', + }, + { + status: 500, + }, + ])('rejects on error with the %o response', async (response) => { + moxios.wait(() => { + moxios.respondAllWith(response) + }) + await expect(updateSlack(baseOptions)).rejects.toBeTruthy() }) + }) + + describe('updateSlackDndStatus', () => { + it('invokes slack api with proper request header', async () => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) - expect(result.request.config.data).toStrictEqual( - '{"profile":{"status_text":"I\'m in a meeting","status_emoji":":warning:","status_expiration":0}}', + const result = await updateSlack(baseOptions) + expect(result[1].request.config.headers.Authorization).toStrictEqual( + 'Bearer xoxp-xxx-xxx', + ) + }) + + it.each` + presenceStatus | expected + ${'Do_Not_Disturb'} | ${'https://slack.com/api/dnd.setSnooze'} + ${'Available'} | ${'https://slack.com/api/dnd.endSnooze'} + ${'Away'} | ${'https://slack.com/api/dnd.endSnooze'} + `( + 'sets proper dnd status when user is in $presenceStatus mode', + async ({ presenceStatus, expected }) => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, DEFAULT_SLACK_RESPONSE) + }) + + let result = await updateSlack({ ...baseOptions, presenceStatus }) + expect(result[1].request.config.url).toStrictEqual(expected) + expect(result[1].request.config.headers['Content-Type']).toStrictEqual( + 'application/x-www-form-urlencoded', + ) + }, ) + + it.each([ + { + status: 200, + response: { + error: 'some error occured', + }, + }, + { + status: 500, + }, + ])('rejects on error with the %o response', async (response) => { + moxios.wait(() => { + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE, response) + }) + await expect(updateSlack(baseOptions)).rejects.toBeTruthy() + }) }) describe('error handling', () => { + it('rejects when no options were provided', async () => { + expect(updateSlack()).rejects.toBeTruthy() + }) + it('does not invoke slack api when mail does not match', () => { expect( updateSlack({ ...baseOptions, - presenceStatus: 'Do_Not_Disturb', workspaces: [ { ...mockExampleConfig[0], @@ -152,38 +239,12 @@ describe('updateSlack', () => { ).rejects.toBeTruthy() }) - it('rejects on error', async () => { + it('rejects when no workspace matches verificationToken', async () => { moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith({ - status: 200, - response: { - error: 'some error occured', - }, - }) + moxios.respondAllWith(DEFAULT_SLACK_RESPONSE) }) - await expect(updateSlack(baseOptions)).rejects.toBeTruthy() + expect(updateSlack({ verificationToken: 'other' })).rejects.toBeTruthy() }) - - it('rejects when slack api returns error code', async () => { - moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith({ - status: 500, - }) - }) - - await expect(updateSlack(baseOptions)).rejects.toBeTruthy() - }) - }) - - it('rejects when no workspace matches verificationToken', async () => { - moxios.wait(() => { - const request = moxios.requests.mostRecent() - request.respondWith(DEFAULT_SLACK_RESPONSE) - }) - - expect(updateSlack({ verificationToken: 'other' })).rejects.toBeTruthy() }) }) diff --git a/src/config.js b/src/config.js index e6dfc09..ce419f7 100644 --- a/src/config.js +++ b/src/config.js @@ -2,8 +2,11 @@ * App Configuration */ module.exports = { - ENDPOINT: 'https://slack.com/api/users.profile.set', PORT: process.env.PORT || 7000, - // why `Do_Not_Disturb`? See https://devforum.zoom.us/t/check-if-a-user-is-on-a-call-or-available/6140/8 + /** + * Why `Do_Not_Disturb`? + * + * @see https://devforum.zoom.us/t/check-if-a-user-is-on-a-call-or-available/6140/8 + */ ZOOM_IN_MEETING_STATUS: 'Do_Not_Disturb', } diff --git a/src/slack.js b/src/slack.js index 260015a..f5788f1 100644 --- a/src/slack.js +++ b/src/slack.js @@ -1,22 +1,23 @@ const axios = require('axios') +const qs = require('qs') const slackWorkspaces = require('../slack-status-config') const logger = require('./logger') -const { ENDPOINT, ZOOM_IN_MEETING_STATUS } = require('./config') +const { ZOOM_IN_MEETING_STATUS } = require('./config') /** * Update slack status * - * @param {string} text for the status update, an empty string resets it - * @param {string} emooji for the slack status, an empty string resets it + * @param {*} workspace + * @param {string} options contains token (string), text (string) and emoji (string) * * @see https://api.slack.com/docs/presence-and-status */ const updateSlackStatus = async (workspace, { token, text, emoji }) => { try { const response = await axios.post( - ENDPOINT, + 'https://slack.com/api/users.profile.set', { profile: { status_text: text || '', @@ -30,11 +31,65 @@ const updateSlackStatus = async (workspace, { token, text, emoji }) => { }, }, ) + if (response.data.error) { throw new Error(response.data.error) } - logger('SLACK', `workspace ${workspace.name} updated`) + logger('SLACK', `workspace ${workspace.name} status updated`) + return response + } catch (error) { + throw new Error(error) + } +} + +/** + * Update slack's dnd status + * + * @param {*} workspace + * @param {*} options contains token (string), numMinutes (number) and snooze (boolean) + * + * @see https://api.slack.com/methods/dnd.setSnooze + * @see https://api.slack.com/methods/dnd.endSnooze + */ +const updateSlackDndStatus = async ( + workspace, + { token, numMinutes, snooze }, +) => { + try { + let config = {} + + switch (snooze) { + case true: + config = { + url: 'https://slack.com/api/dnd.setSnooze', + data: qs.stringify({ + num_minutes: numMinutes, + }), + } + break + + default: + config = { + url: 'https://slack.com/api/dnd.endSnooze', + } + break + } + + const response = await axios({ + method: 'post', + headers: { + Authorization: `Bearer ${token}`, + 'content-type': 'application/x-www-form-urlencoded', + }, + ...config, + }) + + if (response.data.error) { + throw new Error(response.data.error) + } + + logger('SLACK', `workspace ${workspace.name} dnd updated`) return response } catch (error) { throw new Error(error) @@ -71,11 +126,22 @@ module.exports = async (options) => { const isInMeeting = presenceStatus === ZOOM_IN_MEETING_STATUS const status = isInMeeting ? 'meetingStatus' : 'noMeetingStatus' - return await updateSlackStatus(workspaceToUpdate, { - token: workspaceToUpdate.token, - text: workspaceToUpdate[status].text, - emoji: workspaceToUpdate[status].emoji, - }) + return axios.all( + [ + updateSlackStatus(workspaceToUpdate, { + token: workspaceToUpdate.token, + text: workspaceToUpdate[status].text, + emoji: workspaceToUpdate[status].emoji, + }), + // only change DnD when workspace configured dndNumMinutes + workspaceToUpdate.dndNumMinutes > 0 && + updateSlackDndStatus(workspaceToUpdate, { + numMinutes: workspaceToUpdate.dndNumMinutes, + snooze: isInMeeting, + token: workspaceToUpdate.token, + }), + ].filter(Boolean), + ) } else { logger( 'SLACK', diff --git a/yarn.lock b/yarn.lock index 440b7bf..98ada59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3609,6 +3609,11 @@ qs@^6.5.1: resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.3.tgz#bfadcd296c2d549f1dffa560619132c977f5008e" integrity sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw== +qs@^6.9.4: + version "6.9.4" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" + integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"