Project template

A clean starting layout for an Athena bot, with room to grow. Use it as a mental model even if you organise differently.

A clean starting layout for an Athena bot, with room to grow. Use it as a mental model even if you organise differently.

Folder layout

my-bot/
  .env                 your secrets (gitignored)
  .gitignore
  package.json
  tsconfig.json
  src/
    index.ts           creates the client, registers commands/events, connects
    commands/          one file per slash command
      ping.ts
      avatar.ts
    events/            optional Event subclasses (guildMemberAdd, etc.)
      welcome.ts
    lib/               your own shared helpers (database, config, logging)
      context.ts

index.ts

Keep startup in one place. Build a shared context object (database, logger, config) and hand it to every command and event.

import 'dotenv/config';
import { CommandClient, GatewayIntentBits } from 'athena';
import PingCommand from './commands/ping';
import WelcomeEvent from './events/welcome';
 
export interface Context {
  // add your database client, logger, config here
  startedAt: number;
}
 
const context: Context = { startedAt: Date.now() };
 
const client = new CommandClient<Context>({
  token: `Bot ${process.env.DISCORD_TOKEN}`,
  options: { intents: [GatewayIntentBits.Guilds] },
  commandContext: context
});
 
client.registerCommand(new PingCommand());
client.registerEvent(new WelcomeEvent());
 
client.on('ready', async () => {
  console.log(`Ready as ${client.user.username}`);
  await client.deployCommands();
});
client.on('error', console.error);
 
void client.connect();

CommandClient<Context> makes the context argument fully typed inside every handler.

A command file

import { Command, CommandBuilder, SlashCommand } from 'athena';
import type { CommandInteraction } from 'athena';
import type { Context } from '../index';
 
@SlashCommand(
  new CommandBuilder('avatar', "Show a user's avatar").addUserOption({ name: 'user', description: 'Whose avatar', required: false })
)
export default class AvatarCommand extends Command<Context> {
  cooldown = 3; // seconds
 
  async handleCommand(context: Context, interaction: CommandInteraction) {
    const user = interaction.getUser('user') ?? interaction.user;
    await interaction.createMessage({
      embeds: [{ title: user.username, image: { url: user.dynamicAvatarURL('png', 1024) } }]
    });
  }
}

An event file

import { Event } from 'athena';
import type { Guild, Member } from 'athena';
import type { Context } from '../index';
 
export default class WelcomeEvent extends Event<Context> {
  event = 'guildMemberAdd' as const;
 
  async handle(context: Context, guild: Guild, member: Member) {
    // greet the member, write to your database, etc.
  }
}

Loading many commands automatically

As you add commands, registering each by hand gets tedious. A small loader keeps index.ts tidy:

import { readdirSync } from 'node:fs';
import { join } from 'node:path';
 
for (const file of readdirSync(join(__dirname, 'commands'))) {
  if (!file.endsWith('.js') && !file.endsWith('.ts')) continue;
  const mod = await import(join(__dirname, 'commands', file));
  client.registerCommand(new mod.default());
}

Scripts in package.json

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
  • npm run dev runs and restarts on save while developing.
  • npm run build compiles TypeScript to dist/.
  • npm start runs the compiled bot in production.

Where to go next