From 86adac3f0850a758e4e969dbcbb44d69eb3bd2d3 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Sun, 23 Feb 2025 21:39:49 -0500 Subject: [PATCH] Added Warn and Ban Commands, Added Logging, and Much More --- assets/welcome-bg.png | Bin 0 -> 36310 bytes config.example.json | 11 +- src/commands/moderation/ban.ts | 127 ++++++++++++++ src/commands/moderation/unban.ts | 82 +++++++++ src/commands/moderation/warn.ts | 85 +++++++++ src/commands/testing/testJoin.ts | 42 +++++ src/commands/testing/testLeave.ts | 48 ++++++ src/commands/util/members.ts | 124 ++++++++++++-- src/commands/{ => util}/rules.ts | 4 +- src/commands/util/server.ts | 2 +- src/commands/util/user-info.ts | 139 ++++++++++++--- src/db/db.ts | 97 +++++++++++ src/db/schema.ts | 60 ++++++- src/discord-bot.ts | 122 +++---------- src/events/channelEvents.ts | 87 ++++++++++ src/events/interactionCreate.ts | 38 ++++ src/events/memberEvents.ts | 147 ++++++++++++++++ src/events/messageEvents.ts | 58 +++++++ src/events/ready.ts | 9 + src/events/roleEvents.ts | 93 ++++++++++ src/structures/ExtendedClient.ts | 45 +++++ src/types/CommandTypes.ts | 6 + src/types/ConfigTypes.ts | 13 ++ src/types/EventTypes.ts | 7 + src/util/configLoader.ts | 23 +++ src/util/db.ts | 52 ------ src/util/deployCommand.ts | 45 ++++- src/util/eventLoader.ts | 45 +++++ src/util/helpers.ts | 165 ++++++++++++++++++ src/util/logging/constants.ts | 65 +++++++ src/util/logging/logAction.ts | 276 ++++++++++++++++++++++++++++++ src/util/logging/types.ts | 124 ++++++++++++++ src/util/logging/utils.ts | 163 ++++++++++++++++++ 33 files changed, 2200 insertions(+), 204 deletions(-) create mode 100644 assets/welcome-bg.png create mode 100644 src/commands/moderation/ban.ts create mode 100644 src/commands/moderation/unban.ts create mode 100644 src/commands/moderation/warn.ts create mode 100644 src/commands/testing/testJoin.ts create mode 100644 src/commands/testing/testLeave.ts rename src/commands/{ => util}/rules.ts (97%) create mode 100644 src/db/db.ts create mode 100644 src/events/channelEvents.ts create mode 100644 src/events/interactionCreate.ts create mode 100644 src/events/memberEvents.ts create mode 100644 src/events/messageEvents.ts create mode 100644 src/events/ready.ts create mode 100644 src/events/roleEvents.ts create mode 100644 src/structures/ExtendedClient.ts create mode 100644 src/types/CommandTypes.ts create mode 100644 src/types/ConfigTypes.ts create mode 100644 src/types/EventTypes.ts create mode 100644 src/util/configLoader.ts delete mode 100644 src/util/db.ts create mode 100644 src/util/eventLoader.ts create mode 100644 src/util/helpers.ts create mode 100644 src/util/logging/constants.ts create mode 100644 src/util/logging/logAction.ts create mode 100644 src/util/logging/types.ts create mode 100644 src/util/logging/utils.ts diff --git a/assets/welcome-bg.png b/assets/welcome-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..a227b233a21eea48a767e49cca1a1cc5ef335aa8 GIT binary patch literal 36310 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 z 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 Promise; +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('ban') + .setDescription('Ban a member from the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to ban') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the ban') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('duration') + .setDescription( + 'The duration of the ban (ex. 5m, 1h, 1d, 1w). Leave blank for permanent ban.', + ) + .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.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; + } + + 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}.`, + ); + await member.ban({ reason }); + + if (banDuration) { + const durationMs = parseDuration(banDuration); + const expiresAt = new Date(Date.now() + durationMs); + + await scheduleUnban( + interaction.client, + interaction.guild!.id, + member.id, + expiresAt, + ); + } + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'ban', + reason, + duration: banDuration ?? 'indefinite', + createdAt: new Date(), + active: true, + }); + + await updateMember({ + discordId: member.id, + currentlyBanned: true, + }); + + await logAction({ + guild: interaction.guild!, + action: 'ban', + target: member, + moderator: moderator!, + reason, + }); + + await interaction.reply({ + 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({ + content: 'Unable to ban member.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts new file mode 100644 index 0000000..70efff7 --- /dev/null +++ b/src/commands/moderation/unban.ts @@ -0,0 +1,82 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; +import { executeUnban } from '../../util/helpers.js'; + +interface Command { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('unban') + .setDescription('Unban a user from the server') + .addStringOption((option) => + option + .setName('userid') + .setDescription('The Discord ID of the user to unban') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the unban') + .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.memberPermissions?.has(PermissionsBitField.Flags.BanMembers) + ) { + await interaction.reply({ + content: 'You do not have permission to unban users.', + flags: ['Ephemeral'], + }); + return; + } + + try { + try { + const ban = await interaction.guild?.bans.fetch(userId); + if (!ban) { + await interaction.reply({ + content: 'This user is not banned.', + flags: ['Ephemeral'], + }); + return; + } + } catch { + await interaction.reply({ + content: 'Error getting ban. Is this user banned?', + flags: ['Ephemeral'], + }); + return; + } + + await executeUnban( + interaction.client, + interaction.guildId!, + userId, + reason, + ); + + await interaction.reply({ + content: `<@${userId}> has been unbanned. Reason: ${reason}`, + }); + } catch (error) { + console.error(error); + await interaction.reply({ + content: 'Unable to unban user.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts new file mode 100644 index 0000000..f368aa2 --- /dev/null +++ b/src/commands/moderation/warn.ts @@ -0,0 +1,85 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; +import { updateMemberModerationHistory } from '../../db/db.js'; +import logAction from '../../util/logging/logAction.js'; + +interface Command { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('warn') + .setDescription('Warn a member') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to warn') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the warning') + .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.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; + } + + try { + await updateMemberModerationHistory({ + discordId: member!.user.id, + moderatorDiscordId: interaction.user.id, + action: 'warning', + reason: reason, + duration: '', + }); + 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', + target: member!, + moderator: moderator!, + reason: reason, + }); + } catch (error) { + console.error(error); + await interaction.reply({ + content: 'There was an error trying to warn the member.', + flags: ['Ephemeral'], + }); + } + }, +}; + +export default command; diff --git a/src/commands/testing/testJoin.ts b/src/commands/testing/testJoin.ts new file mode 100644 index 0000000..5a0eb28 --- /dev/null +++ b/src/commands/testing/testJoin.ts @@ -0,0 +1,42 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; + +interface Command { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('testjoin') + .setDescription('Simulates a new member joining'), + + execute: async (interaction) => { + const guild = interaction.guild; + + if ( + !interaction.memberPermissions!.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + content: 'You do not have permission to use this command.', + flags: ['Ephemeral'], + }); + } + + const fakeMember = await guild!.members.fetch(interaction.user.id); + guild!.client.emit('guildMemberAdd', fakeMember); + + await interaction.reply({ + content: 'Triggered the join event!', + flags: ['Ephemeral'], + }); + }, +}; + +export default command; diff --git a/src/commands/testing/testLeave.ts b/src/commands/testing/testLeave.ts new file mode 100644 index 0000000..3f13f5c --- /dev/null +++ b/src/commands/testing/testLeave.ts @@ -0,0 +1,48 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; +import { updateMember } from '../../db/db.js'; + +interface Command { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('testleave') + .setDescription('Simulates a member leaving'), + + execute: async (interaction) => { + const guild = interaction.guild; + + if ( + !interaction.memberPermissions!.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.reply({ + 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); + + await interaction.reply({ + content: 'Triggered the leave event!', + flags: ['Ephemeral'], + }); + + await updateMember({ + discordId: interaction.user.id, + currentlyInServer: true, + }); + }, +}; + +export default command; diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index dde0298..bd088f2 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -2,8 +2,14 @@ import { SlashCommandBuilder, CommandInteraction, EmbedBuilder, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, } from 'discord.js'; -import { getAllMembers } from '../../util/db.js'; +import { getAllMembers } from '../../db/db.js'; interface Command { data: Omit; @@ -15,16 +21,112 @@ const command: Command = { .setName('members') .setDescription('Lists all non-bot members of the server'), execute: async (interaction) => { - const members = await getAllMembers(); - const memberList = members - .map((m) => `**${m.discordUsername}** (${m.discordId})`) - .join('\n'); - const membersEmbed = new EmbedBuilder() - .setTitle('Members') - .setDescription(memberList) - .setColor(0x0099ff) - .addFields({ name: 'Total Members', value: members.length.toString() }); - await interaction.reply({ embeds: [membersEmbed] }); + let members = await getAllMembers(); + members = members.sort((a, b) => + a.discordUsername.localeCompare(b.discordUsername), + ); + + const ITEMS_PER_PAGE = 15; + const pages: (APIEmbed | JSONEncodable)[] = []; + for (let i = 0; i < members.length; i += ITEMS_PER_PAGE) { + const pageMembers = members.slice(i, i + ITEMS_PER_PAGE); + const memberList = pageMembers + .map((m) => `**${m.discordUsername}** (${m.discordId})`) + .join('\n'); + const embed = new EmbedBuilder() + .setTitle('Members') + .setDescription(memberList || 'No members to display.') + .setColor(0x0099ff) + .addFields({ name: 'Total Members', value: members.length.toString() }) + .setFooter({ + text: `Page ${Math.floor(i / ITEMS_PER_PAGE) + 1} of ${Math.ceil(members.length / ITEMS_PER_PAGE)}`, + }); + pages.push(embed); + } + + let currentPage = 0; + const getButtonActionRow = () => + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('previous') + .setLabel('Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('next') + .setLabel('Next') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === pages.length - 1), + ); + + const getSelectMenuRow = () => { + const options = pages.map((_, index) => ({ + label: `Page ${index + 1}`, + value: index.toString(), + default: index === currentPage, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('select_page') + .setPlaceholder('Jump to a page') + .addOptions(options); + + return new ActionRowBuilder().addComponents( + select, + ); + }; + + const components = + pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; + + await interaction.reply({ + embeds: [pages[currentPage]], + components, + }); + + const message = await interaction.fetchReply(); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + if (i.customId === 'previous' && currentPage > 0) { + currentPage--; + } else if (i.customId === 'next' && currentPage < pages.length - 1) { + currentPage++; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [getButtonActionRow(), getSelectMenuRow()], + }); + }); + + collector.on('end', async () => { + if (message.editable) { + await message.edit({ components: [] }); + } + }); }, }; diff --git a/src/commands/rules.ts b/src/commands/util/rules.ts similarity index 97% rename from src/commands/rules.ts rename to src/commands/util/rules.ts index 123d295..eb7d5f7 100644 --- a/src/commands/rules.ts +++ b/src/commands/util/rules.ts @@ -34,7 +34,7 @@ const rulesEmbed = new EmbedBuilder() { name: '**Rule #3: Use Common Sense**', value: - 'Think before you act or post. If something seems questionable, it’s probably best not to do it.', + 'Think before you act or post. If something seems questionable, it is probably best not to do it.', }, { name: '**Rule #4: No Spamming**', @@ -69,7 +69,7 @@ const rulesEmbed = new EmbedBuilder() { name: '**Rule #10: No Ping Abuse**', value: - 'Do not ping staff members unless it\'s absolutely necessary. Use pings responsibly for all members.', + 'Do not ping staff members unless it is absolutely necessary. Use pings responsibly for all members.', }, { name: '**Rule #11: Use Appropriate Channels**', diff --git a/src/commands/util/server.ts b/src/commands/util/server.ts index 1ce7cca..565770d 100644 --- a/src/commands/util/server.ts +++ b/src/commands/util/server.ts @@ -11,7 +11,7 @@ const command: Command = { .setDescription('Provides information about the server.'), execute: async (interaction) => { 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 9911cb9..42218ab 100644 --- a/src/commands/util/user-info.ts +++ b/src/commands/util/user-info.ts @@ -3,8 +3,10 @@ import { CommandInteraction, EmbedBuilder, SlashCommandOptionsOnlyBuilder, + GuildMember, + PermissionsBitField, } from 'discord.js'; -import { getMember } from '../../util/db.js'; +import { getMember } from '../../db/db.js'; interface Command { data: SlashCommandOptionsOnlyBuilder; @@ -14,7 +16,7 @@ interface Command { const command: Command = { data: new SlashCommandBuilder() .setName('userinfo') - .setDescription('Provides information about the user.') + .setDescription('Provides information about the specified user.') .addUserOption((option) => option .setName('user') @@ -22,46 +24,127 @@ const command: Command = { .setRequired(true), ), execute: async (interaction) => { - const userOption = interaction.options.get('user'); - if (!userOption) { - await interaction.reply('User not found'); - return; - } + const userOption = interaction.options.get( + 'user', + ) as unknown as GuildMember; const user = userOption.user; - if (!user) { + + if (!userOption || !user) { await interaction.reply('User not found'); return; } - const member = await getMember(user.id); - const [memberData] = member; + if ( + !interaction.memberPermissions!.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.reply( + 'You do not have permission to view member information.', + ); + return; + } + + const memberData = await getMember(user.id); + + const numberOfWarnings = memberData?.moderations.filter( + (moderation) => moderation.action === 'warning', + ).length; + const recentWarnings = memberData?.moderations + .filter((moderation) => moderation.action === 'warning') + .sort((a, b) => b.createdAt!.getTime() - a.createdAt!.getTime()) + .slice(0, 5); + + const numberOfMutes = memberData?.moderations.filter( + (moderation) => moderation.action === 'mute', + ).length; + const currentMute = memberData?.moderations + .filter((moderation) => moderation.action === 'mute') + .sort((a, b) => b.createdAt!.getTime() - a.createdAt!.getTime())[0]; + + const numberOfBans = memberData?.moderations.filter( + (moderation) => moderation.action === 'ban', + ).length; + const currentBan = memberData?.moderations + .filter((moderation) => moderation.action === 'ban') + .sort((a, b) => b.createdAt!.getTime() - a.createdAt!.getTime())[0]; + const embed = new EmbedBuilder() .setTitle(`User Information - ${user?.username}`) - .setColor(user.accentColor || 'Default') + .setColor(user.accentColor || '#5865F2') + .setThumbnail(user.displayAvatarURL({ size: 256 })) + .setTimestamp() .addFields( - { name: 'Username', value: user.username, inline: false }, - { name: 'User ID', value: user.id, inline: false }, { - name: 'Joined Server', - value: - interaction.guild?.members.cache - .get(user.id) - ?.joinedAt?.toLocaleString() || 'Not available', + name: 'πŸ‘€ Basic Information', + value: [ + `**Username:** ${user.username}`, + `**Discord ID:** ${user.id}`, + `**Account Created:** ${user.createdAt.toLocaleString()}`, + `**Joined Server:** ${ + interaction.guild?.members.cache + .get(user.id) + ?.joinedAt?.toLocaleString() || 'Not available' + }`, + `**Currently in Server:** ${memberData?.currentlyInServer ? 'βœ… Yes' : '❌ No'}`, + ].join('\n'), inline: false, }, { - name: 'Account Created', - value: user.createdAt.toLocaleString(), + name: 'πŸ›‘οΈ Moderation History', + value: [ + `**Total Warnings:** ${numberOfWarnings || '0'} ${numberOfWarnings ? '⚠️' : ''}`, + `**Total Mutes:** ${numberOfMutes || '0'} ${numberOfMutes ? 'πŸ”‡' : ''}`, + `**Total Bans:** ${numberOfBans || '0'} ${numberOfBans ? 'πŸ”¨' : ''}`, + `**Currently Muted:** ${memberData?.currentlyMuted ? 'πŸ”‡ Yes' : 'βœ… No'}`, + `**Currently Banned:** ${memberData?.currentlyBanned ? '🚫 Yes' : 'βœ… No'}`, + ].join('\n'), inline: false, }, - { - name: 'Number of Warnings', - value: memberData?.numberOfWarnings.toString() || '0', - }, - { - name: 'Number of Bans', - value: memberData?.numberOfBans.toString() || '0', - }, ); + + if (recentWarnings && recentWarnings.length > 0) { + embed.addFields({ + name: '⚠️ Recent Warnings', + value: recentWarnings + .map( + (warning, index) => + `${index + 1}. \`${warning.createdAt?.toLocaleDateString() || 'Unknown'}\` - ` + + `By <@${warning.moderatorDiscordId}>\n` + + `β”” Reason: ${warning.reason || 'No reason provided'}`, + ) + .join('\n\n'), + inline: false, + }); + } + if (memberData?.currentlyMuted && currentMute) { + embed.addFields({ + name: 'πŸ”‡ Current Mute Details', + value: [ + `**Reason:** ${currentMute.reason || 'No reason provided'}`, + `**Duration:** ${currentMute.duration || 'Indefinite'}`, + `**Muted At:** ${currentMute.createdAt?.toLocaleString() || 'Unknown'}`, + `**Muted By:** <@${currentMute.moderatorDiscordId}>`, + ].join('\n'), + inline: false, + }); + } + if (memberData?.currentlyBanned && currentBan) { + embed.addFields({ + name: 'πŸ“Œ Current Ban Details', + value: [ + `**Reason:** ${currentBan.reason || 'No reason provided'}`, + `**Duration:** ${currentBan.duration || 'Permanent'}`, + `**Banned At:** ${currentBan.createdAt?.toLocaleString() || 'Unknown'}`, + ].join('\n'), + inline: false, + }); + } + + embed.setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL(), + }); + await interaction.reply({ embeds: [embed] }); }, }; diff --git a/src/db/db.ts b/src/db/db.ts new file mode 100644 index 0000000..81d7e49 --- /dev/null +++ b/src/db/db.ts @@ -0,0 +1,97 @@ +import pkg from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import * as schema from './schema.js'; +import { eq } from 'drizzle-orm'; +import { loadConfig } from '../util/configLoader.js'; + +const { Pool } = pkg; +const config = loadConfig(); + +const dbPool = new Pool({ + connectionString: config.dbConnectionString, + ssl: true, +}); +export const db = drizzle({ client: dbPool, schema }); + +export async function getAllMembers() { + return await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.currentlyInServer, true)); +} + +export async function setMembers(nonBotMembers: any) { + nonBotMembers.forEach(async (member: any) => { + const memberExists = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, member.user.id)); + if (memberExists.length > 0) { + await db + .update(schema.memberTable) + .set({ discordUsername: member.user.username }) + .where(eq(schema.memberTable.discordId, member.user.id)); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }); +} + +export async function getMember(discordId: string) { + return await db.query.memberTable.findFirst({ + where: eq(schema.memberTable.discordId, discordId), + with: { + moderations: true, + }, + }); +} + +export async function updateMember({ + discordId, + discordUsername, + currentlyInServer, + currentlyBanned, +}: schema.memberTableTypes) { + return await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + }) + .where(eq(schema.memberTable.discordId, discordId)); +} + +export async function updateMemberModerationHistory({ + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, +}: schema.moderationTableTypes) { + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + return await db.insert(schema.moderationTable).values(moderationEntry); +} + +export async function getMemberModerationHistory(discordId: string) { + return await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 5c125e9..cbf6782 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,9 +1,63 @@ -import { integer, pgTable, varchar } from 'drizzle-orm/pg-core'; +import { + boolean, + integer, + pgTable, + timestamp, + varchar, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export interface memberTableTypes { + id?: number; + discordId: string; + discordUsername?: string; + currentlyInServer?: boolean; + currentlyBanned?: boolean; + currentlyMuted?: boolean; +} export const memberTable = pgTable('members', { id: integer().primaryKey().generatedAlwaysAsIdentity(), discordId: varchar('discord_id').notNull().unique(), discordUsername: varchar('discord_username').notNull(), - numberOfWarnings: integer('number_warnings').notNull().default(0), - numberOfBans: integer('number_bans').notNull().default(0), + currentlyInServer: boolean('currently_in_server').notNull().default(true), + currentlyBanned: boolean('currently_banned').notNull().default(false), + currentlyMuted: boolean('currently_muted').notNull().default(false), }); + +export interface moderationTableTypes { + id?: number; + discordId: string; + moderatorDiscordId: string; + action: 'warning' | 'mute' | 'kick' | 'ban'; + reason: string; + duration: string; + createdAt?: Date; + expiresAt?: Date; + active?: boolean; +} + +export const moderationTable = pgTable('moderations', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('discord_id') + .notNull() + .references(() => memberTable.discordId, { onDelete: 'cascade' }), + moderatorDiscordId: varchar('moderator_discord_id').notNull(), + action: varchar('action').notNull(), + reason: varchar('reason').notNull().default(''), + duration: varchar('duration').default(''), + createdAt: timestamp('created_at').notNull().defaultNow(), + expiresAt: timestamp('expires_at'), + active: boolean('active').notNull().default(true), +}); + +export const memberRelations = relations(memberTable, ({ many }) => ({ + moderations: many(moderationTable), +})); + +export const moderationRelations = relations(moderationTable, ({ one }) => ({ + member: one(memberTable, { + fields: [moderationTable.discordId], + references: [memberTable.discordId], + }), +})); diff --git a/src/discord-bot.ts b/src/discord-bot.ts index 00fc465..d7a5c81 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -1,105 +1,29 @@ -import fs from 'node:fs'; -import { - Client, - Collection, - Events, - GatewayIntentBits, - GuildMember, -} from 'discord.js'; - -import { deployCommands } from './util/deployCommand.js'; -import { removeMember, setMembers } from './util/db.js'; - -const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')); -const { token, guildId } = config; - -const client: any = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], -}); -client.commands = new Collection(); - -try { - const commands = await deployCommands(); - if (!commands) { - throw new Error('No commands found.'); - } - commands.forEach(async (command) => { - try { - client.commands.set(command.data.name, command); - } - catch (error: any) { - console.error(`Error while creating command: ${error}`); - } - }); - console.log('Commands registered successfully.'); -} -catch (error: any) { - console.error(`Error while registering commands: ${error}`); -} - -client.once(Events.ClientReady, async (c: Client) => { - const guild = await client.guilds.fetch(guildId); - const members = await guild.members.fetch(); - const nonBotMembers = members.filter((member: any) => !member.user.bot); - - await setMembers(nonBotMembers); - - console.log(`Ready! Logged in as ${c!.user!.tag}`); -}); - -client.on(Events.InteractionCreate, async (interaction: any) => { - if (!interaction.isChatInputCommand()) return; - - const command = interaction.client.commands.get(interaction.commandName); - - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } +import { GatewayIntentBits } from 'discord.js'; +import { ExtendedClient } from './structures/ExtendedClient.js'; +import { loadConfig } from './util/configLoader.js'; +async function startBot() { try { - await command.execute(interaction); - } - catch (error) { - console.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - ephemeral: true, - }); - } - else { - await interaction.reply({ - content: 'There was an error while executing this command!', - ephemeral: true, - }); - } - } -}); + const config = loadConfig(); -client.on(Events.GuildMemberAdd, async (member: GuildMember) => { - const guild = await client.guilds.fetch(guildId); - const members = await guild.members.fetch(); - const nonBotMembers = members.filter((dbMember: any) => !dbMember.user.bot); - - // TODO: Move this to the config file - const welcomeChannel = guild.channels.cache.get('1007949346031026186'); - - try { - await setMembers(nonBotMembers); - // TODO: Move this to config file - await welcomeChannel.send( - `Welcome to the server, ${member.user.username}!`, + const client = new ExtendedClient( + { + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildModeration, + ], + }, + config, ); - await member.user.send('Welcome to the Poixpixel Discord server!'); - } - catch (error: any) { - console.error(`Error while adding member: ${error}`); - } -}); -client.on(Events.GuildMemberRemove, async (member: GuildMember) => { - await removeMember(member.user.id); -}); + await client.initialize(); + } catch (error) { + console.error('Failed to start bot:', error); + process.exit(1); + } +} -client.login(token); +startBot(); diff --git a/src/events/channelEvents.ts b/src/events/channelEvents.ts new file mode 100644 index 0000000..617f428 --- /dev/null +++ b/src/events/channelEvents.ts @@ -0,0 +1,87 @@ +import { AuditLogEvent, Events, GuildChannel } from 'discord.js'; +import logAction from '../util/logging/logAction.js'; +import { Event } from '../types/EventTypes.js'; + +export const channelCreate = { + name: Events.ChannelCreate, + execute: async (channel: GuildChannel) => { + try { + const { guild } = channel; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelCreate, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'channelCreate', + channel, + moderator, + }); + } catch (error) { + console.error('Error handling channel create:', error); + } + }, +}; + +export const channelDelete = { + name: Events.ChannelDelete, + execute: async (channel: GuildChannel) => { + try { + const { guild } = channel; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelDelete, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'channelDelete', + channel, + moderator, + }); + } catch (error) { + console.error('Error handling channel delete:', error); + } + }, +}; + +export const channelUpdate = { + name: Events.ChannelUpdate, + execute: async (oldChannel: GuildChannel, newChannel: GuildChannel) => { + try { + const { guild } = newChannel; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.ChannelUpdate, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'channelUpdate', + channel: newChannel, + moderator, + oldName: oldChannel.name, + newName: newChannel.name, + oldPermissions: oldChannel.permissionOverwrites.cache.first()?.allow, + newPermissions: newChannel.permissionOverwrites.cache.first()?.allow, + }); + } catch (error) { + console.error('Error handling channel update:', error); + } + }, +}; + +export default [channelCreate, channelDelete, channelUpdate]; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..0afd910 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,38 @@ +import { Events, Interaction } from 'discord.js'; +import { ExtendedClient } from '../structures/ExtendedClient.js'; + +export default { + name: Events.InteractionCreate, + execute: async (interaction: Interaction) => { + if (!interaction.isCommand()) return; + + const client = interaction.client as ExtendedClient; + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.error( + `No command matching ${interaction.commandName} was found.`, + ); + return; + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(`Error executing ${interaction.commandName}`); + console.error(error); + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }); + } else { + await interaction.reply({ + content: 'There was an error while executing this command!', + flags: ['Ephemeral'], + }); + } + } + }, +}; diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts new file mode 100644 index 0000000..2fe3b24 --- /dev/null +++ b/src/events/memberEvents.ts @@ -0,0 +1,147 @@ +import { Events, GuildMember } from 'discord.js'; +import { updateMember, setMembers } from '../db/db.js'; +import { generateMemberBanner } from '../util/helpers.js'; +import { loadConfig } from '../util/configLoader.js'; +import logAction from '../util/logging/logAction.js'; + +export const memberJoin = { + name: Events.GuildMemberAdd, + execute: async (member: GuildMember) => { + const { guild } = member; + const config = loadConfig(); + const welcomeChannel = guild.channels.cache.get(config.channels.welcome); + + if (!welcomeChannel?.isTextBased()) { + console.error('Welcome channel not found or is not a text channel'); + return; + } + + try { + const members = await guild.members.fetch(); + const nonBotMembers = members.filter((m) => !m.user.bot); + await setMembers(nonBotMembers); + + if (!member.user.bot) { + const attachment = await generateMemberBanner({ + member, + width: 1024, + height: 450, + }); + + await Promise.all([ + welcomeChannel.send({ + content: `Welcome to ${guild.name}, ${member}!`, + files: [attachment], + }), + member.send({ + content: `Welcome to ${guild.name}, we hope you enjoy your stay!`, + files: [attachment], + }), + updateMember({ + discordId: member.user.id, + currentlyInServer: true, + }), + member.roles.add(config.roles.joinRoles), + logAction({ + guild, + action: 'memberJoin', + member, + }), + ]); + } + } catch (error) { + console.error('Error handling new member:', error); + } + }, +}; + +export const memberLeave = { + name: Events.GuildMemberRemove, + execute: async (member: GuildMember) => { + const { guild } = member; + + try { + await Promise.all([ + updateMember({ + discordId: member.user.id, + currentlyInServer: false, + }), + logAction({ + guild, + action: 'memberLeave', + member, + }), + ]); + } catch (error) { + console.error('Error handling member leave:', error); + } + }, +}; + +export const memberUpdate = { + name: Events.GuildMemberUpdate, + execute: async (oldMember: GuildMember, newMember: GuildMember) => { + const { guild } = newMember; + + try { + if (oldMember.user.username !== newMember.user.username) { + await updateMember({ + discordId: newMember.user.id, + discordUsername: newMember.user.username, + }); + + await logAction({ + guild, + action: 'memberUsernameUpdate', + member: newMember, + oldValue: oldMember.user.username, + newValue: newMember.user.username, + }); + } + + if (oldMember.nickname !== newMember.nickname) { + await logAction({ + guild, + action: 'memberNicknameUpdate', + member: newMember, + oldValue: oldMember.nickname ?? oldMember.user.username, + newValue: newMember.nickname ?? newMember.user.username, + }); + } + + const addedRoles = newMember.roles.cache.filter( + (role) => !oldMember.roles.cache.has(role.id), + ); + + const removedRoles = oldMember.roles.cache.filter( + (role) => !newMember.roles.cache.has(role.id), + ); + + if (addedRoles.size > 0) { + for (const role of addedRoles.values()) { + await logAction({ + guild, + action: 'roleAdd', + member: newMember, + role, + }); + } + } + + if (removedRoles.size > 0) { + for (const role of removedRoles.values()) { + await logAction({ + guild, + action: 'roleRemove', + member: newMember, + role, + }); + } + } + } catch (error) { + console.error('Error handling member update:', error); + } + }, +}; + +export default [memberJoin, memberLeave, memberUpdate]; diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts new file mode 100644 index 0000000..eaa947a --- /dev/null +++ b/src/events/messageEvents.ts @@ -0,0 +1,58 @@ +import { AuditLogEvent, Events, Message } from 'discord.js'; +import logAction from '../util/logging/logAction.js'; + +export const messageDelete = { + name: Events.MessageDelete, + execute: async (message: Message) => { + try { + if (!message.guild || message.author?.bot) return; + + const { guild } = message; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.MessageDelete, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'messageDelete', + message: message as Message, + moderator, + }); + } catch (error) { + console.error('Error handling message delete:', error); + } + }, +}; + +export const messageUpdate = { + name: Events.MessageUpdate, + execute: async (oldMessage: Message, newMessage: Message) => { + try { + if ( + !oldMessage.guild || + oldMessage.author?.bot || + oldMessage.content === newMessage.content + ) { + return; + } + + await logAction({ + guild: oldMessage.guild, + action: 'messageEdit', + message: newMessage as Message, + oldContent: oldMessage.content ?? '', + newContent: newMessage.content ?? '', + }); + } catch (error) { + console.error('Error handling message update:', error); + } + }, +}; + +export default [messageDelete, messageUpdate]; diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..a3566f4 --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,9 @@ +import { Client, Events } from 'discord.js'; + +export default { + name: Events.ClientReady, + once: true, + execute: async (client: Client) => { + console.log(`Ready! Logged in as ${client.user?.tag}`); + }, +}; diff --git a/src/events/roleEvents.ts b/src/events/roleEvents.ts new file mode 100644 index 0000000..1e7bc54 --- /dev/null +++ b/src/events/roleEvents.ts @@ -0,0 +1,93 @@ +import { AuditLogEvent, Events, Role } from 'discord.js'; +import logAction from '../util/logging/logAction.js'; + +const convertRoleProperties = (role: Role) => ({ + name: role.name, + color: role.hexColor, + hoist: role.hoist, + mentionable: role.mentionable, +}); + +export const roleCreate = { + name: Events.GuildRoleCreate, + execute: async (role: Role) => { + try { + const { guild } = role; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.RoleCreate, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'roleCreate', + role, + moderator, + }); + } catch (error) { + console.error('Error handling role create:', error); + } + }, +}; + +export const roleDelete = { + name: Events.GuildRoleDelete, + execute: async (role: Role) => { + try { + const { guild } = role; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.RoleDelete, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'roleDelete', + role, + moderator, + }); + } catch (error) { + console.error('Error handling role delete:', error); + } + }, +}; + +export const roleUpdate = { + name: Events.GuildRoleUpdate, + execute: async (oldRole: Role, newRole: Role) => { + try { + const { guild } = newRole; + const auditLogs = await guild.fetchAuditLogs({ + type: AuditLogEvent.RoleUpdate, + limit: 1, + }); + const executor = auditLogs.entries.first()?.executor; + const moderator = executor + ? await guild.members.fetch(executor.id) + : undefined; + + await logAction({ + guild, + action: 'roleUpdate', + role: newRole, + oldRole: convertRoleProperties(oldRole), + newRole: convertRoleProperties(newRole), + moderator, + oldPermissions: oldRole.permissions, + newPermissions: newRole.permissions, + }); + } catch (error) { + console.error('Error handling role update:', error); + } + }, +}; + +export default [roleCreate, roleDelete, roleUpdate]; diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts new file mode 100644 index 0000000..4dfba26 --- /dev/null +++ b/src/structures/ExtendedClient.ts @@ -0,0 +1,45 @@ +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 { registerEvents } from '../util/eventLoader.js'; + +export class ExtendedClient extends Client { + public commands: Collection; + private config: Config; + + constructor(options: ClientOptions, config: Config) { + super(options); + this.commands = new Collection(); + this.config = config; + } + + async initialize() { + try { + await this.loadModules(); + await this.login(this.config.token); + } catch (error) { + console.error('Failed to initialize client:', error); + process.exit(1); + } + } + + private async loadModules() { + try { + 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); + } + } +} diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts new file mode 100644 index 0000000..5c8b644 --- /dev/null +++ b/src/types/CommandTypes.ts @@ -0,0 +1,6 @@ +import { CommandInteraction, SlashCommandBuilder } from 'discord.js'; + +export interface Command { + data: Omit; + execute: (interaction: CommandInteraction) => Promise; +} diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts new file mode 100644 index 0000000..f6723b7 --- /dev/null +++ b/src/types/ConfigTypes.ts @@ -0,0 +1,13 @@ +export interface Config { + token: string; + clientId: string; + guildId: string; + dbConnectionString: string; + channels: { + welcome: string; + logs: string; + }; + roles: { + joinRoles: string[]; + }; +} diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts new file mode 100644 index 0000000..f07556d --- /dev/null +++ b/src/types/EventTypes.ts @@ -0,0 +1,7 @@ +import { ClientEvents } from 'discord.js'; + +export interface Event { + name: K; + once?: boolean; + execute: (...args: ClientEvents[K]) => Promise; +} diff --git a/src/util/configLoader.ts b/src/util/configLoader.ts new file mode 100644 index 0000000..497e5a0 --- /dev/null +++ b/src/util/configLoader.ts @@ -0,0 +1,23 @@ +import { Config } from '../types/ConfigTypes.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +export function loadConfig(): Config { + try { + const configPath = path.join(process.cwd(), './config.json'); + const configFile = fs.readFileSync(configPath, 'utf8'); + const config: Config = JSON.parse(configFile); + + const requiredFields = ['token', 'clientId', 'guildId']; + for (const field of requiredFields) { + if (!config[field as keyof Config]) { + throw new Error(`Missing required config field: ${field}`); + } + } + + return config; + } catch (error) { + console.error('Failed to load config:', error); + process.exit(1); + } +} diff --git a/src/util/db.ts b/src/util/db.ts deleted file mode 100644 index 9c23960..0000000 --- a/src/util/db.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from 'node:fs'; -import pkg from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import { memberTable } from '../db/schema.js'; -import { eq } from 'drizzle-orm'; - -const { Pool } = pkg; -const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')); -const { dbConnectionString } = config; - -const dbPool = new Pool({ - connectionString: dbConnectionString, - ssl: true, -}); -const db = drizzle({ client: dbPool }); - -export async function getAllMembers() { - return await db.select().from(memberTable); -} - -export async function setMembers(nonBotMembers: any) { - nonBotMembers.forEach(async (member: any) => { - const memberExists = await db - .select() - .from(memberTable) - .where(eq(memberTable.discordId, member.user.id)); - if (memberExists.length > 0) { - await db - .update(memberTable) - .set({ discordUsername: member.user.username }) - .where(eq(memberTable.discordId, member.user.id)); - } - else { - const members: typeof memberTable.$inferInsert = { - discordId: member.user.id, - discordUsername: member.user.username, - }; - await db.insert(memberTable).values(members); - } - }); -} - -export async function removeMember(discordId: string) { - await db.delete(memberTable).where(eq(memberTable.discordId, discordId)); -} - -export async function getMember(discordId: string) { - return await db - .select() - .from(memberTable) - .where(eq(memberTable.discordId, discordId)); -} diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index cdd27b0..9ce4580 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,9 +1,16 @@ +import { REST, Routes } from 'discord.js'; import fs from 'fs'; import path from 'path'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); +const { token, clientId, guildId } = config; const __dirname = path.resolve(); const commandsPath = path.join(__dirname, 'target', 'commands'); +const rest = new REST({ version: '10' }).setToken(token); + const getFilesRecursively = (directory: string): string[] => { const files: string[] = []; const filesInDirectory = fs.readdirSync(directory); @@ -13,8 +20,7 @@ const getFilesRecursively = (directory: string): string[] => { if (fs.statSync(filePath).isDirectory()) { files.push(...getFilesRecursively(filePath)); - } - else if (file.endsWith('.js')) { + } else if (file.endsWith('.js')) { files.push(filePath); } } @@ -27,9 +33,13 @@ const commandFiles = getFilesRecursively(commandsPath); export const deployCommands = async () => { try { console.log( - `Started refreshing ${commandFiles.length} application (/) commands.`, + `Started refreshing ${commandFiles.length} application (/) commands...`, ); + const existingCommands = (await rest.get( + Routes.applicationGuildCommands(clientId, guildId), + )) as any[]; + const commands = commandFiles.map(async (file) => { const commandModule = await import(`file://${file}`); const command = commandModule.default; @@ -40,8 +50,7 @@ export const deployCommands = async () => { 'execute' in command ) { return command; - } - else { + } else { console.warn( `[WARNING] The command at ${file} is missing a required "data" or "execute" property.`, ); @@ -53,9 +62,31 @@ export const deployCommands = async () => { commands.filter((command) => command !== null), ); + const apiCommands = validCommands.map((command) => command.data.toJSON()); + + const commandsToRemove = existingCommands.filter( + (existingCmd) => + !apiCommands.some((newCmd) => newCmd.name === existingCmd.name), + ); + + for (const cmdToRemove of commandsToRemove) { + await rest.delete( + Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id), + ); + console.log(`Removed command: ${cmdToRemove.name}`); + } + + const data: any = await rest.put( + Routes.applicationGuildCommands(clientId, guildId), + { body: apiCommands }, + ); + + console.log( + `Successfully registered ${data.length} application (/) commands with the Discord API.`, + ); + return validCommands; - } - catch (error) { + } catch (error) { console.error(error); } }; diff --git a/src/util/eventLoader.ts b/src/util/eventLoader.ts new file mode 100644 index 0000000..855f296 --- /dev/null +++ b/src/util/eventLoader.ts @@ -0,0 +1,45 @@ +import { Client } from 'discord.js'; +import { readdirSync } from 'fs'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export async function registerEvents(client: Client): Promise { + try { + const eventsPath = join(__dirname, '..', 'events'); + const eventFiles = readdirSync(eventsPath).filter( + (file) => file.endsWith('.js') || file.endsWith('.ts'), + ); + + for (const file of eventFiles) { + const filePath = join(eventsPath, file); + const eventModule = await import(`file://${filePath}`); + + const events = + eventModule.default || eventModule[`${file.split('.')[0]}Events`]; + + const eventArray = Array.isArray(events) ? events : [events]; + + for (const event of eventArray) { + if (!event?.name) { + console.warn(`Event in ${filePath} is missing a name property`); + continue; + } + + if (event.once) { + client.once(event.name, (...args) => event.execute(...args)); + } else { + client.on(event.name, (...args) => event.execute(...args)); + } + + console.log(`Registered event: ${event.name}`); + } + } + } catch (error) { + console.error('Error registering events:', error); + throw error; + } +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts new file mode 100644 index 0000000..dcc6fca --- /dev/null +++ b/src/util/helpers.ts @@ -0,0 +1,165 @@ +import Canvas from '@napi-rs/canvas'; +import path from 'path'; + +import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js'; +import { and, eq } from 'drizzle-orm'; + +import { moderationTable } from '../db/schema.js'; +import { db, updateMember } from '../db/db.js'; +import logAction from './logging/logAction.js'; + +const __dirname = path.resolve(); + +export function parseDuration(duration: string): number { + const regex = /^(\d+)(s|m|h|d)$/; + const match = duration.match(regex); + if (!match) throw new Error('Invalid duration format'); + const value = parseInt(match[1]); + const unit = match[2]; + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'd': + return value * 24 * 60 * 60 * 1000; + default: + throw new Error('Invalid duration unit'); + } +} + +interface generateMemberBannerTypes { + member: GuildMember; + width: number; + height: number; +} + +export async function generateMemberBanner({ + member, + width, + height, +}: generateMemberBannerTypes) { + const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png'); + const canvas = Canvas.createCanvas(width, height); + const context = canvas.getContext('2d'); + const background = await Canvas.loadImage(welcomeBackground); + const memberCount = member.guild.memberCount; + const avatarSize = 150; + const avatarY = height - avatarSize - 25; + const avatarX = width / 2 - avatarSize / 2; + + context.drawImage(background, 0, 0, width, height); + + context.fillStyle = 'rgba(0, 0, 0, 0.5)'; + context.fillRect(0, 0, width, height); + + context.font = '60px Sans'; + context.fillStyle = '#ffffff'; + context.textAlign = 'center'; + context.fillText('Welcome', width / 2, height / 3.25); + + context.font = '40px Sans'; + context.fillText(member.user.username, width / 2, height / 2.25); + + context.font = '30px Sans'; + context.fillText(`You are member #${memberCount}`, width / 2, height / 1.75); + + context.beginPath(); + context.arc( + width / 2, + height - avatarSize / 2 - 25, + avatarSize / 2, + 0, + Math.PI * 2, + true, + ); + context.closePath(); + context.clip(); + + const avatarURL = member.user.displayAvatarURL({ + extension: 'png', + size: 256, + }); + const avatar = await Canvas.loadImage(avatarURL); + context.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize); + + const attachment = new AttachmentBuilder(await canvas.encode('png'), { + name: 'welcome-image.png', + }); + + return attachment; +} + +export async function scheduleUnban( + client: Client, + guildId: string, + userId: string, + expiresAt: Date, +) { + const timeUntilUnban = expiresAt.getTime() - Date.now(); + if (timeUntilUnban > 0) { + setTimeout(async () => { + await executeUnban(client, guildId, userId); + }, timeUntilUnban); + } +} + +export async function executeUnban( + client: Client, + guildId: string, + userId: string, + reason?: string, +) { + try { + const guild = await client.guilds.fetch(guildId); + await guild.members.unban(userId, reason ?? 'Temporary ban expired'); + + await db + .update(moderationTable) + .set({ active: false }) + .where( + and( + eq(moderationTable.discordId, userId), + eq(moderationTable.action, 'ban'), + eq(moderationTable.active, true), + ), + ); + + await updateMember({ + discordId: userId, + currentlyBanned: false, + }); + + await logAction({ + guild, + action: 'unban', + target: guild.members.cache.get(userId)!, + moderator: guild.members.cache.get(client.user!.id)!, + reason: reason ?? 'Temporary ban expired', + }); + } catch (error) { + console.error(`Failed to unban user ${userId}:`, error); + } +} + +export async function loadActiveBans(client: Client, guild: Guild) { + const activeBans = await db + .select() + .from(moderationTable) + .where( + and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)), + ); + + for (const ban of activeBans) { + if (!ban.expiresAt) continue; + + const timeUntilUnban = ban.expiresAt.getTime() - Date.now(); + if (timeUntilUnban <= 0) { + await executeUnban(client, guild.id, ban.discordId); + } else { + await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + } + } +} diff --git a/src/util/logging/constants.ts b/src/util/logging/constants.ts new file mode 100644 index 0000000..85ed0bf --- /dev/null +++ b/src/util/logging/constants.ts @@ -0,0 +1,65 @@ +import { ChannelType } from 'discord.js'; +import { LogActionType } from './types'; + +export const ACTION_COLORS: Record = { + // Danger actions - Red + ban: 0xff0000, + kick: 0xff0000, + messageDelete: 0xff0000, + channelDelete: 0xff0000, + memberLeave: 0xff0000, + roleDelete: 0xff0000, + + // Warning actions - Orange + warn: 0xffaa00, + mute: 0xffaa00, + roleUpdate: 0xffaa00, + memberUsernameUpdate: 0xffaa00, + memberNicknameUpdate: 0xffaa00, + channelUpdate: 0xffaa00, + messageUpdate: 0xffaa00, + + // Success actions - Green + unban: 0x00ff00, + unmute: 0x00ff00, + memberJoin: 0x00aa00, + channelCreate: 0x00aa00, + roleAdd: 0x00aa00, + roleCreate: 0x00aa00, + + // Default - Blue + default: 0x0099ff, +}; + +export const ACTION_EMOJIS: Record = { + roleCreate: '⭐', + roleDelete: 'πŸ—‘οΈ', + roleUpdate: 'πŸ“', + channelCreate: 'πŸ“’', + channelDelete: 'πŸ—‘οΈ', + channelUpdate: 'πŸ”§', + ban: 'πŸ”¨', + kick: 'πŸ‘’', + mute: 'πŸ”‡', + unban: 'πŸ”“', + unmute: 'πŸ”Š', + warn: '⚠️', + messageDelete: 'πŸ“', + messageEdit: '✏️', + memberJoin: 'πŸ‘‹', + memberLeave: 'πŸ‘‹', + memberUsernameUpdate: 'πŸ“', + memberNicknameUpdate: 'πŸ“', + roleAdd: 'βž•', + roleRemove: 'βž–', +}; + +export const CHANNEL_TYPES: Record = { + [ChannelType.GuildText]: 'Text Channel', + [ChannelType.GuildVoice]: 'Voice Channel', + [ChannelType.GuildCategory]: 'Category', + [ChannelType.GuildStageVoice]: 'Stage Channel', + [ChannelType.GuildForum]: 'Forum Channel', + [ChannelType.GuildAnnouncement]: 'Announcement Channel', + [ChannelType.GuildMedia]: 'Media Channel', +}; diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts new file mode 100644 index 0000000..6c40810 --- /dev/null +++ b/src/util/logging/logAction.ts @@ -0,0 +1,276 @@ +import { + TextChannel, + ButtonStyle, + ButtonBuilder, + ActionRowBuilder, + GuildChannel, +} from 'discord.js'; +import { + LogActionPayload, + ModerationLogAction, + RoleUpdateAction, +} from './types.js'; +import { ACTION_COLORS, CHANNEL_TYPES } from './constants.js'; +import { + createUserField, + createModeratorField, + createChannelField, + createPermissionChangeFields, + createRoleChangeFields, + getLogItemId, + getEmojiForAction, +} from './utils.js'; + +export default async function logAction(payload: LogActionPayload) { + const logChannel = payload.guild.channels.cache.get('1007787977432383611'); + if (!logChannel || !(logChannel instanceof TextChannel)) { + console.error('Log channel not found or is not a Text Channel.'); + return; + } + + const fields = []; + const components = []; + + switch (payload.action) { + case 'ban': + case 'kick': + case 'mute': + case 'unban': + case 'unmute': + case 'warn': { + const moderationPayload = payload as ModerationLogAction; + fields.push( + createUserField(moderationPayload.target, 'User'), + createModeratorField(moderationPayload.moderator, 'Moderator')!, + { name: 'Reason', value: moderationPayload.reason, inline: false }, + ); + if (moderationPayload.duration) { + fields.push({ + name: 'Duration', + value: moderationPayload.duration, + inline: true, + }); + } + break; + } + + case 'messageDelete': { + if (!payload.message.guild) return; + + fields.push( + createUserField(payload.message.author, 'Author'), + createChannelField(payload.message.channel as GuildChannel), + { + name: 'Content', + value: payload.message.content || '*No content*', + inline: false, + }, + ); + break; + } + + case 'messageEdit': { + if (!payload.message.guild) return; + + fields.push( + createUserField(payload.message.author, 'Author'), + createChannelField(payload.message.channel as GuildChannel), + { + name: 'Before', + value: payload.oldContent || '*No content*', + inline: false, + }, + { + name: 'After', + value: payload.newContent || '*No content*', + inline: false, + }, + ); + + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Jump to Message') + .setStyle(ButtonStyle.Link) + .setURL(payload.message.url), + ), + ); + break; + } + + case 'memberJoin': + case 'memberLeave': { + fields.push(createUserField(payload.member, 'User'), { + name: 'Account Created', + value: ``, + inline: true, + }); + break; + } + + case 'memberUsernameUpdate': + case 'memberNicknameUpdate': { + const isUsername = payload.action === 'memberUsernameUpdate'; + + fields.push(createUserField(payload.member, 'User'), { + name: 'πŸ“ Change Details', + value: [ + `**Type:** ${isUsername ? 'Username' : 'Nickname'} Update`, + `**Before:** ${payload.oldValue}`, + `**After:** ${payload.newValue}`, + ].join('\n'), + inline: false, + }); + break; + } + + case 'roleAdd': + case 'roleRemove': { + fields.push(createUserField(payload.member, 'User'), { + name: 'Role', + value: payload.role.name, + inline: true, + }); + const moderatorField = createModeratorField( + payload.moderator, + 'Added/Removed By', + ); + if (moderatorField) fields.push(moderatorField); + break; + } + + case 'roleCreate': + case 'roleDelete': { + fields.push( + { name: 'Role Name', value: payload.role.name, inline: true }, + { + name: 'Role Color', + value: payload.role.hexColor || 'No Color', + inline: true, + }, + { + name: 'Hoisted', + value: payload.role.hoist ? 'Yes' : 'No', + inline: true, + }, + { + name: 'Mentionable', + value: payload.role.mentionable ? 'Yes' : 'No', + inline: true, + }, + ); + const moderatorField = createModeratorField( + payload.moderator, + payload.action === 'roleCreate' ? 'Created By' : 'Deleted By', + ); + if (moderatorField) fields.push(moderatorField); + break; + } + + case 'roleUpdate': { + const rolePayload = payload as RoleUpdateAction; + + fields.push({ + name: 'πŸ“ Role Information', + value: [ + `**Name:** ${rolePayload.role.name}`, + `**Color:** ${rolePayload.role.hexColor}`, + `**Position:** ${rolePayload.role.position}`, + ].join('\n'), + inline: false, + }); + + const changes = createRoleChangeFields( + rolePayload.oldRole, + rolePayload.newRole, + ); + if (changes.length) { + fields.push({ + name: 'πŸ”„ Changes Made', + value: changes + .map((field) => `**${field.name}:** ${field.value}`) + .join('\n'), + inline: false, + }); + } + + const permissionChanges = createPermissionChangeFields( + rolePayload.oldPermissions, + rolePayload.newPermissions, + ); + fields.push(...permissionChanges); + + const moderatorField = createModeratorField( + rolePayload.moderator, + 'πŸ‘€ Modified By', + ); + if (moderatorField) fields.push(moderatorField); + break; + } + + case 'channelUpdate': { + fields.push({ + name: 'πŸ“ Channel Information', + value: [ + `**Channel:** <#${payload.channel.id}>`, + `**Type:** ${CHANNEL_TYPES[payload.channel.type]}`, + payload.oldName !== payload.newName + ? `**Name Change:** ${payload.oldName} β†’ ${payload.newName}` + : null, + ] + .filter(Boolean) + .join('\n'), + inline: false, + }); + + if (payload.oldPermissions && payload.newPermissions) { + const permissionChanges = createPermissionChangeFields( + payload.oldPermissions, + payload.newPermissions, + ); + fields.push(...permissionChanges); + } + + const moderatorField = createModeratorField( + payload.moderator, + 'πŸ‘€ Modified By', + ); + if (moderatorField) fields.push(moderatorField); + break; + } + + case 'channelCreate': + case 'channelDelete': { + fields.push( + { name: 'Channel', value: `<#${payload.channel.id}>`, inline: true }, + { + name: 'Type', + value: + CHANNEL_TYPES[payload.channel.type] || String(payload.channel.type), + inline: true, + }, + ); + const moderatorField = createModeratorField( + payload.moderator, + 'Created/Deleted By', + ); + if (moderatorField) fields.push(moderatorField); + break; + } + } + + const logEmbed = { + color: ACTION_COLORS[payload.action] || ACTION_COLORS.default, + title: `${getEmojiForAction(payload.action)} ${payload.action.toUpperCase()}`, + fields: fields.filter(Boolean), + timestamp: new Date().toISOString(), + footer: { + text: `ID: ${getLogItemId(payload)}`, + }, + }; + + await logChannel.send({ + embeds: [logEmbed], + components: components.length ? components : undefined, + }); +} diff --git a/src/util/logging/types.ts b/src/util/logging/types.ts new file mode 100644 index 0000000..414ffe6 --- /dev/null +++ b/src/util/logging/types.ts @@ -0,0 +1,124 @@ +import { + Guild, + GuildMember, + Message, + Role, + GuildChannel, + PermissionsBitField, +} from 'discord.js'; + +export type ModerationActionType = + | 'ban' + | 'kick' + | 'mute' + | 'unban' + | 'unmute' + | 'warn'; +export type MessageActionType = 'messageDelete' | 'messageEdit'; +export type MemberActionType = + | 'memberJoin' + | 'memberLeave' + | 'memberUsernameUpdate' + | 'memberNicknameUpdate'; +export type RoleActionType = + | 'roleAdd' + | 'roleRemove' + | 'roleCreate' + | 'roleDelete' + | 'roleUpdate'; +export type ChannelActionType = + | 'channelCreate' + | 'channelDelete' + | 'channelUpdate'; + +export type LogActionType = + | ModerationActionType + | MessageActionType + | MemberActionType + | RoleActionType + | ChannelActionType; + +export type RoleProperties = { + name: string; + color: string; + hoist: boolean; + mentionable: boolean; +}; + +export interface BaseLogAction { + guild: Guild; + action: LogActionType; + moderator?: GuildMember; + reason?: string; + duration?: string; +} + +export interface ModerationLogAction extends BaseLogAction { + action: ModerationActionType; + target: GuildMember; + moderator: GuildMember; + reason: string; + duration?: string; +} + +export interface MessageLogAction extends BaseLogAction { + action: MessageActionType; + message: Message; + oldContent?: string; + newContent?: string; +} + +export interface MemberLogAction extends BaseLogAction { + action: 'memberJoin' | 'memberLeave'; + member: GuildMember; +} + +export interface MemberUpdateAction extends BaseLogAction { + action: 'memberUsernameUpdate' | 'memberNicknameUpdate'; + member: GuildMember; + oldValue: string; + newValue: string; +} + +export interface RoleLogAction extends BaseLogAction { + action: 'roleAdd' | 'roleRemove'; + member: GuildMember; + role: Role; + moderator?: GuildMember; +} + +export interface RoleUpdateAction extends BaseLogAction { + action: 'roleUpdate'; + role: Role; + oldRole: Partial; + newRole: Partial; + oldPermissions: Readonly; + newPermissions: Readonly; + moderator?: GuildMember; +} + +export interface RoleCreateDeleteAction extends BaseLogAction { + action: 'roleCreate' | 'roleDelete'; + role: Role; + moderator?: GuildMember; +} + +export interface ChannelLogAction extends BaseLogAction { + action: ChannelActionType; + channel: GuildChannel; + oldName?: string; + newName?: string; + oldPermissions?: Readonly; + newPermissions?: Readonly; + moderator?: GuildMember; +} + +export type LogActionPayload = + | ModerationLogAction + | MessageLogAction + | MemberLogAction + | MemberUpdateAction + | RoleLogAction + | RoleCreateDeleteAction + | RoleUpdateAction + | ChannelLogAction; diff --git a/src/util/logging/utils.ts b/src/util/logging/utils.ts new file mode 100644 index 0000000..f4044e4 --- /dev/null +++ b/src/util/logging/utils.ts @@ -0,0 +1,163 @@ +import { + User, + GuildMember, + GuildChannel, + EmbedField, + PermissionsBitField, +} from 'discord.js'; +import { LogActionPayload, LogActionType, RoleProperties } from './types.js'; +import { ACTION_EMOJIS } from './constants.js'; + +export const formatPermissionName = (perm: string): string => { + return perm + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +}; + +export const createUserField = ( + user: User | GuildMember, + label = 'User', +): EmbedField => ({ + name: label, + value: `<@${user.id}>`, + inline: true, +}); + +export const createModeratorField = ( + moderator?: GuildMember, + label = 'Moderator', +): EmbedField | null => + moderator + ? { + name: label, + value: `<@${moderator.id}>`, + inline: true, + } + : null; + +export const createChannelField = (channel: GuildChannel): EmbedField => ({ + name: 'Channel', + value: `<#${channel.id}>`, + inline: true, +}); + +export const createPermissionChangeFields = ( + oldPerms: Readonly, + newPerms: Readonly, +): EmbedField[] => { + const fields: EmbedField[] = []; + const changes: { added: string[]; removed: string[] } = { + added: [], + removed: [], + }; + + Object.keys(PermissionsBitField.Flags).forEach((perm) => { + const hasOld = oldPerms.has(perm as keyof typeof PermissionsBitField.Flags); + const hasNew = newPerms.has(perm as keyof typeof PermissionsBitField.Flags); + + if (hasOld !== hasNew) { + if (hasNew) { + changes.added.push(formatPermissionName(perm)); + } else { + changes.removed.push(formatPermissionName(perm)); + } + } + }); + + if (changes.added.length) { + fields.push({ + name: 'βœ… Added Permissions', + value: changes.added.join('\n'), + inline: true, + }); + } + + if (changes.removed.length) { + fields.push({ + name: '❌ Removed Permissions', + value: changes.removed.join('\n'), + inline: true, + }); + } + + return fields; +}; + +export const createRoleChangeFields = ( + oldRole: Partial, + newRole: Partial, +): EmbedField[] => { + const fields: EmbedField[] = []; + + if (oldRole.name !== newRole.name) { + fields.push({ + name: 'Name Changed', + value: `${oldRole.name} β†’ ${newRole.name}`, + inline: true, + }); + } + + if (oldRole.color !== newRole.color) { + fields.push({ + name: 'Color Changed', + value: `${oldRole.color || 'None'} β†’ ${newRole.color || 'None'}`, + inline: true, + }); + } + + const booleanProps: Array< + keyof Pick + > = ['hoist', 'mentionable']; + + for (const prop of booleanProps) { + if (oldRole[prop] !== newRole[prop]) { + fields.push({ + name: `${prop.charAt(0).toUpperCase() + prop.slice(1)} Changed`, + value: `${oldRole[prop] ? 'Yes' : 'No'} β†’ ${newRole[prop] ? 'Yes' : 'No'}`, + inline: true, + }); + } + } + + return fields; +}; + +export const getLogItemId = (payload: LogActionPayload): string => { + switch (payload.action) { + case 'roleCreate': + case 'roleDelete': + case 'roleUpdate': + case 'roleAdd': + case 'roleRemove': + return payload.role.id; + + case 'channelCreate': + case 'channelDelete': + case 'channelUpdate': + return payload.channel.id; + + case 'messageDelete': + case 'messageEdit': + return payload.message.id; + + case 'memberJoin': + case 'memberLeave': + return payload.member.id; + + case 'ban': + case 'kick': + case 'mute': + case 'unban': + case 'unmute': + case 'warn': + return payload.target.id; + + default: + return 'N/A'; + } +}; + +export const getEmojiForAction = (action: LogActionType): string => { + return ACTION_EMOJIS[action] || 'πŸ“'; +};