Skip to content
This repository was archived by the owner on Jul 7, 2024. It is now read-only.

Commit

Permalink
*: rudimentary slash command migration
Browse files Browse the repository at this point in the history
  • Loading branch information
zleyyij committed Jul 18, 2023
1 parent e89624b commit 424d603
Show file tree
Hide file tree
Showing 10 changed files with 858 additions and 438 deletions.
4 changes: 2 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
node_modules
docs
node_modules
docs
README.md
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"popconfirm",
"readcursor",
"ringbuffer",
"subc",
"subcg",
"submod",
"turingbot",
"unlogged"
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ COPY config.jsonc ./config.jsonc
COPY Makefile ./Makefile
COPY target/ ./target/

# Run it
CMD ["make", "profile"]
# Run it without compiling
CMD ["make", "run"]
318 changes: 155 additions & 163 deletions package-lock.json

Large diffs are not rendered by default.

262 changes: 258 additions & 4 deletions src/core/discord.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
/**
* @file
* This file contains utilities and abstractions to make interacting with discord easier and more convenient.
* This file contains utilities and abstractions to make interacting with discord easier
*/
import {
APIApplicationCommandOptionChoice,
APIEmbed,
ActionRowBuilder,
ApplicationCommand,
ButtonBuilder,
ButtonStyle,
ChatInputCommandInteraction,
Collection,
Message,
REST,
RESTPostAPIChatInputApplicationCommandsJSONBody,
Routes,
SlashCommandAttachmentOption,
SlashCommandBooleanOption,
SlashCommandBuilder,
SlashCommandChannelOption,
SlashCommandIntegerOption,
SlashCommandMentionableOption,
SlashCommandNumberOption,
SlashCommandRoleOption,
SlashCommandStringOption,
SlashCommandSubcommandBuilder,
SlashCommandUserOption,
} from 'discord.js';

import {client, guild, modules} from './main.js';
import {ModuleInputOption, ModuleOptionType, RootModule} from './modules.js';
import {botConfig} from './config.js';
import {EventCategory, logEvent} from './logger.js';

/**
* Used in pairing with `{@link confirmEmbed()}`, this is a way to indicate whether or not the user confirmed a choice, and is passed as
* the contents of the Promise returned by `{@link confirmEmbed()}`.
Expand All @@ -19,6 +42,17 @@ export enum ConfirmEmbedResponse {
Denied = 'denied',
}

type SlashCommandOption =
| SlashCommandAttachmentOption
| SlashCommandBooleanOption
| SlashCommandChannelOption
| SlashCommandIntegerOption
| SlashCommandMentionableOption
| SlashCommandNumberOption
| SlashCommandRoleOption
| SlashCommandStringOption
| SlashCommandUserOption;

/**
* Helper utilities used to speed up embed work
*/
Expand Down Expand Up @@ -79,7 +113,8 @@ export const embed = {
*/
async confirmEmbed(
prompt: string,
message: Message,
// this might break if reply() is called twice
message: Message | ChatInputCommandInteraction,
timeout = 60
): Promise<ConfirmEmbedResponse> {
// https://discordjs.guide/message-components/action-rows.html
Expand All @@ -104,11 +139,10 @@ export const embed = {
// listen for a button interaction
try {
const interaction = await response.awaitMessageComponent({
filter: i => i.user.id === message.author.id,
filter: i => i.user.id === message.member?.user.id,
time: timeout * 1000,
});
response.delete();
// the custom id is set with the enum values, so we can pass that transparently without worrying about it being invalid
return interaction.customId as ConfirmEmbedResponse;
} catch {
// awaitMessageComponent throws an error when the timeout was reached, so this behavior assumes
Expand All @@ -129,3 +163,223 @@ export const embed = {
}
},
};

/**
* Obtain a list of all commands in {@link modules} that have not been registered yet
* @throws Will throw an error if the discord client has not been instantiated
*/
export async function getUnregisteredSlashCommands(): Promise<RootModule[]> {
if (!client.isReady) {
throw new Error(
'Attempt made to get slash commands before client was initialized'
);
}
/** A list of every root module without a slash command registered */
const unregisteredSlashCommands: RootModule[] = [];
/** A discord.js collection of every command registered */
const allSlashCommands: Collection<string, ApplicationCommand> =
await guild.commands.fetch();
for (const module of modules) {
/** This value is either undefined, or the thing find() found */
const searchResult = allSlashCommands.find(
slashCommand => slashCommand.name === module.name
);
// if it's undefined, than assume a slash command was not registered for that module
if (searchResult === undefined) {
unregisteredSlashCommands.push(module);
}
}
return unregisteredSlashCommands;
}

// there's a lot of deep nesting and misdirection going on down here, this could probably be greatly improved
/**
* Register a root module as a [discord slash command](https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands)
* @param module The root module to register as a slash command.
* All subcommands will also be registered
*/
export async function generateSlashCommandForModule(
module: RootModule
): Promise<SlashCommandBuilder> {
// translate the module to slash command form
const slashCommand = new SlashCommandBuilder()
.setName(module.name)
.setDescription(module.description);

// if the module has submodules, than register those as subcommands
// https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups
// Commands can only be nested 3 layers deep, so command -> subcommand group -> subcommand
for (const submodule of module.submodules) {
// If a submodule has submodules, than it should be treated as a subcommand group.
if (submodule.submodules.length > 0) {
slashCommand.addSubcommandGroup(subcg => {
// apparently this all needs to be set inside of this callback to work?
subcg.setName(submodule.name).setDescription(submodule.description);
const submodulesInGroup = submodule.submodules;
for (const submoduleInGroup of submodulesInGroup) {
// options may need to be added inside of the addSubcommand block
subcg.addSubcommand(subc => {
subc
.setName(submoduleInGroup.name)
.setDescription(submoduleInGroup.description);
for (const option of submoduleInGroup.options) {
addOptionToCommand(subc, option);
}
return subc;
});
}
return subcg;
});
}
// if a submodule does not have submodules, it is treated as an executable subcommand instead of a group
else {
slashCommand.addSubcommand(subcommand => {
subcommand.setName(submodule.name);
subcommand.setDescription(submodule.description);
for (const option of submodule.options) {
addOptionToCommand(subcommand, option);
}
return subcommand;
});
}
}
return slashCommand;
}
/** TODO: fill out docs */
function addOptionToCommand(
command: SlashCommandBuilder | SlashCommandSubcommandBuilder,
option: ModuleInputOption
): void {
switch (option.type) {
case ModuleOptionType.Attachment:
command.addAttachmentOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandAttachmentOption
);
break;
case ModuleOptionType.Boolean:
command.addBooleanOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandBooleanOption
);
break;
case ModuleOptionType.Channel:
command.addChannelOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandChannelOption
);
break;
case ModuleOptionType.Integer:
command.addIntegerOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandIntegerOption
);
break;
case ModuleOptionType.Mentionable:
command.addMentionableOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandMentionableOption
);
break;
case ModuleOptionType.Number:
command.addNumberOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandNumberOption
);
break;
case ModuleOptionType.Role:
command.addRoleOption(
newOption =>
setOptionFieldsForCommand(newOption, option) as SlashCommandRoleOption
);
break;
case ModuleOptionType.String:
command.addStringOption(
newOption =>
setOptionFieldsForCommand(
newOption,
option
) as SlashCommandStringOption
);
break;
case ModuleOptionType.User:
command.addUserOption(
newOption =>
setOptionFieldsForCommand(newOption, option) as SlashCommandUserOption
);
break;
}
}

/**
* Set the name, description, and whether or not the option is required
* @param option The option to set fields on
* @param setFromModuleOption The {@link ModuleInputOption} to read from
*/
function setOptionFieldsForCommand(
option: SlashCommandOption,
setFromModuleOption: ModuleInputOption
) {
// TODO: length and regex validation for the name and description fields,
// so that you can return a concise error
option
.setName(setFromModuleOption.name)
.setDescription(setFromModuleOption.description)
.setRequired(setFromModuleOption.required ?? false);
if (
setFromModuleOption.choices !== undefined &&
[
ModuleOptionType.Integer,
ModuleOptionType.Number,
ModuleOptionType.String,
].includes(setFromModuleOption.type)
) {
// this could be integer, number, or string
(option as SlashCommandStringOption).addChoices(
...(setFromModuleOption.choices! as APIApplicationCommandOptionChoice<string>[])
);
}
return option;
}

/** Register the passed list of slash commands to discord, completely overwriting the previous version. There is no way to register a single new slash command. */
// TODO: maybe there is (https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command)
export async function registerSlashCommandSet(
commandSet: SlashCommandBuilder[]
) {
// ship the provided list off to discord to discord
// https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands
const rest = new REST().setToken(botConfig.authToken);
/** list of slash commands, converted to json, to be sent off to discord */
const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = [];
for (const command of commandSet) {
commands.push(command.toJSON());
}
// send everything to discord
// The put method is used to fully refresh all commands in the guild with the current set
await rest.put(
Routes.applicationGuildCommands(botConfig.applicationId, guild.id),
{
body: commands,
}
);
logEvent(EventCategory.Info, 'core', 'Slash commands refreshed.', 2);
}
Loading

0 comments on commit 424d603

Please sign in to comment.