diff --git a/README.md b/README.md index 5bc92f9..7cbacc3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > [!WARNING] > Documentation is still under construction. Expect incomplete and undocumented features. -All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/) +All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/?utm_source=github&utm_medium=readme&utm_campaign=repository&utm_content=docs_link) ## Development Commands diff --git a/config.example.json b/config.example.json index 25d2f48..19f9201 100644 --- a/config.example.json +++ b/config.example.json @@ -2,6 +2,7 @@ "token": "DISCORD_BOT_TOKEN", "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", + "serverInvite": "DISCORD_SERVER_INVITE_LINK", "database": { "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", "maxRetryAttempts": "MAX_RETRY_ATTEMPTS", diff --git a/package.json b/package.json index f46ba47..e6a3a6a 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "compile": "npx tsc", "target": "node ./target/discord-bot.js", "start:dev": "yarn run compile && yarn run target", + "start:dev:no-deploy": "cross-env SKIP_COMMAND_DEPLOY=true yarn run start:dev", "start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot", "restart": "pm2 restart poixpixel-discord-bot", + "undeploy-commands": "yarn compile && node --experimental-specifier-resolution=node ./target/util/undeployCommands.js", "lint": "npx eslint ./src && npx tsc --noEmit", "format": "prettier --check --ignore-path .prettierignore .", "format:fix": "prettier --write --ignore-path .prettierignore .", @@ -34,6 +36,7 @@ "@types/pg": "^8.11.13", "@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/parser": "^8.30.1", + "cross-env": "^7.0.3", "drizzle-kit": "^0.31.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", diff --git a/src/commands/fun/achievement.ts b/src/commands/fun/achievement.ts index e328956..c1ef61b 100644 --- a/src/commands/fun/achievement.ts +++ b/src/commands/fun/achievement.ts @@ -148,8 +148,9 @@ const command = { ), async execute(interaction: ChatInputCommandInteraction) { - await interaction.deferReply(); + if (!interaction.isChatInputCommand() || !interaction.guild) return; + await interaction.deferReply(); const subcommand = interaction.options.getSubcommand(); switch (subcommand) { diff --git a/src/commands/fun/counting.ts b/src/commands/fun/counting.ts index ce7cb6c..d576637 100644 --- a/src/commands/fun/counting.ts +++ b/src/commands/fun/counting.ts @@ -33,8 +33,9 @@ const command: SubcommandCommand = { ), execute: async (interaction) => { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; + await interaction.deferReply(); const subcommand = interaction.options.getSubcommand(); if (subcommand === 'status') { @@ -82,33 +83,40 @@ const command: SubcommandCommand = { }); } - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } else if (subcommand === 'setcount') { if ( !interaction.memberPermissions?.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You need administrator permissions to use this command.', - flags: ['Ephemeral'], }); return; } const count = interaction.options.getInteger('count'); if (count === null) { - await interaction.reply({ + await interaction.editReply({ content: 'Invalid count specified.', - flags: ['Ephemeral'], }); return; } - await setCount(count); - await interaction.reply({ + try { + await setCount(count); + await interaction.editReply({ + content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, + }); + } catch (error) { + await interaction.editReply({ + content: `Failed to set the count: ${error}`, + }); + } + + await interaction.editReply({ content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts index 45869e0..109bdd3 100644 --- a/src/commands/fun/fact.ts +++ b/src/commands/fun/fact.ts @@ -74,12 +74,11 @@ const command: SubcommandCommand = { ), execute: async (interaction) => { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; await interaction.deferReply({ flags: ['Ephemeral'], }); - await interaction.editReply('Processing...'); const config = loadConfig(); const subcommand = interaction.options.getSubcommand(); @@ -100,7 +99,7 @@ const command: SubcommandCommand = { }); if (!isAdmin) { - const approvalChannel = interaction.guild?.channels.cache.get( + const approvalChannel = interaction.guild.channels.cache.get( config.channels.factApproval, ); diff --git a/src/commands/fun/giveaway.ts b/src/commands/fun/giveaway.ts index 29a557c..9dcdbfc 100644 --- a/src/commands/fun/giveaway.ts +++ b/src/commands/fun/giveaway.ts @@ -18,6 +18,7 @@ import { builder, } from '@/util/giveaways/giveawayManager.js'; import { createPaginationButtons } from '@/util/helpers.js'; +import { loadConfig } from '@/util/configLoader'; const command: SubcommandCommand = { data: new SlashCommandBuilder() @@ -53,16 +54,30 @@ const command: SubcommandCommand = { ), execute: async (interaction) => { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + const config = loadConfig(); + const communityManagerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Community Manager', + )?.roleId; + + if (!communityManagerRoleId) { + await interaction.reply({ + content: + 'Community Manager role not found in the configuration. Please contact a server admin.', + flags: ['Ephemeral'], + }); + return; + } if ( - !interaction.memberPermissions?.has( - PermissionsBitField.Flags.ModerateMembers, - ) + !interaction.guild.members.cache + .find((member) => member.id === interaction.user.id) + ?.roles.cache.has(communityManagerRoleId) ) { await interaction.reply({ content: 'You do not have permission to manage giveaways.', - ephemeral: true, + flags: ['Ephemeral'], }); return; } @@ -152,7 +167,7 @@ async function handleListGiveaways(interaction: ChatInputCommandInteraction) { if (i.user.id !== interaction.user.id) { await i.reply({ content: 'You cannot use these buttons.', - ephemeral: true, + flags: ['Ephemeral'], }); return; } diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts index 5d33eb4..e2d39f3 100644 --- a/src/commands/fun/leaderboard.ts +++ b/src/commands/fun/leaderboard.ts @@ -22,13 +22,12 @@ const command: OptionsCommand = { .setRequired(false), ), execute: async (interaction) => { - if (!interaction.guild) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; await interaction.deferReply(); try { - const usersPerPage = - (interaction.options.get('limit')?.value as number) || 10; + const usersPerPage = interaction.options.getInteger('limit') || 10; const allUsers = await getLevelLeaderboard(100); diff --git a/src/commands/fun/rank.ts b/src/commands/fun/rank.ts index 7863779..a9908ce 100644 --- a/src/commands/fun/rank.ts +++ b/src/commands/fun/rank.ts @@ -15,18 +15,16 @@ const command: OptionsCommand = { .setRequired(false), ), execute: async (interaction) => { - const member = await interaction.guild?.members.fetch( - (interaction.options.get('user')?.value as string) || interaction.user.id, - ); - - if (!member) { - await interaction.reply('User not found in this server.'); - return; - } + if (!interaction.isChatInputCommand() || !interaction.guild) return; await interaction.deferReply(); try { + const member = await interaction.guild.members.fetch( + (interaction.options.get('user')?.value as string) || + interaction.user.id, + ); + const userData = await getUserLevel(member.id); const rankCard = await generateRankCard(member, userData); diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts index 84a5ee8..d2dfccd 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -3,6 +3,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; import { parseDuration, scheduleUnban } from '@/util/helpers.js'; import { OptionsCommand } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; import logAction from '@/util/logging/logAction.js'; const command: OptionsCommand = { @@ -30,40 +31,63 @@ const command: OptionsCommand = { .setRequired(false), ), execute: async (interaction) => { - const moderator = await interaction.guild?.members.fetch( - interaction.user.id, - ); - const member = await interaction.guild?.members.fetch( - interaction.options.get('member')!.value as string, - ); - const reason = interaction.options.get('reason')?.value as string; - const banDuration = interaction.options.get('duration')?.value as - | string - | undefined; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has( - PermissionsBitField.Flags.BanMembers, - ) || - moderator!.roles.highest.position <= member!.roles.highest.position || - !member?.bannable - ) { - await interaction.reply({ - content: - 'You do not have permission to ban members or this member cannot be banned.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { - await member.user.send( - banDuration - ? `You have been banned from ${interaction.guild!.name} for ${banDuration}. Reason: ${reason}. You can join back at ${new Date( - Date.now() + parseDuration(banDuration), - ).toUTCString()} using the link below:\nhttps://discord.gg/KRTGjxx7gY` - : `You been indefinitely banned from ${interaction.guild!.name}. Reason: ${reason}.`, + const moderator = await interaction.guild.members.fetch( + interaction.user.id, ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + const banDuration = interaction.options.get('duration')?.value as + | string + | undefined; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.BanMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to ban members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot ban a member with equal or higher role than yours.', + }); + return; + } + + if (!member.bannable) { + await interaction.editReply({ + content: 'I do not have permission to ban this member.', + }); + return; + } + + const config = loadConfig(); + const invite = interaction.guild.vanityURLCode ?? config.serverInvite; + const until = banDuration + ? new Date(Date.now() + parseDuration(banDuration)).toUTCString() + : 'indefinitely'; + + try { + await member.user.send( + banDuration + ? `You have been banned from ${interaction.guild.name} for ${banDuration}. Reason: ${reason}. You can join back at ${until} using the link below:\n${invite}` + : `You been indefinitely banned from ${interaction.guild.name}. Reason: ${reason}.`, + ); + } catch (error) { + console.error('Failed to send DM:', error); + } await member.ban({ reason }); if (banDuration) { @@ -72,7 +96,7 @@ const command: OptionsCommand = { await scheduleUnban( interaction.client, - interaction.guild!.id, + interaction.guild.id, member.id, expiresAt, ); @@ -94,23 +118,22 @@ const command: OptionsCommand = { }); await logAction({ - guild: interaction.guild!, + guild: interaction.guild, action: 'ban', target: member, - moderator: moderator!, + moderator, reason, }); - await interaction.reply({ + await interaction.editReply({ content: banDuration ? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}` : `<@${member.id}> has been indefinitely banned. Reason: ${reason}`, }); } catch (error) { console.error('Ban command error:', error); - await interaction.reply({ + await interaction.editReply({ content: 'Unable to ban member.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts new file mode 100644 index 0000000..fce2ab7 --- /dev/null +++ b/src/commands/moderation/kick.ts @@ -0,0 +1,103 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMemberModerationHistory } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a member from the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to kick') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the kick') + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.KickMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to kick members.', + }); + return; + } + + if (moderator!.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot kick a member with equal or higher role than yours.', + }); + return; + } + + if (!member.kickable) { + await interaction.editReply({ + content: 'I do not have permission to kick this member.', + }); + return; + } + + try { + await member.user.send( + `You have been kicked from ${interaction.guild!.name}. Reason: ${reason}. You can join back at: \n${interaction.guild.vanityURLCode ?? loadConfig().serverInvite}`, + ); + } catch (error) { + console.error('Failed to send DM to kicked user:', error); + } + + await member.kick(reason); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'kick', + reason, + duration: '', + createdAt: new Date(), + }); + + await logAction({ + guild: interaction.guild!, + action: 'kick', + target: member, + moderator, + reason, + }); + + await interaction.editReply({ + content: `<@${member.id}> has been kicked. Reason: ${reason}`, + }); + } catch (error) { + console.error('Kick command error:', error); + await interaction.editReply({ + content: 'Unable to kick member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts new file mode 100644 index 0000000..ef7a506 --- /dev/null +++ b/src/commands/moderation/mute.ts @@ -0,0 +1,128 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; +import { parseDuration } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Timeout a member in the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('duration') + .setDescription( + 'The duration of the timeout (ex. 5m, 1h, 1d, 1w). Max 28 days.', + ) + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + const muteDuration = interaction.options.get('duration')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.KickMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to mute members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot mute a member with equal or higher role than yours.', + }); + return; + } + + if (!member.moderatable) { + await interaction.editReply({ + content: 'I do not have permission to mute this member.', + }); + return; + } + + const durationMs = parseDuration(muteDuration); + const maxTimeout = 28 * 24 * 60 * 60 * 1000; + + if (durationMs > maxTimeout) { + await interaction.editReply({ + content: 'Timeout duration cannot exceed 28 days.', + }); + return; + } + + await member.user.send( + `You have been timed out in ${interaction.guild!.name} for ${muteDuration}. Reason: ${reason}.`, + ); + + await member.timeout(durationMs, reason); + + const expiresAt = new Date(Date.now() + durationMs); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'mute', + reason, + duration: muteDuration, + createdAt: new Date(), + expiresAt, + active: true, + }); + + await updateMember({ + discordId: member.id, + currentlyMuted: true, + }); + + await logAction({ + guild: interaction.guild!, + action: 'mute', + target: member, + moderator, + reason, + duration: muteDuration, + }); + + await interaction.editReply({ + content: `<@${member.id}> has been muted for ${muteDuration}. Reason: ${reason}`, + }); + } catch (error) { + console.error('Mute command error:', error); + await interaction.editReply({ + content: 'Unable to timeout member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts index c160148..ea7d386 100644 --- a/src/commands/moderation/unban.ts +++ b/src/commands/moderation/unban.ts @@ -20,52 +20,54 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { - const userId = interaction.options.get('userid')!.value as string; - const reason = interaction.options.get('reason')?.value as string; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has(PermissionsBitField.Flags.BanMembers) - ) { - await interaction.reply({ - content: 'You do not have permission to unban users.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { + const userId = interaction.options.get('userid')?.value as string; + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.BanMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to unban users.', + }); + return; + } + try { - const ban = await interaction.guild?.bans.fetch(userId); + const ban = await interaction.guild.bans.fetch(userId); if (!ban) { - await interaction.reply({ + await interaction.editReply({ content: 'This user is not banned.', - flags: ['Ephemeral'], }); return; } } catch { - await interaction.reply({ + await interaction.editReply({ content: 'Error getting ban. Is this user banned?', - flags: ['Ephemeral'], }); return; } await executeUnban( interaction.client, - interaction.guildId!, + interaction.guild.id, userId, reason, ); - await interaction.reply({ + await interaction.editReply({ content: `<@${userId}> has been unbanned. Reason: ${reason}`, }); } catch (error) { - console.error(error); - await interaction.reply({ + console.error(`Unable to unban user: ${error}`); + await interaction.editReply({ content: 'Unable to unban user.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts new file mode 100644 index 0000000..a52069f --- /dev/null +++ b/src/commands/moderation/unmute.ts @@ -0,0 +1,66 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { executeUnmute } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Remove a timeout from a member') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to unmute') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for removing the timeout') + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to unmute members.', + }); + return; + } + await executeUnmute( + interaction.client, + interaction.guild.id, + member.id, + reason, + moderator, + ); + + await interaction.editReply({ + content: `<@${member.id}>'s timeout has been removed. Reason: ${reason}`, + }); + } catch (error) { + console.error('Unmute command error:', error); + await interaction.editReply({ + content: 'Unable to unmute member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index be2f386..ad0ad83 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -21,29 +21,38 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { - const moderator = await interaction.guild?.members.fetch( - interaction.user.id, - ); - const member = await interaction.guild?.members.fetch( - interaction.options.get('member')!.value as unknown as string, - ); - const reason = interaction.options.get('reason') - ?.value as unknown as string; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has( - PermissionsBitField.Flags.ModerateMembers, - ) || - moderator!.roles.highest.position <= member!.roles.highest.position - ) { - await interaction.reply({ - content: 'You do not have permission to warn this member.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as unknown as string, + ); + const reason = interaction.options.getString('reason')!; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to warn members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot warn a member with equal or higher role than yours.', + }); + return; + } + await updateMemberModerationHistory({ discordId: member!.user.id, moderatorDiscordId: interaction.user.id, @@ -54,9 +63,6 @@ const command: OptionsCommand = { await member!.user.send( `You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`, ); - await interaction.reply( - `<@${member!.user.id}> has been warned. Reason: ${reason}`, - ); await logAction({ guild: interaction.guild!, action: 'warn', @@ -64,11 +70,13 @@ const command: OptionsCommand = { moderator: moderator!, reason: reason, }); + await interaction.editReply( + `<@${member!.user.id}> has been warned. Reason: ${reason}`, + ); } catch (error) { console.error(error); - await interaction.reply({ + await interaction.editReply({ content: 'There was an error trying to warn the member.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/testing/testJoin.ts b/src/commands/testing/testJoin.ts index cf66f6b..f812ae9 100644 --- a/src/commands/testing/testJoin.ts +++ b/src/commands/testing/testJoin.ts @@ -8,25 +8,27 @@ const command: Command = { .setDescription('Simulates a new member joining'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; const guild = interaction.guild; + await interaction.deferReply({ flags: ['Ephemeral'] }); + if ( !interaction.memberPermissions!.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command.', - flags: ['Ephemeral'], }); + return; } - const fakeMember = await guild!.members.fetch(interaction.user.id); - guild!.client.emit('guildMemberAdd', fakeMember); + const fakeMember = await guild.members.fetch(interaction.user.id); + guild.client.emit('guildMemberAdd', fakeMember); - await interaction.reply({ + await interaction.editReply({ content: 'Triggered the join event!', - flags: ['Ephemeral'], }); }, }; diff --git a/src/commands/testing/testLeave.ts b/src/commands/testing/testLeave.ts index 1aba95c..fae7e89 100644 --- a/src/commands/testing/testLeave.ts +++ b/src/commands/testing/testLeave.ts @@ -9,25 +9,26 @@ const command: Command = { .setDescription('Simulates a member leaving'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; const guild = interaction.guild; + await interaction.deferReply({ flags: ['Ephemeral'] }); + if ( !interaction.memberPermissions!.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command.', - flags: ['Ephemeral'], }); } - const fakeMember = await guild!.members.fetch(interaction.user.id); - guild!.client.emit('guildMemberRemove', fakeMember); + const fakeMember = await guild.members.fetch(interaction.user.id); + guild.client.emit('guildMemberRemove', fakeMember); - await interaction.reply({ + await interaction.editReply({ content: 'Triggered the leave event!', - flags: ['Ephemeral'], }); await updateMember({ diff --git a/src/commands/util/config.ts b/src/commands/util/config.ts new file mode 100644 index 0000000..46d6a98 --- /dev/null +++ b/src/commands/util/config.ts @@ -0,0 +1,237 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionFlagsBits, +} from 'discord.js'; + +import { Command } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('config') + .setDescription('(Admin Only) Display the current configuration') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + if ( + !interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) + ) { + await interaction.editReply({ + content: 'You do not have permission to use this command.', + }); + return; + } + + const config = loadConfig(); + const displayConfig = JSON.parse(JSON.stringify(config)); + + if (displayConfig.token) displayConfig.token = '••••••••••••••••••••••••••'; + if (displayConfig.database?.dbConnectionString) { + displayConfig.database.dbConnectionString = '••••••••••••••••••••••••••'; + } + if (displayConfig.redis?.redisConnectionString) { + displayConfig.redis.redisConnectionString = '••••••••••••••••••••••••••'; + } + + const pages: EmbedBuilder[] = []; + + const basicConfigEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Bot Configuration') + .setDescription( + 'Current configuration settings (sensitive data redacted)', + ) + .addFields( + { + name: 'Client ID', + value: displayConfig.clientId || 'Not set', + inline: true, + }, + { + name: 'Guild ID', + value: displayConfig.guildId || 'Not set', + inline: true, + }, + { + name: 'Token', + value: displayConfig.token || 'Not set', + inline: true, + }, + ); + + pages.push(basicConfigEmbed); + + if (displayConfig.database || displayConfig.redis) { + const dbRedisEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Database and Redis Configuration') + .setDescription('Database and cache settings'); + + if (displayConfig.database) { + dbRedisEmbed.addFields({ + name: 'Database', + value: `Connection: ${displayConfig.database.dbConnectionString}\nMax Retry: ${displayConfig.database.maxRetryAttempts}\nRetry Delay: ${displayConfig.database.retryDelay}ms`, + }); + } + + if (displayConfig.redis) { + dbRedisEmbed.addFields({ + name: 'Redis', + value: `Connection: ${displayConfig.redis.redisConnectionString}\nRetry Attempts: ${displayConfig.redis.retryAttempts}\nInitial Retry Delay: ${displayConfig.redis.initialRetryDelay}ms`, + }); + } + + pages.push(dbRedisEmbed); + } + + if (displayConfig.channels || displayConfig.roles) { + const channelsRolesEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Channels and Roles Configuration') + .setDescription('Server channel and role settings'); + + if (displayConfig.channels) { + const channelsText = Object.entries(displayConfig.channels) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + channelsRolesEmbed.addFields({ + name: 'Channels', + value: channelsText || 'None configured', + }); + } + + if (displayConfig.roles) { + let rolesText = ''; + + if (displayConfig.roles.joinRoles?.length) { + rolesText += `Join Roles: ${displayConfig.roles.joinRoles.join(', ')}\n`; + } + + if (displayConfig.roles.levelRoles?.length) { + rolesText += `Level Roles: ${displayConfig.roles.levelRoles.length} configured\n`; + } + + if (displayConfig.roles.staffRoles?.length) { + rolesText += `Staff Roles: ${displayConfig.roles.staffRoles.length} configured\n`; + } + + if (displayConfig.roles.factPingRole) { + rolesText += `Fact Ping Role: ${displayConfig.roles.factPingRole}`; + } + + channelsRolesEmbed.addFields({ + name: 'Roles', + value: rolesText || 'None configured', + }); + } + + pages.push(channelsRolesEmbed); + } + + if ( + displayConfig.leveling || + displayConfig.counting || + displayConfig.giveaways + ) { + const featuresEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Feature Configurations') + .setDescription('Settings for bot features'); + + if (displayConfig.leveling) { + featuresEmbed.addFields({ + name: 'Leveling', + value: `XP Cooldown: ${displayConfig.leveling.xpCooldown}s\nMin XP: ${displayConfig.leveling.minXpAwarded}\nMax XP: ${displayConfig.leveling.maxXpAwarded}`, + }); + } + + if (displayConfig.counting) { + const countingText = Object.entries(displayConfig.counting) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Counting', + value: countingText || 'Default settings', + }); + } + + if (displayConfig.giveaways) { + const giveawaysText = Object.entries(displayConfig.giveaways) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Giveaways', + value: giveawaysText || 'Default settings', + }); + } + + pages.push(featuresEmbed); + } + + let currentPage = 0; + + const components = + pages.length > 1 + ? [createPaginationButtons(pages.length, currentPage)] + : []; + + const reply = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = reply.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + flags: ['Ephemeral'], + }); + return; + } + + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Failed to remove pagination buttons:', error); + } + }); + }, +}; + +export default command; diff --git a/src/commands/util/help.ts b/src/commands/util/help.ts new file mode 100644 index 0000000..d2a253c --- /dev/null +++ b/src/commands/util/help.ts @@ -0,0 +1,273 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ComponentType, +} from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; + +const DOC_BASE_URL = 'https://docs.poixpixel.ahmadk953.org/'; +const getDocUrl = (location: string) => + `${DOC_BASE_URL}?utm_source=discord&utm_medium=bot&utm_campaign=help_command&utm_content=${location}`; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('help') + .setDescription('Shows a list of all available commands') + .addStringOption((option) => + option + .setName('command') + .setDescription('Get detailed help for a specific command') + .setRequired(false), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + + try { + const client = interaction.client as ExtendedClient; + const commandName = interaction.options.getString('command'); + + if (commandName) { + return handleSpecificCommand(interaction, client, commandName); + } + + const categories = new Map(); + + for (const [name, cmd] of client.commands) { + const category = getCategoryFromCommand(name); + + if (!categories.has(category)) { + categories.set(category, []); + } + + categories.get(category).push({ + name, + description: cmd.data.toJSON().description, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Poixpixel Bot Commands') + .setDescription( + '**Welcome to Poixpixel Discord Bot!**\n\n' + + 'Select a category from the dropdown menu below to see available commands.\n\n' + + `📚 **Documentation:** [Visit Our Documentation](${getDocUrl('main_description')})`, + ) + .setThumbnail(client.user!.displayAvatarURL()) + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + const categoryEmojis: Record = { + fun: '🎮', + moderation: '🛡️', + util: '🔧', + testing: '🧪', + }; + + Array.from(categories.keys()).forEach((category) => { + const emoji = categoryEmojis[category] || '📁'; + embed.addFields({ + name: `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`, + value: `Use the dropdown to see ${category} commands`, + inline: true, + }); + }); + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl('main_footer_field')})`, + inline: false, + }); + + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('help_category_select') + .setPlaceholder('Select a command category') + .addOptions( + Array.from(categories.keys()).map((category) => { + const emoji = categoryEmojis[category] || '📁'; + return new StringSelectMenuOptionBuilder() + .setLabel( + category.charAt(0).toUpperCase() + category.slice(1), + ) + .setDescription(`View ${category} commands`) + .setValue(category) + .setEmoji(emoji); + }), + ), + ); + + const message = await interaction.editReply({ + embeds: [embed], + components: [selectMenu], + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + const selectedCategory = i.values[0]; + const commands = categories.get(selectedCategory); + const emoji = categoryEmojis[selectedCategory] || '📁'; + + const categoryEmbed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle( + `${emoji} ${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} Commands`, + ) + .setDescription('Here are all the commands in this category:') + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + commands.forEach((cmd: any) => { + categoryEmbed.addFields({ + name: `/${cmd.name}`, + value: cmd.description || 'No description available', + inline: false, + }); + }); + + categoryEmbed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`category_${selectedCategory}`)})`, + inline: false, + }); + + await i.update({ embeds: [categoryEmbed], components: [selectMenu] }); + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(console.error); + }); + } catch (error) { + console.error('Error in help command:', error); + await interaction.editReply({ + content: 'An error occurred while processing your request.', + }); + } + }, +}; + +/** + * Handle showing help for a specific command + */ +async function handleSpecificCommand( + interaction: any, + client: ExtendedClient, + commandName: string, +) { + const cmd = client.commands.get(commandName); + + if (!cmd) { + return interaction.editReply({ + content: `Command \`${commandName}\` not found.`, + ephemeral: true, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Help: /${commandName}`) + .setDescription(cmd.data.toJSON().description || 'No description available') + .addFields({ + name: 'Category', + value: getCategoryFromCommand(commandName), + inline: true, + }) + .setFooter({ + text: `Poixpixel Discord Bot • Documentation: ${getDocUrl(`cmd_footer_${commandName}`)}`, + }); + + const options = cmd.data.toJSON().options; + if (options && options.length > 0) { + if (options[0].type === 1) { + embed.addFields({ + name: 'Subcommands', + value: options + .map((opt: any) => `\`${opt.name}\`: ${opt.description}`) + .join('\n'), + inline: false, + }); + } else { + embed.addFields({ + name: 'Options', + value: options + .map( + (opt: any) => + `\`${opt.name}\`: ${opt.description} ${opt.required ? '(Required)' : '(Optional)'}`, + ) + .join('\n'), + inline: false, + }); + } + } + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`cmd_field_${commandName}`)})`, + inline: false, + }); + + return interaction.editReply({ embeds: [embed] }); +} + +/** + * Get the category of a command based on its name + */ +function getCategoryFromCommand(commandName: string): string { + const commandCategories: Record = { + achievement: 'fun', + fact: 'fun', + rank: 'fun', + counting: 'fun', + giveaway: 'fun', + leaderboard: 'fun', + + ban: 'moderation', + kick: 'moderation', + mute: 'moderation', + unmute: 'moderation', + warn: 'moderation', + unban: 'moderation', + + ping: 'util', + server: 'util', + userinfo: 'util', + members: 'util', + rules: 'util', + restart: 'util', + reconnect: 'util', + xp: 'util', + recalculatelevels: 'util', + help: 'util', + config: 'util', + + testjoin: 'testing', + testleave: 'testing', + }; + + return commandCategories[commandName.toLowerCase()] || 'other'; +} + +export default command; diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index 6dd4762..c76ec28 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -16,6 +16,10 @@ const command: Command = { .setName('members') .setDescription('Lists all non-bot members of the server'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + let members = await getAllMembers(); members = members.sort((a, b) => (a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''), @@ -63,7 +67,7 @@ const command: Command = { const components = pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; - await interaction.reply({ + await interaction.editReply({ embeds: [pages[currentPage]], components, }); diff --git a/src/commands/util/ping.ts b/src/commands/util/ping.ts index 5e2bddc..3bf1894 100644 --- a/src/commands/util/ping.ts +++ b/src/commands/util/ping.ts @@ -8,7 +8,7 @@ const command: Command = { .setDescription('Check the latency from you to the bot'), execute: async (interaction) => { await interaction.reply( - `Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`, + `🏓 Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`, ); }, }; diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts index 4c30806..8653345 100644 --- a/src/commands/util/recalculatelevels.ts +++ b/src/commands/util/recalculatelevels.ts @@ -8,21 +8,22 @@ const command: Command = { .setName('recalculatelevels') .setDescription('(Admin Only) Recalculate all user levels'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + await interaction.editReply('Recalculating levels...'); + if ( !interaction.memberPermissions?.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command.', - flags: ['Ephemeral'], }); return; } - await interaction.deferReply(); - await interaction.editReply('Recalculating levels...'); - try { await recalculateUserLevels(); await interaction.editReply('Levels recalculated successfully!'); diff --git a/src/commands/util/reconnect.ts b/src/commands/util/reconnect.ts index 886fcca..a86d248 100644 --- a/src/commands/util/reconnect.ts +++ b/src/commands/util/reconnect.ts @@ -36,7 +36,9 @@ const command: SubcommandCommand = { ), execute: async (interaction) => { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); const config = loadConfig(); const managerRoleId = config.roles.staffRoles.find( @@ -52,18 +54,15 @@ const command: SubcommandCommand = { PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command. This command is restricted to users with the Manager role.', - flags: ['Ephemeral'], }); return; } const subcommand = interaction.options.getSubcommand(); - await interaction.deferReply({ flags: ['Ephemeral'] }); - try { if (subcommand === 'database') { await handleDatabaseReconnect(interaction); diff --git a/src/commands/util/restart.ts b/src/commands/util/restart.ts index 31ecfc9..b2bd091 100644 --- a/src/commands/util/restart.ts +++ b/src/commands/util/restart.ts @@ -18,12 +18,16 @@ const command: Command = { .setName('restart') .setDescription('(Manager Only) Restart the bot'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + const config = loadConfig(); const managerRoleId = config.roles.staffRoles.find( (role) => role.name === 'Manager', )?.roleId; - const member = await interaction.guild?.members.fetch(interaction.user.id); + const member = await interaction.guild.members.fetch(interaction.user.id); const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); if ( @@ -32,17 +36,15 @@ const command: Command = { PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to restart the bot. This command is restricted to users with the Manager role.', - flags: ['Ephemeral'], }); return; } - await interaction.reply({ + await interaction.editReply({ content: 'Restarting the bot... This may take a few moments.', - flags: ['Ephemeral'], }); const dbConnected = await ensureDatabaseConnection(); diff --git a/src/commands/util/server.ts b/src/commands/util/server.ts index 766c9f0..691cca0 100644 --- a/src/commands/util/server.ts +++ b/src/commands/util/server.ts @@ -7,8 +7,10 @@ const command: Command = { .setName('server') .setDescription('Provides information about the server.'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + await interaction.reply( - `The server **${interaction!.guild!.name}** has **${interaction!.guild!.memberCount}** members and was created on **${interaction!.guild!.createdAt}**. It is **${new Date().getFullYear() - interaction!.guild!.createdAt.getFullYear()!}** years old.`, + `The server **${interaction.guild.name}** has **${interaction.guild.memberCount}** members and was created on **${interaction.guild.createdAt}**. It is **${new Date().getFullYear() - interaction.guild.createdAt.getFullYear()}** years old.`, ); }, }; diff --git a/src/commands/util/user-info.ts b/src/commands/util/user-info.ts index 7b0a52b..f3d83d1 100644 --- a/src/commands/util/user-info.ts +++ b/src/commands/util/user-info.ts @@ -19,21 +19,25 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + const userOption = interaction.options.get( 'user', ) as unknown as GuildMember; const user = userOption.user; if (!userOption || !user) { - await interaction.reply('User not found'); + await interaction.editReply('User not found'); return; } if ( - !interaction.memberPermissions!.has( + !interaction.memberPermissions?.has( PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply( + await interaction.editReply( 'You do not have permission to view member information.', ); return; @@ -140,7 +144,7 @@ const command: OptionsCommand = { iconURL: interaction.user.displayAvatarURL(), }); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); }, }; diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts index 979da6a..3a667c8 100644 --- a/src/commands/util/xp.ts +++ b/src/commands/util/xp.ts @@ -71,12 +71,16 @@ const command: SubcommandCommand = { ), ), execute: async (interaction) => { - if (!interaction.isChatInputCommand()) return; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - const commandUser = interaction.guild?.members.cache.get( + const commandUser = interaction.guild.members.cache.get( interaction.user.id, ); + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + const config = loadConfig(); const managerRoleId = config.roles.staffRoles.find( (role) => role.name === 'Manager', @@ -87,18 +91,12 @@ const command: SubcommandCommand = { !managerRoleId || commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command', - flags: ['Ephemeral'], }); return; } - await interaction.deferReply({ - flags: ['Ephemeral'], - }); - await interaction.editReply('Processing...'); - const subcommand = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); const amount = interaction.options.getInteger('amount', false); diff --git a/src/db/functions/memberFunctions.ts b/src/db/functions/memberFunctions.ts index aa72b30..3ed416d 100644 --- a/src/db/functions/memberFunctions.ts +++ b/src/db/functions/memberFunctions.ts @@ -133,6 +133,7 @@ export async function updateMember({ discordUsername, currentlyInServer, currentlyBanned, + currentlyMuted, }: schema.memberTableTypes): Promise { try { await ensureDbInitialized(); @@ -147,6 +148,7 @@ export async function updateMember({ discordUsername, currentlyInServer, currentlyBanned, + currentlyMuted, }) .where(eq(schema.memberTable.discordId, discordId)); diff --git a/src/discord-bot.ts b/src/discord-bot.ts index f754db7..0183107 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -12,9 +12,10 @@ async function startBot() { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildModeration, + GatewayIntentBits.GuildInvites, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildModeration, ], }, config, diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index 655547d..8e43b61 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -6,7 +6,7 @@ import { } from 'discord.js'; import { updateMember, setMembers } from '@/db/db.js'; -import { generateMemberBanner } from '@/util/helpers.js'; +import { executeUnmute, generateMemberBanner } from '@/util/helpers.js'; import { loadConfig } from '@/util/configLoader.js'; import { Event } from '@/types/EventTypes.js'; import logAction from '@/util/logging/logAction.js'; @@ -144,6 +144,21 @@ export const memberUpdate: Event = { }); } } + + if ( + oldMember.communicationDisabledUntil !== + newMember.communicationDisabledUntil && + newMember.communicationDisabledUntil === null + ) { + await executeUnmute( + newMember.client, + guild.id, + newMember.user.id, + undefined, + guild.members.me!, + true, + ); + } } catch (error) { console.error('Error handling member update:', error); } diff --git a/src/events/ready.ts b/src/events/ready.ts index 3f93c26..a084068 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -11,6 +11,7 @@ import { setDiscordClient as setRedisDiscordClient, } from '@/db/redis.js'; import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; +import { loadActiveBans, loadActiveMutes } from '@/util/helpers.js'; export default { name: Events.ClientReady, @@ -36,6 +37,9 @@ export default { const nonBotMembers = members.filter((m) => !m.user.bot); await setMembers(nonBotMembers); + await loadActiveBans(client, guild); + await loadActiveMutes(client, guild); + await scheduleFactOfTheDay(client); await scheduleGiveaways(client); diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts index 6cf3d9d..d56e002 100644 --- a/src/structures/ExtendedClient.ts +++ b/src/structures/ExtendedClient.ts @@ -1,7 +1,7 @@ import { Client, ClientOptions, Collection } from 'discord.js'; import { Command } from '@/types/CommandTypes.js'; import { Config } from '@/types/ConfigTypes.js'; -import { deployCommands } from '@/util/deployCommand.js'; +import { deployCommands, getFilesRecursively } from '@/util/deployCommand.js'; import { registerEvents } from '@/util/eventLoader.js'; /** @@ -29,20 +29,73 @@ export class ExtendedClient extends Client { private async loadModules() { try { - const commands = await deployCommands(); - if (!commands?.length) { - throw new Error('No commands found'); - } + if (process.env.SKIP_COMMAND_DEPLOY === 'true') { + console.log('Skipping command deployment (SKIP_COMMAND_DEPLOY=true)'); + const commandFiles = await this.loadCommandsWithoutDeploying(); - for (const command of commands) { - this.commands.set(command.data.name, command); - } + if (!commandFiles?.length) { + throw new Error('No commands found'); + } - await registerEvents(this); - console.log(`Loaded ${commands.length} commands and registered events`); + await registerEvents(this); + console.log( + `Loaded ${commandFiles.length} commands and registered events (without deployment)`, + ); + } else { + const commands = await deployCommands(); + if (!commands?.length) { + throw new Error('No commands found'); + } + + for (const command of commands) { + this.commands.set(command.data.name, command); + } + + await registerEvents(this); + console.log(`Loaded ${commands.length} commands and registered events`); + } } catch (error) { console.error('Error loading modules:', error); process.exit(1); } } + + /** + * Loads commands without deploying them to Discord + * @returns Array of command objects + */ + private async loadCommandsWithoutDeploying(): Promise { + try { + const path = await import('path'); + + const __dirname = path.resolve(); + const commandsPath = path.join(__dirname, 'target', 'commands'); + + const commandFiles = getFilesRecursively(commandsPath); + + const commands: Command[] = []; + for (const file of commandFiles) { + const commandModule = await import(`file://${file}`); + const command = commandModule.default; + + if ( + command instanceof Object && + 'data' in command && + 'execute' in command + ) { + commands.push(command); + this.commands.set(command.data.name, command); + } else { + console.warn( + `[WARNING] The command at ${file} is missing a required "data" or "execute" property.`, + ); + } + } + + return commands; + } catch (error) { + console.error('Error loading commands:', error); + throw error; + } + } } diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index b38b126..90418b9 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -5,6 +5,7 @@ export interface Config { token: string; clientId: string; guildId: string; + serverInvite: string; database: { dbConnectionString: string; maxRetryAttempts: number; diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index c7e4486..31d9f88 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -17,7 +17,7 @@ const rest = new REST({ version: '10' }).setToken(token); * @param directory - The directory to get files from * @returns - An array of file paths */ -const getFilesRecursively = (directory: string): string[] => { +export const getFilesRecursively = (directory: string): string[] => { const files: string[] = []; const filesInDirectory = fs.readdirSync(directory); diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 1cb210d..6279d22 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -10,11 +10,12 @@ import { ButtonStyle, ButtonBuilder, ActionRowBuilder, + DiscordAPIError, } from 'discord.js'; import { and, eq } from 'drizzle-orm'; import { moderationTable } from '@/db/schema.js'; -import { db, handleDbError, updateMember } from '@/db/db.js'; +import { db, getMember, handleDbError, updateMember } from '@/db/db.js'; import logAction from './logging/logAction.js'; const __dirname = path.resolve(); @@ -116,6 +117,107 @@ export async function generateMemberBanner({ return attachment; } +/** + * Executes an unmute for a user + * @param client - The client to use + * @param guildId - The guild ID to unmute the user in + * @param userId - The user ID to unmute + * @param reason - The reason for the unmute + * @param moderator - The moderator who is unmuting the user + * @param alreadyUnmuted - Whether the user is already unmuted + */ +export async function executeUnmute( + client: Client, + guildId: string, + userId: string, + reason?: string, + moderator?: GuildMember, + alreadyUnmuted: boolean = false, +): Promise { + try { + const guild = await client.guilds.fetch(guildId); + let member; + + try { + member = await guild.members.fetch(userId); + if (!alreadyUnmuted) { + await member.timeout(null, reason ?? 'Temporary mute expired'); + } + } catch (error) { + console.log( + `Member ${userId} not found in server, just updating database`, + ); + } + + if (!(await getMember(userId))?.currentlyMuted) return; + + await db + .update(moderationTable) + .set({ active: false }) + .where( + and( + eq(moderationTable.discordId, userId), + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + await updateMember({ + discordId: userId, + currentlyMuted: false, + }); + + if (member) { + await logAction({ + guild, + action: 'unmute', + target: member, + reason: reason ?? 'Temporary mute expired', + moderator: moderator ? moderator : guild.members.me!, + }); + } + } catch (error) { + console.error('Error executing unmute:', error); + + if (!(error instanceof DiscordAPIError && error.code === 10007)) { + handleDbError('Failed to execute unmute', error as Error); + } + } +} + +/** + * Loads all active mutes and schedules unmute events + * @param client - The client to use + * @param guild - The guild to load mutes for + */ +export async function loadActiveMutes( + client: Client, + guild: Guild, +): Promise { + try { + const activeMutes = await db + .select() + .from(moderationTable) + .where( + and( + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + for (const mute of activeMutes) { + if (!mute.expiresAt) continue; + + const timeUntilUnmute = mute.expiresAt.getTime() - Date.now(); + if (timeUntilUnmute <= 0) { + await executeUnmute(client, guild.id, mute.discordId); + } + } + } catch (error) { + handleDbError('Failed to load active mutes', error as Error); + } +} + /** * Schedules an unban for a user * @param client - The client to use @@ -174,7 +276,7 @@ export async function executeUnban( guild, action: 'unban', target: guild.members.cache.get(userId)!, - moderator: guild.members.cache.get(client.user!.id)!, + moderator: guild.members.me!, reason: reason ?? 'Temporary ban expired', }); } catch (error) { diff --git a/src/util/undeployCommands.ts b/src/util/undeployCommands.ts new file mode 100644 index 0000000..a80cf3b --- /dev/null +++ b/src/util/undeployCommands.ts @@ -0,0 +1,36 @@ +import { REST, Routes } from 'discord.js'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); +const { token, clientId, guildId } = config; + +const rest = new REST({ version: '10' }).setToken(token); + +/** + * Undeploys all commands from the Discord API + */ +export const undeployCommands = async () => { + try { + console.log('Undeploying all commands from the Discord API...'); + + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: [], + }); + + console.log('Successfully undeployed all commands'); + } catch (error) { + console.error('Error undeploying commands:', error); + throw error; + } +}; + +if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) { + undeployCommands() + .then(() => { + console.log('Undeploy process completed successfully'); + }) + .catch((err) => { + console.error('Undeploy process failed:', err); + process.exitCode = 1; + }); +} diff --git a/yarn.lock b/yarn.lock index 4328c0d..2fb7898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1882,7 +1882,19 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-env@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-env@npm:7.0.3" + dependencies: + cross-spawn: "npm:^7.0.1" + bin: + cross-env: src/bin/cross-env.js + cross-env-shell: src/bin/cross-env-shell.js + checksum: 10c0/f3765c25746c69fcca369655c442c6c886e54ccf3ab8c16847d5ad0e91e2f337d36eedc6599c1227904bf2a228d721e690324446876115bc8e7b32a866735ecf + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -4201,6 +4213,7 @@ __metadata: "@types/pg": "npm:^8.11.13" "@typescript-eslint/eslint-plugin": "npm:^8.30.1" "@typescript-eslint/parser": "npm:^8.30.1" + cross-env: "npm:^7.0.3" discord.js: "npm:^14.18.0" drizzle-kit: "npm:^0.31.0" drizzle-orm: "npm:^0.42.0"