Added Warn and Ban Commands, Added Logging, and Much More

This commit is contained in:
Ahmad 2025-02-23 21:39:49 -05:00
parent d89de72e08
commit 86adac3f08
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
33 changed files with 2200 additions and 204 deletions

BIN
assets/welcome-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -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"
]
}
}

View file

@ -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<void>;
}
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;

View file

@ -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<void>;
}
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;

View file

@ -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<void>;
}
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;

View file

@ -0,0 +1,42 @@
import {
CommandInteraction,
PermissionsBitField,
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
} from 'discord.js';
interface Command {
data: SlashCommandOptionsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;
}
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;

View file

@ -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<void>;
}
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;

View file

@ -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<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
@ -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<APIEmbed>)[] = [];
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<ButtonBuilder>().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<StringSelectMenuBuilder>().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: [] });
}
});
},
};

View file

@ -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, its 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**',

View file

@ -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.`,
);
},
};

View file

@ -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] });
},
};

97
src/db/db.ts Normal file
View file

@ -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));
}

View file

@ -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],
}),
}));

View file

@ -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();

View file

@ -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];

View file

@ -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'],
});
}
}
},
};

147
src/events/memberEvents.ts Normal file
View file

@ -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];

View file

@ -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<true>,
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<true>,
oldContent: oldMessage.content ?? '',
newContent: newMessage.content ?? '',
});
} catch (error) {
console.error('Error handling message update:', error);
}
},
};
export default [messageDelete, messageUpdate];

9
src/events/ready.ts Normal file
View file

@ -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}`);
},
};

93
src/events/roleEvents.ts Normal file
View file

@ -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];

View file

@ -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<string, Command>;
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);
}
}
}

View file

@ -0,0 +1,6 @@
import { CommandInteraction, SlashCommandBuilder } from 'discord.js';
export interface Command {
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
execute: (interaction: CommandInteraction) => Promise<void>;
}

13
src/types/ConfigTypes.ts Normal file
View file

@ -0,0 +1,13 @@
export interface Config {
token: string;
clientId: string;
guildId: string;
dbConnectionString: string;
channels: {
welcome: string;
logs: string;
};
roles: {
joinRoles: string[];
};
}

7
src/types/EventTypes.ts Normal file
View file

@ -0,0 +1,7 @@
import { ClientEvents } from 'discord.js';
export interface Event<K extends keyof ClientEvents> {
name: K;
once?: boolean;
execute: (...args: ClientEvents[K]) => Promise<void>;
}

23
src/util/configLoader.ts Normal file
View file

@ -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);
}
}

View file

@ -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));
}

View file

@ -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);
}
};

45
src/util/eventLoader.ts Normal file
View file

@ -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<void> {
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;
}
}

165
src/util/helpers.ts Normal file
View file

@ -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);
}
}
}

View file

@ -0,0 +1,65 @@
import { ChannelType } from 'discord.js';
import { LogActionType } from './types';
export const ACTION_COLORS: Record<string, number> = {
// 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<LogActionType, string> = {
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<number, string> = {
[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',
};

View file

@ -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<ButtonBuilder>().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: `<t:${Math.floor(payload.member.user.createdTimestamp / 1000)}:R>`,
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,
});
}

124
src/util/logging/types.ts Normal file
View file

@ -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<true>;
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<RoleProperties>;
newRole: Partial<RoleProperties>;
oldPermissions: Readonly<PermissionsBitField>;
newPermissions: Readonly<PermissionsBitField>;
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<PermissionsBitField>;
newPermissions?: Readonly<PermissionsBitField>;
moderator?: GuildMember;
}
export type LogActionPayload =
| ModerationLogAction
| MessageLogAction
| MemberLogAction
| MemberUpdateAction
| RoleLogAction
| RoleCreateDeleteAction
| RoleUpdateAction
| ChannelLogAction;

163
src/util/logging/utils.ts Normal file
View file

@ -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<PermissionsBitField>,
newPerms: Readonly<PermissionsBitField>,
): 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<RoleProperties>,
newRole: Partial<RoleProperties>,
): 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<RoleProperties, 'hoist' | 'mentionable'>
> = ['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] || '📝';
};