Merge pull request #22 from shaydewael/update-command-registration

Update command registration
This commit is contained in:
shay
2023-03-20 10:50:11 -07:00
committed by GitHub
8 changed files with 214 additions and 180 deletions

View File

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

View File

@@ -11,6 +11,7 @@ Below is a basic overview of the project structure:
```
├── examples -> short, feature-specific sample apps
│ ├── app.js -> finished app.js code
│ ├── button.js
│ ├── command.js
│ ├── modal.js
@@ -48,12 +49,20 @@ npm install
```
### Get app credentials
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`), server ID (`GUILD_ID`), bot token (`DISCORD_TOKEN`), and public key (`PUBLIC_KEY`).
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`).
Fetching credentials is covered in detail in the [getting started guide](https://discord.com/developers/docs/getting-started).
> 🔑 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).
### Install slash commands
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`:
```
npm run register
```
### Run the app
After your credentials are added, go ahead and run the app:
@@ -64,6 +73,8 @@ node app.js
> ⚙️ A package [like `nodemon`](https://github.com/remy/nodemon), which watches for local changes and restarts your app, may be helpful while locally developing.
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`.
### Set up interactivity
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.

128
app.js
View File

@@ -9,11 +9,6 @@ import {
} from 'discord-interactions';
import { VerifyDiscordRequest, getRandomEmoji, DiscordRequest } 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();
@@ -46,7 +41,7 @@ app.post('/interactions', async function (req, res) {
if (type === InteractionType.APPLICATION_COMMAND) {
const { name } = data;
// "test" guild command
// "test" command
if (name === 'test') {
// Send a message into the channel where command was triggered from
return res.send({
@@ -57,130 +52,9 @@ app.post('/interactions', async function (req, res) {
},
});
}
// "challenge" guild command
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,
};
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: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
// Append the game ID to use later on
custom_id: `accept_button_${req.body.id}`,
label: 'Accept',
style: ButtonStyleTypes.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
const componentId = data.custom_id;
if (componentId.startsWith('accept_button_')) {
// get the associated game ID
const gameId = componentId.replace('accept_button_', '');
// Delete message with token in request body
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: {
// Fetches a random emoji to send from a helper function
content: 'What is your object of choice?',
// Indicates it'll be an ephemeral message
flags: InteractionResponseFlags.EPHEMERAL,
components: [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.STRING_SELECT,
// Append game ID
custom_id: `select_choice_${gameId}`,
options: getShuffledOptions(),
},
],
},
],
},
});
// Delete previous message
await DiscordRequest(endpoint, { method: 'DELETE' });
} catch (err) {
console.error('Error sending message:', err);
}
} else if (componentId.startsWith('select_choice_')) {
// get the associated game ID
const gameId = componentId.replace('select_choice_', '');
if (activeGames[gameId]) {
// Get user ID and object choice for responding user
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,
});
// Remove game from storage
delete activeGames[gameId];
// Update message with token in request body
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 },
});
// Update ephemeral message
await DiscordRequest(endpoint, {
method: 'PATCH',
body: {
content: 'Nice choice ' + getRandomEmoji(),
components: [],
},
});
} catch (err) {
console.error('Error sending message:', err);
}
}
}
}
});
app.listen(PORT, () => {
console.log('Listening on port', PORT);
// Check if guild commands from commands.js are installed (if not, install them)
HasGuildCommands(process.env.APP_ID, process.env.GUILD_ID, [
TEST_COMMAND,
CHALLENGE_COMMAND,
]);
});

View File

@@ -1,47 +1,6 @@
import 'dotenv/config';
import { getRPSChoices } from './game.js';
import { capitalize, DiscordRequest } from './utils.js';
export async function HasGuildCommands(appId, guildId, commands) {
if (guildId === '' || appId === '') return;
commands.forEach((c) => HasGuildCommand(appId, guildId, c));
}
// Checks for a command
async function HasGuildCommand(appId, guildId, command) {
// API endpoint to get and post guild commands
const endpoint = `applications/${appId}/guilds/${guildId}/commands`;
try {
const res = await DiscordRequest(endpoint, { method: 'GET' });
const data = await res.json();
if (data) {
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'])) {
console.log(`Installing "${command['name']}"`);
InstallGuildCommand(appId, guildId, command);
} else {
console.log(`"${command['name']}" command already installed`);
}
}
} catch (err) {
console.error(err);
}
}
// Installs a command
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 DiscordRequest(endpoint, { method: 'POST', body: command });
} catch (err) {
console.error(err);
}
}
import { capitalize, InstallGlobalCommands } from './utils.js';
// Get the game choices from game.js
function createCommandChoices() {
@@ -59,14 +18,14 @@ function createCommandChoices() {
}
// Simple test command
export const TEST_COMMAND = {
const TEST_COMMAND = {
name: 'test',
description: 'Basic guild command',
description: 'Basic command',
type: 1,
};
// Command containing options
export const CHALLENGE_COMMAND = {
const CHALLENGE_COMMAND = {
name: 'challenge',
description: 'Challenge to a match of rock paper scissors',
options: [
@@ -80,3 +39,7 @@ export const CHALLENGE_COMMAND = {
],
type: 1,
};
const ALL_COMMANDS = [TEST_COMMAND, CHALLENGE_COMMAND];
InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS);

175
examples/app.js Normal file
View File

@@ -0,0 +1,175 @@
import 'dotenv/config';
import express from 'express';
import {
InteractionType,
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { VerifyDiscordRequest, getRandomEmoji, DiscordRequest } from './utils.js';
import { getShuffledOptions, getResult } from './game.js';
// Create an express app
const app = express();
// Get port, or default to 3000
const PORT = process.env.PORT || 3000;
// Parse request body and verifies incoming requests using discord-interactions package
app.use(express.json({ verify: VerifyDiscordRequest(process.env.PUBLIC_KEY) }));
// Store for in-progress games. In production, you'd want to use a DB
const activeGames = {};
/**
* Interactions endpoint URL where Discord will send HTTP requests
*/
app.post('/interactions', async function (req, res) {
// Interaction type and data
const { type, id, data } = req.body;
/**
* Handle verification requests
*/
if (type === InteractionType.PING) {
return res.send({ type: InteractionResponseType.PONG });
}
/**
* Handle slash command requests
* See https://discord.com/developers/docs/interactions/application-commands#slash-commands
*/
if (type === InteractionType.APPLICATION_COMMAND) {
const { name } = data;
// "test" 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" command
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,
};
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: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
// Append the game ID to use later on
custom_id: `accept_button_${req.body.id}`,
label: 'Accept',
style: ButtonStyleTypes.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
const componentId = data.custom_id;
if (componentId.startsWith('accept_button_')) {
// get the associated game ID
const gameId = componentId.replace('accept_button_', '');
// Delete message with token in request body
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: {
// Fetches a random emoji to send from a helper function
content: 'What is your object of choice?',
// Indicates it'll be an ephemeral message
flags: InteractionResponseFlags.EPHEMERAL,
components: [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.STRING_SELECT,
// Append game ID
custom_id: `select_choice_${gameId}`,
options: getShuffledOptions(),
},
],
},
],
},
});
// Delete previous message
await DiscordRequest(endpoint, { method: 'DELETE' });
} catch (err) {
console.error('Error sending message:', err);
}
} else if (componentId.startsWith('select_choice_')) {
// get the associated game ID
const gameId = componentId.replace('select_choice_', '');
if (activeGames[gameId]) {
// Get user ID and object choice for responding user
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,
});
// Remove game from storage
delete activeGames[gameId];
// Update message with token in request body
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 },
});
// Update ephemeral message
await DiscordRequest(endpoint, {
method: 'PATCH',
body: {
content: 'Nice choice ' + getRandomEmoji(),
components: [],
},
});
} catch (err) {
console.error('Error sending message:', err);
}
}
}
}
});
app.listen(PORT, () => {
console.log('Listening on port', PORT);
});

View File

@@ -27,19 +27,18 @@ app.post('/interactions', function (req, res) {
async function createCommand() {
const appId = process.env.APP_ID;
const 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 globalEndpoint = `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 guildEndpoint = `applications/${appId}/guilds/${guildId}/commands`;
// const guildEndpoint = `applications/${appId}/guilds/<your guild id>/commands`;
const commandBody = {
name: 'test',
description: 'Just your average command',
@@ -49,7 +48,7 @@ async function createCommand() {
try {
// Send HTTP request with bot token
const res = await DiscordRequest(guildEndpoint, {
const res = await DiscordRequest(globalEndpoint, {
method: 'POST',
body: commandBody,
});

View File

@@ -1,11 +1,12 @@
{
"name": "getting-started",
"version": "1.0.0",
"description": "",
"description": "Discord example app",
"main": "app.js",
"type": "module",
"scripts": {
"start": "node app.js",
"register": "node commands.js",
"dev": "nodemon app.js"
},
"author": "Shay DeWael",

View File

@@ -39,6 +39,18 @@ export async function DiscordRequest(endpoint, options) {
return res;
}
export async function InstallGlobalCommands(appId, commands) {
// API endpoint to overwrite global commands
const endpoint = `applications/${appId}/commands`;
try {
// This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands
await DiscordRequest(endpoint, { method: 'PUT', body: commands });
} catch (err) {
console.error(err);
}
}
// Simple method that returns a random emoji from list
export function getRandomEmoji() {
const emojiList = ['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨'];