diff --git a/.env.example b/.env.example index c8d45f7..188675b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ DISCORD_TOKEN= DISCORD_CLIENT_ID= -DISCORD_GUILD_ID= +TEST_GUILD_ID= +MAIN_GUILD_ID= +OWNER_ID= +PRIVILEGED_IDS= diff --git a/package.json b/package.json index 198d0af..8f09444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crownutils", - "version": "0.1.0", + "version": "0.2.0", "description": "A Discord bot", "main": "dist/index.js", "type": "module", diff --git a/src/client/crownutils-client.ts b/src/client/crownutils-client.ts index 6c43488..fca61ce 100644 --- a/src/client/crownutils-client.ts +++ b/src/client/crownutils-client.ts @@ -1,9 +1,11 @@ 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; @@ -11,7 +13,11 @@ export class CrownutilsClient { public constructor() { this.discord = new Client({ - intents: [GatewayIntentBits.Guilds], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], presence: { status: 'online', activities: [ @@ -32,6 +38,7 @@ export class CrownutilsClient { public async init(): Promise { await this.registerEvents(); await this.loadCommands(); + await this.loadPrefixCommands(); } /** Loads event files and binds each one onto the discord client. */ @@ -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 { - this.discord.on(event.name, (...args) => event.execute(...args)); + this.discord.on(event.name, (...args) => { + void event.execute(...args); + }); } } @@ -54,6 +65,11 @@ export class CrownutilsClient { logger.info(`Loaded ${slashCommands.size} slash command(s).`); } + private async loadPrefixCommands(): Promise { + await loadPrefixCommands(); + logger.info(`Loaded ${prefixCommands.size} prefix command(s).`); + } + /** * Connects the bot to Discord. */ diff --git a/src/commands/prefix/ping.ts b/src/commands/prefix/ping.ts new file mode 100644 index 0000000..4537648 --- /dev/null +++ b/src/commands/prefix/ping.ts @@ -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 }); + }, +}; diff --git a/src/commands/slash/ping.ts b/src/commands/slash/ping.ts index b4bab7b..c538bdf 100644 --- a/src/commands/slash/ping.ts +++ b/src/commands/slash/ping.ts @@ -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'; @@ -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(); diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 9a728e2..d0bbcc7 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -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). @@ -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'); } 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.`); +} diff --git a/src/events/interaction-create.ts b/src/events/interaction-create.ts index 318f7d7..6cd9675 100644 --- a/src/events/interaction-create.ts +++ b/src/events/interaction-create.ts @@ -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 = { 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); }, }; diff --git a/src/events/message-create.ts b/src/events/message-create.ts new file mode 100644 index 0000000..e5ab06b --- /dev/null +++ b/src/events/message-create.ts @@ -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 = { + 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}`); + } + }, +}; diff --git a/src/handlers/base-loader.ts b/src/handlers/base-loader.ts index a9da5d2..3d7900c 100644 --- a/src/handlers/base-loader.ts +++ b/src/handlers/base-loader.ts @@ -28,7 +28,16 @@ export async function loadModules( for (const file of moduleFiles) { const fileUrl = pathToFileURL(join(dirPath, file)).href; - const module = (await import(fileUrl)) as Record; + + let module: Record; + try { + module = (await import(fileUrl)) as Record; + } 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)) { diff --git a/src/handlers/event-handler.ts b/src/handlers/event-handler.ts index eee0709..41e6f2b 100644 --- a/src/handlers/event-handler.ts +++ b/src/handlers/event-handler.ts @@ -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). diff --git a/src/handlers/prefix-handler.ts b/src/handlers/prefix-handler.ts new file mode 100644 index 0000000..477af55 --- /dev/null +++ b/src/handlers/prefix-handler.ts @@ -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; + if (typeof candidate.name !== 'string') { + return false; + } + + if (typeof candidate.execute !== 'function') { + return false; + } + + return true; +} + +export async function loadPrefixCommands(): Promise { + 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); + } + } + } +} diff --git a/src/handlers/command-handler.ts b/src/handlers/slash-handler.ts similarity index 88% rename from src/handlers/command-handler.ts rename to src/handlers/slash-handler.ts index f40e270..3ed5848 100644 --- a/src/handlers/command-handler.ts +++ b/src/handlers/slash-handler.ts @@ -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`). diff --git a/src/lang/errors.ts b/src/lang/errors.ts new file mode 100644 index 0000000..18c15cd --- /dev/null +++ b/src/lang/errors.ts @@ -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(', ')}\`)`; +} diff --git a/src/lang/index.ts b/src/lang/index.ts index 82dbfe9..3a3629c 100644 --- a/src/lang/index.ts +++ b/src/lang/index.ts @@ -1 +1 @@ -export { pingMessages } from './ping.js'; +export { pingMessages, pingDescription } from './ping.js'; diff --git a/src/lang/ping.ts b/src/lang/ping.ts index 452e0e2..55e3548 100644 --- a/src/lang/ping.ts +++ b/src/lang/ping.ts @@ -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; diff --git a/src/lib/command-requirements.ts b/src/lib/command-requirements.ts new file mode 100644 index 0000000..ca55267 --- /dev/null +++ b/src/lib/command-requirements.ts @@ -0,0 +1,93 @@ +import type { + CommandAuthorization, + CommandPermission, + CommandRequirements, + CommandScope, + CommandValidation, +} from '@/types/command.js'; +import { AUTHORIZATION_LEVELS, SCOPE_LEVELS } from '@/types/command.js'; + +export function checkCommandScope( + requiredScope: CommandScope, + guildId: string | null, +): CommandValidation { + const currentScope: CommandScope = + guildId === null + ? 'everywhere' + : guildId === process.env.MAIN_GUILD_ID + ? 'main_guild_only' + : 'global'; + + const currentScopeLevel = SCOPE_LEVELS[currentScope]; + const requiredScopeLevel = SCOPE_LEVELS[requiredScope]; + + if (currentScopeLevel >= requiredScopeLevel) { + return { + canBeExecuted: true, + missing_permissions: [], + }; + } + + return { + canBeExecuted: false, + missing_permissions: [requiredScope], + }; +} + +export function checkCommandAuthorization( + requiredAuthorization: CommandAuthorization, + userId: string, +): CommandValidation { + const currentAuthorization: CommandAuthorization = + userId === process.env.OWNER_ID + ? 'owner' + : process.env.PRIVILEGED_IDS?.split(',').filter(Boolean).includes(userId) + ? 'privileged' + : 'public'; + + const currentAuthorizationLevel = AUTHORIZATION_LEVELS[currentAuthorization]; + const requiredAuthorizationLevel = + AUTHORIZATION_LEVELS[requiredAuthorization]; + + if (currentAuthorizationLevel >= requiredAuthorizationLevel) { + return { + canBeExecuted: true, + missing_permissions: [], + }; + } + + return { + canBeExecuted: false, + missing_permissions: [requiredAuthorization], + }; +} + +export function checkCommandRequirements( + commandRequirements: CommandRequirements, + guildId: string | null, + userId: string, +): CommandValidation { + const missing_permissions: CommandPermission[] = []; + + const isScopeValid: CommandValidation = commandRequirements.scope + ? checkCommandScope(commandRequirements.scope, guildId) + : { canBeExecuted: true, missing_permissions: [] }; + const isAuthorizationValid: CommandValidation = + commandRequirements.authorizations + ? checkCommandAuthorization(commandRequirements.authorizations, userId) + : { canBeExecuted: true, missing_permissions: [] }; + + if (!isScopeValid.canBeExecuted) { + missing_permissions.push(...isScopeValid.missing_permissions); + } + + if (!isAuthorizationValid.canBeExecuted) { + missing_permissions.push(...isAuthorizationValid.missing_permissions); + } + + return { + canBeExecuted: + isScopeValid.canBeExecuted && isAuthorizationValid.canBeExecuted, + missing_permissions, + }; +} diff --git a/src/lib/components/container.ts b/src/lib/components/container.ts index 694672e..c3c91fd 100644 --- a/src/lib/components/container.ts +++ b/src/lib/components/container.ts @@ -1,4 +1,5 @@ -import { ContainerBuilder, MessageFlags, TextDisplayBuilder } from 'discord.js'; +import type { TextDisplayBuilder } from 'discord.js'; +import { ContainerBuilder, MessageFlags } from 'discord.js'; import type { TextComponent, V2Component } from './component.js'; const COLORS = { @@ -24,7 +25,10 @@ export class Container { return this; } - public build(): { components: ContainerBuilder[]; flags: number } { + public build(options?: { ephemeral?: boolean }): { + components: ContainerBuilder[]; + flags: number; + } { const container = new ContainerBuilder(); if (this.accentColor !== undefined) { container.setAccentColor(this.accentColor); @@ -44,9 +48,13 @@ export class Container { } } + const flags = options?.ephemeral + ? MessageFlags.IsComponentsV2 | MessageFlags.Ephemeral + : MessageFlags.IsComponentsV2; + return { components: [container], - flags: MessageFlags.IsComponentsV2, + flags: flags, }; } } diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..64ba98a --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,12 @@ +import type { CommandPermission } from '@/types/command.js'; +import { Container } from './components/container.js'; +import { buildCommandPermissionsErrorReply } from '@/lang/errors.js'; +import { Text } from './components/text.js'; + +export function buildCommandPermissionsErrorContainer( + missing_permissions: CommandPermission[], +): Container { + return new Container() + .color('error') + .add(new Text(buildCommandPermissionsErrorReply(missing_permissions))); +} diff --git a/src/registries/prefix-registry.ts b/src/registries/prefix-registry.ts new file mode 100644 index 0000000..0b48aa9 --- /dev/null +++ b/src/registries/prefix-registry.ts @@ -0,0 +1,8 @@ +import type { PrefixCommand } from '@/types/command.js'; + +/** + * Shared registry of prefix commands, keyed by name AND aliases. + * Multiple keys can point to the same command (an alias is just + * another entry). + */ +export const prefixCommands = new Map(); diff --git a/src/types/command.ts b/src/types/command.ts index 3b85ab8..99440a3 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -5,13 +5,42 @@ import type { SlashCommandOptionsOnlyBuilder, } from 'discord.js'; +export type CommandAuthorization = 'owner' | 'privileged' | 'public'; +export type CommandScope = 'main_guild_only' | 'global' | 'everywhere'; +export type CommandPermission = CommandScope | CommandAuthorization; + +export const AUTHORIZATION_LEVELS = { + owner: 3, + privileged: 2, + public: 1, +} as const; + +export const SCOPE_LEVELS = { + main_guild_only: 3, + global: 2, + everywhere: 1, +} as const; + +export interface CommandRequirements { + scope?: CommandScope; + authorizations?: CommandAuthorization; +} + +export interface CommandValidation { + canBeExecuted: boolean; + missing_permissions: CommandPermission[]; +} + export interface SlashCommand { data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder; + requirements?: CommandRequirements; execute(interaction: ChatInputCommandInteraction): Promise; } export interface PrefixCommand { name: string; + description: string; aliases?: string[]; + requirements?: CommandRequirements; execute(interaction: Message, args: string[]): Promise; }