diff --git a/.dockerignore b/.dockerignore index 5253d26..441cace 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ node_modules/ .drone.yml .env.example +phrases.json.example .eslintrc.json .gitignore LICENSE diff --git a/.gitignore b/.gitignore index 69b93af..e9054f6 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ typings/ .next keys/ +phrases.json diff --git a/middleware/Authorization.js b/middleware/Authorization.js new file mode 100644 index 0000000..0a4b4c4 --- /dev/null +++ b/middleware/Authorization.js @@ -0,0 +1,17 @@ +require("dotenv").config(); +const API_VERIFICATION = process.env.API_VERIFICATION_TOKEN; + +class Authorization { + static authorization(req, res, next) { + if (!req.headers.authorization) { + return res.status(403).json({error: "No authorization token sent!"}); + } + if (req.headers.authorization === `BEARER ${API_VERIFICATION}`) { + next(); + return; + } + res.status(401).send(); + } +} + +module.exports = {Authorization}; diff --git a/package-lock.json b/package-lock.json index ef49929..836cd21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1327,19 +1327,6 @@ "minimist": "0.0.8" } }, - "moment": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", - "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" - }, - "moment-timezone": { - "version": "0.5.26", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.26.tgz", - "integrity": "sha512-sFP4cgEKTCymBBKgoxZjYzlSovC20Y6J7y3nanDc5RoBIXKlZhoYwBoZGe3flwU6A372AcRwScH8KiwV6zjy1g==", - "requires": { - "moment": ">= 2.9.0" - } - }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 35d2791..2728bfd 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "dotenv": "^8.1.0", "eslint": "^6.5.1", "googleapis": "^43.0.0", - "moment-timezone": "^0.5.26", "node-cron": "^2.0.3" } } diff --git a/utilities/randomPhrases.js b/phrases.json similarity index 71% rename from utilities/randomPhrases.js rename to phrases.json index a1a29e3..e05984a 100644 --- a/utilities/randomPhrases.js +++ b/phrases.json @@ -1,5 +1,5 @@ -const phrases = { - greeting: [ +{ + "greeting": [ ":wave: Good evening!", ":wave: Howdy!", "Hey hey hey!", @@ -12,7 +12,7 @@ const phrases = { "Howdy there! :face_with_cowboy_hat:", "Hey crew! :v:" ], - request: [ + "request": [ "Please click the button below as soon as they're done.", "After you're done, click the \"Done!\" button!", "Please be so kind as to hit that button below once everything is done :hugging_face:", @@ -21,7 +21,7 @@ const phrases = { "Once you're done, be sure to hit that \"Done!\" button!", "Don't forget to hit the button when you're done! Enjoy your night!" ], - no_chore: [ + "no_chore": [ ":+1: There are no chores tonight! Have fun on crew!", "Wow! You lucked out! No chores tonight :simple_smile:", "Have an awesome night! There are no chores to do.", @@ -30,17 +30,4 @@ const phrases = { "Oh man! There are no chores tonight! Enjoy your night!", "Go get some food! :hamburger: There are no chores to do!" ] -}; - -const randomPhrase = type => { - const potentialPhrases = phrases[type]; - if (type === "request") { - const currentMonth = (new Date()).getMonth() + 1; - if (4 <= currentMonth && currentMonth <= 9) { - potentialPhrases.push("After you go to the Snowman :icecream: or something, make sure that gets done! And then click the \"Done!\" button!"); - } - } - return potentialPhrases[Math.floor(Math.random() * potentialPhrases.length)]; -}; - -module.exports = { randomPhrase }; +} diff --git a/phrases.json.example b/phrases.json.example new file mode 100644 index 0000000..9b73147 --- /dev/null +++ b/phrases.json.example @@ -0,0 +1,11 @@ +{ + "greeting": [ + ":wave: Good evening!", + ], + "request": [ + "Please click the button below as soon as they're done." + ], + "no_chore": [ + ":+1: There are no chores tonight! Have fun on crew!" + ] +} diff --git a/server.js b/server.js index d509c69..a90fc29 100644 --- a/server.js +++ b/server.js @@ -4,14 +4,11 @@ const Sentry = require("@sentry/node"); require("dotenv").config(); //local packages -const { app } = require("./utilities/bolt.js"); -const { getTodaysChores } = require("./utilities/getChores.js"); -const { sendNoChores, postToSlack } = require("./utilities/notify.js"); -const { markChoreDone } = require("./utilities/markChoreDone.js"); - -//package configuration - -//local configuration +const {Authorization} = require("./middleware/Authorization"); +const {app, expressReceiver} = require("./utilities/bolt.js"); +const {Notifications} = require("./utilities/Notifications"); +const {Actions} = require("./utilities/Actions"); +const {Chores} = require("./utilities/Chores"); //globals const CRON_SCHEDULE = process.env.CRON_SCHEDULE; @@ -26,14 +23,10 @@ if (process.env.SENTRY_DSN) { Sentry.init(sentryConfig); } -//helper functions -const runChores = async () => { - console.log("Running ChoreBot!"); - const chores = await getTodaysChores(); - if (chores === -1) sendNoChores(); - // TODO: in the future, notify the officers that there are no chores - else postToSlack(chores); -}; +//Initialize Classes +const chores = new Chores(); +const notifications = new Notifications(chores); +const actions = new Actions(); app.action( /^\d+$/, @@ -43,9 +36,19 @@ app.action( }, async ({ action, body }) => { if (!action || !body || !body.user || !body.channel || !body.message) return; - markChoreDone(action.action_id, body.user.id, body.channel.id, body.message.ts, + actions.markChoreDone(action.action_id, body.user.id, body.channel.id, body.message.ts, body.message.blocks); } ); -cron.schedule(CRON_SCHEDULE, runChores); +cron.schedule(CRON_SCHEDULE, notifications.runChores.bind(notifications)); + +expressReceiver.app.use((req, res, next) => { + Authorization.authorization(req, res, next); +}); + +expressReceiver.app.get("/get/chores", async (req, res) => { + let todayschores = await chores.getTodaysChores(); + todayschores = todayschores === -1 ? {chores: []} : {todayschores}; + res.send(todayschores); +}); diff --git a/utilities/Actions.js b/utilities/Actions.js new file mode 100644 index 0000000..2c7c68e --- /dev/null +++ b/utilities/Actions.js @@ -0,0 +1,30 @@ +//local packages +const { + app: { + client: { + chat: {update} + } + } +} = require("./bolt.js"); + +//globals +const TOKEN = process.env.SLACK_BOT_TOKEN; + +class Actions { + + markChoreDone(index, user, channel, ts, initialBlocks) { + const blocks = this.crossOffAndTag(user, parseInt(index), initialBlocks); + update({token: TOKEN, channel, ts, blocks}); + } + +//helper functions + + crossOffAndTag(user, index, blocks) { + const choreText = blocks[index + 2].text.text.replace(">", ""); + blocks[index + 2].text.text = `>~${choreText}~ Completed by <@${user}>`; + delete blocks[index + 2].accessory; + return blocks; + } +} + +module.exports = {Actions}; diff --git a/utilities/Chores.js b/utilities/Chores.js new file mode 100644 index 0000000..fb43f5b --- /dev/null +++ b/utilities/Chores.js @@ -0,0 +1,51 @@ +//node packages +const {google} = require("googleapis"); +require("dotenv").config(); + +//local packages +const privatekey = require("../keys/sheets-api.json"); + +//globals +const SPREADSHEET_ID = process.env.SPREADSHEET_ID; +const GDRIVE_EMAIL = process.env.GDRIVE_EMAIL; + +class Chores { + constructor() { + this.jwtClient = new google.auth.JWT( + privatekey.client_email, + null, + privatekey.private_key, + ["https://www.googleapis.com/auth/spreadsheets.readonly"], + GDRIVE_EMAIL + ); + } + + async getTodaysChores() { + const now = new Date(); + const today = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)) + .toISOString().split("T")[0]; + const todaysChores = (await this.getChores()).map(c => c[0] === today ? c[1] : null).filter(Boolean); + return todaysChores.length === 0 ? -1 : todaysChores; + } + + + async getChores() { + const sheets = google.sheets({ + version: "v4", + auth: this.jwtClient + }); + + this.jwtClient.authorize(err => console.log(err ? err : "Successfully connected to Google Sheets!")); + try { + const {data} = await sheets.spreadsheets.values.get({ + spreadsheetId: SPREADSHEET_ID, + range: "A2:B" + }); + return data ? data.values : null; + } catch (error) { + console.error(error); + } + } +} + +module.exports = {Chores}; diff --git a/utilities/Notifications.js b/utilities/Notifications.js new file mode 100644 index 0000000..c437e32 --- /dev/null +++ b/utilities/Notifications.js @@ -0,0 +1,92 @@ +const { + app: { + client: { + chat: {postMessage} + } + } +} = require("./bolt.js"); + +const fs = require("fs"); + +require("dotenv").config(); + +const TOKEN = process.env.SLACK_BOT_TOKEN; +const CHORES_CHANNEL = process.env.CHORES_CHANNEL; + + +class Notifications { + constructor(chores) { + this.chores = chores; + this.phrases = JSON.parse(fs.readFileSync("./phrases.json")); + } + + async runChores() { + console.log("Running ChoreBot!"); + const todayschores = await this.chores.getTodaysChores(); + if (todayschores === -1) this.postNoChores(); + else this.postChores(todayschores); + } + + postChores(chores) { + this.postSlackMessage("Today's chores have been posted!", [ + this.buildMarkdownSection(this.randomPhrase("greeting")), + this.buildMarkdownSection("Tonight's chores:"), + ...chores.map((c, i) => this.buildChoreElement(c, i)), + this.buildMarkdownSection(this.randomPhrase("request") + " If you have any questions " + + ":thinking_face: please reach out to the vice president!") + ]); + } + + postNoChores() { + this.postSlackMessage("No chores tonight!", [ + this.buildMarkdownSection(this.randomPhrase("no_chore")) + ]); + } + +//helper functions + + postSlackMessage(text, blocks) { + postMessage({ + token: TOKEN, + channel: CHORES_CHANNEL, + text, blocks + }); + } + + buildChoreElement(chore, index) { + let section = this.buildMarkdownSection(`>${chore}`); + section.accessory = { + type: "button", + text: { + type: "plain_text", + text: "Done!", + emoji: true + }, + action_id: `${index}` + }; + return section; + } + + buildMarkdownSection(text) { + return { + type: "section", + text: { + type: "mrkdwn", text + } + }; + } + + randomPhrase(type) { + const potentialPhrases = this.phrases[type]; + if (type === "request") { + const currentMonth = (new Date()).getMonth() + 1; + if (4 <= currentMonth <= 9) { + potentialPhrases.push("After you go to the Snowman :icecream: or something, make sure that gets done!" + + " And then click the \"Done!\" button!"); + } + } + return potentialPhrases[Math.floor(Math.random() * potentialPhrases.length)]; + } +} + +module.exports = {Notifications}; diff --git a/utilities/bolt.js b/utilities/bolt.js index 8acec8b..e4b417b 100644 --- a/utilities/bolt.js +++ b/utilities/bolt.js @@ -1,16 +1,11 @@ //node packages const {App, ExpressReceiver} = require("@slack/bolt"); -const moment = require("moment-timezone"); - -//local packages -const {getTodaysChores} = require("./getChores.js"); //globals const PORT = process.env.NODE_PORT || 3000; const TOKEN = process.env.SLACK_BOT_TOKEN; const SECRET = process.env.SLACK_SIGNING_SECRET; const CRON_SCHEDULE = process.env.CRON_SCHEDULE; -const API_VERIFICATION = process.env.API_VERIFICATION_TOKEN; const expressReceiver = new ExpressReceiver({ signingSecret: SECRET @@ -18,28 +13,17 @@ const expressReceiver = new ExpressReceiver({ //package config const app = new App({ - token: TOKEN, + token: TOKEN, receiver: expressReceiver }); -expressReceiver.app.get("/get/chores", async (req, res) => { - if (API_VERIFICATION !== req.query.token) { - res.sendStatus(403); - } else { - let chores = await getTodaysChores(); - chores = chores === -1 ? {chores: []} : {chores}; - res.send(chores); - } -}); - - (async () => { await app.start(PORT); console.log( - `${moment().tz("America/New_York")}: ChoreBot running on port ${PORT}...` + `${new Date()}: ChoreBot running on port ${PORT}...` ); console.log(`cron schedule: "${CRON_SCHEDULE}"`); })(); -module.exports = { app }; +module.exports = {app, expressReceiver}; diff --git a/utilities/getChores.js b/utilities/getChores.js deleted file mode 100644 index 04890c2..0000000 --- a/utilities/getChores.js +++ /dev/null @@ -1,46 +0,0 @@ -//node pacakges -const { google } = require("googleapis"); -const moment = require("moment-timezone"); -require("dotenv").config(); - -//local packages -const privatekey = require("../keys/sheets-api.json"); - -//globals -const SPREADSHEET_ID = process.env.SPREADSHEET_ID; -const GDRIVE_EMAIL = process.env.GDRIVE_EMAIL; - -const jwtClient = new google.auth.JWT( - privatekey.client_email, - null, - privatekey.private_key, - ["https://www.googleapis.com/auth/spreadsheets.readonly"], - GDRIVE_EMAIL -); - -const getChores = async () => { - const sheets = google.sheets({ - version: "v4", - auth: jwtClient - }); - - jwtClient.authorize(err => console.log(err ? err : "Successfully connected!")); - - try { - const { data } = await sheets.spreadsheets.values.get({ - spreadsheetId: SPREADSHEET_ID, - range: "A2:B" - }); - return data ? data.values : null; - } catch (error) { - console.error(error); - } -}; - -const getTodaysChores = async () => { - const today = moment().format("YYYY-MM-DD"); - const todaysChores = (await getChores()).map(c => c[0] === today ? c[1] : null).filter(Boolean); - return todaysChores.length === 0 ? -1 : todaysChores; -}; - -module.exports = { getTodaysChores }; diff --git a/utilities/markChoreDone.js b/utilities/markChoreDone.js deleted file mode 100644 index d2bf5f9..0000000 --- a/utilities/markChoreDone.js +++ /dev/null @@ -1,26 +0,0 @@ -//local packages -const { - app: { - client: { - chat: { update } - } - } -} = require("./bolt.js"); - -//globals -const TOKEN = process.env.SLACK_BOT_TOKEN; - -//helper functions -const crossOffAndTag = (user, index, blocks) => { - const choreText = blocks[index + 2].text.text.replace(">", ""); - blocks[index + 2].text.text = `>~${choreText}~ Completed by <@${user}>`; - delete blocks[index + 2].accessory; - return blocks; -}; - -const markChoreDone = (index, user, channel, ts, initialBlocks) => { - const blocks = crossOffAndTag(user, parseInt(index), initialBlocks); - update({ token: TOKEN, channel, ts, blocks }); -}; - -module.exports = { markChoreDone }; diff --git a/utilities/notify.js b/utilities/notify.js deleted file mode 100644 index 3458851..0000000 --- a/utilities/notify.js +++ /dev/null @@ -1,58 +0,0 @@ -//local packages -const { - app: { - client: { - chat: { postMessage } - } - } -} = require("./bolt.js"); -const { randomPhrase } = require("./randomPhrases.js"); - -//globals -const TOKEN = process.env.SLACK_BOT_TOKEN; -const CHORES_CHANNEL = process.env.CHORES_CHANNEL; - -const buildMarkdownSection = text => ({ - type: "section", - text: { type: "mrkdwn", text } -}); - -//helper functions -const buildChoreElement = (chore, index) => { - let section = buildMarkdownSection(`>${chore}`); - section.accessory = { - type: "button", - text: { - type: "plain_text", - text: "Done!", - emoji: true - }, - action_id: `${index}` - }; - return section; -}; - -const postSlackMessage = (text, blocks) => postMessage({ - token: TOKEN, - channel: CHORES_CHANNEL, - text, blocks -}); - -// const notifyOfficers = () => { -// console.log("Notifying officers!"); -// }; -// TODO: build function to notify officers there aren't any chores - -const sendNoChores = () => postSlackMessage("No chores tonight!", [ - buildMarkdownSection(randomPhrase("no_chore")) -]); - -const postToSlack = chores => postSlackMessage("Today's chores have been posted!", [ - buildMarkdownSection(randomPhrase("greeting")), - buildMarkdownSection("Tonight's chores:"), - ...chores.map((c, i) => buildChoreElement(c, i)), - buildMarkdownSection(randomPhrase("request") + " If you have any questions " + - ":thinking_face: please reach out to the vice president!") -]); - -module.exports = { sendNoChores, postToSlack };