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

FieldEffect
definitionthe CommandBuilder or raw JSON; the decorator sets it
guildsrestrict to specific guild IDs (and deploy only there in production)
developerOnlyreject outside process.env.DEV_GUILD
userPermissionscaller must hold all of these
userswhitelist of allowed user IDs
cooldownper-user seconds between uses
fetchDatawhether 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_ENVDEV_GUILDdeployGloballyResult
productionanyanyglobal; commands with guilds go to those guilds
othersetfalseall commands to DEV_GUILD only (instant)
othersettruesame as production
otherunsetfalsethrows
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.