diff --git a/config.example.json b/config.example.json index 490683f..28fca15 100644 --- a/config.example.json +++ b/config.example.json @@ -3,6 +3,7 @@ "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "redisConnectionString": "REDIS_CONNECTION_STRING", "channels": { "welcome": "WELCOME_CHANNEL_ID", "logs": "LOG_CHAANNEL_ID" diff --git a/package.json b/package.json index 269de6f..9c50242 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@napi-rs/canvas": "^0.1.67", "discord.js": "^14.18.0", "drizzle-orm": "^0.40.0", + "ioredis": "^5.5.0", "pg": "^8.13.3" }, "devDependencies": { diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts index 9b40b0d..4cbc40f 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -1,19 +1,11 @@ -import { - CommandInteraction, - PermissionsBitField, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + import { updateMember, updateMemberModerationHistory } from '../../db/db.js'; import { parseDuration, scheduleUnban } from '../../util/helpers.js'; +import { OptionsCommand } from '../../types/CommandTypes.js'; import logAction from '../../util/logging/logAction.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} - -const command: Command = { +const command: OptionsCommand = { data: new SlashCommandBuilder() .setName('ban') .setDescription('Ban a member from the server') diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts index 70efff7..8a458f2 100644 --- a/src/commands/moderation/unban.ts +++ b/src/commands/moderation/unban.ts @@ -1,17 +1,9 @@ -import { - CommandInteraction, - PermissionsBitField, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + import { executeUnban } from '../../util/helpers.js'; +import { OptionsCommand } from '../../types/CommandTypes.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} - -const command: Command = { +const command: OptionsCommand = { data: new SlashCommandBuilder() .setName('unban') .setDescription('Unban a user from the server') diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index f368aa2..c92c7ae 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -1,18 +1,10 @@ -import { - CommandInteraction, - PermissionsBitField, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + import { updateMemberModerationHistory } from '../../db/db.js'; +import { OptionsCommand } from '../../types/CommandTypes.js'; import logAction from '../../util/logging/logAction.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} - -const command: Command = { +const command: OptionsCommand = { data: new SlashCommandBuilder() .setName('warn') .setDescription('Warn a member') diff --git a/src/commands/testing/testJoin.ts b/src/commands/testing/testJoin.ts index 5a0eb28..b1998e0 100644 --- a/src/commands/testing/testJoin.ts +++ b/src/commands/testing/testJoin.ts @@ -1,14 +1,6 @@ -import { - CommandInteraction, - PermissionsBitField, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} +import { Command } from '../../types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/testing/testLeave.ts b/src/commands/testing/testLeave.ts index 3f13f5c..b643b0e 100644 --- a/src/commands/testing/testLeave.ts +++ b/src/commands/testing/testLeave.ts @@ -1,15 +1,7 @@ -import { - CommandInteraction, - PermissionsBitField, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, -} from 'discord.js'; -import { updateMember } from '../../db/db.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} +import { updateMember } from '../../db/db.js'; +import { Command } from '../../types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index bd088f2..083ee64 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -1,6 +1,5 @@ import { SlashCommandBuilder, - CommandInteraction, EmbedBuilder, ButtonBuilder, ActionRowBuilder, @@ -9,12 +8,9 @@ import { APIEmbed, JSONEncodable, } from 'discord.js'; -import { getAllMembers } from '../../db/db.js'; -interface Command { - data: Omit; - execute: (interaction: CommandInteraction) => Promise; -} +import { getAllMembers } from '../../db/db.js'; +import { Command } from '../../types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/ping.ts b/src/commands/util/ping.ts index c67554e..ed64529 100644 --- a/src/commands/util/ping.ts +++ b/src/commands/util/ping.ts @@ -1,9 +1,6 @@ -import { SlashCommandBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; -interface Command { - data: Omit; - execute: (interaction: CommandInteraction) => Promise; -} +import { Command } from '../../types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/rules.ts b/src/commands/util/rules.ts index eb7d5f7..c8ff029 100644 --- a/src/commands/util/rules.ts +++ b/src/commands/util/rules.ts @@ -1,13 +1,6 @@ -import { - SlashCommandBuilder, - CommandInteraction, - EmbedBuilder, -} from 'discord.js'; +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -interface Command { - data: Omit; - execute: (interaction: CommandInteraction) => Promise; -} +import { Command } from '../../types/CommandTypes.js'; const rulesEmbed = new EmbedBuilder() .setColor(0x0099ff) diff --git a/src/commands/util/server.ts b/src/commands/util/server.ts index 565770d..a391e03 100644 --- a/src/commands/util/server.ts +++ b/src/commands/util/server.ts @@ -1,9 +1,6 @@ -import { SlashCommandBuilder, CommandInteraction } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; -interface Command { - data: Omit; - execute: (interaction: CommandInteraction) => Promise; -} +import { Command } from '../../types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() diff --git a/src/commands/util/user-info.ts b/src/commands/util/user-info.ts index 42218ab..7f1ac68 100644 --- a/src/commands/util/user-info.ts +++ b/src/commands/util/user-info.ts @@ -1,19 +1,14 @@ import { SlashCommandBuilder, - CommandInteraction, EmbedBuilder, - SlashCommandOptionsOnlyBuilder, GuildMember, PermissionsBitField, } from 'discord.js'; + import { getMember } from '../../db/db.js'; +import { OptionsCommand } from '../../types/CommandTypes.js'; -interface Command { - data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; -} - -const command: Command = { +const command: OptionsCommand = { data: new SlashCommandBuilder() .setName('userinfo') .setDescription('Provides information about the specified user.') diff --git a/src/db/db.ts b/src/db/db.ts index 81d7e49..ebc2db8 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,8 +1,10 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from './schema.js'; import { eq } from 'drizzle-orm'; + +import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; +import { del, exists, getJson, setJson } from './redis.js'; const { Pool } = pkg; const config = loadConfig(); @@ -13,41 +15,121 @@ const dbPool = new Pool({ }); export const db = drizzle({ client: dbPool, schema }); +class DatabaseError extends Error { + constructor( + message: string, + public originalError?: Error, + ) { + super(message); + this.name = 'DatabaseError'; + } +} + export async function getAllMembers() { - return await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.currentlyInServer, true)); + try { + if (await exists('nonBotMembers')) { + const memberData = + await getJson<(typeof schema.memberTable.$inferSelect)[]>( + 'nonBotMembers', + ); + if (memberData && memberData.length > 0) { + return memberData; + } else { + await del('nonBotMembers'); + return await getAllMembers(); + } + } else { + const nonBotMembers = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.currentlyInServer, true)); + await setJson<(typeof schema.memberTable.$inferSelect)[]>( + 'nonBotMembers', + nonBotMembers, + ); + return nonBotMembers; + } + } catch (error) { + console.error('Error getting all members: ', error); + throw new DatabaseError('Failed to get all members: ', error as Error); + } } 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 }) + try { + nonBotMembers.forEach(async (member: any) => { + const memberInfo = await db + .select() + .from(schema.memberTable) .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); - } - }); + if (memberInfo.length > 0) { + await updateMember({ + discordId: member.user.id, + discordUsername: member.user.username, + currentlyInServer: true, + }); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }); + } catch (error) { + console.error('Error setting members: ', error); + throw new DatabaseError('Failed to set members: ', error as Error); + } } export async function getMember(discordId: string) { - return await db.query.memberTable.findFirst({ - where: eq(schema.memberTable.discordId, discordId), - with: { - moderations: true, - }, - }); + try { + if (await exists(`${discordId}-memberInfo`)) { + const cachedMember = await getJson< + typeof schema.memberTable.$inferSelect + >(`${discordId}-memberInfo`); + const cachedModerationHistory = await getJson< + (typeof schema.moderationTable.$inferSelect)[] + >(`${discordId}-moderationHistory`); + + if ( + cachedMember && + 'discordId' in cachedMember && + cachedModerationHistory && + cachedModerationHistory.length > 0 + ) { + return { + ...cachedMember, + moderations: cachedModerationHistory, + }; + } else { + await del(`${discordId}-memberInfo`); + await del(`${discordId}-moderationHistory`); + return await getMember(discordId); + } + } else { + const member = await db.query.memberTable.findFirst({ + where: eq(schema.memberTable.discordId, discordId), + with: { + moderations: true, + }, + }); + + await setJson( + `${discordId}-memberInfo`, + member!, + ); + await setJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + member!.moderations, + ); + + return member; + } + } catch (error) { + console.error('Error getting member: ', error); + throw new DatabaseError('Failed to get member: ', error as Error); + } } export async function updateMember({ @@ -56,14 +138,28 @@ export async function updateMember({ currentlyInServer, currentlyBanned, }: schema.memberTableTypes) { - return await db - .update(schema.memberTable) - .set({ - discordUsername, - currentlyInServer, - currentlyBanned, - }) - .where(eq(schema.memberTable.discordId, discordId)); + try { + const result = await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + }) + .where(eq(schema.memberTable.discordId, discordId)); + + if (await exists(`${discordId}-memberInfo`)) { + await del(`${discordId}-memberInfo`); + } + if (await exists('nonBotMembers')) { + await del('nonBotMembers'); + } + + return result; + } catch (error) { + console.error('Error updating member: ', error); + throw new DatabaseError('Failed to update member: ', error as Error); + } } export async function updateMemberModerationHistory({ @@ -76,22 +172,61 @@ export async function updateMemberModerationHistory({ expiresAt, active, }: schema.moderationTableTypes) { - const moderationEntry = { - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, - }; - return await db.insert(schema.moderationTable).values(moderationEntry); + try { + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + const result = await db + .insert(schema.moderationTable) + .values(moderationEntry); + + if (await exists(`${discordId}-moderationHistory`)) { + await del(`${discordId}-moderationHistory`); + } + if (await exists(`${discordId}-memberInfo`)) { + await del(`${discordId}-memberInfo`); + } + + return result; + } catch (error) { + console.error('Error updating moderation history: ', error); + throw new DatabaseError( + 'Failed to update moderation history: ', + error as Error, + ); + } } export async function getMemberModerationHistory(discordId: string) { - return await db - .select() - .from(schema.moderationTable) - .where(eq(schema.moderationTable.discordId, discordId)); + try { + if (await exists(`${discordId}-moderationHistory`)) { + return await getJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + ); + } else { + const moderationHistory = await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); + + await setJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + moderationHistory, + ); + return moderationHistory; + } + } catch (error) { + console.error('Error getting moderation history: ', error); + throw new DatabaseError( + 'Failed to get moderation history: ', + error as Error, + ); + } } diff --git a/src/db/redis.ts b/src/db/redis.ts new file mode 100644 index 0000000..8938d17 --- /dev/null +++ b/src/db/redis.ts @@ -0,0 +1,105 @@ +import Redis from 'ioredis'; +import { loadConfig } from '../util/configLoader.js'; + +const config = loadConfig(); +const redis = new Redis(config.redisConnectionString); + +class RedisError extends Error { + constructor( + message: string, + public originalError?: Error, + ) { + super(message); + this.name = 'RedisError'; + } +} + +redis.on('error', (error) => { + console.error('Redis connection error:', error); + throw new RedisError('Failed to connect to Redis instance: ', error); +}); + +redis.on('connect', () => { + console.log('Successfully connected to Redis'); +}); + +export async function set( + key: string, + value: string, + ttl?: number, +): Promise<'OK'> { + try { + await redis.set(key, value); + if (ttl) await redis.expire(key, ttl); + } catch (error) { + console.error('Redis set error: ', error); + throw new RedisError(`Failed to set key: ${key}, `, error as Error); + } + return Promise.resolve('OK'); +} + +export async function setJson( + key: string, + value: T, + ttl?: number, +): Promise<'OK'> { + return await set(key, JSON.stringify(value), ttl); +} + +export async function incr(key: string): Promise { + try { + return await redis.incr(key); + } catch (error) { + console.error('Redis increment error: ', error); + throw new RedisError(`Failed to increment key: ${key}, `, error as Error); + } +} + +export async function exists(key: string): Promise { + try { + return (await redis.exists(key)) === 1; + } catch (error) { + console.error('Redis exists error: ', error); + throw new RedisError( + `Failed to check if key exists: ${key}, `, + error as Error, + ); + } +} + +export async function get(key: string): Promise { + try { + return await redis.get(key); + } catch (error) { + console.error('Redis get error: ', error); + throw new RedisError(`Failed to get key: ${key}, `, error as Error); + } +} + +export async function mget(...keys: string[]): Promise<(string | null)[]> { + try { + return await redis.mget(keys); + } catch (error) { + console.error('Redis mget error: ', error); + throw new RedisError(`Failed to get keys: ${keys}, `, error as Error); + } +} + +export async function getJson(key: string): Promise { + const value = await get(key); + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +export async function del(key: string): Promise { + try { + return await redis.del(key); + } catch (error) { + console.error('Redis del error: ', error); + throw new RedisError(`Failed to delete key: ${key}, `, error as Error); + } +} diff --git a/src/events/channelEvents.ts b/src/events/channelEvents.ts index 617f428..523eb29 100644 --- a/src/events/channelEvents.ts +++ b/src/events/channelEvents.ts @@ -1,8 +1,101 @@ -import { AuditLogEvent, Events, GuildChannel } from 'discord.js'; -import logAction from '../util/logging/logAction.js'; -import { Event } from '../types/EventTypes.js'; +import { + AuditLogEvent, + ChannelType, + DMChannel, + Events, + GuildChannel, + PermissionOverwrites, +} from 'discord.js'; -export const channelCreate = { +import { ChannelLogAction } from '../util/logging/types.js'; +import { Event } from '../types/EventTypes.js'; +import logAction from '../util/logging/logAction.js'; + +function arePermissionsEqual( + oldPerms: Map, + newPerms: Map, +): boolean { + if (oldPerms.size !== newPerms.size) return false; + + for (const [id, oldPerm] of oldPerms.entries()) { + const newPerm = newPerms.get(id); + if (!newPerm) return false; + + if ( + !oldPerm.allow.equals(newPerm.allow) || + !oldPerm.deny.equals(newPerm.deny) + ) { + return false; + } + } + + return true; +} +function getPermissionChanges( + oldChannel: GuildChannel, + newChannel: GuildChannel, +): ChannelLogAction['permissionChanges'] { + const changes: ChannelLogAction['permissionChanges'] = []; + const newPerms = newChannel.permissionOverwrites.cache; + const oldPerms = oldChannel.permissionOverwrites.cache; + + for (const [id, newPerm] of newPerms.entries()) { + const oldPerm = oldPerms.get(id); + const targetType = newPerm.type === 0 ? 'role' : 'member'; + const targetName = + newPerm.type === 0 + ? newChannel.guild.roles.cache.get(id)?.name || id + : newChannel.guild.members.cache.get(id)?.user.username || id; + + if (!oldPerm) { + changes.push({ + action: 'added', + targetId: id, + targetType, + targetName, + allow: newPerm.allow, + deny: newPerm.deny, + }); + } else if ( + !oldPerm.allow.equals(newPerm.allow) || + !oldPerm.deny.equals(newPerm.deny) + ) { + changes.push({ + action: 'modified', + targetId: id, + targetType, + targetName, + oldAllow: oldPerm.allow, + oldDeny: oldPerm.deny, + newAllow: newPerm.allow, + newDeny: newPerm.deny, + }); + } + } + + for (const [id, oldPerm] of oldPerms.entries()) { + if (!newPerms.has(id)) { + const targetType = oldPerm.type === 0 ? 'role' : 'member'; + const targetName = + oldPerm.type === 0 + ? oldChannel.guild.roles.cache.get(id)?.name || id + : oldChannel.guild.members.cache.get(id)?.user.username || id; + + changes.push({ + action: 'removed', + targetId: id, + targetType, + targetName, + allow: oldPerm.allow, + deny: oldPerm.deny, + }); + } + } + + return changes; +} + +export const channelCreate: Event = { name: Events.ChannelCreate, execute: async (channel: GuildChannel) => { try { @@ -28,10 +121,12 @@ export const channelCreate = { }, }; -export const channelDelete = { +export const channelDelete: Event = { name: Events.ChannelDelete, - execute: async (channel: GuildChannel) => { + execute: async (channel: GuildChannel | DMChannel) => { try { + if (channel.type === ChannelType.DM) return; + const { guild } = channel; const auditLogs = await guild.fetchAuditLogs({ type: AuditLogEvent.ChannelDelete, @@ -54,20 +149,46 @@ export const channelDelete = { }, }; -export const channelUpdate = { +export const channelUpdate: Event = { name: Events.ChannelUpdate, - execute: async (oldChannel: GuildChannel, newChannel: GuildChannel) => { + execute: async ( + oldChannel: GuildChannel | DMChannel, + newChannel: GuildChannel | DMChannel, + ) => { try { + if ( + oldChannel.type === ChannelType.DM || + newChannel.type === ChannelType.DM + ) { + return; + } + if ( + oldChannel.name === newChannel.name && + oldChannel.type === newChannel.type && + oldChannel.permissionOverwrites.cache.size === + newChannel.permissionOverwrites.cache.size && + arePermissionsEqual( + oldChannel.permissionOverwrites.cache, + newChannel.permissionOverwrites.cache, + ) && + oldChannel.position !== newChannel.position + ) { + return; + } + const { guild } = newChannel; const auditLogs = await guild.fetchAuditLogs({ type: AuditLogEvent.ChannelUpdate, limit: 1, }); - const executor = auditLogs.entries.first()?.executor; + const log = auditLogs.entries.first(); + const executor = log?.executor; const moderator = executor ? await guild.members.fetch(executor.id) : undefined; + const permissionChanges = getPermissionChanges(oldChannel, newChannel); + await logAction({ guild, action: 'channelUpdate', @@ -75,8 +196,8 @@ export const channelUpdate = { moderator, oldName: oldChannel.name, newName: newChannel.name, - oldPermissions: oldChannel.permissionOverwrites.cache.first()?.allow, - newPermissions: newChannel.permissionOverwrites.cache.first()?.allow, + permissionChanges: + (permissionChanges ?? []).length > 0 ? permissionChanges : undefined, }); } catch (error) { console.error('Error handling channel update:', error); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 0afd910..328b10f 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,5 +1,7 @@ import { Events, Interaction } from 'discord.js'; + import { ExtendedClient } from '../structures/ExtendedClient.js'; +import { Event } from '../types/EventTypes.js'; export default { name: Events.InteractionCreate, @@ -35,4 +37,4 @@ export default { } } }, -}; +} as Event; diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index 2fe3b24..a6ddc80 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -1,10 +1,12 @@ -import { Events, GuildMember } from 'discord.js'; +import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js'; + import { updateMember, setMembers } from '../db/db.js'; import { generateMemberBanner } from '../util/helpers.js'; import { loadConfig } from '../util/configLoader.js'; +import { Event } from '../types/EventTypes.js'; import logAction from '../util/logging/logAction.js'; -export const memberJoin = { +export const memberJoin: Event = { name: Events.GuildMemberAdd, execute: async (member: GuildMember) => { const { guild } = member; @@ -17,9 +19,12 @@ export const memberJoin = { } try { - const members = await guild.members.fetch(); - const nonBotMembers = members.filter((m) => !m.user.bot); - await setMembers(nonBotMembers); + await setMembers([ + { + discordId: member.user.id, + discordUsername: member.user.username, + }, + ]); if (!member.user.bot) { const attachment = await generateMemberBanner({ @@ -37,10 +42,6 @@ export const memberJoin = { 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, @@ -55,9 +56,9 @@ export const memberJoin = { }, }; -export const memberLeave = { +export const memberLeave: Event = { name: Events.GuildMemberRemove, - execute: async (member: GuildMember) => { + execute: async (member: GuildMember | PartialGuildMember) => { const { guild } = member; try { @@ -69,7 +70,7 @@ export const memberLeave = { logAction({ guild, action: 'memberLeave', - member, + member: member as GuildMember, }), ]); } catch (error) { @@ -78,9 +79,12 @@ export const memberLeave = { }, }; -export const memberUpdate = { +export const memberUpdate: Event = { name: Events.GuildMemberUpdate, - execute: async (oldMember: GuildMember, newMember: GuildMember) => { + execute: async ( + oldMember: GuildMember | PartialGuildMember, + newMember: GuildMember, + ) => { const { guild } = newMember; try { diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index eaa947a..8bd0f33 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -1,9 +1,13 @@ -import { AuditLogEvent, Events, Message } from 'discord.js'; +import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; + +import { Event } from '../types/EventTypes.js'; import logAction from '../util/logging/logAction.js'; -export const messageDelete = { +export const messageDelete: Event = { name: Events.MessageDelete, - execute: async (message: Message) => { + execute: async ( + message: Omit | PartialMessage>, 'channel'>, + ) => { try { if (!message.guild || message.author?.bot) return; @@ -30,9 +34,12 @@ export const messageDelete = { }, }; -export const messageUpdate = { +export const messageUpdate: Event = { name: Events.MessageUpdate, - execute: async (oldMessage: Message, newMessage: Message) => { + execute: async ( + oldMessage: Omit | PartialMessage>, 'channel'>, + newMessage: Message, + ) => { try { if ( !oldMessage.guild || diff --git a/src/events/ready.ts b/src/events/ready.ts index a3566f4..2430295 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,9 +1,30 @@ import { Client, Events } from 'discord.js'; +import { setMembers } from '../db/db.js'; +import { loadConfig } from '../util/configLoader.js'; +import { Event } from '../types/EventTypes.js'; + export default { name: Events.ClientReady, once: true, execute: async (client: Client) => { + const config = loadConfig(); + try { + const guild = client.guilds.cache.find( + (guilds) => guilds.id === config.guildId, + ); + if (!guild) { + console.error(`Guild with ID ${config.guildId} not found.`); + return; + } + + const members = await guild.members.fetch(); + const nonBotMembers = members.filter((m) => !m.user.bot); + await setMembers(nonBotMembers); + } catch (error) { + console.error('Failed to initialize members in database:', error); + } + console.log(`Ready! Logged in as ${client.user?.tag}`); }, -}; +} as Event; diff --git a/src/events/roleEvents.ts b/src/events/roleEvents.ts index 1e7bc54..8fc06f6 100644 --- a/src/events/roleEvents.ts +++ b/src/events/roleEvents.ts @@ -1,4 +1,6 @@ import { AuditLogEvent, Events, Role } from 'discord.js'; + +import { Event } from '../types/EventTypes.js'; import logAction from '../util/logging/logAction.js'; const convertRoleProperties = (role: Role) => ({ @@ -8,7 +10,7 @@ const convertRoleProperties = (role: Role) => ({ mentionable: role.mentionable, }); -export const roleCreate = { +export const roleCreate: Event = { name: Events.GuildRoleCreate, execute: async (role: Role) => { try { @@ -34,7 +36,7 @@ export const roleCreate = { }, }; -export const roleDelete = { +export const roleDelete: Event = { name: Events.GuildRoleDelete, execute: async (role: Role) => { try { @@ -60,7 +62,7 @@ export const roleDelete = { }, }; -export const roleUpdate = { +export const roleUpdate: Event = { name: Events.GuildRoleUpdate, execute: async (oldRole: Role, newRole: Role) => { try { diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 5c8b644..30d175c 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -1,6 +1,15 @@ -import { CommandInteraction, SlashCommandBuilder } from 'discord.js'; +import { + CommandInteraction, + SlashCommandBuilder, + SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; export interface Command { data: Omit; execute: (interaction: CommandInteraction) => Promise; } + +export interface OptionsCommand { + data: SlashCommandOptionsOnlyBuilder; + execute: (interaction: CommandInteraction) => Promise; +} diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index f6723b7..e92ae94 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -3,6 +3,7 @@ export interface Config { clientId: string; guildId: string; dbConnectionString: string; + redisConnectionString: string; channels: { welcome: string; logs: string; diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts index 6c40810..7becbe5 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -19,6 +19,8 @@ import { createRoleChangeFields, getLogItemId, getEmojiForAction, + getPermissionDifference, + getPermissionNames, } from './utils.js'; export default async function logAction(payload: LogActionPayload) { @@ -209,6 +211,12 @@ export default async function logAction(payload: LogActionPayload) { } case 'channelUpdate': { + const changesExist = + payload.oldName !== payload.newName || + (payload.permissionChanges && payload.permissionChanges.length > 0); + + if (!changesExist) return; + fields.push({ name: '📝 Channel Information', value: [ @@ -223,12 +231,138 @@ export default async function logAction(payload: LogActionPayload) { inline: false, }); - if (payload.oldPermissions && payload.newPermissions) { - const permissionChanges = createPermissionChangeFields( - payload.oldPermissions, - payload.newPermissions, - ); - fields.push(...permissionChanges); + if (payload.permissionChanges && payload.permissionChanges.length > 0) { + const changes = { + added: payload.permissionChanges.filter((c) => c.action === 'added'), + modified: payload.permissionChanges.filter( + (c) => c.action === 'modified', + ), + removed: payload.permissionChanges.filter( + (c) => c.action === 'removed', + ), + }; + + if (changes.added.length > 0) { + fields.push({ + name: '➕ Added Permissions', + value: changes.added + .map((c) => { + const targetMention = + c.targetType === 'role' + ? `<@&${c.targetId}>` + : `<@${c.targetId}>`; + return `For ${c.targetType} ${targetMention} (${c.targetName})`; + }) + .join('\n'), + inline: false, + }); + + changes.added.forEach((c) => { + if (c.allow?.bitfield || c.deny?.bitfield) { + const permList = []; + if (c.allow?.bitfield) { + const allowedPerms = getPermissionNames(c.allow); + if (allowedPerms.length) { + permList.push(`✅ **Allowed:** ${allowedPerms.join(', ')}`); + } + } + if (c.deny?.bitfield) { + const deniedPerms = getPermissionNames(c.deny); + if (deniedPerms.length) { + permList.push(`❌ **Denied:** ${deniedPerms.join(', ')}`); + } + } + + if (permList.length > 0) { + fields.push({ + name: `Permissions for ${c.targetType} ${c.targetName}`, + value: permList.join('\n'), + inline: false, + }); + } + } + }); + } + + if (changes.modified.length > 0) { + fields.push({ + name: '🔄 Modified Permissions', + value: changes.modified + .map((c) => { + const targetMention = + c.targetType === 'role' + ? `<@&${c.targetId}>` + : `<@${c.targetId}>`; + return `For ${c.targetType} ${targetMention} (${c.targetName})`; + }) + .join('\n'), + inline: false, + }); + + changes.modified.forEach((c) => { + if (c.oldAllow && c.newAllow && c.oldDeny && c.newDeny) { + const addedPerms = getPermissionDifference( + c.newAllow, + c.oldAllow, + ); + const removedPerms = getPermissionDifference( + c.oldAllow, + c.newAllow, + ); + const addedDenies = getPermissionDifference(c.newDeny, c.oldDeny); + const removedDenies = getPermissionDifference( + c.oldDeny, + c.newDeny, + ); + + const permissionChanges = []; + if (addedPerms.length) { + permissionChanges.push( + `✅ **Newly Allowed:** ${addedPerms.join(', ')}`, + ); + } + if (removedPerms.length) { + permissionChanges.push( + `⬇️ **No Longer Allowed:** ${removedPerms.join(', ')}`, + ); + } + if (addedDenies.length) { + permissionChanges.push( + `❌ **Newly Denied:** ${addedDenies.join(', ')}`, + ); + } + if (removedDenies.length) { + permissionChanges.push( + `⬆️ **No Longer Denied:** ${removedDenies.join(', ')}`, + ); + } + + if (permissionChanges.length > 0) { + fields.push({ + name: `Changes for ${c.targetType} ${c.targetName}`, + value: permissionChanges.join('\n'), + inline: false, + }); + } + } + }); + } + + if (changes.removed.length > 0) { + fields.push({ + name: '➖ Removed Permissions', + value: changes.removed + .map((c) => { + const targetMention = + c.targetType === 'role' + ? `<@&${c.targetId}>` + : `<@${c.targetId}>`; + return `For ${c.targetType} ${targetMention} (${c.targetName})`; + }) + .join('\n'), + inline: false, + }); + } } const moderatorField = createModeratorField( @@ -242,7 +376,11 @@ export default async function logAction(payload: LogActionPayload) { case 'channelCreate': case 'channelDelete': { fields.push( - { name: 'Channel', value: `<#${payload.channel.id}>`, inline: true }, + { + name: 'Channel', + value: `<#${payload.channel.id}> (#${payload.channel.name})`, + inline: true, + }, { name: 'Type', value: diff --git a/src/util/logging/types.ts b/src/util/logging/types.ts index 414ffe6..36eed72 100644 --- a/src/util/logging/types.ts +++ b/src/util/logging/types.ts @@ -108,8 +108,18 @@ export interface ChannelLogAction extends BaseLogAction { channel: GuildChannel; oldName?: string; newName?: string; - oldPermissions?: Readonly; - newPermissions?: Readonly; + permissionChanges?: Array<{ + action: 'added' | 'modified' | 'removed'; + targetId: string; + targetType: 'role' | 'member'; + targetName: string; + allow?: Readonly; + deny?: Readonly; + oldAllow?: Readonly; + oldDeny?: Readonly; + newAllow?: Readonly; + newDeny?: Readonly; + }>; moderator?: GuildMember; } diff --git a/src/util/logging/utils.ts b/src/util/logging/utils.ts index f4044e4..d47d0ce 100644 --- a/src/util/logging/utils.ts +++ b/src/util/logging/utils.ts @@ -84,6 +84,36 @@ export const createPermissionChangeFields = ( return fields; }; +export const getPermissionNames = ( + permissions: Readonly, +): string[] => { + const names: string[] = []; + + Object.keys(PermissionsBitField.Flags).forEach((perm) => { + if (permissions.has(perm as keyof typeof PermissionsBitField.Flags)) { + names.push(formatPermissionName(perm)); + } + }); + + return names; +}; + +export const getPermissionDifference = ( + a: Readonly, + b: Readonly, +): string[] => { + const names: string[] = []; + + Object.keys(PermissionsBitField.Flags).forEach((perm) => { + const permKey = perm as keyof typeof PermissionsBitField.Flags; + if (a.has(permKey) && !b.has(permKey)) { + names.push(formatPermissionName(perm)); + } + }); + + return names; +}; + export const createRoleChangeFields = ( oldRole: Partial, newRole: Partial, diff --git a/yarn.lock b/yarn.lock index 7eb7ae2..7756734 100644 --- a/yarn.lock +++ b/yarn.lock @@ -780,6 +780,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:^1.1.1": + version: 1.2.0 + resolution: "@ioredis/commands@npm:1.2.0" + checksum: 10c0/a5d3c29dd84d8a28b7c67a441ac1715cbd7337a7b88649c0f17c345d89aa218578d2b360760017c48149ef8a70f44b051af9ac0921a0622c2b479614c4f65b36 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1443,6 +1450,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:^1.1.0": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -1515,6 +1529,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -2568,6 +2589,23 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:^5.5.0": + version: 5.5.0 + resolution: "ioredis@npm:5.5.0" + dependencies: + "@ioredis/commands": "npm:^1.1.1" + cluster-key-slot: "npm:^1.1.0" + debug: "npm:^4.3.4" + denque: "npm:^2.1.0" + lodash.defaults: "npm:^4.2.0" + lodash.isarguments: "npm:^3.1.0" + redis-errors: "npm:^1.2.0" + redis-parser: "npm:^3.0.0" + standard-as-callback: "npm:^2.1.0" + checksum: 10c0/ba64502fc92d9e05465793fafcd0568cb668af6e2350462b61daadfd499e3a48239d9a723d3ce08b08c93f3f745d05dda91136cdc597d4d485604e6730305305 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -2723,6 +2761,20 @@ __metadata: languageName: node linkType: hard +"lodash.defaults@npm:^4.2.0": + version: 4.2.0 + resolution: "lodash.defaults@npm:4.2.0" + checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 + languageName: node + linkType: hard + +"lodash.isarguments@npm:^3.1.0": + version: 3.1.0 + resolution: "lodash.isarguments@npm:3.1.0" + checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -3211,6 +3263,7 @@ __metadata: eslint: "npm:^9.21.0" eslint-config-prettier: "npm:^10.0.2" globals: "npm:^16.0.0" + ioredis: "npm:^5.5.0" pg: "npm:^8.13.3" prettier: "npm:3.5.2" ts-node: "npm:^10.9.2" @@ -3333,6 +3386,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3504,6 +3573,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:^2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": version: 4.2.3 resolution: "string-width@npm:4.2.3"