mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-03-29 15:54:15 +00:00
Added Warn and Ban Commands, Added Logging, and Much More
This commit is contained in:
parent
d89de72e08
commit
86adac3f08
33 changed files with 2200 additions and 204 deletions
BIN
assets/welcome-bg.png
Normal file
BIN
assets/welcome-bg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
|
@ -2,5 +2,14 @@
|
||||||
"token": "DISCORD_BOT_API_KEY",
|
"token": "DISCORD_BOT_API_KEY",
|
||||||
"clientId": "DISCORD_BOT_ID",
|
"clientId": "DISCORD_BOT_ID",
|
||||||
"guildId": "DISCORD_SERVER_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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
127
src/commands/moderation/ban.ts
Normal file
127
src/commands/moderation/ban.ts
Normal 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;
|
82
src/commands/moderation/unban.ts
Normal file
82
src/commands/moderation/unban.ts
Normal 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;
|
85
src/commands/moderation/warn.ts
Normal file
85
src/commands/moderation/warn.ts
Normal 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;
|
42
src/commands/testing/testJoin.ts
Normal file
42
src/commands/testing/testJoin.ts
Normal 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;
|
48
src/commands/testing/testLeave.ts
Normal file
48
src/commands/testing/testLeave.ts
Normal 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;
|
|
@ -2,8 +2,14 @@ import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
CommandInteraction,
|
CommandInteraction,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
APIEmbed,
|
||||||
|
JSONEncodable,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { getAllMembers } from '../../util/db.js';
|
import { getAllMembers } from '../../db/db.js';
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
||||||
|
@ -15,16 +21,112 @@ const command: Command = {
|
||||||
.setName('members')
|
.setName('members')
|
||||||
.setDescription('Lists all non-bot members of the server'),
|
.setDescription('Lists all non-bot members of the server'),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
const members = await getAllMembers();
|
let members = await getAllMembers();
|
||||||
const memberList = members
|
members = members.sort((a, b) =>
|
||||||
.map((m) => `**${m.discordUsername}** (${m.discordId})`)
|
a.discordUsername.localeCompare(b.discordUsername),
|
||||||
.join('\n');
|
);
|
||||||
const membersEmbed = new EmbedBuilder()
|
|
||||||
.setTitle('Members')
|
const ITEMS_PER_PAGE = 15;
|
||||||
.setDescription(memberList)
|
const pages: (APIEmbed | JSONEncodable<APIEmbed>)[] = [];
|
||||||
.setColor(0x0099ff)
|
for (let i = 0; i < members.length; i += ITEMS_PER_PAGE) {
|
||||||
.addFields({ name: 'Total Members', value: members.length.toString() });
|
const pageMembers = members.slice(i, i + ITEMS_PER_PAGE);
|
||||||
await interaction.reply({ embeds: [membersEmbed] });
|
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: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ const rulesEmbed = new EmbedBuilder()
|
||||||
{
|
{
|
||||||
name: '**Rule #3: Use Common Sense**',
|
name: '**Rule #3: Use Common Sense**',
|
||||||
value:
|
value:
|
||||||
'Think before you act or post. If something seems questionable, it’s probably best not to do it.',
|
'Think before you act or post. If something seems questionable, it is probably best not to do it.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '**Rule #4: No Spamming**',
|
name: '**Rule #4: No Spamming**',
|
||||||
|
@ -69,7 +69,7 @@ const rulesEmbed = new EmbedBuilder()
|
||||||
{
|
{
|
||||||
name: '**Rule #10: No Ping Abuse**',
|
name: '**Rule #10: No Ping Abuse**',
|
||||||
value:
|
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**',
|
name: '**Rule #11: Use Appropriate Channels**',
|
|
@ -11,7 +11,7 @@ const command: Command = {
|
||||||
.setDescription('Provides information about the server.'),
|
.setDescription('Provides information about the server.'),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.reply(
|
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.`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,8 +3,10 @@ import {
|
||||||
CommandInteraction,
|
CommandInteraction,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
SlashCommandOptionsOnlyBuilder,
|
SlashCommandOptionsOnlyBuilder,
|
||||||
|
GuildMember,
|
||||||
|
PermissionsBitField,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { getMember } from '../../util/db.js';
|
import { getMember } from '../../db/db.js';
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
data: SlashCommandOptionsOnlyBuilder;
|
data: SlashCommandOptionsOnlyBuilder;
|
||||||
|
@ -14,7 +16,7 @@ interface Command {
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('userinfo')
|
.setName('userinfo')
|
||||||
.setDescription('Provides information about the user.')
|
.setDescription('Provides information about the specified user.')
|
||||||
.addUserOption((option) =>
|
.addUserOption((option) =>
|
||||||
option
|
option
|
||||||
.setName('user')
|
.setName('user')
|
||||||
|
@ -22,46 +24,127 @@ const command: Command = {
|
||||||
.setRequired(true),
|
.setRequired(true),
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
const userOption = interaction.options.get('user');
|
const userOption = interaction.options.get(
|
||||||
if (!userOption) {
|
'user',
|
||||||
await interaction.reply('User not found');
|
) as unknown as GuildMember;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const user = userOption.user;
|
const user = userOption.user;
|
||||||
if (!user) {
|
|
||||||
|
if (!userOption || !user) {
|
||||||
await interaction.reply('User not found');
|
await interaction.reply('User not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const member = await getMember(user.id);
|
if (
|
||||||
const [memberData] = member;
|
!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()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle(`User Information - ${user?.username}`)
|
.setTitle(`User Information - ${user?.username}`)
|
||||||
.setColor(user.accentColor || 'Default')
|
.setColor(user.accentColor || '#5865F2')
|
||||||
|
.setThumbnail(user.displayAvatarURL({ size: 256 }))
|
||||||
|
.setTimestamp()
|
||||||
.addFields(
|
.addFields(
|
||||||
{ name: 'Username', value: user.username, inline: false },
|
|
||||||
{ name: 'User ID', value: user.id, inline: false },
|
|
||||||
{
|
{
|
||||||
name: 'Joined Server',
|
name: '👤 Basic Information',
|
||||||
value:
|
value: [
|
||||||
interaction.guild?.members.cache
|
`**Username:** ${user.username}`,
|
||||||
.get(user.id)
|
`**Discord ID:** ${user.id}`,
|
||||||
?.joinedAt?.toLocaleString() || 'Not available',
|
`**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,
|
inline: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Account Created',
|
name: '🛡️ Moderation History',
|
||||||
value: user.createdAt.toLocaleString(),
|
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,
|
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] });
|
await interaction.reply({ embeds: [embed] });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
97
src/db/db.ts
Normal file
97
src/db/db.ts
Normal 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));
|
||||||
|
}
|
|
@ -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', {
|
export const memberTable = pgTable('members', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
discordId: varchar('discord_id').notNull().unique(),
|
discordId: varchar('discord_id').notNull().unique(),
|
||||||
discordUsername: varchar('discord_username').notNull(),
|
discordUsername: varchar('discord_username').notNull(),
|
||||||
numberOfWarnings: integer('number_warnings').notNull().default(0),
|
currentlyInServer: boolean('currently_in_server').notNull().default(true),
|
||||||
numberOfBans: integer('number_bans').notNull().default(0),
|
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
@ -1,105 +1,29 @@
|
||||||
import fs from 'node:fs';
|
import { GatewayIntentBits } from 'discord.js';
|
||||||
import {
|
import { ExtendedClient } from './structures/ExtendedClient.js';
|
||||||
Client,
|
import { loadConfig } from './util/configLoader.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function startBot() {
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
const config = loadConfig();
|
||||||
}
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on(Events.GuildMemberAdd, async (member: GuildMember) => {
|
const client = new ExtendedClient(
|
||||||
const guild = await client.guilds.fetch(guildId);
|
{
|
||||||
const members = await guild.members.fetch();
|
intents: [
|
||||||
const nonBotMembers = members.filter((dbMember: any) => !dbMember.user.bot);
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
// TODO: Move this to the config file
|
GatewayIntentBits.GuildMessages,
|
||||||
const welcomeChannel = guild.channels.cache.get('1007949346031026186');
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildModeration,
|
||||||
try {
|
],
|
||||||
await setMembers(nonBotMembers);
|
},
|
||||||
// TODO: Move this to config file
|
config,
|
||||||
await welcomeChannel.send(
|
|
||||||
`Welcome to the server, ${member.user.username}!`,
|
|
||||||
);
|
);
|
||||||
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 client.initialize();
|
||||||
await removeMember(member.user.id);
|
} catch (error) {
|
||||||
});
|
console.error('Failed to start bot:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
client.login(token);
|
startBot();
|
||||||
|
|
87
src/events/channelEvents.ts
Normal file
87
src/events/channelEvents.ts
Normal 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];
|
38
src/events/interactionCreate.ts
Normal file
38
src/events/interactionCreate.ts
Normal 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
147
src/events/memberEvents.ts
Normal 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];
|
58
src/events/messageEvents.ts
Normal file
58
src/events/messageEvents.ts
Normal 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
9
src/events/ready.ts
Normal 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
93
src/events/roleEvents.ts
Normal 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];
|
45
src/structures/ExtendedClient.ts
Normal file
45
src/structures/ExtendedClient.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
src/types/CommandTypes.ts
Normal file
6
src/types/CommandTypes.ts
Normal 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
13
src/types/ConfigTypes.ts
Normal 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
7
src/types/EventTypes.ts
Normal 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
23
src/util/configLoader.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
|
||||||
}
|
|
|
@ -1,9 +1,16 @@
|
||||||
|
import { REST, Routes } from 'discord.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const { token, clientId, guildId } = config;
|
||||||
|
|
||||||
const __dirname = path.resolve();
|
const __dirname = path.resolve();
|
||||||
const commandsPath = path.join(__dirname, 'target', 'commands');
|
const commandsPath = path.join(__dirname, 'target', 'commands');
|
||||||
|
|
||||||
|
const rest = new REST({ version: '10' }).setToken(token);
|
||||||
|
|
||||||
const getFilesRecursively = (directory: string): string[] => {
|
const getFilesRecursively = (directory: string): string[] => {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
const filesInDirectory = fs.readdirSync(directory);
|
const filesInDirectory = fs.readdirSync(directory);
|
||||||
|
@ -13,8 +20,7 @@ const getFilesRecursively = (directory: string): string[] => {
|
||||||
|
|
||||||
if (fs.statSync(filePath).isDirectory()) {
|
if (fs.statSync(filePath).isDirectory()) {
|
||||||
files.push(...getFilesRecursively(filePath));
|
files.push(...getFilesRecursively(filePath));
|
||||||
}
|
} else if (file.endsWith('.js')) {
|
||||||
else if (file.endsWith('.js')) {
|
|
||||||
files.push(filePath);
|
files.push(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,9 +33,13 @@ const commandFiles = getFilesRecursively(commandsPath);
|
||||||
export const deployCommands = async () => {
|
export const deployCommands = async () => {
|
||||||
try {
|
try {
|
||||||
console.log(
|
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 commands = commandFiles.map(async (file) => {
|
||||||
const commandModule = await import(`file://${file}`);
|
const commandModule = await import(`file://${file}`);
|
||||||
const command = commandModule.default;
|
const command = commandModule.default;
|
||||||
|
@ -40,8 +50,7 @@ export const deployCommands = async () => {
|
||||||
'execute' in command
|
'execute' in command
|
||||||
) {
|
) {
|
||||||
return command;
|
return command;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[WARNING] The command at ${file} is missing a required "data" or "execute" property.`,
|
`[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),
|
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;
|
return validCommands;
|
||||||
}
|
} catch (error) {
|
||||||
catch (error) {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
45
src/util/eventLoader.ts
Normal file
45
src/util/eventLoader.ts
Normal 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
165
src/util/helpers.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
65
src/util/logging/constants.ts
Normal file
65
src/util/logging/constants.ts
Normal 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',
|
||||||
|
};
|
276
src/util/logging/logAction.ts
Normal file
276
src/util/logging/logAction.ts
Normal 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
124
src/util/logging/types.ts
Normal 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
163
src/util/logging/utils.ts
Normal 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] || '📝';
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue