Commands
Create a slash command from two files, one that declares it to Discord and one that answers it. Covers the command file, deploying globally or to one server, codegen, the handler file, a command with no handler, and a constructor that throws.
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.
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
'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 a command you're still shaping belongs in a guild. You edit it, restart, and run it. Move it to 'global' once the shape has settled, since every change after that waits on Discord.
The guild copy stays behind when you move it, and Discord shows both in the picker. seedcord commands finds the duplicates and deletes them.
@RegisterCommand('guild', ['613425648685547541'])The ids are required. @RegisterCommand('guild') on its own matches neither overload, so TypeScript rejects it before you run anything. A JavaScript caller reaching the same line throws DecoratorCommandGuildWithoutGuilds at startup.
Codegen
seedcord codegen imports every command file, constructs each command it finds, calls toJSON() on the builder, and writes 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.
Warning
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.
import { defineConfig } from 'seedcord';
export default defineConfig({
root: './src',
instance: './bot.ts',
entry: './index.ts',
hmr: {
typecheck: true
}
});Run seedcord codegen after you add a command or change its options. seedcord dev runs it when you answer y to the re-register prompt.
The handler file
Now the code that runs when someone uses /ban.
@SlashRoute<"ban">(...routes: "ban"[]): <TCtor>(constructor: AssertSlashRoute<"ban", TCtor>) => voidSlashRoute('ban')
export class class BanBan extends class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler<'ban'> {
public async Ban.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
const const target: Usertarget = this.SlashHandler<"ban", "cached">.options: SlashOptions<"ban", "cached">options.getUser: <"target">(name: "target") => UsergetUser('target');
const const reason: string | nullreason = this.SlashHandler<"ban", "cached">.options: SlashOptions<"ban", "cached">options.getString: <"reason">(name: "reason") => string | nullgetString('reason');
await this.RepliableHandler<ChatInputCommandInteraction<"cached">, Core, SentMessage, BufferResolvable | Stream | JSONEncodable<...> | Attachment | AttachmentBuilder | AttachmentPayload, ReplySender>.reply(response: string | ReplyResponse<BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload>, opts?: SendOpts): Promise<SentMessage>reply(
`Banned ${const target: Usertarget.User.username: stringusername}. ${const reason: string | nullreason ?? 'No reason given.'}`
);
}
}'ban' appears twice, on the decorator and on the generic. You write the command's own name in both, and 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.
Handler files go under the interactions path.
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')