commit 1ea58eac8c5f821b6c3f6e23689055d7843ac791 Author: Shay Date: Fri Apr 1 17:27:39 2022 -0700 Add basic app with examples diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..ed9591f --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +APP_ID= +GUILD_ID= +DISCORD_TOKEN= +PUBLIC_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fe20f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e256cc9 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Getting started app for Discord + +This project contains a basic Rock-Paper-Scissors-style Discord app built for the [getting started guide](TODO). + +A version of this code is also hosted [on Glitch](TODO). + +## Project structure +Below is a basic overview of the project structure: + +``` +├── examples -> short, feature-specific sample apps +│ ├── button.js +│ ├── command.js +│ ├── modal.js +│ ├── selectMenu.js +├── .env.sample -> sample .env file +├── app.js -> main entrypoint for app +├── commands.js -> slash command payloads + helpers +├── game.js -> logic specific to RPS +├── utils.js -> utility functions and enums +├── package.json +├── README.md +└── .gitignore +``` + +## Running app locally + + +## Resources +- 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. +- Read **[the documentation](https://discord.com/developers/docs/intro)** for in-depth information about API features + + + diff --git a/app.js b/app.js new file mode 100644 index 0000000..b89a431 --- /dev/null +++ b/app.js @@ -0,0 +1,154 @@ +import 'dotenv/config' +import express from 'express' +import axios from 'axios'; +import { InteractionType, InteractionResponseType } from 'discord-interactions'; +import { VerifyDiscordRequest, getRandomEmoji, ComponentType, ButtonStyle, DiscordAPI } from './utils.js'; +import { getShuffledOptions, getResult } from './game.js'; +import { CHALLENGE_COMMAND, TEST_COMMAND, HasGuildCommands } from './commands.js'; + +// Create an express app +const app = express(); +// Parse request body and verifies incoming requests using discord-interactions package +app.use(express.json({verify: VerifyDiscordRequest(process.env.PUBLIC_KEY)})); + +// Create HTTP client instance with token +const client = axios.create({ + headers: {'Authorization': `Bot ${process.env.DISCORD_TOKEN}`} +}); + +// Store for in-progress games. In production, you'd want to use a DB +let activeGames = {}; + +/** + * Interactions endpoint URL where Discord will send HTTP requests + */ +app.post('/interactions', function (req, res) { + // Interaction type and data + let { type, id, data } = req.body; + + /** + * Handle verification requests + */ + if (type === InteractionType.PING) { + return res.json({ "type": InteractionResponseType.PONG }); + } + + /** + * Handle slash command requests + * See https://discord.com/developers/docs/interactions/application-commands#slash-commands + */ + if (type === InteractionType.APPLICATION_COMMAND){ + let { name } = data; + + // "test" guild command + if (name === "test") { + // Send a message into the channel where command was triggered from + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + // Fetches a random emoji to send from a helper function + "content": "hello world " + getRandomEmoji() + } + }); + } + // "challenge" guild command + if (name === "challenge" && id) { + let userId = req.body.member.user.id; + // User's object choice + let objectName = req.body.data.options[0].value; + + // Create active game using message ID as the game ID + activeGames[id] = { + "id": userId, + "objectName": objectName + }; + + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + // Fetches a random emoji to send from a helper function + "content": `Rock papers scissors challenge from <@${userId}>`, + "components": [{ + "type": ComponentType.ACTION, + "components": [{ + "type": ComponentType.BUTTON, + // Append the game ID to use later on + "custom_id": `accept_button_${req.body.id}`, + "label": "Accept", + "style": ButtonStyle.PRIMARY + }] + }] + } + }); + } + } + + /** + * Handle requests from interactive components + * See https://discord.com/developers/docs/interactions/message-components#responding-to-a-component-interaction + */ + if (type === InteractionType.MESSAGE_COMPONENT){ + // custom_id set in payload when sending message component + let componentId = data.custom_id; + + if (componentId.startsWith('accept_button_')) { + // get the associated game ID + let gameId = componentId.replace('accept_button_', ''); + // Delete message with token in request body + let url = DiscordAPI(`/webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`); + client({ url, method: 'delete' }).catch(e => console.error(`Error deleting message: ${e}`)); + + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + // Fetches a random emoji to send from a helper function + "content": "What's your object of choice?", + // Indicates it'll be an ephemeral message + "flags": 64, + "components": [{ + "type": ComponentType.ACTION, + "components": [{ + "type": ComponentType.SELECT, + // Append game ID + "custom_id": `select_choice_${gameId}`, + "options": getShuffledOptions() + }] + }] + } + }); + } else if (componentId.startsWith('select_choice_')) { + // get the associated game ID + let gameId = componentId.replace('select_choice_', ''); + + if (activeGames[gameId]) { + // Get user ID and object choice for responding user + let userId = req.body.member.user.id; + let objectName = data.values[0]; + // Calculate result from helper function + let resultStr = getResult(activeGames[gameId], {id: userId, objectName}); + + // Remove game from storage + delete activeGames[gameId]; + // Update message with token in request body + let url = DiscordAPI(`/webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`); + client({ url, method: 'patch', data: { + "content": `Nice choice ${getRandomEmoji()}`, + "components": [] + }}).catch(e => console.error(`Error deleting message: ${e}`)); + + // Send results + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { "content": resultStr } + }); + } + } + } +}); + +app.listen(3000, () => { + console.log('Listening on port 3000'); + + // Check if guild commands from commands.json are installed (if not, install them) + HasGuildCommands(client, process.env.APP_ID, process.env.GUILD_ID, [TEST_COMMAND, CHALLENGE_COMMAND]); +}); \ No newline at end of file diff --git a/commands.js b/commands.js new file mode 100644 index 0000000..c516716 --- /dev/null +++ b/commands.js @@ -0,0 +1,75 @@ +import { getRPSChoices } from "./game.js"; +import { capitalize, DiscordAPI } from "./utils.js"; + +export function HasGuildCommands(client, appId, guildId, commands) { + if (guildId === '' || appId === '') return; + + commands.forEach((c) => HasGuildCommand(client, appId, guildId, c)); +} + +// Checks for a command +async function HasGuildCommand(client, appId, guildId, command) { + // API URL to get and post guild commands + const url = DiscordAPI(`applications/${appId}/guilds/${guildId}/commands`); + + try { + let { data } = await client({ url, method: 'get'}); + if (data) { + let installedNames = data.map((c) => c["name"]); + // This is just matching on the name, so it's not good for updates + if (!installedNames.includes(command["name"])) { + await InstallGuildCommand(client, appId, guildId, command); + } else { + console.log(`"${command["name"]}" command already installed`) + } + } + } catch (e) { + console.error(`Error installing commands: ${e}`) + } +} + +// Installs a command +export async function InstallGuildCommand(client, appId, guildId, command) { + // API URL to get and post guild commands + const url = DiscordAPI(`applications/${appId}/guilds/${guildId}/commands`); + // install command + return client({ url, method: 'post', data: command}); +} + +// Get the game choices from game.js +function createCommandChoices() { + let choices = getRPSChoices(); + let commandChoices = []; + + for (let choice of choices) { + commandChoices.push({ + "name": capitalize(choice), + "value": choice.toLowerCase() + }); + } + + return commandChoices; +} + +// Simple test command +export const TEST_COMMAND = { + "name": "test", + "description": "Basic guild command", + "type": 1 +}; + +// Command containing options +export const CHALLENGE_COMMAND = { + "name": "challenge", + "description": "Challenge to a match of rock paper scissors", + "options": [ + { + "type": 3, + "name": "object", + "description": "Pick your object", + "required": true, + "choices": createCommandChoices() + } + ], + "type": 1 +}; \ No newline at end of file diff --git a/examples/button.js b/examples/button.js new file mode 100644 index 0000000..2afad39 --- /dev/null +++ b/examples/button.js @@ -0,0 +1,61 @@ +import 'dotenv/config' +import express from 'express' +import { InteractionType, InteractionResponseType } from 'discord-interactions'; +import { VerifyDiscordRequest, ComponentType, ButtonStyle } from './utils.js'; + +// Create and configure express app +const app = express(); +app.use(express.json({verify: VerifyDiscordRequest(process.env.PUBLIC_KEY)})); + +app.post('/interactions', function (req, res) { + // Interaction type and data + let { type, data } = req.body; + /** + * Handle slash command requests + */ + if (type === InteractionType.APPLICATION_COMMAND){ + // Slash command with name of "test" + if (data.name === "test") { + // Send a message with a button + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + "content": 'A message with a button', + // Buttons are inside of action rows + "components": [{ + "type": ComponentType.ACTION, + "components": [{ + "type": ComponentType.BUTTON, + // Value for your app to identify the button + "custom_id": "my_button", + "label": "Click", + "style": ButtonStyle.PRIMARY + }] + }] + } + }); + } + } + + /** + * Handle requests from interactive components + */ + if (type === InteractionType.MESSAGE_COMPONENT){ + // custom_id set in payload when sending message component + let componentId = data.custom_id; + // user who clicked button + let userId = req.body.member.user.id; + + if (componentId === 'my_button') { + console.log(req.body); + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { "content": `<@${userId} clicked the button` } + }); + } + } +}); + +app.listen(3000, () => { + console.log('Listening on port 3000'); +}); \ No newline at end of file diff --git a/examples/command.js b/examples/command.js new file mode 100644 index 0000000..e3de94d --- /dev/null +++ b/examples/command.js @@ -0,0 +1,70 @@ +import 'dotenv/config' +import express from 'express' +import { InteractionType, InteractionResponseType } from 'discord-interactions'; +import { VerifyDiscordRequest } from './utils.js'; +import axios from 'axios'; + +// Create and configure express app +const app = express(); +app.use(express.json({verify: VerifyDiscordRequest(process.env.PUBLIC_KEY)})); + +app.post('/interactions', function (req, res) { + // Interaction type and data + let { type, data } = req.body; + /** + * Handle slash command requests + */ + if (type === InteractionType.APPLICATION_COMMAND){ + // Slash command with name of "test" + if (data.name === "test") { + // Send a message as response + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { "content": "A wild message appeared" } + }); + } + } +}); + +function createCommand() { + let appId = process.env.APP_ID; + let guildId = process.env.GUILD_ID; + + /** + * Globally-scoped slash commands (generally only recommended for production) + * See https://discord.com/developers/docs/interactions/application-commands#create-global-application-command + */ + // const globalUrl = DiscordAPI(`applications/${appId}/commands`); + + /** + * Guild-scoped slash commands + * See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command + */ + const guildUrl = DiscordAPI(`applications/${appId}/guilds/${guildId}/commands`); + let commandBody = { + "name": "test", + "description": "Just your average command", + // chat command (see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-types) + "type": 1 + }; + + try { + // Send HTTP request with bot token + let res = await axios({ + url: guildUrl, + method: 'post', + data: commandBody, + headers: {'Authorization': `Bot ${process.env.DISCORD_TOKEN}`} + }); + console.log(res.body); + } catch (err) { + console.error(`Error installing command: ${err}`) + } + +} + +app.listen(3000, () => { + console.log('Listening on port 3000'); + + createCommand(); +}); \ No newline at end of file diff --git a/examples/modal.js b/examples/modal.js new file mode 100644 index 0000000..e69de29 diff --git a/examples/selectMenu.js b/examples/selectMenu.js new file mode 100644 index 0000000..dcf5f4f --- /dev/null +++ b/examples/selectMenu.js @@ -0,0 +1,76 @@ +import 'dotenv/config' +import express from 'express' +import { InteractionType, InteractionResponseType } from 'discord-interactions'; +import { VerifyDiscordRequest, ComponentType } from './utils.js'; + +// Create and configure express app +const app = express(); +app.use(express.json({verify: VerifyDiscordRequest(process.env.PUBLIC_KEY)})); + +app.post('/interactions', function (req, res) { + // Interaction type and data + let { type, data } = req.body; + /** + * Handle slash command requests + */ + if (type === InteractionType.APPLICATION_COMMAND){ + // Slash command with name of "test" + if (data.name === "test") { + // Send a message with a button + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { + "content": 'A message with a button', + // Selects are inside of action rows + "components": [{ + "type": ComponentType.ACTION, + "components": [{ + "type": ComponentType.SELECT, + // Value for your app to identify the select menu interactions + "custom_id": "my_select", + // Select options - see https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure + "options": [ + { + "label": "Option #1", + "value": "option_1", + "description": "The very first option" + }, + { + "label": "Second option", + "value": "option_2", + "description": "The second AND last option" + } + ] + }] + }] + } + }); + } + } + + /** + * Handle requests from interactive components + */ + if (type === InteractionType.MESSAGE_COMPONENT){ + // custom_id set in payload when sending message component + let componentId = data.custom_id; + + if (componentId === 'my_select') { + console.log(req.body); + + // Get selected option from payload + let selectedOption = data.values[0]; + let userId = req.body.member.user.id; + + // Send results + return res.send({ + "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + "data": { "content": `<@${userId}> selected ${selectedOption}` } + }); + } + } +}); + +app.listen(3000, () => { + console.log('Listening on port 3000'); +}); \ No newline at end of file diff --git a/game.js b/game.js new file mode 100644 index 0000000..f31dbae --- /dev/null +++ b/game.js @@ -0,0 +1,92 @@ +import { capitalize } from './utils.js' + +export function getResult(p1, p2) { + let gameResult; + if (RPSChoices[p1.objectName] && RPSChoices[p1.objectName][p2.objectName]) { + // o1 wins + gameResult = { win: p1, lose: p2, verb: RPSChoices[p1.objectName][p2.objectName] }; + } else if (RPSChoices[p2.objectName] && RPSChoices[p2.objectName][p1.objectName]) { + // o2 wins + gameResult = { win: p2, lose: p1, verb: RPSChoices[p2.objectName][p1.objectName] }; + } else { + // tie -- win/lose don't + gameResult = { win: p1, lose: p2, verb: 'tie' }; + } + + return formatResult(gameResult) +} + +function formatResult(result) { + let { win, lose, verb } = result; + return verb === 'tie' ? + `<@${win.id}> and <@${lose.id}> draw with **${win.objectName}**` : + `<@${win.id}>'s **${win.objectName}** ${verb} <@${lose.id}>'s **${lose.objectName}**`; +} + +// this is just to figure out winner + verb +const RPSChoices = { + "rock": { + "description": "sedimentary, igneous, or perhaps even metamorphic", + "virus": "outwaits", + "computer": "smashes", + "scissors": "crushes" + }, + "cowboy": { + "description": "yeehaw~", + "scissors": "puts away", + "wumpus": "lassos", + "rock": "steel-toe kicks" + }, + "scissors": { + "description": "careful ! sharp ! edges !!", + "paper": "cuts", + "computer": "cuts cord of", + "virus": "cuts DNA of" + }, + "virus": { + "description": "genetic mutation, malware, or something inbetween", + "cowboy": "infects", + "computer": "corrupts", + "wumpus": "infects" + }, + "computer": { + "description": "beep boop beep bzzrrhggggg", + "cowboy": "overwhelms", + "paper": "uninstalls firmware for", + "wumpus": "deletes assets for" + }, + "wumpus": { + "description": "the purple Discord fella", + "paper": "draws picture on", + "rock": "paints cute face on", + "scissors": "admires own reflection in" + }, + "paper": { + "description": "versatile and iconic", + "virus": "ignores", + "cowboy": "gives papercut to", + "rock": "covers" + } +}; + +export function getRPSChoices() { + return Object.keys(RPSChoices); +} + +// Function to fetch shuffled options for select menu +export function getShuffledOptions() { + let allChoices = getRPSChoices(); + let options = []; + + for (let c of allChoices) { + // Formatted for select menus + // https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure + options.push({ + "label": capitalize(choice), + "value": c.toLowerCase(), + "description": RPSChoices[c]["description"] + }); + } + + return options.sort(() => Math.random() - 0.5); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..79c8ea9 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "getting-started", + "version": "1.0.0", + "description": "", + "main": "app.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^0.26.1", + "discord-interactions": "^3.1.0", + "dotenv": "^16.0.0", + "express": "^4.17.3" + }, + "devDependencies": { + "nodemon": "^2.0.15" + } +} diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..399d102 --- /dev/null +++ b/utils.js @@ -0,0 +1,40 @@ +import { verifyKey } from 'discord-interactions'; + +export function VerifyDiscordRequest(clientKey) { + return function (req, res, buf, encoding) { + const signature = req.get('X-Signature-Ed25519'); + const timestamp = req.get('X-Signature-Timestamp'); + + const isValidRequest = verifyKey(buf, signature, timestamp, clientKey); + if (!isValidRequest) { + return res.status(401).end('Bad request signature'); + } + } +} + +export function DiscordAPI(url) { return 'https://discord.com/api/v9/' + url }; + +// Simple method that returns a random emoji from list +export function getRandomEmoji() { + let emojiList = ['😭', '😄', '😌', '🤓', '😎', '😤', '🤖', '😶‍🌫️', '🌏', '📸', '💿', '👋', '🌊', '✨']; + return emojiList[Math.floor(Math.random() * emojiList.length)]; +} + +export function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export const ComponentType = { + ACTION: 1, + BUTTON: 2, + SELECT: 3, + INPUT: 4 +} + +export const ButtonStyle = { + PRIMARY: 1, + SECONDARY: 2, + SUCCESS: 3, + DANGER: 4, + LINK: 5 +}