├── .gitignore ├── .vscode └── extensions.json ├── README.md ├── assets └── food-fight.png ├── netlify.toml ├── package-lock.json ├── package.json ├── src ├── reminder.ts ├── slack.ts └── util │ ├── notion.ts │ └── slack.ts ├── tsconfig.json └── types └── index.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | 4 | # Local Netlify folder 5 | .netlify 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Frontend Masters](https://static.frontendmasters.com/assets/brand/logos/full.png)](https://frontendmasters.com) 2 | 3 | This is a companion repo for the [Building a Slack Chat Bot with Jason Lengstorf](https://frontendmasters.com/courses/chat-apis/) course on [Frontend Masters](https://frontendmasters.com). 4 | 5 | The `main` branch contains the final application. Use the `start` branch when following the course. 6 | 7 | ## Setup Instructions 8 | 9 | > Note: Node version 18 is required for this course 10 | 11 | ### Netlify 12 | 13 | Netlify is used as a live tunnel for testing the application. If you don't already have an account, create one before beginning the course: 14 | 15 | 1. Visit https://www.netlify.com 16 | 2. Click **Sign Up** to create a new account. 17 | 18 | The Netlify CLI is also required for the course. These installation steps are covered in the **Creating a Netlify Tunnel** lesson: 19 | 20 | 1. Install the Netlify CLI: `npm i -g netlify-cli` 21 | 2. Login to your Netlify account: `ntl login` 22 | 3. Initialize the project: `ntl init` 23 | 4. Start the dev server: `ntl dev --live` 24 | 25 | ### Slack 26 | 27 | A free Slack account and workspace is required for this course. We recommend creating a new workspace for testing the application. If you don't have a Slack account or workspace: 28 | 29 | 1. Visit https://slack.com 30 | 2. Create a new account or sign in 31 | 3. Click **Create a New Workspace** and follow the instructions 32 | 33 | Creating a new Slack application is demonstrated in the **Slack App Setup** lesson. 34 | 35 | 1. Visit https://api.slack.com 36 | 2. Click **Create new App** and choose to create it from scratch 37 | 3. Name the application Food Fight and choose to deploy it to your test workspace 38 | 39 | **Long description for the application (provided here to copy/paste)** 40 | 41 | Is your workday going too smoothly? Everyone is being productive and that makes you suspicious? Why not derail the whole team by starting a heated argument about food? 42 | 43 | - Is sous vide an acceptable way to cook a burger? 44 | - Does mayonnaise go on french fries? 45 | - Wnat to convince everyone that pineapple belongs on pizza? 46 | 47 | Make your spiciest assertions and watch your team devolve into culinary fisticuffs. With Food Fight, remind your cowordkers that you are an agent of chaos! 48 | 49 | ### Notion 50 | 51 | Notion is used to store data from the Slack application. You'll need to create a free Notion account: 52 | 53 | 1. Visit https://notion.com 54 | 2. Create a new account if you don't already have one. 55 | 3. [optional] Create a new workspace 56 | 4. Duplicate [the example database](https://frontendmasters-chatops.notion.site/7818ece038cc43129307fd41e91fd9c8) 57 | 58 | A new integration is created during the **Integration with Notion** lesson: 59 | 60 | 1. Visit https://www.notion.so/my-integrations 61 | 2. Click the New Integration button 62 | 3. Select the workspace you want to use for the application 63 | 4. Add the basic information and an image from the `assets` directory. 64 | 5. Visit the Food Fight Database you duplicated into your workspace and add the Food Fight connection. 65 | 6. From the integrations section, copy the secret into a `NOTION_SECRET` environment variable 66 | 7. Copy the database ID into a `NOTION_DATABASE_ID` environment variable 67 | -------------------------------------------------------------------------------- /assets/food-fight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learnwithjason/chatops-frontend-masters/88978e90891aa5d4b3726bac44300febd73811c7/assets/food-fight.png -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/api/*" 3 | to = "/.netlify/functions/:splat" 4 | status = 200 5 | 6 | [functions] 7 | directory = "src" 8 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatops-frontend-masters", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@netlify/functions": "^1.6.0" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "^20.1.4" 12 | } 13 | }, 14 | "node_modules/@netlify/functions": { 15 | "version": "1.6.0", 16 | "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-1.6.0.tgz", 17 | "integrity": "sha512-6G92AlcpFrQG72XU8YH8pg94eDnq7+Q0YJhb8x4qNpdGsvuzvrfHWBmqFGp/Yshmv4wex9lpsTRZOocdrA2erQ==", 18 | "dependencies": { 19 | "is-promise": "^4.0.0" 20 | }, 21 | "engines": { 22 | "node": ">=14.0.0" 23 | } 24 | }, 25 | "node_modules/@types/node": { 26 | "version": "20.1.4", 27 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", 28 | "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==", 29 | "dev": true 30 | }, 31 | "node_modules/is-promise": { 32 | "version": "4.0.0", 33 | "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", 34 | "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@netlify/functions": "^1.6.0" 4 | }, 5 | "devDependencies": { 6 | "@types/node": "^20.1.4" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/reminder.ts: -------------------------------------------------------------------------------- 1 | import { type Handler, schedule } from '@netlify/functions'; 2 | import { getNewItems } from './util/notion'; 3 | import { blocks, slackApi } from './util/slack'; 4 | 5 | const postNewNotionItemsToSlack: Handler = async () => { 6 | const items = await getNewItems(); 7 | 8 | await slackApi('chat.postMessage', { 9 | channel: 'C0438E823SP', 10 | blocks: [ 11 | blocks.section({ 12 | text: [ 13 | 'Here are the opinions awaiting judgment:', 14 | '', 15 | ...items.map( 16 | (item) => `- ${item.opinion} (spice level: ${item.spiceLevel})`, 17 | ), 18 | '', 19 | `See all items .`, 20 | ].join('\n'), 21 | }), 22 | ], 23 | }); 24 | 25 | return { 26 | statusCode: 200, 27 | }; 28 | }; 29 | 30 | // see https://crontab.guru for more info on how this syntax works 31 | export const handler = schedule('0 12 * * 1', postNewNotionItemsToSlack); 32 | -------------------------------------------------------------------------------- /src/slack.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from '@netlify/functions'; 2 | 3 | import { parse } from 'querystring'; 4 | import { blocks, modal, slackApi, verifySlackRequest } from './util/slack'; 5 | import { saveItem } from './util/notion'; 6 | 7 | async function handleSlashCommand(payload: SlackSlashCommandPayload) { 8 | switch (payload.command) { 9 | case '/foodfight': 10 | const response = await slackApi( 11 | 'views.open', 12 | modal({ 13 | id: 'foodfight-modal', 14 | title: 'Start a food fight!', 15 | trigger_id: payload.trigger_id, 16 | blocks: [ 17 | blocks.section({ 18 | text: 'The discourse demands food drama! *Send in your spiciest food takes so we can all argue about them and feel alive.*', 19 | }), 20 | blocks.input({ 21 | id: 'opinion', 22 | label: 'Deposit your controversial food opinions here.', 23 | placeholder: 24 | 'Example: peanut butter and mayonnaise sandwiches are delicious!', 25 | initial_value: payload.text ?? '', 26 | hint: 'What do you believe about food that people find appalling? Say it with your chest!', 27 | }), 28 | blocks.select({ 29 | id: 'spice_level', 30 | label: 'How spicy is this opinion?', 31 | placeholder: 'Select a spice level', 32 | options: [ 33 | { label: 'mild', value: 'mild' }, 34 | { label: 'medium', value: 'medium' }, 35 | { label: 'spicy', value: 'spicy' }, 36 | { label: 'nuclear', value: 'nuclear' }, 37 | ], 38 | }), 39 | ], 40 | }), 41 | ); 42 | 43 | if (!response.ok) { 44 | console.log(response); 45 | } 46 | 47 | break; 48 | 49 | default: 50 | return { 51 | statusCode: 200, 52 | body: `Command ${payload.command} is not recognized`, 53 | }; 54 | } 55 | 56 | return { 57 | statusCode: 200, 58 | body: '', 59 | }; 60 | } 61 | 62 | async function handleInteractivity(payload: SlackModalPayload) { 63 | const callback_id = payload.callback_id ?? payload.view.callback_id; 64 | 65 | switch (callback_id) { 66 | case 'foodfight-modal': 67 | const data = payload.view.state.values; 68 | const fields = { 69 | opinion: data.opinion_block.opinion.value, 70 | spiceLevel: data.spice_level_block.spice_level.selected_option.value, 71 | submitter: payload.user.name, 72 | }; 73 | 74 | await saveItem(fields); 75 | 76 | await slackApi('chat.postMessage', { 77 | channel: 'C0438E823SP', 78 | text: `Oh dang, y’all! :eyes: <@${payload.user.id}> just started a food fight with a ${fields.spiceLevel} take:\n\n*${fields.opinion}*\n\n...discuss.`, 79 | }); 80 | break; 81 | 82 | case 'start-food-fight-nudge': 83 | const channel = payload.channel?.id; 84 | const user_id = payload.user.id; 85 | const thread_ts = payload.message.thread_ts ?? payload.message.ts; 86 | 87 | await slackApi('chat.postMessage', { 88 | channel, 89 | thread_ts, 90 | text: `Hey <@${user_id}>, an opinion like this one deserves a heated public debate. Run the \`/foodfight\` slash command in a main channel to start one!`, 91 | }); 92 | 93 | break; 94 | 95 | default: 96 | console.log(`No handler defined for ${payload.view.callback_id}`); 97 | return { 98 | statusCode: 400, 99 | body: `No handler defined for ${payload.view.callback_id}`, 100 | }; 101 | } 102 | 103 | return { 104 | statusCode: 200, 105 | body: '', 106 | }; 107 | } 108 | 109 | export const handler: Handler = async (event) => { 110 | const valid = verifySlackRequest(event); 111 | 112 | if (!valid) { 113 | console.error('invalid request'); 114 | 115 | return { 116 | statusCode: 400, 117 | body: 'invalid request', 118 | }; 119 | } 120 | 121 | const body = parse(event.body ?? '') as SlackPayload; 122 | 123 | if (body.command) { 124 | return handleSlashCommand(body as SlackSlashCommandPayload); 125 | } 126 | 127 | // TODO handle interactivity (e.g. context commands, modals) 128 | if (body.payload) { 129 | const payload = JSON.parse(body.payload); 130 | return handleInteractivity(payload); 131 | } 132 | 133 | return { 134 | statusCode: 200, 135 | body: 'TODO: handle Slack commands and interactivity', 136 | }; 137 | }; 138 | -------------------------------------------------------------------------------- /src/util/notion.ts: -------------------------------------------------------------------------------- 1 | export async function notionApi(endpoint: string, body: {}) { 2 | const res = await fetch(`https://api.notion.com/v1${endpoint}`, { 3 | method: 'POST', 4 | headers: { 5 | accept: 'application/json', 6 | authorization: `Bearer ${process.env.NOTION_SECRET}`, 7 | 'Notion-Version': '2022-06-28', 8 | 'content-type': 'application/json', 9 | }, 10 | body: JSON.stringify(body), 11 | }).catch((err) => console.error(err)); 12 | 13 | if (!res || !res.ok) { 14 | console.error(res); 15 | } 16 | 17 | const data = await res?.json(); 18 | 19 | return data; 20 | } 21 | 22 | export async function getNewItems(): Promise { 23 | const notionData = await notionApi( 24 | `/databases/${process.env.NOTION_DATABASE_ID}/query`, 25 | { 26 | filter: { 27 | property: 'Status', 28 | status: { 29 | equals: 'new', 30 | }, 31 | }, 32 | page_size: 100, 33 | }, 34 | ); 35 | 36 | const openItems = notionData.results.map((item: NotionItem) => { 37 | return { 38 | opinion: item.properties.opinion.title[0].text.content, 39 | spiceLevel: item.properties.spiceLevel.select.name, 40 | status: item.properties.Status.status.name, 41 | }; 42 | }); 43 | 44 | return openItems; 45 | } 46 | 47 | export async function saveItem(item: NewItem) { 48 | const res = await notionApi('/pages', { 49 | parent: { 50 | database_id: process.env.NOTION_DATABASE_ID, 51 | }, 52 | properties: { 53 | opinion: { 54 | title: [{ text: { content: item.opinion } }], 55 | }, 56 | spiceLevel: { 57 | select: { 58 | name: item.spiceLevel, 59 | }, 60 | }, 61 | submitter: { 62 | rich_text: [{ text: { content: `@${item.submitter} on Slack` } }], 63 | }, 64 | }, 65 | }); 66 | 67 | if (!res.ok) { 68 | console.log(res); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/util/slack.ts: -------------------------------------------------------------------------------- 1 | import type { HandlerEvent } from '@netlify/functions'; 2 | 3 | import { createHmac } from 'crypto'; 4 | 5 | export function slackApi( 6 | endpoint: SlackApiEndpoint, 7 | body: SlackApiRequestBody, 8 | ) { 9 | return fetch(`https://slack.com/api/${endpoint}`, { 10 | method: 'POST', 11 | headers: { 12 | Authorization: `Bearer ${process.env.SLACK_BOT_OAUTH_TOKEN}`, 13 | 'Content-Type': 'application/json; charset=utf-8', 14 | }, 15 | body: JSON.stringify(body), 16 | }).then((res) => res.json()); 17 | } 18 | 19 | export function verifySlackRequest(request: HandlerEvent) { 20 | const secret = process.env.SLACK_SIGNING_SECRET!; 21 | const signature = request.headers['x-slack-signature']; 22 | const timestamp = Number(request.headers['x-slack-request-timestamp']); 23 | const now = Math.floor(Date.now() / 1000); // match Slack timestamp precision 24 | 25 | // if the timestamp is more than five minutes off assume something’s funky 26 | if (Math.abs(now - timestamp) > 300) { 27 | return false; 28 | } 29 | 30 | // make a hash of the request using the same approach Slack used 31 | const hash = createHmac('sha256', secret) 32 | .update(`v0:${timestamp}:${request.body}`) 33 | .digest('hex'); 34 | 35 | // we know the request is valid if our hash matches Slack’s 36 | return `v0=${hash}` === signature; 37 | } 38 | 39 | export const blocks = { 40 | section: ({ text }: SectionBlockArgs): SlackBlockSection => { 41 | return { 42 | type: 'section', 43 | text: { 44 | type: 'mrkdwn', 45 | text, 46 | }, 47 | }; 48 | }, 49 | input({ 50 | id, 51 | label, 52 | placeholder, 53 | initial_value = '', 54 | hint = '', 55 | }: InputBlockArgs): SlackBlockInput { 56 | return { 57 | block_id: `${id}_block`, 58 | type: 'input', 59 | label: { 60 | type: 'plain_text', 61 | text: label, 62 | }, 63 | element: { 64 | action_id: id, 65 | type: 'plain_text_input', 66 | placeholder: { 67 | type: 'plain_text', 68 | text: placeholder, 69 | }, 70 | initial_value, 71 | }, 72 | hint: { 73 | type: 'plain_text', 74 | text: hint, 75 | }, 76 | }; 77 | }, 78 | select({ 79 | id, 80 | label, 81 | placeholder, 82 | options, 83 | }: SelectBlockArgs): SlackBlockInput { 84 | return { 85 | block_id: `${id}_block`, 86 | type: 'input', 87 | label: { 88 | type: 'plain_text', 89 | text: label, 90 | emoji: true, 91 | }, 92 | element: { 93 | action_id: id, 94 | type: 'static_select', 95 | placeholder: { 96 | type: 'plain_text', 97 | text: placeholder, 98 | emoji: true, 99 | }, 100 | options: options.map(({ label, value }) => { 101 | return { 102 | text: { 103 | type: 'plain_text', 104 | text: label, 105 | emoji: true, 106 | }, 107 | value, 108 | }; 109 | }), 110 | }, 111 | }; 112 | }, 113 | }; 114 | 115 | export function modal({ 116 | trigger_id, 117 | id, 118 | title, 119 | submit_text = 'Submit', 120 | blocks, 121 | }: ModalArgs) { 122 | return { 123 | trigger_id, 124 | view: { 125 | type: 'modal', 126 | callback_id: id, 127 | title: { 128 | type: 'plain_text', 129 | text: title, 130 | }, 131 | submit: { 132 | type: 'plain_text', 133 | text: submit_text, 134 | }, 135 | blocks, 136 | }, 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "typeRoots": ["./types"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | type SlackSlashCommandPayload = { 2 | token: string; 3 | team_id: string; 4 | team_domain: string; 5 | channel_id: string; 6 | channel_name: string; 7 | user_id: string; 8 | user_name: string; 9 | command: string; 10 | text: string; 11 | api_app_id: string; 12 | is_enterprise_install: boolean; 13 | response_url: string; 14 | trigger_id: string; 15 | payload: never; 16 | }; 17 | 18 | type SlackInteractivityPayload = { 19 | payload: string; 20 | command: never; 21 | }; 22 | 23 | type SlackPayload = SlackSlashCommandPayload | SlackInteractivityPayload; 24 | 25 | type SlackBlockSection = { 26 | type: 'section'; 27 | text: { 28 | type: 'plain_text' | 'mrkdwn'; 29 | text: string; 30 | verbatim?: boolean; 31 | }; 32 | }; 33 | 34 | type SlackBlockInput = { 35 | type: 'input'; 36 | block_id: string; 37 | label: { 38 | type: 'plain_text'; 39 | text: string; 40 | emoji?: boolean; 41 | }; 42 | hint?: { 43 | type: 'plain_text'; 44 | text: string; 45 | emoji?: boolean; 46 | }; 47 | optional?: boolean; 48 | dispatch_action?: boolean; 49 | element: { 50 | type: string; 51 | action_id: string; 52 | placeholder?: { 53 | type: string; 54 | text: string; 55 | emoji?: boolean; 56 | }; 57 | options?: { 58 | text: { 59 | type: 'plain_text'; 60 | text: string; 61 | emoji?: boolean; 62 | }; 63 | value: string; 64 | }[]; 65 | initial_value?: string; 66 | dispatch_action_config?: { 67 | trigger_actions_on: string[]; 68 | }; 69 | }; 70 | }; 71 | 72 | type SlackBlock = SlackBlockSection | SlackBlockInput; 73 | 74 | type FoodOpinionModalState = { 75 | values: { 76 | opinion_block: { 77 | opinion: { 78 | type: 'plain_text_input'; 79 | value: string; 80 | }; 81 | }; 82 | spice_level_block: { 83 | spice_level: { 84 | type: 'static_select'; 85 | selected_option: { 86 | text: { 87 | type: 'plain_text'; 88 | text: string; 89 | emoji: boolean; 90 | }; 91 | value: string; 92 | }; 93 | }; 94 | }; 95 | }; 96 | }; 97 | 98 | type ModalArgs = { 99 | trigger_id: string; 100 | id: string; 101 | title: string; 102 | submit_text?: string; 103 | blocks: SlackBlock[]; 104 | }; 105 | 106 | type SlackModalPayload = { 107 | type: string; 108 | callback_id?: string; 109 | team: { 110 | id: string; 111 | domain: string; 112 | }; 113 | user: { 114 | id: string; 115 | username: string; 116 | name: string; 117 | team_id: string; 118 | }; 119 | channel?: { 120 | id: string; 121 | name: string; 122 | }; 123 | message: { 124 | ts: string; 125 | thread_ts?: string; 126 | }; 127 | api_app_id: string; 128 | token: string; 129 | trigger_id: string; 130 | view: { 131 | id: string; 132 | team_id: string; 133 | type: string; 134 | blocks: SlackBlock[]; 135 | private_metadata: string; 136 | callback_id: string; 137 | state: FoodOpinionModalState; 138 | hash: string; 139 | title: { 140 | type: 'plain_text'; 141 | text: string; 142 | emoji: boolean; 143 | }; 144 | clear_on_close: boolean; 145 | notify_on_close: boolean; 146 | close: null; 147 | submit: { 148 | type: 'plain_text'; 149 | text: string; 150 | emoji: boolean; 151 | }; 152 | app_id: string; 153 | external_id: string; 154 | app_installed_team_id: string; 155 | bot_id: string; 156 | }; 157 | }; 158 | 159 | type SlackApiEndpoint = 'chat.postMessage' | 'views.open'; 160 | 161 | type SlackApiRequestBody = {}; 162 | 163 | type BlockArgs = { 164 | id: string; 165 | label: string; 166 | placeholder: string; 167 | }; 168 | 169 | type SectionBlockArgs = { 170 | text: string; 171 | }; 172 | 173 | type InputBlockArgs = { 174 | initial_value?: string; 175 | hint?: string; 176 | } & BlockArgs; 177 | 178 | type SelectBlockArgs = { 179 | options: { 180 | label: string; 181 | value: string; 182 | }[]; 183 | } & BlockArgs; 184 | 185 | type NotionItem = { 186 | properties: { 187 | opinion: { 188 | title: { 189 | text: { 190 | content: string; 191 | }; 192 | }[]; 193 | }; 194 | spiceLevel: { 195 | select: { 196 | name: string; 197 | }; 198 | }; 199 | Status: { 200 | status: { 201 | name: string; 202 | }; 203 | }; 204 | }; 205 | }; 206 | 207 | type NewItem = { 208 | opinion: string; 209 | spiceLevel: string; 210 | status?: string; 211 | submitter?: string; 212 | }; 213 | --------------------------------------------------------------------------------