replace axios with node-fetch and change code formatting

This commit is contained in:
Shay
2022-04-07 15:26:43 -07:00
parent 04b7ea9add
commit 6cea182ff0
9 changed files with 559 additions and 506 deletions

135
app.js
View File

@@ -1,21 +1,25 @@
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 'dotenv/config';
import express from 'express';
import { InteractionType, InteractionResponseType, InteractionResponseFlags } from 'discord-interactions';
import {
VerifyDiscordRequest,
getRandomEmoji,
ComponentType,
ButtonStyle,
DiscordRequest,
} from './utils.js';
import { getShuffledOptions, getResult } from './game.js';
import { CHALLENGE_COMMAND, TEST_COMMAND, HasGuildCommands } from './commands.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
const activeGames = {};
@@ -30,7 +34,7 @@ app.post('/interactions', async function (req, res) {
* Handle verification requests
*/
if (type === InteractionType.PING) {
return res.send({ "type": InteractionResponseType.PONG });
return res.send({ type: InteractionResponseType.PONG });
}
/**
@@ -41,44 +45,48 @@ app.post('/interactions', async function (req, res) {
const { name } = data;
// "test" guild command
if (name === "test") {
if (name === 'test') {
// Send a message into the channel where command was triggered from
return res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
// Fetches a random emoji to send from a helper function
"content": "hello world " + getRandomEmoji()
}
content: 'hello world ' + getRandomEmoji(),
},
});
}
// "challenge" guild command
if (name === "challenge" && id) {
if (name === 'challenge' && id) {
const userId = req.body.member.user.id;
// User's object choice
const objectName = req.body.data.options[0].value;
// Create active game using message ID as the game ID
activeGames[id] = {
"id": userId,
"objectName": objectName
id: userId,
objectName: objectName,
};
return res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {
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,
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
}]
}]
}
custom_id: `accept_button_${req.body.id}`,
label: 'Accept',
style: ButtonStyle.PRIMARY,
},
],
},
],
},
});
}
}
@@ -95,28 +103,32 @@ app.post('/interactions', async function (req, res) {
// get the associated game ID
const gameId = componentId.replace('accept_button_', '');
// Delete message with token in request body
const url = DiscordAPI(`webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`);
const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`;
try {
await res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
// Fetches a random emoji to send from a helper function
"content": "What's your object of choice?",
content: 'What is your object of choice?',
// Indicates it'll be an ephemeral message
"flags": 64,
"components": [{
"type": ComponentType.ACTION,
"components": [{
"type": ComponentType.SELECT,
flags: InteractionResponseFlags.EPHEMERAL,
components: [
{
type: ComponentType.ACTION,
components: [
{
type: ComponentType.SELECT,
// Append game ID
"custom_id": `select_choice_${gameId}`,
"options": getShuffledOptions()
}]
}]
}
custom_id: `select_choice_${gameId}`,
options: getShuffledOptions(),
},
],
},
],
},
});
await client({ url, method: 'delete' });
await DiscordRequest(endpoint, { method: 'DELETE' });
} catch (err) {
console.error('Error sending message:', err);
}
@@ -129,24 +141,30 @@ app.post('/interactions', async function (req, res) {
const userId = req.body.member.user.id;
const objectName = data.values[0];
// Calculate result from helper function
const resultStr = getResult(activeGames[gameId], {id: userId, objectName});
const resultStr = getResult(activeGames[gameId], {
id: userId,
objectName,
});
// Remove game from storage
delete activeGames[gameId];
// Update message with token in request body
const url = DiscordAPI(`webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`);
const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`;
try {
// Send results
await res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": { "content": resultStr }
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: resultStr },
});
await client({ url, method: 'patch', data: {
"content": "Nice choice " + getRandomEmoji(),
"components": []
}});
await DiscordRequest(endpoint, {
method: 'PATCH',
body: {
content: "Nice choice " + getRandomEmoji(),
components: []
}
});
} catch (err) {
console.error('Error sending message:', err);
}
@@ -159,5 +177,8 @@ 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]);
HasGuildCommands(process.env.APP_ID, process.env.GUILD_ID, [
TEST_COMMAND,
CHALLENGE_COMMAND,
]);
});

View File

@@ -1,26 +1,27 @@
import { getRPSChoices } from "./game.js";
import { capitalize, DiscordAPI } from "./utils.js";
import { getRPSChoices } from './game.js';
import { capitalize, DiscordRequest } from './utils.js';
export async function HasGuildCommands(client, appId, guildId, commands) {
export async function HasGuildCommands(appId, guildId, commands) {
if (guildId === '' || appId === '') return;
commands.forEach(c => HasGuildCommand(client, appId, guildId, c));
commands.forEach((c) => HasGuildCommand(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`);
async function HasGuildCommand(appId, guildId, command) {
// API endpoint to get and post guild commands
const endpoint = `applications/${appId}/guilds/${guildId}/commands`;
try {
const { data } = await client({ url, method: 'get'});
const res = await DiscordRequest(endpoint, { method: 'GET' });
const data = await res.json();
if (data) {
const installedNames = data.map((c) => c["name"]);
const 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"])) {
InstallGuildCommand(client, appId, guildId, command);
if (!installedNames.includes(command['name'])) {
InstallGuildCommand(appId, guildId, command);
} else {
console.log(`"${command["name"]}" command already installed`)
console.log(`"${command['name']}" command already installed`);
}
}
} catch (err) {
@@ -29,12 +30,12 @@ async function HasGuildCommand(client, appId, guildId, command) {
}
// 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`);
export async function InstallGuildCommand(appId, guildId, command) {
// API endpoint to get and post guild commands
const endpoint = `applications/${appId}/guilds/${guildId}/commands`;
// install command
try {
await client({ url, method: 'post', data: command});
await DiscordRequest(endpoint, { method: 'POST', body: command });
} catch (err) {
console.error('Error installing commands: ', err);
}
@@ -47,8 +48,8 @@ function createCommandChoices() {
for (let choice of choices) {
commandChoices.push({
"name": capitalize(choice),
"value": choice.toLowerCase()
name: capitalize(choice),
value: choice.toLowerCase(),
});
}
@@ -57,23 +58,23 @@ function createCommandChoices() {
// Simple test command
export const TEST_COMMAND = {
"name": "test",
"description": "Basic guild command",
"type": 1
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": [
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: 3,
name: 'object',
description: 'Pick your object',
required: true,
choices: createCommandChoices(),
},
],
"type": 1
type: 1,
};

View File

@@ -1,5 +1,5 @@
import 'dotenv/config'
import express from 'express'
import 'dotenv/config';
import express from 'express';
import { InteractionType, InteractionResponseType } from 'discord-interactions';
import { VerifyDiscordRequest, ComponentType, ButtonStyle } from '../utils.js';
@@ -15,24 +15,28 @@ app.post('/interactions', function (req, res) {
*/
if (type === InteractionType.APPLICATION_COMMAND) {
// Slash command with name of "test"
if (data.name === "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',
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,
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
}]
}]
}
custom_id: 'my_button',
label: 'Click',
style: ButtonStyle.PRIMARY,
},
],
},
],
},
});
}
}
@@ -49,8 +53,8 @@ app.post('/interactions', function (req, res) {
if (componentId === 'my_button') {
console.log(req.body);
return res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": { "content": `<@${userId} clicked the button` }
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: `<@${userId} clicked the button` },
});
}
}

View File

@@ -1,8 +1,6 @@
import 'dotenv/config'
import express from 'express'
import express from 'express';
import { InteractionType, InteractionResponseType } from 'discord-interactions';
import { VerifyDiscordRequest, DiscordAPI } from '../utils.js';
import axios from 'axios';
import { VerifyDiscordRequest, DiscordRequest } from '../utils.js';
// Create and configure express app
const app = express();
@@ -16,17 +14,17 @@ app.post('/interactions', function (req, res) {
*/
if (type === InteractionType.APPLICATION_COMMAND) {
// Slash command with name of "test"
if (data.name === "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" }
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: 'A wild message appeared' },
});
}
}
});
function createCommand() {
async function createCommand() {
const appId = process.env.APP_ID;
const guildId = process.env.GUILD_ID;
@@ -34,33 +32,30 @@ function createCommand() {
* 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`);
// const globalEndpoint = `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`);
const guildEndpoint = `applications/${appId}/guilds/${guildId}/commands`;
const commandBody = {
"name": "test",
"description": "Just your average command",
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
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}`}
const res = await DiscordRequest(guildEndpoint, {
method: 'POST',
body: commandBody,
});
console.log(res.body);
console.log(await res.json());
} catch (err) {
console.error('Error installing commands: ', err);
}
}
app.listen(3000, () => {

View File

@@ -1,5 +1,5 @@
import 'dotenv/config'
import express from 'express'
import 'dotenv/config';
import express from 'express';
import { InteractionType, InteractionResponseType } from 'discord-interactions';
import { VerifyDiscordRequest, ComponentType } from '../utils.js';
@@ -15,41 +15,41 @@ app.post('/interactions', function (req, res) {
*/
if (type === InteractionType.APPLICATION_COMMAND) {
// Slash command with name of "test"
if (data.name === "test") {
if (data.name === 'test') {
// Send a modal as response
return res.send({
"type": InteractionResponseType.APPLICATION_MODAL,
"data": {
"custom_id": "my_modal",
"title": "Modal title",
"components": [
type: InteractionResponseType.APPLICATION_MODAL,
data: {
custom_id: 'my_modal',
title: 'Modal title',
components: [
{
// Text inputs must be inside of an action component
"type": ComponentType.ACTION,
"components": [
type: ComponentType.ACTION,
components: [
{
// See https://discord.com/developers/docs/interactions/message-components#text-inputs-text-input-structure
"type": ComponentType.INPUT,
"custom_id": "my_text",
"style": 1,
"label": "Type some text"
}
]
type: ComponentType.INPUT,
custom_id: 'my_text',
style: 1,
label: 'Type some text',
},
],
},
{
"type": ComponentType.ACTION,
"components": [
type: ComponentType.ACTION,
components: [
{
"type": ComponentType.INPUT,
"custom_id": "my_longer_text",
type: ComponentType.INPUT,
custom_id: 'my_longer_text',
// Bigger text box for input
"style": 2,
"label": "Type some (longer) text"
}
]
}
]
}
style: 2,
label: 'Type some (longer) text',
},
],
},
],
},
});
}
}
@@ -68,13 +68,15 @@ app.post('/interactions', function (req, res) {
// Get value of text inputs
for (let action of data.components) {
let inputComponent = action.components[0];
modalValues += `${inputComponent.custom_id}: ${inputComponent.value}\n`
modalValues += `${inputComponent.custom_id}: ${inputComponent.value}\n`;
}
return res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": { "content": `<@${userId}> typed the following (in a modal):\n\n${modalValues}` }
})
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `<@${userId}> typed the following (in a modal):\n\n${modalValues}`,
},
});
}
}
});

View File

@@ -1,5 +1,5 @@
import 'dotenv/config'
import express from 'express'
import 'dotenv/config';
import express from 'express';
import { InteractionType, InteractionResponseType } from 'discord-interactions';
import { VerifyDiscordRequest, ComponentType } from '../utils.js';
@@ -15,35 +15,39 @@ app.post('/interactions', function (req, res) {
*/
if (type === InteractionType.APPLICATION_COMMAND) {
// Slash command with name of "test"
if (data.name === "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',
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": [
components: [
{
"label": "Option #1",
"value": "option_1",
"description": "The very first option"
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"
}
]
}]
}]
}
label: 'Second option',
value: 'option_2',
description: 'The second AND last option',
},
],
},
],
},
],
},
});
}
}
@@ -64,8 +68,8 @@ app.post('/interactions', function (req, res) {
// Send results
return res.send({
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": { "content": `<@${userId}> selected ${selectedOption}` }
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: `<@${userId}> selected ${selectedOption}` },
});
}
}

105
game.js
View File

@@ -1,72 +1,83 @@
import { capitalize } from './utils.js'
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]) {
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] };
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)
return formatResult(gameResult);
}
function formatResult(result) {
const { 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}**`;
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"
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"
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"
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"
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"
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"
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',
},
"paper": {
"description": "versatile and iconic",
"virus": "ignores",
"cowboy": "gives papercut to",
"rock": "covers"
}
};
export function getRPSChoices() {
@@ -82,9 +93,9 @@ export function getShuffledOptions() {
// Formatted for select menus
// https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure
options.push({
"label": capitalize(c),
"value": c.toLowerCase(),
"description": RPSChoices[c]["description"]
label: capitalize(c),
value: c.toLowerCase(),
description: RPSChoices[c]['description'],
});
}

View File

@@ -10,10 +10,10 @@
"author": "Shay DeWael",
"license": "MIT",
"dependencies": {
"axios": "^0.26.1",
"discord-interactions": "^3.1.0",
"dotenv": "^16.0.0",
"express": "^4.17.3"
"express": "^4.17.3",
"node-fetch": "^3.2.3"
},
"devDependencies": {
"nodemon": "^2.0.15"

View File

@@ -1,3 +1,5 @@
import 'dotenv/config';
import fetch from 'node-fetch';
import { verifyKey } from 'discord-interactions';
export function VerifyDiscordRequest(clientKey) {
@@ -10,10 +12,23 @@ export function VerifyDiscordRequest(clientKey) {
res.status(401).send('Bad request signature');
throw new Error('Bad request signature');
}
}
};
}
export function DiscordAPI(url) { return 'https://discord.com/api/v9/' + url };
export function DiscordRequest(endpoint, options) {
// append endpoint to root API URL
const url = 'https://discord.com/api/v9/' + endpoint;
// Stringify payloads
if (options.body) options.body = JSON.stringify(options.body);
// Use node-fetch to make requests
return fetch(url, {
headers: {
Authorization: `Bot ${process.env.DISCORD_TOKEN}`,
'Content-Type': 'application/json; charset=UTF-8',
},
...options
});
}
// Simple method that returns a random emoji from list
export function getRandomEmoji() {
@@ -29,13 +44,13 @@ export const ComponentType = {
ACTION: 1,
BUTTON: 2,
SELECT: 3,
INPUT: 4
}
INPUT: 4,
};
export const ButtonStyle = {
PRIMARY: 1,
SECONDARY: 2,
SUCCESS: 3,
DANGER: 4,
LINK: 5
}
LINK: 5,
};