├── .env.sample ├── .eslintrc ├── .github └── CODE_OF_CONDUCT.md ├── .gitignore ├── .nvmrc ├── README.md ├── index.js ├── lib ├── bot.js └── menu.js ├── package.json └── support └── demo.gif /.env.sample: -------------------------------------------------------------------------------- 1 | # Uncomment if you'd like to see verbose logging 2 | # DEBUG=* 3 | 4 | SLACK_BOT_TOKEN= 5 | SLACK_VERIFICATION_TOKEN= 6 | SLACK_WEBHOOK_URL= 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "node": true 5 | }, 6 | "rules": { 7 | "no-console": ["off"], 8 | "comma-dangle": ["error", { 9 | "arrays": "always-multiline", 10 | "objects": "always-multiline", 11 | "imports": "always-multiline", 12 | "exports": "always-multiline", 13 | "functions": "never" 14 | }] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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 | .env 3 | tmp/ 4 | *.log 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v6 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Message Menus API Sample for Node 2 | 3 | [Message menus](https://api.slack.com/docs/message-menus) are a feature of the Slack Platform that allow your Slack app to display a set of choices to users within a message. 4 | 5 | This sample demonstrates building a coffeebot, which helps you customize a drink order using message menus. 6 | 7 | ![Demo](support/demo.gif "Demo") 8 | 9 | Start by DMing the bot (or it will DM you when you join the team). Coffeebot introduces itself and gives you a message button to start a drink order. Coffees can be complicated so the bot gives you menus to make your drink just right (e.g. mocha, non fat milk, with a triple shot). It sends your completed order off to a channel where your baristas are standing by. 10 | 11 | You can either develop this app locally or you can [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/slack-message-menus-node) 12 | 13 | ## Setup 14 | 15 | ### Create a Slack app 16 | 17 | To start, create an app at [api.slack.com/apps](https://api.slack.com/apps) and configure it with a bot user, event subscriptions, interactive messages, and an incoming webhook. This sample app uses the [Slack Event Adapter](https://github.com/slackapi/node-slack-events-api), where you can find some configuration steps to get the Events API ready to use in your app. 18 | 19 | 20 | ### Bot user 21 | 22 | Click on the Bot user feature on your app configuration page. Assign it a username (such as 23 | `@coffeebot`), enable it to be always online, and save changes. 24 | 25 | ### Event subscriptions 26 | 27 | Turn on Event Subscriptions for the Slack app. You must input and verify a Request URL, and the easiest way to do this is to [use a development proxy as described in the Events API module](https://github.com/slackapi/node-slack-events-api#configuration). The application listens for events at the path `/slack/events`: 28 | 29 | - ngrok or Glitch URL + `/slack/events` 30 | 31 | Create a subscription to the team event `team_join` and a bot event for `message.im`. Save your changes. 32 | 33 | ### Interactive Messages 34 | Click on `Interactive Messages` on the left side navigation, and enable it. Input your *Request URL*: 35 | 36 | - ngrok or Glitch URL + `/slack/actions` 37 | 38 | _(there's a more complete explanation of Interactive Message configuration on the [Node Slack Interactive Messages module](https://github.com/slackapi/node-slack-interactive-messages#configuration))._ 39 | 40 | ### Incoming webhook 41 | 42 | Create a channel in your development team for finished coffee orders (such as `#coffee`). Add an incoming webhook to your app's configuration and select this team. Complete it by authorizing the webhook on your team. 43 | 44 | ### Environment variables 45 | 46 | You should now have a Slack verification token (basic information), access token, and webhook URL (install app). 47 | 48 | You can develop the app locally by cloning this repository. Or you can [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/slack-message-menus-node) 49 | 50 | **If you're developing locally:** 51 | 52 | 1. Create a new file named `.env` (see `.env.sample`) within the directory and place the values as shown below 53 | 2. Download the dependencies for the application by running `npm install`. Note that this example assumes you are using a currently supported LTS version of Node (at this time, v6 or above). 54 | 3. Start the app (`npm start`) 55 | 56 | **If you're using Glitch:** 57 | 1. Enter the enviornmental variables in `.env` as shown below 58 | 59 | 60 | ``` 61 | SLACK_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxx 62 | SLACK_BOT_TOKEN=xoxb-0000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 63 | SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxxxxxxxx/yyyyyyyyy/zzzzzzzzzzzzzzzzzzzzzzzz 64 | ``` 65 | 66 | 67 | ## Usage 68 | 69 | Go ahead and DM `@coffeebot` to see the app in action! 70 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const http = require('http'); 4 | const express = require('express'); 5 | const bodyParser = require('body-parser'); 6 | const slackEventsAPI = require('@slack/events-api'); 7 | const slackInteractiveMessages = require('@slack/interactive-messages'); 8 | const normalizePort = require('normalize-port'); 9 | const cloneDeep = require('lodash.clonedeep'); 10 | const bot = require('./lib/bot'); 11 | 12 | // --- Slack Events --- 13 | const slackEvents = slackEventsAPI.createSlackEventAdapter(process.env.SLACK_VERIFICATION_TOKEN); 14 | 15 | slackEvents.on('team_join', (event) => { 16 | bot.introduceToUser(event.user.id); 17 | }); 18 | 19 | slackEvents.on('message', (event) => { 20 | // Filter out messages from this bot itself or updates to messages 21 | if (event.subtype === 'bot_message' || event.subtype === 'message_changed') { 22 | return; 23 | } 24 | bot.handleDirectMessage(event); 25 | }); 26 | 27 | // --- Slack Interactive Messages --- 28 | const slackMessages = 29 | slackInteractiveMessages.createMessageAdapter(process.env.SLACK_VERIFICATION_TOKEN); 30 | 31 | // Helper functions 32 | 33 | function findAttachment(message, actionCallbackId) { 34 | return message.attachments.find(a => a.callback_id === actionCallbackId); 35 | } 36 | 37 | function acknowledgeActionFromMessage(originalMessage, actionCallbackId, ackText) { 38 | const message = cloneDeep(originalMessage); 39 | const attachment = findAttachment(message, actionCallbackId); 40 | delete attachment.actions; 41 | attachment.text = `:white_check_mark: ${ackText}`; 42 | return message; 43 | } 44 | 45 | function findSelectedOption(originalMessage, actionCallbackId, selectedValue) { 46 | const attachment = findAttachment(originalMessage, actionCallbackId); 47 | return attachment.actions[0].options.find(o => o.value === selectedValue); 48 | } 49 | 50 | // Action handling 51 | 52 | slackMessages.action('order:start', (payload, respond) => { 53 | // Create an updated message that acknowledges the user's action (even if the result of that 54 | // action is not yet complete). 55 | const updatedMessage = acknowledgeActionFromMessage(payload.original_message, 'order:start', 56 | 'I\'m getting an order started for you.'); 57 | 58 | // Start an order, and when that completes, send another message to the user. 59 | bot.startOrder(payload.user.id) 60 | .then(respond) 61 | .catch(console.error); 62 | 63 | // The updated message is returned synchronously in response 64 | return updatedMessage; 65 | }); 66 | 67 | slackMessages.action('order:select_type', (payload, respond) => { 68 | const selectedType = findSelectedOption(payload.original_message, 'order:select_type', payload.actions[0].selected_options[0].value); 69 | const updatedMessage = acknowledgeActionFromMessage(payload.original_message, 'order:select_type', 70 | `You chose a ${selectedType.text.toLowerCase()}.`); 71 | 72 | bot.selectTypeForOrder(payload.user.id, selectedType.value) 73 | .then((response) => { 74 | // Keep the context from the updated message but use the new text and attachment 75 | updatedMessage.text = response.text; 76 | if (response.attachments && response.attachments.length > 0) { 77 | updatedMessage.attachments.push(response.attachments[0]); 78 | } 79 | return updatedMessage; 80 | }) 81 | .then(respond) 82 | .catch(console.error); 83 | 84 | return updatedMessage; 85 | }); 86 | 87 | slackMessages.action('order:select_option', (payload, respond) => { 88 | const optionName = payload.actions[0].name; 89 | const selectedChoice = findSelectedOption(payload.original_message, 'order:select_option', payload.actions[0].selected_options[0].value); 90 | const updatedMessage = acknowledgeActionFromMessage(payload.original_message, 'order:select_option', 91 | `You chose ${selectedChoice.text.toLowerCase()} for ${optionName.toLowerCase()}`); 92 | 93 | bot.selectOptionForOrder(payload.user.id, optionName, selectedChoice.value) 94 | .then((response) => { 95 | // Keep the context from the updated message but use the new text and attachment 96 | updatedMessage.text = response.text; 97 | if (response.attachments && response.attachments.length > 0) { 98 | updatedMessage.attachments.push(response.attachments[0]); 99 | } 100 | return updatedMessage; 101 | }) 102 | .then(respond) 103 | .catch(console.error); 104 | 105 | return updatedMessage; 106 | }); 107 | 108 | // Create the server 109 | const port = normalizePort(process.env.PORT || '3000'); 110 | const app = express(); 111 | app.use(bodyParser.json()); 112 | app.use('/slack/events', slackEvents.expressMiddleware()); 113 | app.use(bodyParser.urlencoded({ extended: false })); 114 | app.use('/slack/actions', slackMessages.expressMiddleware()); 115 | // Start the server 116 | http.createServer(app).listen(port, () => { 117 | console.log(`server listening on port ${port}`); 118 | }); 119 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | const { WebClient } = require('@slack/client'); 2 | const menu = require('./menu'); 3 | const map = require('lodash.map'); 4 | const axios = require('axios'); 5 | 6 | // Helper functions 7 | function nextOptionForOrder(order) { 8 | const item = menu.items.find(i => i.id === order.type); 9 | if (!item) { 10 | throw new Error('This menu item was not found.'); 11 | } 12 | return item.options.find(o => !Object.prototype.hasOwnProperty.call(order.options, o)); 13 | } 14 | 15 | function orderIsComplete(order) { 16 | return !nextOptionForOrder(order); 17 | } 18 | 19 | function summarizeOrder(order) { 20 | const item = menu.items.find(i => i.id === order.type); 21 | let summary = item.name; 22 | const optionsText = map(order.options, (choice, optionName) => `${choice} ${optionName}`); 23 | if (optionsText.length !== 0) { 24 | summary += ` with ${optionsText.join(' and ')}`; 25 | } 26 | return summary.toLowerCase(); 27 | } 28 | 29 | function capitalizeFirstChar(string) { 30 | return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); 31 | } 32 | 33 | // Bot 34 | // TODO: remove 35 | const slackClientOptions = {}; 36 | if (process.env.SLACK_ENV) { 37 | slackClientOptions.slackAPIUrl = process.env.SLACK_ENV; 38 | } 39 | const bot = { 40 | web: new WebClient(process.env.SLACK_BOT_TOKEN, slackClientOptions), 41 | orders: {}, 42 | 43 | introduceToUser(userId) { 44 | this.web.im.open(userId) 45 | .then(resp => this.web.chat.postMessage(resp.channel.id, 'I am coffeebot, and I\'m here to help bring you fresh coffee :coffee:, made to order.\n', { 46 | attachments: [ 47 | { 48 | color: '#5A352D', 49 | title: 'How can I help you?', 50 | callback_id: 'order:start', 51 | actions: [ 52 | { 53 | name: 'start', 54 | text: 'Start a coffee order', 55 | type: 'button', 56 | value: 'order:start', 57 | }, 58 | ], 59 | }, 60 | ], 61 | })) 62 | .catch(console.error); 63 | }, 64 | 65 | startOrder(userId) { 66 | // TODO: error handling 67 | if (this.orders[userId]) { 68 | return Promise.resolve({ 69 | text: 'I\'m already working on an order for you, please be patient', 70 | replace_original: false, 71 | }); 72 | } 73 | 74 | // Initialize the order 75 | this.orders[userId] = { 76 | options: {}, 77 | }; 78 | 79 | return Promise.resolve({ 80 | text: 'Great! What can I get started for you?', 81 | attachments: [ 82 | { 83 | color: '#5A352D', 84 | callback_id: 'order:select_type', 85 | text: '', // attachments must have text property defined (abstractable) 86 | actions: [ 87 | { 88 | name: 'select_type', 89 | type: 'select', 90 | options: menu.listOfTypes(), 91 | }, 92 | ], 93 | }, 94 | ], 95 | }); 96 | }, 97 | 98 | selectTypeForOrder(userId, itemId) { 99 | const order = this.orders[userId]; 100 | 101 | // TODO: error handling 102 | if (!order) { 103 | return Promise.resolve({ 104 | text: 'I cannot find that order. Message me to start a new order.', 105 | replace_original: false, 106 | }); 107 | } 108 | 109 | // TODO: validation? 110 | order.type = itemId; 111 | 112 | if (!orderIsComplete(order)) { 113 | return this.optionSelectionForOrder(userId); 114 | } 115 | return this.finishOrder(userId); 116 | }, 117 | 118 | optionSelectionForOrder(userId) { 119 | const order = this.orders[userId]; 120 | // TODO: what happens if this throws? 121 | const optionId = nextOptionForOrder(order); 122 | return Promise.resolve({ 123 | text: `Working on your ${summarizeOrder(order)}.`, 124 | attachments: [ 125 | { 126 | color: '#5A352D', 127 | callback_id: 'order:select_option', 128 | text: `Which ${optionId} would you like?`, 129 | actions: [ 130 | { 131 | name: optionId, 132 | type: 'select', 133 | options: menu.listOfChoicesForOption(optionId), 134 | }, 135 | ], 136 | }, 137 | ], 138 | }); 139 | }, 140 | 141 | selectOptionForOrder(userId, optionId, optionValue) { 142 | const order = this.orders[userId]; 143 | 144 | // TODO: error handling 145 | if (!order) { 146 | return Promise.resolve({ 147 | text: 'I cannot find that order. Message me to start a new order.', 148 | replace_original: false, 149 | }); 150 | } 151 | 152 | // TODO: validation? 153 | order.options[optionId] = optionValue; 154 | 155 | if (!orderIsComplete(order)) { 156 | return this.optionSelectionForOrder(userId); 157 | } 158 | return this.finishOrder(userId); 159 | }, 160 | 161 | // TODO: error handling 162 | finishOrder(userId) { 163 | const order = this.orders[userId]; 164 | const item = menu.items.find(i => i.id === order.type); 165 | let fields = [ 166 | { 167 | title: 'Drink', 168 | value: item.name, 169 | }, 170 | ]; 171 | fields = fields.concat(map(order.options, (choiceId, optionId) => { 172 | const choiceName = menu.choiceNameForId(optionId, choiceId); 173 | return { title: capitalizeFirstChar(optionId), value: choiceName }; 174 | })); 175 | 176 | return axios.post(process.env.SLACK_WEBHOOK_URL, { 177 | text: `<@${userId}> has submitted a new coffee order.`, 178 | attachments: [ 179 | { 180 | color: '#5A352D', 181 | title: 'Order details', 182 | text: summarizeOrder(order), 183 | fields, 184 | }, 185 | ], 186 | }).then(() => Promise.resolve({ 187 | text: `Your order of a ${summarizeOrder(order)} is coming right up!`, 188 | })); 189 | }, 190 | 191 | handleDirectMessage(message) { 192 | if (!this.orders[message.user]) { 193 | this.introduceToUser(message.user); 194 | } else { 195 | this.web.chat.postMessage(message.channel, 'Let\'s keep working on the open order.') 196 | .catch(console.error); 197 | } 198 | }, 199 | }; 200 | 201 | module.exports = bot; 202 | -------------------------------------------------------------------------------- /lib/menu.js: -------------------------------------------------------------------------------- 1 | const menu = { 2 | items: [ 3 | { 4 | id: 'cappuccino', 5 | name: 'Cappuccino', 6 | options: ['milk'], 7 | }, 8 | { 9 | id: 'americano', 10 | name: 'Americano', 11 | options: ['strength'], 12 | }, 13 | { 14 | id: 'gibraltar', 15 | name: 'Gibraltar', 16 | options: ['milk'], 17 | }, 18 | { 19 | id: 'latte', 20 | name: 'Latte', 21 | options: ['milk'], 22 | }, 23 | { 24 | id: 'lavlatte', 25 | name: 'Lavendar Latte', 26 | options: ['milk'], 27 | }, 28 | { 29 | id: 'mintlatte', 30 | name: 'Mint Latte', 31 | options: ['milk'], 32 | }, 33 | { 34 | id: 'espresso', 35 | name: 'Espresso', 36 | options: ['strength'], 37 | }, 38 | { 39 | id: 'espressomach', 40 | name: 'Espresso Macchiato', 41 | options: ['milk'], 42 | }, 43 | { 44 | id: 'mocha', 45 | name: 'Mocha', 46 | options: ['milk'], 47 | }, 48 | { 49 | id: 'tea', 50 | name: 'Hot Tea', 51 | options: ['milk'], 52 | }, 53 | ], 54 | options: [ 55 | { 56 | id: 'strength', 57 | choices: [ 58 | { 59 | id: 'single', 60 | name: 'Single', 61 | }, 62 | { 63 | id: 'double', 64 | name: 'Double', 65 | }, 66 | { 67 | id: 'triple', 68 | name: 'Triple', 69 | }, 70 | { 71 | id: 'quad', 72 | name: 'Quad', 73 | }, 74 | ], 75 | }, 76 | { 77 | id: 'milk', 78 | choices: [ 79 | { 80 | id: 'whole', 81 | name: 'Whole', 82 | }, 83 | { 84 | id: 'lowfat', 85 | name: 'Low fat', 86 | }, 87 | { 88 | id: 'almond', 89 | name: 'Almond', 90 | }, 91 | { 92 | id: 'soy', 93 | name: 'Soy', 94 | }, 95 | ], 96 | }, 97 | ], 98 | 99 | listOfTypes() { 100 | return menu.items.map(i => ({ text: i.name, value: i.id })); 101 | }, 102 | 103 | listOfChoicesForOption(optionId) { 104 | return menu.options.find(o => o.id === optionId).choices 105 | .map(c => ({ text: c.name, value: c.id })); 106 | }, 107 | 108 | choiceNameForId(optionId, choiceId) { 109 | const option = menu.options.find(o => o.id === optionId); 110 | if (option) { 111 | return option.choices.find(c => c.id === choiceId).name; 112 | } 113 | return false; 114 | }, 115 | }; 116 | 117 | module.exports = menu; 118 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@slack/sample-message-menus", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "An example Slack app that demonstrates use of Message Menus", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "Ankur Oberoi ", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "eslint": "^3.19.0", 14 | "eslint-config-airbnb-base": "^11.1.3", 15 | "eslint-plugin-import": "^2.2.0" 16 | }, 17 | "dependencies": { 18 | "@slack/client": "^3.9.0", 19 | "@slack/events-api": "^1.0.1", 20 | "@slack/interactive-messages": "^0.1.1", 21 | "axios": "^0.16.1", 22 | "body-parser": "^1.17.1", 23 | "dotenv": "^4.0.0", 24 | "express": "^4.15.2", 25 | "lodash.clonedeep": "^4.5.0", 26 | "lodash.map": "^4.6.0", 27 | "normalize-port": "^1.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /support/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/javascript-message-menus/e9eed1987d84c2019ffe0492fc3576c2722245bd/support/demo.gif --------------------------------------------------------------------------------