From 09eaaea3a2415597b2b938617a926060285484ba Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:55:46 +0000 Subject: [PATCH 1/6] feat(prefix): add prefix command support with !ping (#9) --- src/client/crownutils-client.ts | 16 +++++++- src/commands/prefix/ping.ts | 30 ++++++++++++++ src/deploy-commands.ts | 2 +- src/events/message-create.ts | 40 +++++++++++++++++++ src/handlers/prefix-handler.ts | 36 +++++++++++++++++ .../{command-handler.ts => slash-handler.ts} | 0 src/lang/ping.ts | 1 + src/registries/prefix-registry.ts | 8 ++++ 8 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/commands/prefix/ping.ts create mode 100644 src/events/message-create.ts create mode 100644 src/handlers/prefix-handler.ts rename src/handlers/{command-handler.ts => slash-handler.ts} (100%) create mode 100644 src/registries/prefix-registry.ts diff --git a/src/client/crownutils-client.ts b/src/client/crownutils-client.ts index 6c43488..4cf7617 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. */ @@ -54,6 +61,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..62dbc6e --- /dev/null +++ b/src/commands/prefix/ping.ts @@ -0,0 +1,30 @@ +import { 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 { PrefixCommand } from '@/types/command.js'; + +export const command: PrefixCommand = { + name: 'ping', + 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/deploy-commands.ts b/src/deploy-commands.ts index 9a728e2..e45636d 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -1,5 +1,5 @@ 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; diff --git a/src/events/message-create.ts b/src/events/message-create.ts new file mode 100644 index 0000000..2ff7aff --- /dev/null +++ b/src/events/message-create.ts @@ -0,0 +1,40 @@ +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'; + +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; + } + + try { + await command.execute(message, args); + } catch (error) { + logger.error({ error }, `Error in prefix command: ${commandName}`); + } + }, +}; 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 100% rename from src/handlers/command-handler.ts rename to src/handlers/slash-handler.ts diff --git a/src/lang/ping.ts b/src/lang/ping.ts index 452e0e2..ade7bd9 100644 --- a/src/lang/ping.ts +++ b/src/lang/ping.ts @@ -1,5 +1,6 @@ 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; 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(); From 9e29f57364f9a993de3bf0398dc381afd9aed4b5 Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:13:22 +0000 Subject: [PATCH 2/6] Wrap dynamic import in try/catch to skip broken files (#10) * fix(handlers): wrap dynamic import in try/catch to skip broken files #5 * Fix cacth error #5 --- src/handlers/base-loader.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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)) { From 48b385ca86041a6611b221345a5ca541dcf37a66 Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:41:11 +0000 Subject: [PATCH 3/6] feat(commands): add description field to prefix commands #11 (#13) --- src/commands/prefix/ping.ts | 3 ++- src/commands/slash/ping.ts | 4 ++-- src/lang/index.ts | 2 +- src/lang/ping.ts | 2 ++ src/types/command.ts | 1 + 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/prefix/ping.ts b/src/commands/prefix/ping.ts index 62dbc6e..022bea1 100644 --- a/src/commands/prefix/ping.ts +++ b/src/commands/prefix/ping.ts @@ -1,4 +1,4 @@ -import { pingMessages } from '@/lang/ping.js'; +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'; @@ -7,6 +7,7 @@ import { PrefixCommand } from '@/types/command.js'; export const command: PrefixCommand = { name: 'ping', + description: pingDescription, aliases: ['p', 'latency'], async execute(message, _args) { 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/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 ade7bd9..55e3548 100644 --- a/src/lang/ping.ts +++ b/src/lang/ping.ts @@ -4,3 +4,5 @@ export const pingMessages = { 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/types/command.ts b/src/types/command.ts index 3b85ab8..1d35e37 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -12,6 +12,7 @@ export interface SlashCommand { export interface PrefixCommand { name: string; + description: string; aliases?: string[]; execute(interaction: Message, args: string[]): Promise; } From bd7cde0ff9799e89c9f6b3ac60c484eaa8f5cb15 Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:07:20 +0000 Subject: [PATCH 4/6] Command authorizations and scopes (#15) * feat(types): Add CommandAuthorization & CommandScope #12 * feat(types): add CommandRequirements, CommandValidation and permission types #12 * feat(lib): add command requirements checker #12 * feat(lib): add error container builder and permission error messages #12 * feat(components): add ephemeral option to Container.build() #12 * fix(client): wrap event listeners to handle async execute #12 * fix(handlers): remove unused imports #12 * feat(events): check command requirements before execution #12 * chore(config): add MAIN_GUILD_ID, OWNER_ID and PRIVILEGED_IDS to env.example #12 * Fix name #12 --- .env.example | 5 +- src/client/crownutils-client.ts | 8 ++- src/commands/prefix/ping.ts | 2 +- src/deploy-commands.ts | 8 +-- src/events/interaction-create.ts | 21 +++++++- src/events/message-create.ts | 19 +++++++ src/handlers/event-handler.ts | 5 -- src/handlers/slash-handler.ts | 4 -- src/lang/errors.ts | 7 +++ src/lib/command-requirements.ts | 93 ++++++++++++++++++++++++++++++++ src/lib/components/container.ts | 14 +++-- src/lib/errors.ts | 12 +++++ src/types/command.ts | 28 ++++++++++ 13 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 src/lang/errors.ts create mode 100644 src/lib/command-requirements.ts create mode 100644 src/lib/errors.ts 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/src/client/crownutils-client.ts b/src/client/crownutils-client.ts index 4cf7617..fca61ce 100644 --- a/src/client/crownutils-client.ts +++ b/src/client/crownutils-client.ts @@ -47,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); + }); } } diff --git a/src/commands/prefix/ping.ts b/src/commands/prefix/ping.ts index 022bea1..4537648 100644 --- a/src/commands/prefix/ping.ts +++ b/src/commands/prefix/ping.ts @@ -3,7 +3,7 @@ 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 { PrefixCommand } from '@/types/command.js'; +import type { PrefixCommand } from '@/types/command.js'; export const command: PrefixCommand = { name: 'ping', diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index e45636d..a9cf90b 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -4,7 +4,7 @@ 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,7 +13,7 @@ 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'); } @@ -27,9 +27,9 @@ const rest = new REST().setToken(token); // Choose the route based on the environment. const route = isProduction ? Routes.applicationCommands(clientId) - : Routes.applicationGuildCommands(clientId, guildId!); + : Routes.applicationGuildCommands(clientId, testGuildId!); -const target = isProduction ? 'globally' : `to guild ${guildId}`; +const target = isProduction ? 'globally' : `to guild ${testGuildId}`; console.log(`Deploying ${body.length} command(s) ${target}...`); const data = (await rest.put(route, { body })) as unknown[]; 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 index 2ff7aff..e5ab06b 100644 --- a/src/events/message-create.ts +++ b/src/events/message-create.ts @@ -2,6 +2,8 @@ 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 = '!'; @@ -31,6 +33,23 @@ export const event: Event = { 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) { 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/slash-handler.ts b/src/handlers/slash-handler.ts index f40e270..3ed5848 100644 --- a/src/handlers/slash-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/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/types/command.ts b/src/types/command.ts index 1d35e37..99440a3 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -5,8 +5,35 @@ 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; } @@ -14,5 +41,6 @@ export interface PrefixCommand { name: string; description: string; aliases?: string[]; + requirements?: CommandRequirements; execute(interaction: Message, args: string[]): Promise; } From be64f9fd01362b31bda8ecde2d8c82b9961f4aa0 Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:25:42 +0000 Subject: [PATCH 5/6] Add scope deployment for slash commands #16 (#18) --- src/deploy-commands.ts | 50 ++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index a9cf90b..d0bbcc7 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -18,20 +18,42 @@ if (!isProduction && !testGuildId) { } 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, testGuildId!); - -const target = isProduction ? 'globally' : `to guild ${testGuildId}`; -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.`); +} From 44a05f4378cca7bffb7b35a15fa90acb6928ff13 Mon Sep 17 00:00:00 2001 From: Ntalcme <57364571+Ntalcme@users.noreply.github.com> Date: Sat, 6 Jun 2026 20:27:15 +0200 Subject: [PATCH 6/6] Update package.json version to v0.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",