Using a single codebase for Discord slash commands & message commands

A good way to migrate before message content becomes privileged

·

6 min read

Using a single codebase for Discord slash commands & message commands

Intro

For the longest time, you could only interact with Discord bots via message commands. In April of 2020 Discord published "The Future of Bots on Discord". This outlined how Discord was planning on releasing new features to allow users to interact with Discord bots even better than before. One of these features was Slash Commands. Slash commands allow bots to describe exactly how a command should be formatted while Discord gives the user feedback before the command is even sent. This is valuable because some bots had to implement their own ways for users to show exactly what they want. Take Wick for example, just like non-required options in slash commands, Wick had flags. For example: !mute @member ?reason spam ?t 1h. With slash commands, you would do /mute member:@member reason:spam time:1h and Discord would validate this and send information about these arguments (including the member so the bot doesn't need to fetch the member) to the bot.

What does this mean for the future?

Discord has been showing that they are trying to make the platform more secure and started with bots. Recently they announced that they will make message content a privileged intent meaning bots in over 75 servers will need to be whitelisted in order to read messages (and wont take message commands for an answer). This breaks a lot of bots and requires verified bot developers to rethink and rewrite how their bot works. All bot developers, and myself at first, thought this was a really bad idea, but it might not be so bad after all. This forces bot developers to start using Discord's new bot features which brings us back to slash commands. As I said before, slash commands are valuable because everything we would need to validate, is automatically validated by Discord. This means bot developers don't need to write as much code and since libraries like Discord.JS support these in ways that does a lot of things for you, it is very easy to do so.

"But I don't want to switch from message commands!"

What if you could support both, without writing two sets of commands.

I came up with a system called Incoming Commands which uses the options and validation of slash commands, and maps interactions and message commands into a single structure that can be sent (possibly as an event) to a command structure.

If you want to skip the explanation and see the code for yourself, my bot Crossant used a basic version of Incoming Commands here and a more complex system (which allows for the same system to be used for context menus such as message commands and user commands) here

Before you continue, this isn't beginner friendly and may be hard for beginners to understand especially since I will write it in typescript.

Lets start out by creating a new class:

// IncomingCommand.ts

export interface IncomingCommandOptions {

}

export default class IncomingCommand {
    opts: IncomingCommandOptions;

    constructor(opts: IncomingCommandOptions) {
        this.opts = opts;
    }
}

This is the starting point. Beware, this class may get pretty big as you add more features.

Let's make it take in some information

// IncomingCommand.ts

import { Client, CommandInteraction, Message } from "discord.js";

// for better type safety
export type IncomingCommandOptions = IncomingSlashCommandOptions|IncomingMessageCommandOptions;

export interface BaseIncomingCommandOptions {
    type: "interaction"|"message";
    client: Client;
}

export interface IncomingInteractionCommandOptions 
    extends BaseIncomingCommandOptions {
    type: "interaction";
    interaction: CommandInteraction;
    deferred?: boolean; // if the interaction previously had its reply deferred
}

export interface IncomingMessageCommandOptions {
    type: "message";
    originalMessage: Message; // the original message (that contains the command, e.g. !help)
    deferredMessage?: Message; // if the command has to be deferred, the message that was originally sent
}

This defines all of the information we will need to pass to an incoming command when a user sends one.

Now let's map these to class variables:

import { Client, CommandInteraction, Message, Guild, TextBasedChannel, User, GuildMember } from "discord.js";

export default class IncomingCommand {
    opts: IncomingCommandOptions;

    interaction: CommandInteraction;

    guild: Guild;
    channel: TextBasedChannel;
    originalMessage?: Message;
    deferredMessage?: Message;
    user: User;
    member?: GuildMember;

    deferred?: boolean;
    options: CommandInteractionOptionResolver;

    constructor(opts: IncomingCommandOptions) {
        this.opts = opts;

        if (opts.type === "interaction") {
            this.interaction = opts.interaction;
            if(this.interaction.guild) this.guild = this.interaction.guild;
            this.user = this.interaction.user;

            if(this.interaction.member) {
                if(this.guild && !("bannable" in this.interaction.member)) {
                    // bannable is a property that exists on GuildMember but not APIGuildMember
                    // see:
                    //   https://discord.js.org/#/docs/main/stable/class/CommandInteraction?scrollTo=member
                    //   https://discord.com/developers/docs/resources/guild#guild-member-object
                    const found = this.guild.members.resolve(this.interaction.user.id)
                    if(found) this.member = found;
                } else {
                    this.member = this.interaction.member as GuildMember
                }
            }

            this.options = this.interactions.options;
            this.channel = this.interaction.channel ?? undefined;
            this.deferred = i.deferred;
        } else {
            this.originalMessage = opts.originalMessage;
            this.guild = opts.originalMessage.guild ?? undefined; // could be a dm
            this.channel = opts.originalMessage.channel;
            this.user = opts.originalMessage.author;
            this.member = opts.originalMessage.member ?? undefined; // could be a dm
            this.deferred = !!opts.deferredMessage;
            this.deferredMessage = opts.deferredMessage;
        }
    }
}

That looks like a lot of code, but its only the beginning. It isn't complex code, it is just mostly stuff like this... mapping...

Alright, now that we have written that, we can start the command structure.

// Command.ts

import IncomingCommand "./wherever/your/class/is";

export interface CommandOptions {
    deferred: boolean; // makes discord wait 15 minutes for a reply instead of 5 seconds
    ephemeral: boolean; // in interactions, makes only the user that sent the command see the output, we will make this delete a message after 5 seconds in message commands
}

export default abstract class Command {
    name: string;
    description: string;
    options: ApplicationCommandOption[];
    opts: CommandOptions;

    protected constructor(name: string, description: string, options: ApplicationCommandOption[] = [], opts: Partial<CommandOptions>) {
        this.name = name;
        this.description = description;
        this.options = options;
        this.opts = { // because it's partial, we need to set some defaults
            deferred: false,
            ephemeral: false,
            ...opts,
        }
    }

    abstract incoming(i: IncomingCommand): void | Promise<void>;
}

See this for an example on how to create a command

Now, its time to make these structures mean something.

The next function is an implementation of an algorithm however it is very big (onwards of 300 lines) which maps message content (properly, e.g. multi worded arguments) to a CommandInteractionOptionResolver

You can see the version I embedded into the IncomingCommand class here however that was taken from a private bot where i cant give context on how the class variables are. This article is meant for people who are pretty good at typescript, so you should be able to figure it out from that. There is also another version here which is from the more complex version of Crossant and uses Discord api option type enum numbers instead of option type names. This one has a lot more context and isn't embedded in the IncomingCommand, it is a separate function.

Wherever you initialised your bot, create a new map (or discord.js collection) called Commands and add your commands to it.

Somewhere inside it, add this

someClient.on("interactionCreate", (i) => {
    if(i.isCommand() && i.commandName && commandMap.has(i.commandName)) {
        const command = commandMap.get(i.commandName) as Command;

        if(command.opts.deferred) await i.deferReply({
                ephemeral: command.opts.ephemeral,
            });

        const incoming = new IncomingCommand({
            type: "interaction",
            interaction: i, 
            deferred: command.opts.deferred,
            client: someClient,
        });

        try {
            await command.incoming(incoming);
        } catch(e) {
            console.error(e);
            (i.replied ? i.editReply : i.reply)(`An error occured:\n\n\`\`\`\n${e.message}\n\`\`\``)
        }
    }
});

someClient.on("messageCreate", (msg) => {
    if(msg.content.startsWith("!")) {
        let content = `${msg.content}`;
        content = content.replace(/^!$/, "");

        const initialMatches = content.match(/^[A-z0-9]+/);
        if(!initialMatches || initialmatches.length !== 1) return;
        const cmd = initialMatches[0].toLowercase();

        content = content.replace(new Regexp(`^${cmd} ?`), "");
        if(commandMap.has(cmd)) {
            const command = commandMap.get(cmd) as Command;
            let deferredMessge: Message|undefined = undefined;

            if(command.opts.deferred) deferredMessage = await msg.reply("Processing...");

            const incoming = new IncomingCommand({
                type: "message",
                client: someClient,
                deferredMessage,
                originalMessage: msg,
            });

            try {
                incoming.options = parseCommand(content, msg, someClient) // pass content without command and prefix
                command.incoming(incoming);
            } catch(e) {
                (deferredMessage? deferredMessage.edit : msg.reply)(`An error occured:\n\n\`\`\`\n${e.message}\n\`\`\``)
            }
        }
    }
});

Yep, that is a lot of code, but it works, and is worth it. I know near the end of this article I wrote more code than words, but it is the easiest way to describe what I came up with.

I hope you enjoyed, and if you need more help with this, join my discord and ping me!

Thank you for reading!