Skip to content

Commit a793e94

Browse files
authored
Add (/) commands (#41)
1 parent 7ba357c commit a793e94

File tree

10 files changed

+300
-8
lines changed

10 files changed

+300
-8
lines changed

.env.example

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
# https://discordjs.guide/preparations/setting-up-a-bot-application.html#your-token
55
BOT_TOKEN=<bot-token>
6-
7-
# For slash commands
8-
# CLIENT_ID=
9-
# GUILD_ID=
6+
# Required to register slash commands
7+
CLIENT_ID=<client-id>
8+
GUILD_ID=<guild-id>

package-lock.json

+72
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@
3636
"typescript": "^4.3.2"
3737
},
3838
"dependencies": {
39+
"@discordjs/rest": "^0.3.0",
3940
"@types/node-fetch": "^2.5.12",
4041
"common-tags": "^1.8.0",
42+
"discord-api-types": "^0.26.1",
4143
"discord.js": "^13.6.0",
4244
"dotenv": "^16.0.0",
4345
"node-fetch": "^2.6.6",

src/commands/echo.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { CommandInteraction } from "discord.js";
2+
import {
3+
SlashCommandBuilder,
4+
blockQuote,
5+
bold,
6+
codeBlock,
7+
inlineCode,
8+
italic,
9+
quote,
10+
spoiler,
11+
strikethrough,
12+
underscore,
13+
} from "@discordjs/builders";
14+
15+
const formats: { [k: string]: (s: string) => string } = {
16+
blockQuote,
17+
bold,
18+
codeBlock,
19+
inlineCode,
20+
italic,
21+
quote,
22+
spoiler,
23+
strikethrough,
24+
underscore,
25+
};
26+
27+
export const data = new SlashCommandBuilder()
28+
.setName("echo")
29+
.setDescription("Replies with your input, optionally formatted")
30+
.addStringOption((option) =>
31+
option.setName("input").setDescription("The input to echo back").setRequired(true)
32+
)
33+
.addStringOption((option) =>
34+
option
35+
.setName("format")
36+
.setDescription("The format to use")
37+
.addChoices(Object.keys(formats).map((k) => [k, k]))
38+
)
39+
.toJSON();
40+
41+
export const call = async (interaction: CommandInteraction) => {
42+
const input = interaction.options.getString("input", true);
43+
const format = interaction.options.getString("format");
44+
const msg = format && formats.hasOwnProperty(format) ? formats[format](input) : input;
45+
await interaction.reply(msg);
46+
};
47+
48+
/*
49+
// It's annoying having to repeat options name/type in `data` and `call`.
50+
// Maybe it's possible to define `options` in `data` with `zod`?
51+
// Registering should work as long as we can produce an object matching
52+
// `RESTPostAPIApplicationCommandsJSONBody`.
53+
const data = new SlashCommandBuilder()
54+
.setName("echo")
55+
.setDescription("Replies with your input")
56+
.addStringOption((option) =>
57+
option.setName("input").setDescription("The input to echo back").setRequired(true)
58+
)
59+
.toJSON();
60+
// is equivalent to
61+
const data = {
62+
name: "echo",
63+
description: "Replies with your input",
64+
options: [
65+
{
66+
// ApplicationCommandOptionType.String
67+
type: 3,
68+
name: "input",
69+
description: "The input to echo back",
70+
required: true,
71+
},
72+
],
73+
};
74+
// will be nice to use zod to define options, so we can extract in `call`.
75+
const Options = z.preprocess(
76+
// preprocess `interaction.options.data` array into an object
77+
preprocessor,
78+
// schema
79+
z.object({
80+
input: z.string().nonempty().describe("The input to echo back"),
81+
})
82+
);
83+
const data = {
84+
name: "echo",
85+
description: "Replies with your input",
86+
options: toDiscordOptions(Options),
87+
};
88+
// so we can get typed options
89+
// const { input } = Options.parse(interaction.options.data);
90+
*/

src/commands/index.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { RESTPostAPIApplicationCommandsJSONBody, Routes } from "discord-api-types/v9";
2+
import { CommandInteraction } from "discord.js";
3+
import { REST } from "@discordjs/rest";
4+
5+
import { Config } from "../config";
6+
7+
import * as echo from "./echo";
8+
import * as info from "./info";
9+
10+
export type Command = {
11+
// Data to send when registering.
12+
data: RESTPostAPIApplicationCommandsJSONBody;
13+
// Handler.
14+
call: (interaction: CommandInteraction) => Promise<void>;
15+
};
16+
17+
export const commands: { [k: string]: Command } = {
18+
echo,
19+
info,
20+
};
21+
22+
// The caller is responsible for catching any error thrown
23+
export const updateCommands = async (config: Config) => {
24+
const rest = new REST({ version: "9" }).setToken(config.BOT_TOKEN);
25+
const body = Object.values(commands).map((c) => c.data);
26+
// Global commands are cached for one hour.
27+
// Guild commands update instantly.
28+
// discord.js recommends guild command when developing and global in production.
29+
// For now, always use guild commands because our bot is only added to our server.
30+
await rest.put(Routes.applicationGuildCommands(config.CLIENT_ID, config.GUILD_ID), {
31+
body,
32+
});
33+
// Ideally, we shouldn't show a command if the user cannot use it.
34+
// If we decide to use permissions, the response for PUT contains command ids.
35+
// Restricted commands should have default permission false.
36+
// Then explicitly allow certain roles to use it.
37+
// If a required config is missing, we can skip registering the command and show a warning.
38+
// const fullPermissions = [{id: commandId, permissions: [{type: "ROLE", permission: true, id: config.MODS_ID}]}];
39+
// client.guilds.cache.get(config.GUILD_ID)?.commands?.permissions?.set({ fullPermissions });
40+
};

src/commands/info.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// A subcommand example
2+
import { CommandInteraction } from "discord.js";
3+
import { SlashCommandBuilder } from "@discordjs/builders";
4+
5+
export const data = new SlashCommandBuilder()
6+
.setName("info")
7+
.setDescription("Replies with info")
8+
.addSubcommand((sub) =>
9+
sub
10+
.setName("user")
11+
.setDescription("Info about a user")
12+
.addUserOption((o) => o.setName("user").setDescription("The user"))
13+
)
14+
.addSubcommand((sub) => sub.setName("server").setDescription("Info about the server"))
15+
.toJSON();
16+
17+
export const call = async (interaction: CommandInteraction) => {
18+
switch (interaction.options.getSubcommand()) {
19+
case "user":
20+
const user = interaction.options.getUser("target") || interaction.user;
21+
await interaction.reply(`Username: ${user.username}\nID: ${user.id}`);
22+
return;
23+
24+
case "server":
25+
const { guild } = interaction;
26+
if (guild) {
27+
await interaction.reply(`Server name: ${guild.name}\nTotal members: ${guild.memberCount}`);
28+
}
29+
return;
30+
}
31+
};

src/config.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,17 @@ export const fromEnv = () => {
1919
schema: z.string().nonempty(),
2020
},
2121
// Required for slash commands
22-
// CLIENT_ID: { schema: z.string().nonempty() },
23-
// GUILD_ID: { schema: z.string().nonempty() },
22+
// TODO Should this be APPLICATION_ID?
23+
CLIENT_ID: {
24+
// TODO Add a link to some documentation
25+
description: "Your bot's client id.",
26+
schema: z.string().nonempty(),
27+
},
28+
GUILD_ID: {
29+
// TODO Add a link to some documentation
30+
description: "The server id",
31+
schema: z.string().nonempty(),
32+
},
2433
});
2534
} catch (e) {
2635
if (e instanceof Error) {
@@ -31,3 +40,5 @@ export const fromEnv = () => {
3140
process.exit(1);
3241
}
3342
};
43+
44+
export type Config = ReturnType<typeof fromEnv>;

src/events/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export { onCommand } from "./interactionCreate";
12
export { onMessageCreate } from "./messageCreate";
23
export { makeOnReady } from "./ready";

src/events/interactionCreate.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { CacheType, Interaction } from "discord.js";
2+
import { oneLine } from "common-tags";
3+
4+
import { commands } from "../commands";
5+
6+
// Listener for (/) commands from humans.
7+
// We may add more listeners like `onButton`.
8+
export const onCommand = async <T extends CacheType>(interaction: Interaction<T>) => {
9+
if (!interaction.isCommand() || interaction.user.bot) return;
10+
11+
const { commandName } = interaction;
12+
if (commands.hasOwnProperty(commandName)) {
13+
try {
14+
await commands[commandName].call(interaction);
15+
} catch (e) {
16+
console.error(e);
17+
await interaction.reply({
18+
content: ERROR_MESSAGE,
19+
// Only show this message to the user who used the command.
20+
ephemeral: true,
21+
});
22+
}
23+
}
24+
};
25+
26+
const ERROR_MESSAGE = oneLine`
27+
Something went wrong!
28+
If the issue persists, please open an [issue](https://github.com/codewars/discord-bot/issues).
29+
`;

src/main.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Client, Intents } from "discord.js";
22

33
import { fromEnv } from "./config";
4-
import { onMessageCreate, makeOnReady } from "./events";
4+
import { updateCommands } from "./commands";
5+
import { onCommand, onMessageCreate, makeOnReady } from "./events";
56

67
const config = fromEnv();
78

@@ -21,7 +22,23 @@ const bot = new Client({
2122
},
2223
});
2324

25+
// Add event listeners
2426
bot.once("ready", makeOnReady(bot));
2527
bot.on("messageCreate", onMessageCreate);
28+
bot.on("interactionCreate", onCommand);
2629

27-
bot.login(config.BOT_TOKEN);
30+
// Update commands and join
31+
(async () => {
32+
try {
33+
console.log("Updating application (/) commands");
34+
await updateCommands(config);
35+
console.log("Updated application (/) commands");
36+
} catch (error) {
37+
console.error(error);
38+
console.error("Failed to register commands. Aborting.");
39+
// Prevent the bot from running with outdated commands data.
40+
process.exit(1);
41+
}
42+
43+
await bot.login(config.BOT_TOKEN);
44+
})();

0 commit comments

Comments
 (0)