Commands framework
CommandClient extends Client and adds command registration, deployment, dispatch, cooldowns, permission checks, and middleware. It is additive: you can...
CommandClient extends Client and adds command registration, deployment, dispatch, cooldowns, permission checks, and middleware. It is additive: you can still wire interactionCreate yourself.
Setup
import { CommandClient, GatewayIntentBits } from 'athena';
const client = new CommandClient({
token: `Bot ${process.env.DISCORD_TOKEN}`,
options: { intents: [GatewayIntentBits.Guilds] },
commandContext: { db, logger }, // passed to every handler
commandMiddleware: async (interaction) => ({ // runs before each handler
user: await db.users.get(interaction.user.id)
}),
cooldownLogic: myCustomCooldownStore, // optional, defaults to in-memory
deployGlobally: false
});A command
import { Command, CommandBuilder, SlashCommand } from 'athena';
import type { CommandInteraction } from 'athena';
@SlashCommand(
new CommandBuilder('purge', 'Bulk delete messages')
.addIntegerOption({ name: 'count', description: 'How many', required: true, min_value: 1, max_value: 100 })
.setMemberPermission(PermissionFlagsBits.ManageMessages)
)
export default class PurgeCommand extends Command {
cooldown = 5; // seconds
userPermissions = [PermissionFlagsBits.ManageMessages];
async handleCommand(context, interaction: CommandInteraction) {
const count = interaction.getRequiredInteger('count');
// ...
}
// optional, all by custom_id prefix:
async handleAutocomplete(context, interaction) {}
async handleModal(context, interaction) {}
async handleComponent(context, interaction) {}
}
client.registerCommand(new PurgeCommand());Command fields
| Field | Effect |
|---|---|
definition | the CommandBuilder or raw JSON; the decorator sets it |
guilds | restrict to specific guild IDs (and deploy only there in production) |
developerOnly | reject outside process.env.DEV_GUILD |
userPermissions | caller must hold all of these |
users | whitelist of allowed user IDs |
cooldown | per-user seconds between uses |
fetchData | whether to run middleware for each interaction type |
Routing modals and components
The framework matches by custom_id prefix. A command named tickets handles any interaction whose custom_id starts with tickets:. So build them as tickets:close, tickets:reply, etc.
Deployment
deployCommands(names?) picks scope from the environment:
NODE_ENV | DEV_GUILD | deployGlobally | Result |
|---|---|---|---|
production | any | any | global; commands with guilds go to those guilds |
| other | set | false | all commands to DEV_GUILD only (instant) |
| other | set | true | same as production |
| other | unset | false | throws |
client.on('ready', async () => { await client.deployCommands(); });Recommended: NODE_ENV=development DEV_GUILD=123 npm run dev locally, NODE_ENV=production npm start in production.
Middleware
commandMiddleware runs before each handler; its return value is the third argument.
class MyCommand extends Command<MyContext, MyData> {
async handleCommand(context: MyContext, interaction: CommandInteraction, data: MyData) {
if (data.user.banned) return interaction.createMessage('You are banned.');
}
}Disable per handler type with fetchData = { command: true, autocomplete: false }. Middleware errors emit commandError with CommandError.MiddlewareError.
Cooldowns
Per user, per command. The default handler stores expiries in memory. For multi-process bots, supply a CooldownLogic:
import type { CooldownLogic, CooldownType } from 'athena';
class RedisCooldowns implements CooldownLogic {
async getCooldown(userID: string, command: string): Promise<CooldownType> {
const expiry = await redis.get(`cd:${userID}:${command}`);
return expiry ? { onCooldown: Number(expiry) > Date.now(), expiry: Number(expiry) } : { onCooldown: false, expiry: null };
}
async setCooldown(userID: string, command: string, expiry: number) {
await redis.set(`cd:${userID}:${command}`, expiry, 'PXAT', expiry);
}
}Events as classes
import { Event } from 'athena';
class WelcomeEvent extends Event {
event = 'guildMemberAdd' as const;
async handle(context, guild, member) {
await member.guild.systemChannel?.createMessage(`Welcome ${member.mention}`);
}
}
client.registerEvent(new WelcomeEvent());Multiple Events for the same name all run. Errors are caught and emitted as error.
Reacting to outcomes
client.on('commandExecuted', (command, type, interaction) => { /* metrics */ });
client.on('commandError', (command, interaction, data) => {
switch (data.type) {
case CommandError.OnCooldown: /* data.data.cooldown.expiry */ break;
case CommandError.MissingPermission: /* data.data.permissions */ break;
case CommandError.UncaughtError: /* data.data is the Error */ break;
}
});See Errors for the full CommandError union.