Skip to content

Commands

Create your first slash command from two files, one that declares it to Discord and one that answers it when someone runs it.

Slash commands are the way Discord gives people to reach your bot, and they run without a privileged intent. Discord needs your command's shape before anyone can run one, and when someone does, the interaction it sends back carries that command's name. Uploading a shape and routing a name are different jobs.

Each of those gets a file. A command file declares what Discord shows people. A handler file runs when someone uses it.

The command file

This /ban command takes a user and an optional reason.

src/commands/Ban.ts
import {
    BuilderComponent,
    RegisterCommand
} from '@seedcord/gateway';

@RegisterCommand('global')
export class Ban extends BuilderComponent<'command'> {
    constructor() {
        super('command');

        this.instance
            .setName('ban')
            .setDescription('Ban a member')
            .addUserOption((option) =>
                option
                    .setName('target')
                    .setDescription('Who to ban')
                    .setRequired(true)
            )
            .addStringOption((option) =>
                option.setName('reason').setDescription('Why')
            );
    }
}

'command' picks which discord.js builder you get. TypeScript reads it from the generic and the constructor takes it at runtime.

this.instance is that builder. Every builder method you already know works on it.

BuilderComponent calls setContexts with Guild, so your command only shows up in servers.

setContexts replaces the whole list every time it runs, so pass Guild again alongside whatever you are adding. BotDM covers DMs with your bot and PrivateChannel covers group DMs.

this.instance.setContexts(
    InteractionContextType.Guild,
    InteractionContextType.BotDM
);

The file goes under the commands path your bot.ts declares. seedcord imports every file under that path at startup and sends the commands to Discord.

Global or one server

The /ban sample passes 'global' to show that form. A bare @RegisterCommand(), which a scaffolded bot uses, follows commands.guilds instead.

'global' puts the command in every server your bot is in. A global command takes a while to show up, and Discord doesn't publish how long that takes.

For one server, pass 'guild' with the ids. The command appears there right away.

That speed is why you point your test application at test servers. With the decorator bare, commands.guilds picks the servers for each environment. Pass 'guild' with ids only for a command that belongs in those servers in production too, like an admin command for your own server.

If you change a command's decorator from 'guild' to 'global', Discord keeps the guild copy, so those servers show it twice in the picker. seedcord commands clean deletes the guild copy.

@RegisterCommand('guild', ['613425648685547541'])

'guild' needs its ids. @RegisterCommand('guild') on its own matches no overload, so TypeScript rejects it before you run anything. A JavaScript caller reaching the same line throws DecoratorCommandGuildWithoutGuilds at startup.

One list of servers for every command

You paste the same ids into every command file. Eighty commands means eighty copies of one array, and a new server would mean editing all of them.

commands.guilds holds the list once to save you that effort.

src/bot.ts
import { Seedcord } from '@seedcord/gateway';
import { GatewayIntentBits } from 'discord.js';

export const seedcord = new Seedcord({
    bot: {
        clientOptions: { intents: [GatewayIntentBits.Guilds] },
        interactions: {
            path: './handlers'
        },
        commands: {
            path: './commands',
            guilds: ['613425648685547541']
        },
        events: { path: null }
    },
    subscribers: { path: null }
});

A command decorated with @RegisterCommand() deploys to every server in that list.

@RegisterCommand()

With guilds left out, or with no ids in it, those same commands deploy globally. Your command files don't change. Leave TESTING_GUILD_IDS unset where your production bot runs, as In production shows.

When you pass a scope yourself, seedcord skips the list. 'global' keeps the command global. 'guild' with ids sends it to those servers. Add 'config' to that array to send the command to the configured servers as well.

@RegisterCommand('guild', ['config', '613425648685547541'])

'config' there stands for every id under commands.guilds, so this command goes to those servers and to 613425648685547541. If commands.guilds is empty, the same line throws CoreCommandGuildsEmpty at startup, and the message points to your command class.

Codegen

seedcord codegen imports every command file, constructs each command it finds, calls toJSON() on the builder, and writes src/seedcord-gen.d.ts.

src/seedcord-gen.d.ts
declare module '@seedcord/gateway' {
    interface SlashRegistry {
        ban: {
            options: {
                target: { kind: 'user'; required: true };
                reason: { kind: 'string'; required: false };
            };
            cache: 'cached';
        };
    }
}

Commit that file. The next codegen run overwrites whatever you put there by hand, and a checkout without it fails tc on every handler.

SlashRegistry ships empty, so keyof SlashRegistry is never until codegen fills it in. A handler can't declare its route before that.

Tip

A stale generated file doesn't break anything at runtime, since nothing there reads these types. Run your project's tc script to catch it.

You can turn on hmr.typecheck to run tsc --watch next to the bot during seedcord dev, at the cost of a second node process.

seedcord.config.ts
import { defineConfig } from 'seedcord';

export default defineConfig({
    root: './src',
    instance: './bot.ts',
    build: { start: './index.ts' },
    hmr: {
        typecheck: true
    }
});

Outside seedcord dev, run seedcord codegen after you add a command or change its options. Dev runs codegen every time the bot starts and when you answer y to the refresh prompt.

The handler file

Now the code that runs when someone uses /ban.

src/handlers/Ban.ts
@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        const target = this.options.getUser('target');
const target: User
const reason = this.options.getString('reason');
const reason: string | null
await this.reply( `Banned ${target.username}. ${reason ?? 'No reason given.'}` ); } }

'ban' appears twice, on the decorator and on the generic. You write the command's own name in both. A mismatch is a compile error.

The two do different jobs. The decorator registers the route at startup, and the generic types this.options from what the command declared. Writing the name in both keeps extends a plain class name, so go-to-definition lands on SlashHandler itself.

target was required on the command, so it arrives without a null check. reason was optional, so it can be null.

Put this handler's file under the interactions path, the folder seedcord scans for handlers.

A command with no handler

seedcord warns at startup about any route with nothing to answer it. The error says which route.

Slash route ban has no registered @SlashRoute handler.

The command still reaches Discord. Anyone who runs it gets Feature not implemented yet.

When a command's constructor throws

Codegen constructs every command it finds. A throw stops the run, and the error says which class and which file.

Roster threw while codegen constructed it. Fix its constructor in
src/commands/Roster.ts. Cannot read properties of undefined (reading 'guildId')