From 907c1a4cbb45eb57f1fb9dc5ba96af52371c2481 Mon Sep 17 00:00:00 2001 From: GhomKrosmonaute Date: Tue, 5 Mar 2024 13:36:10 +0100 Subject: [PATCH] fix(slash.ts): import discord and add Guild type for better type checking feat(slash.ts): add util module import for better utility functions in the codebase feat(slash.ts): add support for ISlashCommand interface to improve type safety feat(slash.ts): add support for process.env.BOT_OWNER for bot owner specific commands feat(slash.ts): add support for guild specific commands and permissions feat(slash.ts): add support for channel types in slash commands for better control feat(slash.ts): add support for user permissions and role restrictions in slash commands feat(slash.ts): add support for guild owner only and bot owner only commands feat(slash.ts): add support for ISlashCommandInteraction interface for better interaction handling feat(slash.ts): add support for validating slash commands with proper logging feat(slash.ts): add support for preparing slash commands with proper validation feat(slash.ts): add support for PermissionsNames type for better permission handling --- src/app/slash.ts | 173 ++++++++++++++++-- src/app/util.ts | 4 +- .../slash.interactionCreate.native.ts | 4 +- 3 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/app/slash.ts b/src/app/slash.ts index 4d0c18b..f605dea 100644 --- a/src/app/slash.ts +++ b/src/app/slash.ts @@ -1,6 +1,6 @@ // system file, please don't modify it -import discord from "discord.js" +import discord, { Guild } from "discord.js" import * as rest from "@discordjs/rest" import v10 from "discord-api-types/v10" import path from "path" @@ -8,6 +8,7 @@ import chalk from "chalk" import * as handler from "@ghom/handler" import * as logger from "./logger.js" +import * as util from "./util.js" import { filename } from "dirname-filename-esm" @@ -19,7 +20,7 @@ export const slashCommandHandler = new handler.Handler( pattern: /\.js$/, loader: async (filepath) => { const file = await import("file://" + filepath) - return file.default as SlashCommand + return file.default as ISlashCommand }, onLoad: async (filepath, command) => { command.filepath = filepath @@ -31,42 +32,113 @@ export const slashCommandHandler = new handler.Handler( export const slashCommands = new (class extends discord.Collection< string, - SlashCommand + ISlashCommand > { - add(command: SlashCommand) { + add(command: ISlashCommand) { validateSlashCommand(command) this.set(command.options.name, command) } })() -export interface SlashCommandOptions { +export type SlashCommandChannelType = "guild" | "dm" | "thread" + +export interface ISlashCommandOptions { name: string description: string + channelType?: SlashCommandChannelType guildOnly?: boolean - threadOnly?: boolean + guildOwnerOnly?: boolean + botOwnerOnly?: boolean + userPermissions?: util.PermissionsNames[] + allowRoles?: discord.RoleResolvable[] + denyRoles?: discord.RoleResolvable[] + run: (interaction: ISlashCommandInteraction) => unknown | Promise + build?: (builder: discord.SlashCommandBuilder) => unknown | Promise +} + +export interface SlashCommandOptions< + GuildOnly extends boolean, + ChannelType extends SlashCommandChannelType, +> { + name: string + description: string + channelType?: ChannelType + guildOnly?: GuildOnly + guildOwnerOnly?: boolean + botOwnerOnly?: boolean + userPermissions?: discord.PermissionsString[] + allowRoles?: discord.RoleResolvable[] + denyRoles?: discord.RoleResolvable[] build?: ( this: discord.SlashCommandBuilder, builder: discord.SlashCommandBuilder, ) => unknown run: ( - this: discord.CommandInteraction, - interaction: discord.CommandInteraction, + this: SlashCommandInteraction, + interaction: SlashCommandInteraction, ) => unknown } -export class SlashCommand { +export class SlashCommand< + GuildOnly extends boolean, + ChannelType extends SlashCommandChannelType, +> { filepath?: string native = false builder = new discord.SlashCommandBuilder() - constructor(public options: SlashCommandOptions) {} + constructor(public options: SlashCommandOptions) {} +} + +export interface ISlashCommand { + filepath?: string + native: boolean + builder: discord.SlashCommandBuilder + options: ISlashCommandOptions +} + +export interface ISlashCommandInteraction + extends Omit { + guild?: discord.Guild + guildId?: string + channel: + | discord.ThreadChannel + | discord.NewsChannel + | discord.DMChannel + | discord.TextChannel + | discord.TextBasedChannel + | discord.GuildTextBasedChannel } -export function validateSlashCommand(command: SlashCommand) { +export interface SlashCommandInteraction< + GuildOnly extends boolean, + ChannelType extends SlashCommandChannelType, +> extends Omit { + guild: GuildOnly extends true ? discord.Guild : undefined + guildId: GuildOnly extends true ? string : undefined + channel: ChannelType extends "dm" + ? discord.DMChannel + : ChannelType extends "thread" + ? discord.ThreadChannel + : GuildOnly extends true + ? discord.GuildTextBasedChannel + : ChannelType extends "guild" + ? discord.GuildTextBasedChannel + : discord.TextBasedChannel +} + +export function validateSlashCommand(command: ISlashCommand) { command.builder .setName(command.options.name) .setDescription(command.options.description) + if (command.options.userPermissions) + command.builder.setDefaultMemberPermissions( + command.options.userPermissions.reduce((bit, name) => { + return bit | v10.PermissionFlagsBits[name] + }, 0n), + ) + command.options.build?.bind(command.builder)(command.builder) logger.log( @@ -101,20 +173,87 @@ export async function registerSlashCommands(guildId?: string) { export async function prepareSlashCommand( interaction: discord.CommandInteraction, - command: SlashCommand, -): Promise { - if (command.options.guildOnly && !interaction.inGuild()) { + command: ISlashCommand, +): Promise { + // @ts-ignore + const output: ISlashCommandInteraction = { + ...interaction, + guild: undefined, + guildId: undefined, + channel: interaction.channel!, + } + + if ( + command.options.botOwnerOnly && + interaction.user.id !== process.env.BOT_OWNER + ) return new discord.EmbedBuilder() .setColor("Red") - .setDescription("This command can only be used in a server") + .setDescription("This command can only be used by the bot owner") + + if ( + command.options.guildOnly || + (command.options.guildOnly !== false && + command.options.channelType !== "dm") + ) { + if (!interaction.inGuild() || !interaction.guild) + return new discord.EmbedBuilder() + .setColor("Red") + .setDescription("This command can only be used in a guild") + + output.guild = interaction.guild + output.guildId = interaction.guildId + + if ( + command.options.guildOwnerOnly && + interaction.user.id !== interaction.guild.ownerId + ) + return new discord.EmbedBuilder() + .setColor("Red") + .setDescription("This command can only be used by the guild owner") + + if (command.options.allowRoles || command.options.denyRoles) { + const member = await interaction.guild.members.fetch(interaction.user.id) + + if (command.options.allowRoles) { + if ( + !member.roles.cache.some((role) => + command.options.allowRoles?.includes(role.id), + ) + ) + return new discord.EmbedBuilder() + .setColor("Red") + .setDescription( + "You don't have the required role to use this command", + ) + } + + if (command.options.denyRoles) { + if ( + member.roles.cache.some((role) => + command.options.denyRoles?.includes(role.id), + ) + ) + return new discord.EmbedBuilder() + .setColor("Red") + .setDescription( + "You have a role that is not allowed to use this command", + ) + } + } } - if (command.options.threadOnly) { + if (command.options.channelType === "thread") { if (!interaction.channel || !interaction.channel.isThread()) return new discord.EmbedBuilder() .setColor("Red") .setDescription("This command can only be used in a thread") + } else if (command.options.channelType === "dm") { + if (!interaction.channel || !interaction.channel.isDMBased()) + return new discord.EmbedBuilder() + .setColor("Red") + .setDescription("This command can only be used in a DM") } - return true + return output } diff --git a/src/app/util.ts b/src/app/util.ts index ebe1951..fe033b0 100644 --- a/src/app/util.ts +++ b/src/app/util.ts @@ -10,6 +10,7 @@ import axios from "axios" import chalk from "chalk" import EventEmitter from "events" +import v10 from "discord-api-types/v10" import utc from "dayjs/plugin/utc.js" import relative from "dayjs/plugin/relativeTime.js" import timezone from "dayjs/plugin/timezone.js" @@ -17,7 +18,8 @@ import toObject from "dayjs/plugin/toObject.js" import * as logger from "./logger.js" import * as util from "./util.js" -import { ModuleLinker } from "vm" + +export type PermissionsNames = keyof typeof v10.PermissionFlagsBits export async function checkUpdates() { // fetch latest bot.ts codebase diff --git a/src/listeners/slash.interactionCreate.native.ts b/src/listeners/slash.interactionCreate.native.ts index 721089d..6b58da9 100644 --- a/src/listeners/slash.interactionCreate.native.ts +++ b/src/listeners/slash.interactionCreate.native.ts @@ -12,13 +12,13 @@ const listener: app.Listener<"interactionCreate"> = { const prepared = await app.prepareSlashCommand(interaction, cmd) - if (typeof prepared !== "boolean") + if (prepared instanceof app.EmbedBuilder) return interaction.reply({ embeds: [prepared] }).catch() if (!prepared) return try { - await cmd.options.run.bind(interaction)(interaction) + await cmd.options.run.bind(prepared)(prepared) } catch (error: any) { app.error(error, cmd.filepath!, true)