diff --git a/assets/welcome-bg.png b/assets/welcome-bg.png new file mode 100644 index 0000000..a227b23 Binary files /dev/null and b/assets/welcome-bg.png differ diff --git a/config.example.json b/config.example.json index 2869f19..490683f 100644 --- a/config.example.json +++ b/config.example.json @@ -2,5 +2,14 @@ "token": "DISCORD_BOT_API_KEY", "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", - "dbConnectionString": "POSTGRESQL_CONNECTION_STRING" + "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "channels": { + "welcome": "WELCOME_CHANNEL_ID", + "logs": "LOG_CHAANNEL_ID" + }, + "roles": { + "joinRoles": [ + "JOIN_ROLE_IDS" + ] + } } diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts new file mode 100644 index 0000000..9b40b0d --- /dev/null +++ b/src/commands/moderation/ban.ts @@ -0,0 +1,127 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; +import { updateMember, updateMemberModerationHistory } from '../../db/db.js'; +import { parseDuration, scheduleUnban } from '../../util/helpers.js'; +import logAction from '../../util/logging/logAction.js'; + +interface Command { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => 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] || 'πŸ“'; +};