├── .env.sample ├── .github └── CODE_OF_CONDUCT.md ├── .gitignore ├── LICENSE ├── README.md ├── diff.md ├── images ├── app_icon.png ├── demo_approval_flow.gif └── diagram_approval_flow.png ├── package.json └── src ├── api.js ├── index.js ├── payloads.js └── verifySignature.js /.env.sample: -------------------------------------------------------------------------------- 1 | SLACK_ACCESS_TOKEN=xoxb- 2 | SLACK_SIGNING_SECRET= -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | package-lock.json 4 | test 5 | temp* 6 | 7 | .env 8 | 9 | ### https://raw.github.com/github/gitignore/b304edf487ce607174e188712225b5269d43f279/Global/OSX.gitignore 10 | 11 | .DS_Store 12 | .AppleDouble 13 | .LSOverride 14 | 15 | ### IDE Settings (EditorConfig/Sublime) 16 | .editorconfig 17 | 18 | ### IDE Settings (VSCode) 19 | .vscode 20 | .eslintrc 21 | .nvmrc 22 | 23 | ### App-specific 24 | users.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Slack Technologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # "AnnounceBot" Approval Workflows with Modal 2 | 3 | 4 | > :sparkles: *Updated October 2020: As we have introduced some new features, this tutorial and the code samples have been updated! All the changes from the previous version of this example, read the [DIFF.md](diff.md)* 5 | 6 | --- 7 | 8 | This app lets its bot to post an approved message into a public channel- A user DMs to bot to create an announcement, and once it is approved by another user, the message will be posted to public. 9 | 10 | User A ("girlie_mac") wants to announce about donuts on `#random` channel, and User B ("Slack Boss") approves it: 11 | 12 | ![announcements_approvals](images/demo_approval_flow.gif) 13 | 14 | ## API & Features 15 | 16 | This app uses: 17 | - Web API 18 | - `chat.postMessage` to post messages 19 | - `conversations.open` to send direct messages from the bot to a user 20 | - `users.conversations` to get channels the bot user is a member of 21 | - `views.publish` to publish a view to the Home tab 22 | - `views.open` to open a Block Kit modal and collect information for the announcement to be sent 23 | - `conversations.history` to view historical messages between the bot and user 24 | - Events API `message.im` to see when a DM message is sent 25 | - Block Kit messages with interactive buttons 26 | - Block Kit Modals API with dynamic menus 27 | 28 | ## Setup 29 | 30 | ### 1. Clone this repo, or Remix this Glitch repo 31 | 32 | Clone the repo (then `npm install` to install the dependencies), or if you'd like to work on Glitch, remix from the button below: 33 | 34 | [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/slack-announcements-approval-blueprint) 35 | 36 | #### 2. Create a Slack app 37 | 38 | 1. Create an app at [api.slack.com/apps](https://api.slack.com/apps) 39 | 2. Navigate to the OAuth & Permissions page and add the following Bot token scopes: 40 | * `channels:read` 41 | * `chat:write` 42 | * `im:write` 43 | * `im:history` 44 | 3. Enable the events (See below *Enable the Events API*) 45 | 4. Enable the interactive messages (See below *Enable Interactive Messages*) 46 | 5. Enable App Home (See below *App Home*) 47 | 6. Click 'Save Changes' and install the app (You should get an OAuth access token after the installation 48 | 49 | #### Enable the Events API 50 | 1. Click on **Events Subscriptions** and enable events. 51 | 2. Set the Request URL to your server (or Glitch URL) + `/events` (*e.g.* `https://your-server.com/events`) 52 | 3. On the same page, go down to **Subscribe to Bot Events** section and subscribe to these events 53 | - `message.im` 54 | - `app_home_opened` 55 | 4. Save 56 | 57 | #### Enable Interactive Messages 58 | 59 | To enable interactive UI components (This example uses buttons): 60 | 61 | 1. Click on **Interactive Components** and enable the interactivity. 62 | 2. Set the Request URL to your server (or Glitch URL) + `/interactions` 63 | 64 | To dynamically populate a drop-down menu list in a dialog (This example uses a list of channels): 65 | 66 | 1. Insert the Options Load URL (*e.g.* `https://your-server.com/options`) in the **Message Menus** section 67 | 2. Save 68 | 69 | #### Enable App Home 70 | 71 | To enable App Home: 72 | 73 | 1. Click on **App Home** and make sure both `Home Tab` and `Messages Tab` are enabled. 74 | 75 | #### 3. Run this App 76 | Set Environment Variables and run: 77 | 78 | 1. Set the following environment variables in `.env` (copy from `.env.sample`): 79 | * `SLACK_ACCESS_TOKEN`: Your app's `xoxb-` token (available on the Install App page after the installation) 80 | * `SLACK_SIGNING_SECRET`: Your app's Signing Secret (available on the **Basic Information** page) 81 | 2. If you're running the app locally: 82 | * Start the app (`npm start`) 83 | 84 | On Slack client, "invite" your bot to appropriate channels. The bot cannot post messages to the channels where the bot is not added. 85 | 86 | ## The app sequence diagram 87 | 88 | ![dialog](images/diagram_approval_flow.png) 89 | -------------------------------------------------------------------------------- /diff.md: -------------------------------------------------------------------------------- 1 | # What's New? - Updates from the Previous Example 2 | 3 | --- 4 | ## Changes made in October 2020 5 | 6 | ### Chat API 7 | 8 | *Major updates!: This requires to update your code!* 9 | 10 | You will notice deprecation messages relating to the `im.open` and `im.history` Web API methods. Now, you can use the conversations api to communicate with members on Slack. 11 | 12 | To learn more about the change, please refer to the [Slack Conversations API](https://api.slack.com/docs/conversations-api). 13 | 14 | --- 15 | ## Changes made in January 2020 16 | 17 | ### Modals 18 | 19 | *Major updates!: This requires to update your scopes in App Management!* 20 | 21 | We have intruduced more granular OAuth permissions for the apps that uses a bot token. Now, this sample app requires the scopes, `chat:write`, `im:write`, `im:history`, and `channels:read`, where it used to require only `bot` scope. 22 | 23 | To learn more about the change, please refer [Migration guide for classic Slack apps](https://api.slack.com/authentication/migration). 24 | 25 | --- 26 | ## Changes made in October 2019 27 | 28 | ### Modals 29 | 30 | *Major updates!: This requires to update your code!* 31 | 32 | We released [Modals](https://api.slack.com/block-kit/surfaces/modals), which is replacing the existing Dialogs, with more powerful features. 33 | 34 | Now, instead of calling an API to open a dialog is replaced with the new view API to open a modal with Block Kit in the code sample. 35 | 36 | --- 37 | ## Changes made in January 2019 38 | 39 | ### App design changes 40 | 41 | * The overall user flow is simplifed. 42 | * This app sample is now more consistent with other Blueprints examples- *e.g.* file names, using the same frameworks such as Express.js. 43 | * Sticking with the Web APIs- instead of using a webhook, the app sends messages via `chat.postMessage` method. 44 | * UX Change: A user can pick an approver and the channel where an announcement to be posted. This change gives you more use cases with Dialogs API's dynamic menu! 45 | 46 | ### OAuth Scopes 47 | 48 | Some scopes are no longer valid with workspace apps. 49 | 50 | In previous example, these scopes were required: 51 | * `chat:write:bot` 52 | 53 | In the new version, you need to enable: 54 | * `bot` 55 | 56 | ### OAuth Token 57 | 58 | Your OAuth access token should begins with `xoxb-`, instead of `xoxp-`. 59 | 60 | 61 | ### Sigining Secret 62 | 63 | *This requires to update your code!* 64 | 65 | Previously, you needed to verify a *verificatin token* to see if a request was coming from Slack, not from some malicious place by simply comparing a string with the legacy token with a token received with a payload. But now you must use more secure *sigining secrets*. 66 | 67 | Basically, you need to compare the value of the `X-Slack-Signature`, the HMAC-SHA256 keyed hash of the raw request payload, with a hashed string containing your Slack signin secret code, combined with the version and `X-Slack-Request-Timestamp`. 68 | 69 | Learn more at [Verifying requests from Slack](https://api.slack.com/docs/verifying-requests-from-slack). 70 | -------------------------------------------------------------------------------- /images/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/javascript-announcement-bot/7e3698ab24f6d76b1f7eb702aaa35312a6ae7389/images/app_icon.png -------------------------------------------------------------------------------- /images/demo_approval_flow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/javascript-announcement-bot/7e3698ab24f6d76b1f7eb702aaa35312a6ae7389/images/demo_approval_flow.gif -------------------------------------------------------------------------------- /images/diagram_approval_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/javascript-announcement-bot/7e3698ab24f6d76b1f7eb702aaa35312a6ae7389/images/diagram_approval_flow.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slack/template-announcement-approvals", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "An example Slack app", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node src/index.js", 10 | "dev": "nodemon src/index.js" 11 | }, 12 | "authors": [ 13 | "David Pichsenmeister ", 14 | "Tomomi Imura " 15 | ], 16 | "license": "MIT", 17 | "dependencies": { 18 | "axios": ">=0.18.1", 19 | "body-parser": "^1.17.1", 20 | "dotenv": "^4.0.0", 21 | "express": "^4.15.2", 22 | "tsscmp": "^1.0.6" 23 | }, 24 | "devDependencies": { 25 | "nodemon": "^1.19.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const axios = require('axios'); 4 | const payloads = require('./payloads'); 5 | const apiUrl = 'https://slack.com/api'; 6 | 7 | /** 8 | * helper function to call POST methods of Slack API 9 | */ 10 | const callAPIMethodPost = async (method, payload) => { 11 | let result = await axios.post(`${apiUrl}/${method}`, payload, { 12 | headers: { Authorization: "Bearer " + process.env.SLACK_ACCESS_TOKEN } 13 | }); 14 | return result.data; 15 | } 16 | 17 | /** 18 | * helper function to call GET methods of Slack API 19 | */ 20 | const callAPIMethodGet = async (method, payload) => { 21 | payload.token = process.env.SLACK_ACCESS_TOKEN 22 | let params = Object.keys(payload).map(key => `${key}=${payload[key]}`).join('&') 23 | let result = await axios.get(`${apiUrl}/${method}?${params}`); 24 | return result.data; 25 | } 26 | 27 | /** 28 | * helper function to receive all channels our bot user is a member of 29 | */ 30 | const getChannels = async (userId, channels, cursor) => { 31 | channels = channels || [] 32 | 33 | let payload = {} 34 | if (cursor) payload.cursor = cursor 35 | let result = await callAPIMethodPost('users.conversations', payload) 36 | channels = channels.concat(result.channels) 37 | if (result.response_metadata && result.response_metadata.next_cursor && result.response_metadata.next_cursor.length) 38 | return getChannels(userId, channels, result.response_metadata.next_cursor) 39 | 40 | return channels 41 | } 42 | 43 | const requestAnnouncement = async (user, submission) => { 44 | // Send the approver a direct message with "Approve" and "Reject" buttons 45 | let res = await callAPIMethodPost('conversations.open', { 46 | users: submission.approver 47 | }) 48 | submission.requester = user.id; 49 | submission.channel = res.channel.id; 50 | await callAPIMethodPost('chat.postMessage', payloads.approve(submission)); 51 | }; 52 | 53 | const rejectAnnouncement = async (payload, announcement) => { 54 | // 1. update the approver's message that this request has been denied 55 | await callAPIMethodPost('chat.update', { 56 | channel: payload.channel.id, 57 | ts: payload.message.ts, 58 | text: 'This request has been denied. I am letting the requester know!', 59 | blocks: null 60 | }); 61 | 62 | // 2. send a notification to the requester 63 | let res = await callAPIMethodPost('conversations.open', { 64 | users: announcement.requester 65 | }) 66 | await callAPIMethodPost('chat.postMessage', payloads.rejected({ 67 | channel: res.channel.id, 68 | title: announcement.title, 69 | details: announcement.details, 70 | channelString: announcement.channelString 71 | })); 72 | } 73 | 74 | const postAnnouncement = async (payload, announcement) => { 75 | await callAPIMethodPost('chat.update', { 76 | channel: payload.channel.id, 77 | ts: payload.message.ts, 78 | text: 'Thanks! This post has been announced.', 79 | blocks: null 80 | }); 81 | 82 | announcement.channels.forEach(channel => { 83 | callAPIMethodPost('chat.postMessage', payloads.announcement({ 84 | channel: channel, 85 | title: announcement.title, 86 | details: announcement.details, 87 | requester: announcement.requester, 88 | approver: announcement.approver 89 | })); 90 | }) 91 | } 92 | 93 | 94 | 95 | module.exports = { 96 | callAPIMethodPost, 97 | callAPIMethodGet, 98 | getChannels, 99 | rejectAnnouncement, 100 | postAnnouncement, 101 | requestAnnouncement 102 | } 103 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const api = require('./api'); 6 | const payloads = require('./payloads'); 7 | const signature = require('./verifySignature'); 8 | 9 | const app = express(); 10 | 11 | /* 12 | * Parse application/x-www-form-urlencoded && application/json 13 | * Use body-parser's `verify` callback to export a parsed raw body 14 | * that you need to use to verify the signature 15 | */ 16 | 17 | const rawBodyBuffer = (req, res, buf, encoding) => { 18 | if (buf && buf.length) { 19 | req.rawBody = buf.toString(encoding || 'utf8'); 20 | } 21 | }; 22 | 23 | app.use(bodyParser.urlencoded({ verify: rawBodyBuffer, extended: true })); 24 | app.use(bodyParser.json({ verify: rawBodyBuffer })); 25 | 26 | app.get('/', (req, res) => { 27 | res.send('

The Approval Flow app is running

Follow the' + 28 | ' instructions in the README to configure the Slack App and your' + 29 | ' environment variables.

'); 30 | }); 31 | 32 | /* 33 | * Endpoint to receive events from Slack's Events API. 34 | * It handles `message.im` event callbacks. 35 | */ 36 | 37 | app.post('/events', (req, res) => { 38 | switch (req.body.type) { 39 | case 'url_verification': { 40 | // verify Events API endpoint by returning challenge if present 41 | return res.send({ challenge: req.body.challenge }); 42 | } 43 | case 'event_callback': { 44 | // Verify the signing secret 45 | if (!signature.isVerified(req)) return res.status(400).send(); 46 | 47 | const event = req.body.event; 48 | // ignore events from bots 49 | if (event.bot_id) return res.status(200).send(); 50 | 51 | handleEvent(event); 52 | return res.status(200).send(); 53 | } 54 | default: 55 | return res.status(404).send(); 56 | } 57 | }); 58 | 59 | /* 60 | * Endpoint to receive events from interactive message and a dialog on Slack. 61 | * Verify the signing secret before continuing. 62 | */ 63 | app.post('/interactions', async (req, res) => { 64 | if (!signature.isVerified(req)) return res.status(400).send(); 65 | 66 | const payload = JSON.parse(req.body.payload); 67 | 68 | if (payload.type === 'block_actions') { 69 | // acknowledge the event before doing heavy-lifting on our servers 70 | res.status(200).send(); 71 | 72 | let action = payload.actions[0] 73 | 74 | switch (action.action_id) { 75 | case 'make_announcement': 76 | // await api.openRequestModal(payload.trigger_id); 77 | await api.callAPIMethodPost('views.open', { 78 | trigger_id: payload.trigger_id, 79 | view: payloads.request_announcement() 80 | }); 81 | break; 82 | case 'dismiss': 83 | await api.callAPIMethodPost('chat.delete', { 84 | channel: payload.channel.id, 85 | ts: payload.message.ts 86 | }); 87 | break; 88 | case 'approve': 89 | await api.postAnnouncement(payload, JSON.parse(action.value)); 90 | break; 91 | case 'reject': 92 | await api.rejectAnnouncement(payload, JSON.parse(action.value)); 93 | break; 94 | } 95 | } else if (payload.type === 'view_submission') { 96 | return handleViewSubmission(payload, res); 97 | } 98 | 99 | return res.status(404).send(); 100 | 101 | }); 102 | 103 | /* 104 | * Endpoint to receive events from interactive message and a dialog on Slack. 105 | * Verify the signing secret before continuing. 106 | */ 107 | app.post('/options', async (req, res) => { 108 | if (!signature.isVerified(req)) return res.status(400).send(); 109 | const payload = JSON.parse(req.body.payload); 110 | 111 | let botUser = await api.callAPIMethodPost('auth.test', {}) 112 | let conversations = await api.getChannels(botUser.user_id) 113 | let options = conversations.map(c => { 114 | return { 115 | text: { 116 | type: 'plain_text', 117 | text: c.name 118 | }, 119 | value: c.id 120 | } 121 | }) 122 | 123 | options = options.filter(option => { 124 | return option.text.text.indexOf(payload.value) >= 0 125 | }) 126 | 127 | return res.send({ 128 | options: options 129 | }) 130 | }) 131 | 132 | 133 | /** 134 | * Handle all incoming events from the Events API 135 | */ 136 | const handleEvent = async (event) => { 137 | switch (event.type) { 138 | case 'app_home_opened': 139 | if (event.tab === 'messages') { 140 | // only send initial message for the first time users opens the messages tab, 141 | // we can check for that by requesting the message history 142 | let history = await api.callAPIMethodGet('conversations.history', { 143 | channel: event.channel, 144 | limit: 1 145 | }) 146 | 147 | if (!history.messages.length) await api.callAPIMethodPost('chat.postMessage', payloads.welcome_message({ 148 | channel: event.channel 149 | })); 150 | } else if (event.tab === 'home') { 151 | await api.callAPIMethodPost('views.publish', { 152 | user_id: event.user, 153 | view: payloads.welcome_home() 154 | }); 155 | } 156 | break; 157 | case 'message': 158 | // only respond to new messages posted by user, those won't carry a subtype 159 | if (!event.subtype) { 160 | await api.callAPIMethodPost('chat.postMessage', payloads.welcome_message({ 161 | channel: event.channel 162 | })); 163 | } 164 | break; 165 | } 166 | } 167 | 168 | /** 169 | * Handle all Block Kit Modal submissions 170 | */ 171 | const handleViewSubmission = async (payload, res) => { 172 | switch (payload.view.callback_id) { 173 | case 'request_announcement': 174 | const values = payload.view.state.values; 175 | let channels = values.channel.channel_id.selected_options.map(channel => channel.value); 176 | let channelString = channels.map(channel => `<#${channel}>`).join(', '); 177 | 178 | // respond with a stacked modal to the user to confirm selection 179 | let announcement = { 180 | title: values.title.title_id.value, 181 | details: values.details.details_id.value, 182 | approver: values.approver.approver_id.selected_user, 183 | channels: channels, 184 | channelString: channelString 185 | } 186 | return res.send(payloads.confirm_announcement({ 187 | announcement 188 | })); 189 | case 'confirm_announcement': 190 | await api.requestAnnouncement(payload.user, JSON.parse(payload.view.private_metadata)); 191 | // show a final confirmation modal that the request has been sent 192 | return res.send(payloads.finish_announcement()); 193 | } 194 | } 195 | 196 | 197 | const server = app.listen(process.env.PORT || 5000, () => { 198 | console.log('Express server listening on port %d in %s mode', server.address().port, app.settings.env); 199 | }); -------------------------------------------------------------------------------- /src/payloads.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | short_message: context => { 3 | return { 4 | channel: context.channel, 5 | text: context.text 6 | } 7 | }, 8 | welcome_message: context => { 9 | return { 10 | channel: context.channel, 11 | text: ':wave: Hello! I\'m here to help your team make approved announcements into a channel.', 12 | blocks: [ 13 | { 14 | type: 'section', 15 | text: { 16 | type: 'mrkdwn', 17 | text: ':wave: Hello! I\'m here to help your team make approved announcements into a channel.' 18 | } 19 | }, 20 | { 21 | type: 'actions', 22 | elements: [ 23 | { 24 | action_id: 'make_announcement', 25 | type: 'button', 26 | text: { 27 | type: 'plain_text', 28 | text: 'Make Announcement' 29 | }, 30 | style: 'primary', 31 | value: 'make_announcement' 32 | }, 33 | { 34 | action_id: 'dismiss', 35 | type: 'button', 36 | text: { 37 | type: 'plain_text', 38 | text: 'Dismiss' 39 | }, 40 | value: 'dismiss' 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | }, 47 | welcome_home: context => { 48 | return { 49 | type: 'home', 50 | blocks: [ 51 | { 52 | type: 'section', 53 | text: { 54 | type: 'mrkdwn', 55 | text: ':wave: Hello! I\'m here to help your team make approved announcements into a channel.' 56 | } 57 | }, 58 | { 59 | type: 'actions', 60 | elements: [ 61 | { 62 | action_id: 'make_announcement', 63 | type: 'button', 64 | text: { 65 | type: 'plain_text', 66 | text: 'Make Announcement' 67 | }, 68 | style: 'primary', 69 | value: 'make_announcement' 70 | } 71 | ] 72 | } 73 | ] 74 | } 75 | }, 76 | request_announcement: context => { 77 | return { 78 | type: 'modal', 79 | title: { 80 | type: 'plain_text', 81 | text: 'Request an announcement' 82 | }, 83 | callback_id: 'request_announcement', 84 | blocks: [ 85 | { 86 | block_id: 'title', 87 | type: 'input', 88 | label: { 89 | type: 'plain_text', 90 | text: 'Title' 91 | }, 92 | element: { 93 | action_id: 'title_id', 94 | type: 'plain_text_input', 95 | max_length: 100 96 | } 97 | }, 98 | { 99 | block_id: 'details', 100 | type: 'input', 101 | label: { 102 | type: 'plain_text', 103 | text: 'Details' 104 | }, 105 | element: { 106 | action_id: 'details_id', 107 | type: 'plain_text_input', 108 | multiline: true, 109 | max_length: 500 110 | } 111 | }, 112 | { 113 | block_id: 'approver', 114 | type: 'input', 115 | label: { 116 | type: 'plain_text', 117 | text: 'Select approver' 118 | }, 119 | element: { 120 | action_id: 'approver_id', 121 | type: 'users_select' 122 | } 123 | }, 124 | { 125 | block_id: 'channel', 126 | type: 'input', 127 | label: { 128 | type: 'plain_text', 129 | text: 'Select channels' 130 | }, 131 | element: { 132 | action_id: 'channel_id', 133 | type: 'multi_external_select', 134 | min_query_length: 0 135 | } 136 | } 137 | ], 138 | submit: { 139 | type: 'plain_text', 140 | text: 'Next' 141 | } 142 | } 143 | }, 144 | confirm_announcement: context => { 145 | return { 146 | response_action: 'push', 147 | view: { 148 | callback_id: 'confirm_announcement', 149 | type: 'modal', 150 | title: { 151 | type: 'plain_text', 152 | text: 'Confirm request' 153 | }, 154 | blocks: [ 155 | { 156 | type: 'section', 157 | text: { 158 | type: 'mrkdwn', 159 | text: `*TITLE*` 160 | } 161 | }, 162 | { 163 | type: 'divider' 164 | }, 165 | { 166 | type: 'section', 167 | text: { 168 | type: 'mrkdwn', 169 | text: context.announcement.title 170 | } 171 | }, 172 | { 173 | type: 'section', 174 | text: { 175 | type: 'mrkdwn', 176 | text: `*DETAILS*` 177 | } 178 | }, 179 | { 180 | type: 'divider' 181 | }, 182 | { 183 | type: 'section', 184 | text: { 185 | type: 'mrkdwn', 186 | text: context.announcement.details 187 | } 188 | }, 189 | { 190 | type: 'section', 191 | text: { 192 | type: 'mrkdwn', 193 | text: `*APPROVER*` 194 | } 195 | }, 196 | { 197 | type: 'divider' 198 | }, 199 | { 200 | type: 'section', 201 | text: { 202 | type: 'mrkdwn', 203 | text: `<@${context.announcement.approver}>` 204 | } 205 | }, 206 | { 207 | type: 'section', 208 | text: { 209 | type: 'mrkdwn', 210 | text: `*CHANNELS*` 211 | } 212 | }, 213 | { 214 | type: 'divider' 215 | }, 216 | { 217 | type: 'section', 218 | text: { 219 | type: 'mrkdwn', 220 | text: context.announcement.channelString 221 | } 222 | } 223 | ], 224 | close: { 225 | type: 'plain_text', 226 | text: 'Back' 227 | }, 228 | submit: { 229 | type: 'plain_text', 230 | text: 'Submit' 231 | }, 232 | private_metadata: JSON.stringify(context.announcement) 233 | } 234 | } 235 | }, 236 | finish_announcement: context => { 237 | return { 238 | response_action: 'update', 239 | view: { 240 | callback_id: 'finish_announcement', 241 | clear_on_close: true, 242 | type: 'modal', 243 | title: { 244 | type: 'plain_text', 245 | text: 'Success :tada:', 246 | emoji: true 247 | }, 248 | blocks: [ 249 | { 250 | type: 'section', 251 | text: { 252 | type: 'mrkdwn', 253 | text: `Your announcement has been sent for approval.` 254 | } 255 | } 256 | ], 257 | close: { 258 | type: 'plain_text', 259 | text: 'Done' 260 | } 261 | } 262 | } 263 | }, 264 | approve: context => { 265 | return { 266 | channel: context.channel, 267 | text: `Announcement approval is requested by <@${context.requester}>`, 268 | blocks: [ 269 | { 270 | type: 'section', 271 | text: { 272 | type: 'mrkdwn', 273 | text: `<@${context.requester}> is requesting an announcement.` 274 | } 275 | }, 276 | { 277 | type: 'section', 278 | text: { 279 | type: 'mrkdwn', 280 | text: `>>> *TITLE*\n${context.title}\n\n*DETAILS*\n${context.details}` 281 | } 282 | }, 283 | { 284 | type: 'context', 285 | elements: [ 286 | { 287 | type: 'mrkdwn', 288 | text: `Requested channels: ${context.channelString}` 289 | } 290 | ] 291 | }, 292 | { 293 | type: 'actions', 294 | elements: [ 295 | { 296 | action_id: 'approve', 297 | type: 'button', 298 | text: { 299 | type: 'plain_text', 300 | text: 'Approve', 301 | emoji: true 302 | }, 303 | style: 'primary', 304 | value: JSON.stringify(context) 305 | }, 306 | { 307 | action_id: 'reject', 308 | type: 'button', 309 | text: { 310 | type: 'plain_text', 311 | text: 'Reject', 312 | emoji: true 313 | }, 314 | style: 'danger', 315 | value: JSON.stringify(context) 316 | } 317 | ] 318 | } 319 | ] 320 | } 321 | }, 322 | rejected: context => { 323 | return { 324 | channel: context.channel, 325 | text: 'Your announcement has been rejected.', 326 | blocks: [ 327 | { 328 | type: 'section', 329 | text: { 330 | type: 'mrkdwn', 331 | text: 'Your announcement has been rejected.' 332 | } 333 | }, 334 | { 335 | type: 'divider' 336 | }, 337 | { 338 | type: 'section', 339 | text: { 340 | type: 'mrkdwn', 341 | text: `>>> *TITLE*\n${context.title}\n\n*DETAILS*\n${context.details}` 342 | } 343 | }, 344 | { 345 | type: 'context', 346 | elements: [ 347 | { 348 | type: 'mrkdwn', 349 | text: `Requested channels: ${context.channelString}` 350 | } 351 | ] 352 | } 353 | ] 354 | } 355 | }, 356 | announcement: context => { 357 | return { 358 | channel: context.channel, 359 | text: `:loudspeaker: Announcement from: <@${context.requester}>`, 360 | blocks: [ 361 | { 362 | type: 'section', 363 | text: { 364 | type: 'mrkdwn', 365 | text: `*${context.title}*` 366 | } 367 | }, 368 | { 369 | type: 'divider' 370 | }, 371 | { 372 | type: 'section', 373 | text: { 374 | type: 'mrkdwn', 375 | text: context.details 376 | } 377 | }, 378 | { 379 | type: 'context', 380 | elements: [ 381 | { 382 | type: 'mrkdwn', 383 | text: `:memo: Posted by <@${context.requester}>` 384 | }, 385 | { 386 | type: 'mrkdwn', 387 | text: `:heavy_check_mark: Approved by <@${context.approver}>` 388 | } 389 | ] 390 | } 391 | ] 392 | } 393 | } 394 | 395 | } -------------------------------------------------------------------------------- /src/verifySignature.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const timingSafeCompare = require('tsscmp'); 3 | 4 | const isVerified = (req) => { 5 | const signature = req.headers['x-slack-signature']; 6 | const timestamp = req.headers['x-slack-request-timestamp']; 7 | const hmac = crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET); 8 | const [version, hash] = signature.split('='); 9 | 10 | // Check if the timestamp is too old 11 | const fiveMinutesAgo = ~~(Date.now() / 1000) - (60 * 5); 12 | if (timestamp < fiveMinutesAgo) return false; 13 | 14 | hmac.update(`${version}:${timestamp}:${req.rawBody}`); 15 | 16 | // check that the request signature matches expected value 17 | return timingSafeCompare(hmac.digest('hex'), hash); 18 | }; 19 | 20 | module.exports = { isVerified }; 21 | 22 | --------------------------------------------------------------------------------