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 devruns and restarts on save while developing.npm run buildcompiles TypeScript todist/.npm startruns the compiled bot in production.
Where to go next
- Commands framework explains cooldowns, permissions, middleware, and component/modal routing.
- Caching and memory matters once your bot grows.