├── .gitignore ├── .env.sample ├── assets └── getting-started-demo.gif ├── renovate.json ├── package.json ├── LICENSE ├── commands.js ├── utils.js ├── examples ├── command.js ├── button.js ├── modal.js ├── selectMenu.js └── app.js ├── app.js ├── game.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | APP_ID= 2 | DISCORD_TOKEN= 3 | PUBLIC_KEY= 4 | -------------------------------------------------------------------------------- /assets/getting-started-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/discord/discord-example-app/HEAD/assets/getting-started-demo.gif -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard", 6 | ":preserveSemverRanges" 7 | ], 8 | "ignorePaths": [ 9 | "**/node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-getting-started", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Discord example app", 6 | "main": "app.js", 7 | "type": "module", 8 | "engines": { 9 | "node": ">=18.x" 10 | }, 11 | "scripts": { 12 | "start": "node app.js", 13 | "register": "node commands.js", 14 | "dev": "nodemon app.js" 15 | }, 16 | "author": "Shay DeWael", 17 | "license": "MIT", 18 | "dependencies": { 19 | "discord-interactions": "^4.0.0", 20 | "dotenv": "^16.0.3", 21 | "express": "^4.18.2" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^3.0.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shay DeWael 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 | -------------------------------------------------------------------------------- /commands.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { getRPSChoices } from './game.js'; 3 | import { capitalize, InstallGlobalCommands } from './utils.js'; 4 | 5 | // Get the game choices from game.js 6 | function createCommandChoices() { 7 | const choices = getRPSChoices(); 8 | const commandChoices = []; 9 | 10 | for (let choice of choices) { 11 | commandChoices.push({ 12 | name: capitalize(choice), 13 | value: choice.toLowerCase(), 14 | }); 15 | } 16 | 17 | return commandChoices; 18 | } 19 | 20 | // Simple test command 21 | const TEST_COMMAND = { 22 | name: 'test', 23 | description: 'Basic command', 24 | type: 1, 25 | integration_types: [0, 1], 26 | contexts: [0, 1, 2], 27 | }; 28 | 29 | // Command containing options 30 | const CHALLENGE_COMMAND = { 31 | name: 'challenge', 32 | description: 'Challenge to a match of rock paper scissors', 33 | options: [ 34 | { 35 | type: 3, 36 | name: 'object', 37 | description: 'Pick your object', 38 | required: true, 39 | choices: createCommandChoices(), 40 | }, 41 | ], 42 | type: 1, 43 | integration_types: [0, 1], 44 | contexts: [0, 2], 45 | }; 46 | 47 | const ALL_COMMANDS = [TEST_COMMAND, CHALLENGE_COMMAND]; 48 | 49 | InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS); 50 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | export async function DiscordRequest(endpoint, options) { 4 | // append endpoint to root API URL 5 | const url = 'https://discord.com/api/v10/' + endpoint; 6 | // Stringify payloads 7 | if (options.body) options.body = JSON.stringify(options.body); 8 | // Use fetch to make requests 9 | const res = await fetch(url, { 10 | headers: { 11 | Authorization: `Bot ${process.env.DISCORD_TOKEN}`, 12 | 'Content-Type': 'application/json; charset=UTF-8', 13 | 'User-Agent': 'DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)', 14 | }, 15 | ...options 16 | }); 17 | // throw API errors 18 | if (!res.ok) { 19 | const data = await res.json(); 20 | console.log(res.status); 21 | throw new Error(JSON.stringify(data)); 22 | } 23 | // return original response 24 | return res; 25 | } 26 | 27 | export async function InstallGlobalCommands(appId, commands) { 28 | // API endpoint to overwrite global commands 29 | const endpoint = `applications/${appId}/commands`; 30 | 31 | try { 32 | // This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands 33 | await DiscordRequest(endpoint, { method: 'PUT', body: commands }); 34 | } catch (err) { 35 | console.error(err); 36 | } 37 | } 38 | 39 | // Simple method that returns a random emoji from list 40 | export function getRandomEmoji() { 41 | const emojiList = ['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨']; 42 | return emojiList[Math.floor(Math.random() * emojiList.length)]; 43 | } 44 | 45 | export function capitalize(str) { 46 | return str.charAt(0).toUpperCase() + str.slice(1); 47 | } 48 | -------------------------------------------------------------------------------- /examples/command.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { InteractionType, InteractionResponseType, verifyKeyMiddleware } from 'discord-interactions'; 4 | import { DiscordRequest } from '../utils.js'; 5 | 6 | // Create and configure express app 7 | const app = express(); 8 | 9 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), function (req, res) { 10 | // Interaction type and data 11 | const { type, data } = req.body; 12 | /** 13 | * Handle slash command requests 14 | */ 15 | if (type === InteractionType.APPLICATION_COMMAND) { 16 | // Slash command with name of "test" 17 | if (data.name === 'test') { 18 | // Send a message as response 19 | return res.send({ 20 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 21 | data: { content: 'A wild message appeared' }, 22 | }); 23 | } 24 | } 25 | }); 26 | 27 | async function createCommand() { 28 | const appId = process.env.APP_ID; 29 | 30 | /** 31 | * Globally-scoped slash commands (generally only recommended for production) 32 | * See https://discord.com/developers/docs/interactions/application-commands#create-global-application-command 33 | */ 34 | const globalEndpoint = `applications/${appId}/commands`; 35 | 36 | /** 37 | * Guild-scoped slash commands 38 | * See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command 39 | */ 40 | // const guildEndpoint = `applications/${appId}/guilds//commands`; 41 | const commandBody = { 42 | name: 'test', 43 | description: 'Just your average command', 44 | // chat command (see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types) 45 | type: 1, 46 | }; 47 | 48 | try { 49 | // Send HTTP request with bot token 50 | const res = await DiscordRequest(globalEndpoint, { 51 | method: 'POST', 52 | body: commandBody, 53 | }); 54 | console.log(await res.json()); 55 | } catch (err) { 56 | console.error('Error installing commands: ', err); 57 | } 58 | } 59 | 60 | app.listen(3000, () => { 61 | console.log('Listening on port 3000'); 62 | 63 | createCommand(); 64 | }); 65 | -------------------------------------------------------------------------------- /examples/button.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { 4 | InteractionType, 5 | InteractionResponseType, 6 | MessageComponentTypes, 7 | ButtonStyleTypes, 8 | verifyKeyMiddleware, 9 | } from 'discord-interactions'; 10 | 11 | // Create and configure express app 12 | const app = express(); 13 | 14 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), function (req, res) { 15 | // Interaction type and data 16 | const { type, data } = req.body; 17 | /** 18 | * Handle slash command requests 19 | */ 20 | if (type === InteractionType.APPLICATION_COMMAND) { 21 | // Slash command with name of "test" 22 | if (data.name === 'test') { 23 | // Send a message with a button 24 | return res.send({ 25 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 26 | data: { 27 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 28 | // Buttons are inside of action rows 29 | components: [ 30 | { 31 | type: MessageComponentTypes.TEXT_DISPLAY, 32 | content: 'A message with a button', 33 | }, 34 | { 35 | type: MessageComponentTypes.ACTION_ROW, 36 | components: [ 37 | { 38 | type: MessageComponentTypes.BUTTON, 39 | // Value for your app to identify the button 40 | custom_id: 'my_button', 41 | label: 'Click', 42 | style: ButtonStyleTypes.PRIMARY, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | }); 49 | } 50 | } 51 | 52 | /** 53 | * Handle requests from interactive components 54 | */ 55 | if (type === InteractionType.MESSAGE_COMPONENT) { 56 | // custom_id set in payload when sending message component 57 | const componentId = data.custom_id; 58 | // user who clicked button 59 | const userId = req.body.member.user.id; 60 | 61 | if (componentId === 'my_button') { 62 | console.log(req.body); 63 | return res.send({ 64 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 65 | data: { content: `<@${userId}> clicked the button` }, 66 | }); 67 | } 68 | } 69 | }); 70 | 71 | app.listen(3000, () => { 72 | console.log('Listening on port 3000'); 73 | }); 74 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { 4 | ButtonStyleTypes, 5 | InteractionResponseFlags, 6 | InteractionResponseType, 7 | InteractionType, 8 | MessageComponentTypes, 9 | verifyKeyMiddleware, 10 | } from 'discord-interactions'; 11 | import { getRandomEmoji, DiscordRequest } from './utils.js'; 12 | import { getShuffledOptions, getResult } from './game.js'; 13 | 14 | // Create an express app 15 | const app = express(); 16 | // Get port, or default to 3000 17 | const PORT = process.env.PORT || 3000; 18 | // To keep track of our active games 19 | const activeGames = {}; 20 | 21 | /** 22 | * Interactions endpoint URL where Discord will send HTTP requests 23 | * Parse request body and verifies incoming requests using discord-interactions package 24 | */ 25 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async function (req, res) { 26 | // Interaction id, type and data 27 | const { id, type, data } = req.body; 28 | 29 | /** 30 | * Handle verification requests 31 | */ 32 | if (type === InteractionType.PING) { 33 | return res.send({ type: InteractionResponseType.PONG }); 34 | } 35 | 36 | /** 37 | * Handle slash command requests 38 | * See https://discord.com/developers/docs/interactions/application-commands#slash-commands 39 | */ 40 | if (type === InteractionType.APPLICATION_COMMAND) { 41 | const { name } = data; 42 | 43 | // "test" command 44 | if (name === 'test') { 45 | // Send a message into the channel where command was triggered from 46 | return res.send({ 47 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 48 | data: { 49 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 50 | components: [ 51 | { 52 | type: MessageComponentTypes.TEXT_DISPLAY, 53 | // Fetches a random emoji to send from a helper function 54 | content: `hello world ${getRandomEmoji()}` 55 | } 56 | ] 57 | }, 58 | }); 59 | } 60 | 61 | console.error(`unknown command: ${name}`); 62 | return res.status(400).json({ error: 'unknown command' }); 63 | } 64 | 65 | console.error('unknown interaction type', type); 66 | return res.status(400).json({ error: 'unknown interaction type' }); 67 | }); 68 | 69 | app.listen(PORT, () => { 70 | console.log('Listening on port', PORT); 71 | }); 72 | -------------------------------------------------------------------------------- /examples/modal.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { 4 | InteractionType, 5 | InteractionResponseType, 6 | MessageComponentTypes, 7 | verifyKeyMiddleware, 8 | } from 'discord-interactions'; 9 | 10 | // Create and configure express app 11 | const app = express(); 12 | 13 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), function (req, res) { 14 | // Interaction type and data 15 | const { type, data } = req.body; 16 | /** 17 | * Handle slash command requests 18 | */ 19 | if (type === InteractionType.APPLICATION_COMMAND) { 20 | // Slash command with name of "test" 21 | if (data.name === 'test') { 22 | // Send a modal as response 23 | return res.send({ 24 | type: InteractionResponseType.MODAL, 25 | data: { 26 | custom_id: 'my_modal', 27 | title: 'Modal title', 28 | components: [ 29 | { 30 | // Text inputs must be inside of an action component 31 | type: MessageComponentTypes.ACTION_ROW, 32 | components: [ 33 | { 34 | // See https://discord.com/developers/docs/components/reference#text-input 35 | type: MessageComponentTypes.INPUT_TEXT, 36 | custom_id: 'my_text', 37 | style: 1, 38 | label: 'Type some text', 39 | }, 40 | ], 41 | }, 42 | { 43 | type: MessageComponentTypes.ACTION_ROW, 44 | components: [ 45 | { 46 | type: MessageComponentTypes.INPUT_TEXT, 47 | custom_id: 'my_longer_text', 48 | // Bigger text box for input 49 | style: 2, 50 | label: 'Type some (longer) text', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | }); 57 | } 58 | } 59 | 60 | /** 61 | * Handle modal submissions 62 | */ 63 | if (type === InteractionType.MODAL_SUBMIT) { 64 | // custom_id of modal 65 | const modalId = data.custom_id; 66 | // user ID of member who filled out modal 67 | const userId = req.body.member.user.id; 68 | 69 | if (modalId === 'my_modal') { 70 | let modalValues = ''; 71 | // Get value of text inputs 72 | for (let action of data.components) { 73 | let inputComponent = action.components[0]; 74 | modalValues += `${inputComponent.custom_id}: ${inputComponent.value}\n`; 75 | } 76 | 77 | return res.send({ 78 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 79 | data: { 80 | content: `<@${userId}> typed the following (in a modal):\n\n${modalValues}`, 81 | }, 82 | }); 83 | } 84 | } 85 | }); 86 | 87 | app.listen(3000, () => { 88 | console.log('Listening on port 3000'); 89 | }); 90 | -------------------------------------------------------------------------------- /examples/selectMenu.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { 4 | InteractionType, 5 | InteractionResponseType, 6 | MessageComponentTypes, 7 | verifyKeyMiddleware, 8 | } from 'discord-interactions'; 9 | 10 | // Create and configure express app 11 | const app = express(); 12 | 13 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), function (req, res) { 14 | // Interaction type and data 15 | const { type, data } = req.body; 16 | /** 17 | * Handle slash command requests 18 | */ 19 | if (type === InteractionType.APPLICATION_COMMAND) { 20 | // Slash command with name of "test" 21 | if (data.name === 'test') { 22 | // Send a message with a button 23 | return res.send({ 24 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 25 | data: { 26 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 27 | // Selects are inside of action rows 28 | components: [ 29 | { 30 | type: MessageComponentTypes.TEXT_DISPLAY, 31 | content: 'What is your object of choice?', 32 | }, 33 | { 34 | type: MessageComponentTypes.ACTION_ROW, 35 | components: [ 36 | { 37 | type: MessageComponentTypes.STRING_SELECT, 38 | // Value for your app to identify the select menu interactions 39 | custom_id: 'my_select', 40 | // Select options - see https://discord.com/developers/docs/components/reference#string-select 41 | options: [ 42 | { 43 | label: 'Option #1', 44 | value: 'option_1', 45 | description: 'The very first option', 46 | }, 47 | { 48 | label: 'Second option', 49 | value: 'option_2', 50 | description: 'The second AND last option', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | ], 57 | }, 58 | }); 59 | } 60 | } 61 | 62 | /** 63 | * Handle requests from interactive components 64 | */ 65 | if (type === InteractionType.MESSAGE_COMPONENT) { 66 | // custom_id set in payload when sending message component 67 | const componentId = data.custom_id; 68 | 69 | if (componentId === 'my_select') { 70 | console.log(req.body); 71 | 72 | // Get selected option from payload 73 | const selectedOption = data.values[0]; 74 | const userId = req.body.member.user.id; 75 | 76 | // Send results 77 | return res.send({ 78 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 79 | data: { content: `<@${userId}> selected ${selectedOption}` }, 80 | }); 81 | } 82 | } 83 | }); 84 | 85 | app.listen(3000, () => { 86 | console.log('Listening on port 3000'); 87 | }); 88 | -------------------------------------------------------------------------------- /game.js: -------------------------------------------------------------------------------- 1 | import { capitalize } from './utils.js'; 2 | 3 | export function getResult(p1, p2) { 4 | let gameResult; 5 | if (RPSChoices[p1.objectName] && RPSChoices[p1.objectName][p2.objectName]) { 6 | // o1 wins 7 | gameResult = { 8 | win: p1, 9 | lose: p2, 10 | verb: RPSChoices[p1.objectName][p2.objectName], 11 | }; 12 | } else if ( 13 | RPSChoices[p2.objectName] && 14 | RPSChoices[p2.objectName][p1.objectName] 15 | ) { 16 | // o2 wins 17 | gameResult = { 18 | win: p2, 19 | lose: p1, 20 | verb: RPSChoices[p2.objectName][p1.objectName], 21 | }; 22 | } else { 23 | // tie -- win/lose don't 24 | gameResult = { win: p1, lose: p2, verb: 'tie' }; 25 | } 26 | 27 | return formatResult(gameResult); 28 | } 29 | 30 | function formatResult(result) { 31 | const { win, lose, verb } = result; 32 | return verb === 'tie' 33 | ? `<@${win.id}> and <@${lose.id}> draw with **${win.objectName}**` 34 | : `<@${win.id}>'s **${win.objectName}** ${verb} <@${lose.id}>'s **${lose.objectName}**`; 35 | } 36 | 37 | // this is just to figure out winner + verb 38 | const RPSChoices = { 39 | rock: { 40 | description: 'sedimentary, igneous, or perhaps even metamorphic', 41 | virus: 'outwaits', 42 | computer: 'smashes', 43 | scissors: 'crushes', 44 | }, 45 | cowboy: { 46 | description: 'yeehaw~', 47 | scissors: 'puts away', 48 | wumpus: 'lassos', 49 | rock: 'steel-toe kicks', 50 | }, 51 | scissors: { 52 | description: 'careful ! sharp ! edges !!', 53 | paper: 'cuts', 54 | computer: 'cuts cord of', 55 | virus: 'cuts DNA of', 56 | }, 57 | virus: { 58 | description: 'genetic mutation, malware, or something inbetween', 59 | cowboy: 'infects', 60 | computer: 'corrupts', 61 | wumpus: 'infects', 62 | }, 63 | computer: { 64 | description: 'beep boop beep bzzrrhggggg', 65 | cowboy: 'overwhelms', 66 | paper: 'uninstalls firmware for', 67 | wumpus: 'deletes assets for', 68 | }, 69 | wumpus: { 70 | description: 'the purple Discord fella', 71 | paper: 'draws picture on', 72 | rock: 'paints cute face on', 73 | scissors: 'admires own reflection in', 74 | }, 75 | paper: { 76 | description: 'versatile and iconic', 77 | virus: 'ignores', 78 | cowboy: 'gives papercut to', 79 | rock: 'covers', 80 | }, 81 | }; 82 | 83 | export function getRPSChoices() { 84 | return Object.keys(RPSChoices); 85 | } 86 | 87 | // Function to fetch shuffled options for select menu 88 | export function getShuffledOptions() { 89 | const allChoices = getRPSChoices(); 90 | const options = []; 91 | 92 | for (let c of allChoices) { 93 | // Formatted for select menus 94 | // https://discord.com/developers/docs/components/reference#string-select-select-option-structure 95 | options.push({ 96 | label: capitalize(c), 97 | value: c.toLowerCase(), 98 | description: RPSChoices[c]['description'], 99 | }); 100 | } 101 | 102 | return options.sort(() => Math.random() - 0.5); 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting Started app for Discord 2 | 3 | This project contains a basic rock-paper-scissors-style Discord app written in JavaScript, built for the [getting started guide](https://discord.com/developers/docs/getting-started). 4 | 5 | ![Demo of app](https://github.com/discord/discord-example-app/raw/main/assets/getting-started-demo.gif?raw=true) 6 | 7 | ## Project structure 8 | Below is a basic overview of the project structure: 9 | 10 | ``` 11 | ├── examples -> short, feature-specific sample apps 12 | │ ├── app.js -> finished app.js code 13 | │ ├── button.js 14 | │ ├── command.js 15 | │ ├── modal.js 16 | │ ├── selectMenu.js 17 | ├── .env.sample -> sample .env file 18 | ├── app.js -> main entrypoint for app 19 | ├── commands.js -> slash command payloads + helpers 20 | ├── game.js -> logic specific to RPS 21 | ├── utils.js -> utility functions and enums 22 | ├── package.json 23 | ├── README.md 24 | └── .gitignore 25 | ``` 26 | 27 | ## Running app locally 28 | 29 | Before you start, you'll need to install [NodeJS](https://nodejs.org/en/download/) and [create a Discord app](https://discord.com/developers/applications) with the proper permissions: 30 | - `applications.commands` 31 | - `bot` (with Send Messages enabled) 32 | 33 | 34 | Configuring the app is covered in detail in the [getting started guide](https://discord.com/developers/docs/getting-started). 35 | 36 | ### Setup project 37 | 38 | First clone the project: 39 | ``` 40 | git clone https://github.com/discord/discord-example-app.git 41 | ``` 42 | 43 | Then navigate to its directory and install dependencies: 44 | ``` 45 | cd discord-example-app 46 | npm install 47 | ``` 48 | ### Get app credentials 49 | 50 | Fetch the credentials from your app's settings and add them to a `.env` file (see `.env.sample` for an example). You'll need your app ID (`APP_ID`), bot token (`DISCORD_TOKEN`), and public key (`PUBLIC_KEY`). 51 | 52 | Fetching credentials is covered in detail in the [getting started guide](https://discord.com/developers/docs/getting-started). 53 | 54 | > 🔑 Environment variables can be added to the `.env` file in Glitch or when developing locally, and in the Secrets tab in Replit (the lock icon on the left). 55 | 56 | ### Install slash commands 57 | 58 | The commands for the example app are set up in `commands.js`. All of the commands in the `ALL_COMMANDS` array at the bottom of `commands.js` will be installed when you run the `register` command configured in `package.json`: 59 | 60 | ``` 61 | npm run register 62 | ``` 63 | 64 | ### Run the app 65 | 66 | After your credentials are added, go ahead and run the app: 67 | 68 | ``` 69 | node app.js 70 | ``` 71 | 72 | > ⚙️ A package [like `nodemon`](https://github.com/remy/nodemon), which watches for local changes and restarts your app, may be helpful while locally developing. 73 | 74 | If you aren't following the [getting started guide](https://discord.com/developers/docs/getting-started), you can move the contents of `examples/app.js` (the finished `app.js` file) to the top-level `app.js`. 75 | 76 | ### Set up interactivity 77 | 78 | The project needs a public endpoint where Discord can send requests. To develop and test locally, you can use something like [`ngrok`](https://ngrok.com/) to tunnel HTTP traffic. 79 | 80 | Install ngrok if you haven't already, then start listening on port `3000`: 81 | 82 | ``` 83 | ngrok http 3000 84 | ``` 85 | 86 | You should see your connection open: 87 | 88 | ``` 89 | Tunnel Status online 90 | Version 2.0/2.0 91 | Web Interface http://127.0.0.1:4040 92 | Forwarding https://1234-someurl.ngrok.io -> localhost:3000 93 | 94 | Connections ttl opn rt1 rt5 p50 p90 95 | 0 0 0.00 0.00 0.00 0.00 96 | ``` 97 | 98 | Copy the forwarding address that starts with `https`, in this case `https://1234-someurl.ngrok.io`, then go to your [app's settings](https://discord.com/developers/applications). 99 | 100 | On the **General Information** tab, there will be an **Interactions Endpoint URL**. Paste your ngrok address there, and append `/interactions` to it (`https://1234-someurl.ngrok.io/interactions` in the example). 101 | 102 | Click **Save Changes**, and your app should be ready to run 🚀 103 | 104 | ## Other resources 105 | - Read **[the documentation](https://discord.com/developers/docs/intro)** for in-depth information about API features. 106 | - Browse the `examples/` folder in this project for smaller, feature-specific code examples 107 | - Join the **[Discord Developers server](https://discord.gg/discord-developers)** to ask questions about the API, attend events hosted by the Discord API team, and interact with other devs. 108 | - Check out **[community resources](https://discord.com/developers/docs/topics/community-resources#community-resources)** for language-specific tools maintained by community members. 109 | -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import { 4 | InteractionType, 5 | InteractionResponseType, 6 | InteractionResponseFlags, 7 | MessageComponentTypes, 8 | ButtonStyleTypes, 9 | verifyKeyMiddleware, 10 | } from 'discord-interactions'; 11 | import { getRandomEmoji, DiscordRequest } from './utils.js'; 12 | import { getShuffledOptions, getResult } from './game.js'; 13 | 14 | // Create an express app 15 | const app = express(); 16 | // Get port, or default to 3000 17 | const PORT = process.env.PORT || 3000; 18 | 19 | // Store for in-progress games. In production, you'd want to use a DB 20 | const activeGames = {}; 21 | 22 | /** 23 | * Interactions endpoint URL where Discord will send HTTP requests 24 | * Parse request body and verifies incoming requests using discord-interactions package 25 | */ 26 | app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async function (req, res) { 27 | // Interaction type and data 28 | const { type, id, data } = req.body; 29 | 30 | /** 31 | * Handle verification requests 32 | */ 33 | if (type === InteractionType.PING) { 34 | return res.send({ type: InteractionResponseType.PONG }); 35 | } 36 | 37 | /** 38 | * Handle slash command requests 39 | * See https://discord.com/developers/docs/interactions/application-commands#slash-commands 40 | */ 41 | if (type === InteractionType.APPLICATION_COMMAND) { 42 | const { name } = data; 43 | 44 | // "test" command 45 | if (name === 'test') { 46 | // Send a message into the channel where command was triggered from 47 | return res.send({ 48 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 49 | data: { 50 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 51 | components: [ 52 | { 53 | type: MessageComponentTypes.TEXT_DISPLAY, 54 | // Fetches a random emoji to send from a helper function 55 | content: `hello world ${getRandomEmoji()}` 56 | } 57 | ] 58 | }, 59 | }); 60 | } 61 | 62 | // "challenge" command 63 | if (name === 'challenge' && id) { 64 | // Interaction context 65 | const context = req.body.context; 66 | // User ID is in user field for (G)DMs, and member for servers 67 | const userId = context === 0 ? req.body.member.user.id : req.body.user.id; 68 | // User's object choice 69 | const objectName = req.body.data.options[0].value; 70 | 71 | // Create active game using message ID as the game ID 72 | activeGames[id] = { 73 | id: userId, 74 | objectName, 75 | }; 76 | 77 | return res.send({ 78 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 79 | data: { 80 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 81 | components: [ 82 | { 83 | type: MessageComponentTypes.TEXT_DISPLAY, 84 | // Fetches a random emoji to send from a helper function 85 | content: `Rock papers scissors challenge from <@${userId}>`, 86 | }, 87 | { 88 | type: MessageComponentTypes.ACTION_ROW, 89 | components: [ 90 | { 91 | type: MessageComponentTypes.BUTTON, 92 | // Append the game ID to use later on 93 | custom_id: `accept_button_${req.body.id}`, 94 | label: 'Accept', 95 | style: ButtonStyleTypes.PRIMARY, 96 | }, 97 | ], 98 | }, 99 | ], 100 | }, 101 | }); 102 | } 103 | 104 | console.error(`unknown command: ${name}`); 105 | return res.status(400).json({ error: 'unknown command' }); 106 | } 107 | 108 | /** 109 | * Handle requests from interactive components 110 | * See https://discord.com/developers/docs/components/using-message-components#using-message-components-with-interactions 111 | */ 112 | if (type === InteractionType.MESSAGE_COMPONENT) { 113 | // custom_id set in payload when sending message component 114 | const componentId = data.custom_id; 115 | 116 | if (componentId.startsWith('accept_button_')) { 117 | // get the associated game ID 118 | const gameId = componentId.replace('accept_button_', ''); 119 | // Delete message with token in request body 120 | const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; 121 | try { 122 | await res.send({ 123 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 124 | data: { 125 | // Indicates it'll be an ephemeral message 126 | flags: InteractionResponseFlags.EPHEMERAL | InteractionResponseFlags.IS_COMPONENTS_V2, 127 | components: [ 128 | { 129 | type: MessageComponentTypes.TEXT_DISPLAY, 130 | content: 'What is your object of choice?', 131 | }, 132 | { 133 | type: MessageComponentTypes.ACTION_ROW, 134 | components: [ 135 | { 136 | type: MessageComponentTypes.STRING_SELECT, 137 | // Append game ID 138 | custom_id: `select_choice_${gameId}`, 139 | options: getShuffledOptions(), 140 | }, 141 | ], 142 | }, 143 | ], 144 | }, 145 | }); 146 | // Delete previous message 147 | await DiscordRequest(endpoint, { method: 'DELETE' }); 148 | } catch (err) { 149 | console.error('Error sending message:', err); 150 | } 151 | } else if (componentId.startsWith('select_choice_')) { 152 | // get the associated game ID 153 | const gameId = componentId.replace('select_choice_', ''); 154 | 155 | if (activeGames[gameId]) { 156 | // Interaction context 157 | const context = req.body.context; 158 | // Get user ID and object choice for responding user 159 | // User ID is in user field for (G)DMs, and member for servers 160 | const userId = context === 0 ? req.body.member.user.id : req.body.user.id; 161 | const objectName = data.values[0]; 162 | // Calculate result from helper function 163 | const resultStr = getResult(activeGames[gameId], { 164 | id: userId, 165 | objectName, 166 | }); 167 | 168 | // Remove game from storage 169 | delete activeGames[gameId]; 170 | // Update message with token in request body 171 | const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`; 172 | 173 | try { 174 | // Send results 175 | await res.send({ 176 | type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 177 | data: { 178 | flags: InteractionResponseFlags.IS_COMPONENTS_V2, 179 | components: [ 180 | { 181 | type: MessageComponentTypes.TEXT_DISPLAY, 182 | content: resultStr 183 | } 184 | ] 185 | }, 186 | }); 187 | // Update ephemeral message 188 | await DiscordRequest(endpoint, { 189 | method: 'PATCH', 190 | body: { 191 | components: [ 192 | { 193 | type: MessageComponentTypes.TEXT_DISPLAY, 194 | content: 'Nice choice ' + getRandomEmoji() 195 | } 196 | ], 197 | }, 198 | }); 199 | } catch (err) { 200 | console.error('Error sending message:', err); 201 | } 202 | } 203 | } 204 | 205 | return; 206 | } 207 | 208 | console.error('unknown interaction type', type); 209 | return res.status(400).json({ error: 'unknown interaction type' }); 210 | }); 211 | 212 | app.listen(PORT, () => { 213 | console.log('Listening on port', PORT); 214 | }); 215 | --------------------------------------------------------------------------------