Add basic app with examples

This commit is contained in:
Shay
2022-04-01 17:27:39 -07:00
commit 1ea58eac8c
12 changed files with 630 additions and 0 deletions

4
.env.sample Normal file
View File

@@ -0,0 +1,4 @@
APP_ID=<YOUR_APP_ID>
GUILD_ID=<YOUR_GUILD_ID>
DISCORD_TOKEN=<YOUR_BOT_TOKEN>
PUBLIC_KEY=<YOUR_PUBLIC_KEY>

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
package-lock.json

34
README.md Normal file
View File

@@ -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

154
app.js Normal file
View File

@@ -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]);
});

75
commands.js Normal file
View File

@@ -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
};

61
examples/button.js Normal file
View File

@@ -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');
});

70
examples/command.js Normal file
View File

@@ -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();
});

0
examples/modal.js Normal file
View File

76
examples/selectMenu.js Normal file
View File

@@ -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');
});

92
game.js Normal file
View File

@@ -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);
}

21
package.json Normal file
View File

@@ -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"
}
}

40
utils.js Normal file
View File

@@ -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
}