From 58b7b0bfebf577bc5f221d58fc2578acabfdff48 Mon Sep 17 00:00:00 2001 From: Shaun Burdick Date: Thu, 15 Sep 2016 14:53:49 -0400 Subject: [PATCH] Adding Announce functionality (#14) * Adding announcement feature from #5 * Added announcement loop and tested it all out * Added more details about announce feature in README --- Dockerfile | 4 ++- README.md | 17 +++++++++- app.json | 8 +++++ config.default.js | 6 ++++ docker-cloud.yml | 2 ++ lib/bot.js | 76 +++++++++++++++++++++++++++++++++++++++++++++ lib/config.js | 20 +++++++++++- test/bot.test.js | 36 +++++++++++++++++++++ test/config.test.js | 10 ++++++ 9 files changed, 176 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index d68ab99..009730d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,9 @@ RUN apk add -U tzdata ENV NODE_ENV=production \ SLACK_TOKEN=xoxb-foo \ - SLACK_AUTO_RECONNECT=true + SLACK_AUTO_RECONNECT=true\ + APP_ANNOUNCE_CHANNELS= \ + APP_ANNOUNCE_TIMES= ADD . /usr/src/myapp diff --git a/README.md b/README.md index cfb7acd..86b4149 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,12 @@ This bot can be told when you are out of the office. It will then listen to conv **If you want a personal OoO bot that acts as you, check out [shaunburdick/slack-ooo-personal](https://github.com/shaunburdick/slack-ooo-personal)!** -##Usage +## Features +- Users set themselve out of the office by talking to the bot +- When a user that is set out of office is mentioned in a channel the bot is in, it will send a message to that channel that the user if out of the office and display their out of office message +- The bot can be configured to announce out of office users to specific channels on specific times (bot must be in that channel to make the announcement) + +## Usage To use this bot, you start a conversation with it: ``` @@ -53,6 +58,14 @@ OOO BotBOT [5:29 PM] I will be out until next week ``` +## Announcements +The bot can announce which users are out of office on a schedule. To enable this feature, you need to provide the bot with a list of channels to make the announcement and at what times (local to bot). +- **app.announce.channels**: this is an array of channel names to announce to + - The bot *must* be a member of the channel in order to make the announcements + - channels names *must* not contain the starting `#` +- **app.announce.times**: this is an array of times each day to make the announcement + - Each time will be considered as 24-hour time, (ie `13:00` is 1:00 pm) + ## Install 1. Clone this [repository](https://github.com/shaunburdick/slack-ooo.git) 2. `npm install` @@ -73,6 +86,8 @@ Official Image [shaunburdick/slack-ooo](https://registry.hub.docker.com/u/shaunb You can set the configuration of the bot by using environment variables. *ENVIRONMENT_VARIABLE*=Default Value +- *APP_ANNOUNCE_CHANNELS*=general,random, A list of channels to announce OoO on +- *APP_ANNOUNCE_TIMES*=08:00,16:00, A list of times to announce OoO users - *SLACK_TOKEN*=xoxb-foo, Your Slack Token - *SLACK_AUTO_RECONNECT*=true, Reconnect on disconnect diff --git a/app.json b/app.json index 44ae356..f03c89c 100644 --- a/app.json +++ b/app.json @@ -9,6 +9,14 @@ ], "repository": "https://github.com/shaunburdick/slack-ooo", "env": { + "APP_ANNOUNCE_TIMES": { + "description": "A list of times to announce OoO users", + "value": "08:00, 16:00" + }, + "APP_ANNOUNCE_CHANNELS": { + "description": "A list of channels to announce OoO users on", + "value": "general" + }, "SLACK_TOKEN": { "description": "Your Slack token" }, diff --git a/config.default.js b/config.default.js index 9ed4753..34d585a 100644 --- a/config.default.js +++ b/config.default.js @@ -1,4 +1,10 @@ var config = { + app: { + announce: { + channels: [], // no need to include # + times: [] // 24 hours + } + }, slack: { token: 'xoxb-foo', autoReconnect: true diff --git a/docker-cloud.yml b/docker-cloud.yml index 06d21ab..196f1ba 100644 --- a/docker-cloud.yml +++ b/docker-cloud.yml @@ -4,3 +4,5 @@ bot: environment: SLACK_TOKEN: 'xoxb-foo' SLACK_AUTO_RECONNECT: true + APP_ANNOUNCE_CHANNELS: '' + APP_ANNOUNCE_TIMES: '' diff --git a/lib/bot.js b/lib/bot.js index ddf3622..c91f8c4 100644 --- a/lib/bot.js +++ b/lib/bot.js @@ -1,6 +1,7 @@ 'use strict'; const Botkit = require('botkit'); +const moment = require('moment'); const logger = require('./logger')(); const OOOUser = require('./ooouser'); @@ -21,6 +22,8 @@ class Bot { this.lookup = new Map(); this.ooo_users = new Map(); + + this.nextAnnounce = null; // timeout pointer } /** @@ -170,6 +173,74 @@ class Bot { return this; } + /** + * Start the loop to announce offline users + * + * @return {null} + */ + startAnnounceLoop () { + const self = this; + + // Check to see if we have any channels and times + if (this.config.app.announce.channels.length && this.config.app.announce.times.length) { + const nextInterval = this.msTillNextTime(this.config.app.announce.times); + if (nextInterval > 0) { + logger.info(`Next announcement in: ${moment.duration(nextInterval).humanize()}`); + this.nextAnnounce = setTimeout(() => { + logger.info('Announcing on schedule to:', this.config.app.announce.channels); + // loop through channels and announce + self.config.app.announce.channels.forEach((channel) => { + // find the channel id + this.lookup.forEach((item) => { + // only announce on channel or group + if ( + (item.is_channel || item.is_group) && // channel or group + self.config.app.announce.channels.indexOf(item.name) !== -1 + ) { + // send message to channel + logger.info(`Announcing offline on channel: ${item.name}(${item.id})`); + self.bot.say({ + channel: item.id, + text: self.announceOffline() + }); + } + }); + }); + self.startAnnounceLoop(); + }, nextInterval); + } + } else { + logger.info('Not activating announce loop, No channels or times set'); + } + } + + /** + * Returns the number of milleseconds until the next time + * + * @param {string[]} times a list of times + * @return {integer} number of milleseconds until next time in list + */ + msTillNextTime (times) { + let retVal = -1; + + if (times.length) { + const curTime = moment(); + times.forEach((time) => { + const mTime = moment(time, 'HH:mm'); + if (mTime.isBefore()) { // if we passed this time today + mTime.add(24, 'hours'); // move it to tomorrow + } + + const diff = mTime.diff(curTime); + if (retVal === -1 || retVal > diff) { + retVal = diff; // we have a new winner + } + }); + } + + return retVal; + } + /** * Start the bot * @@ -226,6 +297,7 @@ class Bot { this.payload = payload; this.populateLookup(payload); this.slackOpen(payload); + this.startAnnounceLoop(); }); return this; @@ -239,6 +311,10 @@ class Bot { stop () { this.bot.closeRTM(); + if (this.nextInterval) { + clearTimeout(this.nextInterval); // stop announcement loop + } + return this; } } diff --git a/lib/config.js b/lib/config.js index 04a8f14..f9d6bf0 100644 --- a/lib/config.js +++ b/lib/config.js @@ -14,6 +14,20 @@ function parseBool (string) { return string; } +/** + * Parse a comma separated list into an array of terms + * + * @param {string} list A comma separated list + * @return {string[]} an array of parsed terms + */ +function parseList (list) { + if (list) { + return list.replace(/^\s+|\s+$/, '').split(/\s*,\s*/); + } + + return null; +} + /** * Parses and enhances config object * @@ -31,6 +45,9 @@ function parse (cfg) { /** * Pull config from ENV if set */ + config.app.announce.channels = parseList(process.env.APP_ANNOUNCE_CHANNELS) || config.app.announce.channels; + config.app.announce.times = parseList(process.env.APP_ANNOUNCE_TIMES) || config.app.announce.times; + config.slack.token = process.env.SLACK_TOKEN || config.slack.token; config.slack.autoReconnect = parseBool(process.env.SLACK_AUTO_RECONNECT) || config.slack.autoReconnect; @@ -40,5 +57,6 @@ function parse (cfg) { module.exports = { parse, - parseBool + parseBool, + parseList }; diff --git a/test/bot.test.js b/test/bot.test.js index 9b61192..d8559d5 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -1,6 +1,7 @@ 'use strict'; const test = require('tape'); +const moment = require('moment'); const Bot = require('../lib/bot'); const config = require('../config.default'); @@ -11,3 +12,38 @@ test('Bot: Instantiation', (assert) => { assert.end(); }); + +test('Bot: Find ms until next time', (assert) => { + const bot = new Bot(config); + const curDate = moment(); + + const times = [ + moment(curDate).subtract(4, 'hours').format('HH:mm'), + moment(curDate).subtract(3, 'hours').format('HH:mm'), + moment(curDate).subtract(2, 'hours').format('HH:mm'), + moment(curDate).subtract(1, 'hours').format('HH:mm'), + moment(curDate).add(1, 'hours').format('HH:mm'), + moment(curDate).add(2, 'hours').format('HH:mm'), + moment(curDate).add(3, 'hours').format('HH:mm'), + moment(curDate).add(4, 'hours').format('HH:mm') + ]; + + // rounding because we are rarely on the minute + assert.equal( + Math.round(bot.msTillNextTime(times) / 100000), 36, + 'Next time should be about an hour away' + ); + + times.splice(4, 1); // remove one hour time + assert.equal( + Math.round(bot.msTillNextTime(times) / 100000), 72, + 'Next time should be about two hours away' + ); + + assert.equal( + Math.round(bot.msTillNextTime(times.slice(0, 4)) / 1000000), 72, + 'Next time should be about 20 hours away' + ); + + assert.end(); +}); diff --git a/test/config.test.js b/test/config.test.js index d2e5ec8..9e21ad7 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -28,6 +28,16 @@ test('Config: pass the original value if not a string', (assert) => { assert.end(); }); +test('Config: Parse a list from a comma separated list', (assert) => { + assert.deepEqual( + Config.parseList('foo, bar,fizz,buzz '), + ['foo', 'bar', 'fizz', 'buzz'], + 'Parse a list into an array' + ); + + assert.end(); +}); + test('Config: parse default config as is', (assert) => { assert.equal(Config.parse(rawConfig), rawConfig); assert.end();