Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use native bindings for slash commands #24

Merged
merged 10 commits into from
May 27, 2021
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ const Command = {
name: 'type',
// option hint text to describe modified behavior
description: 'Type of message to respond with',
// Type of option (case-insensitive, see COMMAND_OPTION_TYPES in src/constants.js)
type: 'string',
// Type of option (case-sensitive `ApplicationCommandOptionTypes`)
type: 'STRING',
// Whether to require a value to invoke
required: true,
},
Expand Down Expand Up @@ -125,14 +125,14 @@ const Command = {
// Send an ephemeral message (supports markdown)
return {
content: 'Ephemeral response.',
// Support for inline flags, or via flags: Integer (see INTERACTION_RESPONSE_FLAGS in src/constants.js)
// Support for inline `MessageFlags` or explicit via `flags: Integer`
ephemeral: true,
};
} else if (messageType === 'tts') {
// Send a TTS (text-to-speech) message
return {
content: 'TTS response.',
// Support for overloaded message options
// Support for `APIMessage` options
tts: true,
};
}
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@
"main": "src/config.js",
"private": true,
"devDependencies": {
"eslint": "^7.26.0",
"eslint": "^7.27.0",
"jest": "26.6.3",
"nodemon": "2.0.7",
"prettier": "^2.3.0",
"rimraf": "^3.0.2"
},
"dependencies": {
"@babel/cli": "^7.13.16",
"@babel/core": "^7.14.2",
"@babel/cli": "^7.14.3",
"@babel/core": "^7.14.3",
"@babel/node": "^7.14.2",
"@babel/plugin-transform-runtime": "^7.14.2",
"@babel/plugin-transform-runtime": "^7.14.3",
"@babel/preset-env": "^7.14.2",
"babel-plugin-module-resolver": "^4.1.0",
"chalk": "4.1.1",
"discord.js": "12.5.3",
"discord.js": "discordjs/discord.js#b376f31",
"dompurify": "^2.2.8",
"dotenv": "9.0.2",
"dotenv": "10.0.0",
"jsdom": "^16.5.3",
"node-fetch": "^2.6.1"
},
Expand Down
112 changes: 27 additions & 85 deletions src/bot/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,20 @@ import chalk from 'chalk';
import { Client, Collection } from 'discord.js';
import { readdirSync } from 'fs';
import { resolve } from 'path';
import { validateMessage, validateCommand } from 'utils/discord';
import { loadDocs, loadExamples } from 'utils/three';
import { INTERACTION_RESPONSE_TYPE } from 'constants';
import { CLIENT_INTENTS } from 'constants';
import config from 'config';

/**
* An extended `Client` to support slash-command interactions and events.
*/
class Bot extends Client {
/**
* Sends a message over an interaction endpoint.
*/
async send(interaction, message) {
const { name, options } = interaction.data;

try {
const response = await this.api
.interactions(interaction.id, interaction.token)
.callback.post({
data: {
type: INTERACTION_RESPONSE_TYPE.CHANNEL_MESSAGE_WITH_SOURCE,
data: validateMessage(message),
},
});

return response;
} catch (error) {
console.error(
chalk.red(
`bot#send ${name}${
options?.length ? ` ${options.map(({ value }) => value)}` : ''
} >> ${error.stack}`
)
);
}
constructor({ ...rest }) {
super({ intents: CLIENT_INTENTS, ...rest });
}

/**
* Loads and registers `Client` events from the events folder.
* Loads and registers events from the events folder.
*/
loadEvents() {
if (!this.events) this.events = new Collection();
Expand All @@ -65,7 +40,7 @@ class Bot extends Client {
}

/**
* Loads and registers interaction commands from the commands folder.
* Loads and registers commands from the commands folder.
*/
loadCommands() {
if (!this.commands) this.commands = new Collection();
Expand All @@ -88,59 +63,27 @@ class Bot extends Client {
}

/**
* Updates slash commands with Discord.
* Loads and generates three.js docs and examples
*/
async updateCommands() {
try {
// Get remote target
const remote = () =>
config.guild
? this.api.applications(this.user.id).guilds(config.guild)
: this.api.applications(this.user.id);

// Get remote cache
const cache = await remote().commands.get();

// Update remote
await Promise.all(
this.commands.map(async command => {
// Validate command props
const data = validateCommand(command);

// Check for cache
const cached = cache?.find(({ name }) => name === command.name);

// Create if no remote
if (!cached?.id) return await remote().commands.post({ data });

// Check if updated
const needsUpdate =
data.title !== cached.title ||
data.description !== cached.description ||
data.options?.length !== cached.options?.length ||
data.options?.some(
(option, index) =>
JSON.stringify(option) !== JSON.stringify(cached.options[index])
);
if (needsUpdate) return await remote().commands(cached.id).patch({ data });
})
);

// Cleanup cache
await Promise.all(
cache.map(async command => {
const exists = this.commands.get(command.name);

if (!exists) {
await remote().commands(command.id).delete();
}
})
);

console.info(`${chalk.cyanBright('[Bot]')} updated slash commands`);
} catch (error) {
console.error(chalk.red(`bot#updateCommands >> ${error.stack}`));
}
async loadThree() {
this.docs = await loadDocs();
console.info(`${chalk.cyanBright('[Bot]')} ${this.docs.length} docs loaded`);

this.examples = await loadExamples();
console.info(`${chalk.cyanBright('[Bot]')} ${this.examples.length} examples loaded`);
}

/**
* Loads and registers interactions with Discord remote
*/
async loadInteractions() {
const remote = config.guild ? this.guilds.cache.get(config.guild) : this.application;

await remote.commands.set(this.commands.array());

console.info(
`${chalk.cyanBright('[Bot]')} ${this.commands.array().length} interactions loaded`
);
}

/**
Expand All @@ -151,12 +94,11 @@ class Bot extends Client {
this.loadEvents();
this.loadCommands();

this.docs = await loadDocs();
this.examples = await loadExamples();
await this.loadThree();

if (process.env.NODE_ENV !== 'test') {
await this.login(config.token);
await this.updateCommands();
await this.loadInteractions();
}
} catch (error) {
console.error(chalk.red(`bot#start >> ${error.message}`));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/Docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const Docs = {
{
name: 'query',
description: 'A query or class to search matching docs for',
type: 'string',
type: 'STRING',
required: true,
},
],
Expand Down
2 changes: 1 addition & 1 deletion src/commands/Examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const Examples = {
{
name: 'query',
description: 'Query to search matching examples for',
type: 'string',
type: 'STRING',
required: true,
},
],
Expand Down
69 changes: 7 additions & 62 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Intents } from 'discord.js';

/**
* Three.js settings, links, endpoints, and data files.
*/
Expand All @@ -7,6 +9,11 @@ export const THREE = {
EXAMPLES_URL: 'https://threejs.org/examples/',
};

/**
* Default bot intents and permission scopes.
*/
export const CLIENT_INTENTS = [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES];

/**
* Embed default properties.
*/
Expand All @@ -25,65 +32,3 @@ export const MESSAGE_LIMITS = {
FIELD_NAME_LENGTH: 256,
FIELD_VALUE_LENGTH: 1024,
};

/**
* The TTL for message-based ephemeral responses.
*/
export const INTERACTION_TIMEOUT = 10000;

/**
* The type of interaction this request is.
*/
export const INTERACTION_TYPE = {
/**
* A ping.
*/
PING: 1,
/**
* A command invocation.
*/
APPLICATION_COMMAND: 2,
};

/**
* The type of response that is being sent.
*/
export const INTERACTION_RESPONSE_TYPE = {
/**
* Acknowledge a `PING`.
*/
PONG: 1,
/**
* Respond with a message, showing the user's input.
*/
CHANNEL_MESSAGE_WITH_SOURCE: 4,
/**
* Acknowledge a command without sending a message, showing the user's input. Requires follow-up.
*/
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5,
};

/**
* Flags that can be included in an Interaction Response.
*/
export const INTERACTION_RESPONSE_FLAGS = {
/**
* Show the message only to the user that performed the interaction. Message
* does not persist between sessions.
*/
EPHEMERAL: 64, // 1 << 6
};

/**
* Valid option `type` values.
*/
export const COMMAND_OPTION_TYPES = {
SUB_COMMAND: 1,
SUB_COMMAND_GROUP: 2,
STRING: 3,
INTEGER: 4,
BOOLEAN: 5,
USER: 6,
CHANNEL: 7,
ROLE: 8,
};
30 changes: 30 additions & 0 deletions src/events/interaction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import chalk from 'chalk';
import { sanitize, validateMessage } from 'utils/discord';

/**
* Handles interaction events.
*/
const InteractionEvent = {
name: 'interaction',
async execute(client, interaction) {
try {
if (!interaction.isCommand()) return;

const command = client.commands.get(interaction.commandName);
if (!command) return;

const output = await command.execute({
...client,
options: interaction.options?.map(({ value }) => sanitize(value)),
});
if (!output) return;

const message = validateMessage(output);
return interaction.reply(message);
} catch (error) {
console.error(chalk.red(`interaction >> ${error.stack}`));
}
},
};

export default InteractionEvent;
9 changes: 1 addition & 8 deletions src/events/message.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import chalk from 'chalk';
import config from 'config';
import { sanitize, validateMessage } from 'utils/discord';
import { INTERACTION_RESPONSE_FLAGS, INTERACTION_TIMEOUT } from 'constants';

/**
* Handles Discord message events.
Expand All @@ -23,13 +22,7 @@ const MessageEvent = {
if (!output) return;

const message = validateMessage(output);
const response = await msg.channel.send(message);

if (message.flags === INTERACTION_RESPONSE_FLAGS.EPHEMERAL) {
response.delete({ timeout: INTERACTION_TIMEOUT });
}

return response;
return msg.channel.send(message);
} catch (error) {
console.error(chalk.red(`message >> ${error.stack}`));
}
Expand Down