diff --git a/README.md b/README.md index 6f078fc..528c5fd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -72,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. diff --git a/app.js b/app.js index cc83e7f..40ddc7d 100644 --- a/app.js +++ b/app.js @@ -52,121 +52,6 @@ app.post('/interactions', async function (req, res) { }, }); } - // "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); - } - } - } } }); diff --git a/examples/app.js b/examples/app.js new file mode 100644 index 0000000..cc83e7f --- /dev/null +++ b/examples/app.js @@ -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); +});