Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_GUILD_ID=
TEST_GUILD_ID=
MAIN_GUILD_ID=
OWNER_ID=
PRIVILEGED_IDS=
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crownutils",
"version": "0.1.0",
"version": "0.2.0",
"description": "A Discord bot",
"main": "dist/index.js",
"type": "module",
Expand Down
24 changes: 20 additions & 4 deletions src/client/crownutils-client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { ActivityType, Client, GatewayIntentBits } from 'discord.js';
import { loadSlashCommands } from '@/handlers/command-handler.js';
import { loadSlashCommands } from '@/handlers/slash-handler.js';
import type { SlashCommand } from '@/types/command.js';
import { loadEvents } from '@/handlers/event-handler.js';
import { slashCommands } from '@/registries/slash-registry.js';
import { logger } from '@/lib/logger.js';
import { loadPrefixCommands } from '@/handlers/prefix-handler.js';
import { prefixCommands } from '@/registries/prefix-registry.js';

export class CrownutilsClient {
private readonly discord: Client;
public readonly slashCommands = new Map<string, SlashCommand>();

public constructor() {
this.discord = new Client({
intents: [GatewayIntentBits.Guilds],
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
presence: {
status: 'online',
activities: [
Expand All @@ -32,6 +38,7 @@ export class CrownutilsClient {
public async init(): Promise<void> {
await this.registerEvents();
await this.loadCommands();
await this.loadPrefixCommands();
}

/** Loads event files and binds each one onto the discord client. */
Expand All @@ -40,9 +47,13 @@ export class CrownutilsClient {

for (const event of events) {
if (event.once) {
this.discord.once(event.name, (...args) => event.execute(...args));
this.discord.once(event.name, (...args) => {
void event.execute(...args);
});
} else {
Comment on lines 49 to 53
this.discord.on(event.name, (...args) => event.execute(...args));
this.discord.on(event.name, (...args) => {
void event.execute(...args);
});
}
}

Expand All @@ -54,6 +65,11 @@ export class CrownutilsClient {
logger.info(`Loaded ${slashCommands.size} slash command(s).`);
}

private async loadPrefixCommands(): Promise<void> {
await loadPrefixCommands();
logger.info(`Loaded ${prefixCommands.size} prefix command(s).`);
}
Comment on lines +69 to +71

/**
* Connects the bot to Discord.
*/
Expand Down
31 changes: 31 additions & 0 deletions src/commands/prefix/ping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { pingDescription, pingMessages } from '@/lang/ping.js';
import { Container, render } from '@/lib/components/container.js';
import { Separator } from '@/lib/components/separator.js';
import { Text } from '@/lib/components/text.js';
import { Title } from '@/lib/components/title.js';
import type { PrefixCommand } from '@/types/command.js';

export const command: PrefixCommand = {
name: 'ping',
description: pingDescription,
aliases: ['p', 'latency'],

async execute(message, _args) {
const before = Date.now();
const sent = await message.reply(
render(new Text(pingMessages.calculating)),
);
const totalLatency = sent.createdTimestamp - before;
const discordLatency = Math.round(message.client.ws.ping);
const final = new Container()
.color('info')
.add(
new Title(pingMessages.title),
new Separator(),
new Text(pingMessages.result(totalLatency, discordLatency)),
)
.build();

await sent.edit({ ...final, content: null });
},
};
4 changes: 2 additions & 2 deletions src/commands/slash/ping.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SlashCommandBuilder } from 'discord.js';
import { pingMessages } from '@/lang/index.js';
import { pingDescription, pingMessages } from '@/lang/index.js';
import type { SlashCommand } from '@/types/command.js';
import { Container } from '@/lib/components/container.js';
import { Text } from '@/lib/components/text.js';
Expand All @@ -9,7 +9,7 @@ import { Title } from '@/lib/components/title.js';
export const command: SlashCommand = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Affiche la latence du bot.'),
.setDescription(pingDescription),

async execute(interaction) {
const before = Date.now();
Expand Down
56 changes: 39 additions & 17 deletions src/deploy-commands.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { REST, Routes } from 'discord.js';
import { loadSlashCommands } from '@/handlers/command-handler.js';
import { loadSlashCommands } from '@/handlers/slash-handler.js';
import { slashCommands } from './registries/slash-registry.js';

const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const testGuildId = process.env.TEST_GUILD_ID;
const isProduction = process.env.NODE_ENV === 'production';

// In production we deploy globally (no guild needed).
Expand All @@ -13,25 +13,47 @@ if (!token || !clientId) {
throw new Error('Missing DISCORD_TOKEN or DISCORD_CLIENT_ID in .env');
}

if (!isProduction && !guildId) {
if (!isProduction && !testGuildId) {
throw new Error('Missing DISCORD_GUILD_ID for development deployment');
}
Comment on lines +16 to 18

await loadSlashCommands();
const body = [...slashCommands.values()].map((command) =>
command.data.toJSON(),
);

const rest = new REST().setToken(token);

// Choose the route based on the environment.
const route = isProduction
? Routes.applicationCommands(clientId)
: Routes.applicationGuildCommands(clientId, guildId!);

const target = isProduction ? 'globally' : `to guild ${guildId}`;
console.log(`Deploying ${body.length} command(s) ${target}...`);

const data = (await rest.put(route, { body })) as unknown[];

console.log(`✅ Successfully deployed ${data.length} command(s).`);
if (isProduction) {
const mainGuildId = process.env.MAIN_GUILD_ID;
if (!mainGuildId) throw new Error('Missing MAIN_GUILD_ID in .env');

const allCommands = [...slashCommands.values()];

const guildCommands = allCommands.filter(
(cmd) => cmd.requirements?.scope === 'main_guild_only',
);
const globalCommands = allCommands.filter(
(cmd) => cmd.requirements?.scope !== 'main_guild_only',
);

const guildBody = guildCommands.map((cmd) => cmd.data.toJSON());
const globalBody = globalCommands.map((cmd) => cmd.data.toJSON());

const guildData = (await rest.put(
Routes.applicationGuildCommands(clientId, mainGuildId),
{ body: guildBody },
)) as unknown[];
console.log(
`✅ Deployed ${guildData.length} guild command(s) to main guild.`,
);

const globalData = (await rest.put(Routes.applicationCommands(clientId), {
body: globalBody,
})) as unknown[];
console.log(`✅ Deployed ${globalData.length} global command(s).`);
} else {
const body = [...slashCommands.values()].map((cmd) => cmd.data.toJSON());
const data = (await rest.put(
Routes.applicationGuildCommands(clientId, testGuildId!),
{ body },
)) as unknown[];
console.log(`✅ Deployed ${data.length} command(s) to test guild.`);
}
21 changes: 20 additions & 1 deletion src/events/interaction-create.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
import { Events } from 'discord.js';
import { slashCommands } from '@/registries/slash-registry.js';
import type { Event } from '@/types/event.js';
import { checkCommandRequirements } from '@/lib/command-requirements.js';
import { buildCommandPermissionsErrorContainer } from '@/lib/errors.js';

export const event: Event<Events.InteractionCreate> = {
name: Events.InteractionCreate,
execute(interaction) {
async execute(interaction) {
if (!interaction.isChatInputCommand()) return;

const command = slashCommands.get(interaction.commandName);
if (!command) return;

if (command.requirements) {
const isRequirementsValid = checkCommandRequirements(
command.requirements,
interaction.guildId,
interaction.user.id,
);

if (!isRequirementsValid.canBeExecuted) {
const reply = buildCommandPermissionsErrorContainer(
isRequirementsValid.missing_permissions,
).build({ ephemeral: true });

await interaction.reply(reply);
return;
}
}

return command.execute(interaction);
},
};
59 changes: 59 additions & 0 deletions src/events/message-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Events } from 'discord.js';
import type { Event } from '@/types/event.js';
import { prefixCommands } from '@/registries/prefix-registry.js';
import { logger } from '@/lib/logger.js';
import { buildCommandPermissionsErrorContainer } from '@/lib/errors.js';
import { checkCommandRequirements } from '@/lib/command-requirements.js';

const PREFIX = '!';

export const event: Event<Events.MessageCreate> = {
name: Events.MessageCreate,

async execute(message) {
if (message.author.bot) {
return;
}

if (!message.content.startsWith(PREFIX)) {
return;
}

const withoutPrefix = message.content.slice(PREFIX.length);
const parts = withoutPrefix.trim().split(/\s+/);
const commandName = parts.shift()?.toLowerCase();
const args = parts;

if (!commandName) {
return;
}

const command = prefixCommands.get(commandName);
if (!command) {
return;
}

if (command.requirements) {
const isRequirementsValid = checkCommandRequirements(
command.requirements,
message.guildId,
message.author.id,
);

if (!isRequirementsValid.canBeExecuted) {
const reply = buildCommandPermissionsErrorContainer(
isRequirementsValid.missing_permissions,
).build();

await message.reply(reply);
return;
}
}

try {
await command.execute(message, args);
} catch (error) {
logger.error({ error }, `Error in prefix command: ${commandName}`);
}
},
};
11 changes: 10 additions & 1 deletion src/handlers/base-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ export async function loadModules<T>(

for (const file of moduleFiles) {
const fileUrl = pathToFileURL(join(dirPath, file)).href;
const module = (await import(fileUrl)) as Record<string, unknown>;

let module: Record<string, unknown>;
try {
module = (await import(fileUrl)) as Record<string, unknown>;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.error({ err }, `Failed to import file: ${fileUrl}, skipping.`);
continue;
}

const candidate = module[exportName];

if (!isValid(candidate)) {
Expand Down
5 changes: 0 additions & 5 deletions src/handlers/event-handler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { readdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ClientEvents } from 'discord.js';
import type { Event } from '@/types/event.js';
import { loadModules } from './base-loader.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
* Type guard ensuring a dynamically imported value is a usable Event
* (has a string name and a callable execute).
Expand Down
36 changes: 36 additions & 0 deletions src/handlers/prefix-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { loadModules } from '@/handlers/base-loader.js';
import { prefixCommands } from '@/registries/prefix-registry.js';
import type { PrefixCommand } from '@/types/command.js';

function isPrefixCommand(obj: unknown): obj is PrefixCommand {
if (typeof obj !== 'object' || obj === null) {
return false;
}

const candidate = obj as Record<string, unknown>;
if (typeof candidate.name !== 'string') {
return false;
}

if (typeof candidate.execute !== 'function') {
return false;
}

return true;
Comment on lines +10 to +19
}

export async function loadPrefixCommands(): Promise<void> {
const commands = await loadModules(
'commands/prefix',
'command',
isPrefixCommand,
);
for (const command of commands) {
prefixCommands.set(command.name, command);
if (command.aliases) {
for (const alias of command.aliases) {
prefixCommands.set(alias, command);
}
}
Comment on lines +28 to +34
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import type { SlashCommand } from '@/types/command.js';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { slashCommands } from '@/registries/slash-registry.js';
import { loadModules } from './base-loader.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
* Type guard that checks whether a dynamically imported object is a
* usable SlashCommand (has a `data.name` and a callable `execute`).
Expand Down
7 changes: 7 additions & 0 deletions src/lang/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { CommandPermission } from '@/types/command.js';

export function buildCommandPermissionsErrorReply(
missing_permissions: CommandPermission[],
): string {
return `La commande n'a pas pu être exécutée. (\`${missing_permissions.length > 1 ? 'erreurs' : 'erreur'}: ${missing_permissions.join(', ')}\`)`;
}
2 changes: 1 addition & 1 deletion src/lang/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { pingMessages } from './ping.js';
export { pingMessages, pingDescription } from './ping.js';
3 changes: 3 additions & 0 deletions src/lang/ping.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export const pingMessages = {
title: '🏓 Pong',
calculating: 'Calcul en cours...',
result: (totalMs: number, discordMs: number): string =>
`Latence totale : ${totalMs} ms\nLatence Discord : ${discordMs} ms`,
} as const;

export const pingDescription = 'Affiche la latence du bot.' as const;
Loading