mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-07 15:39:30 +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
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 path from 'path';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const { token, clientId, guildId } = config;
|
||||
|
||||
const __dirname = path.resolve();
|
||||
const commandsPath = path.join(__dirname, 'target', 'commands');
|
||||
|
||||
const rest = new REST({ version: '10' }).setToken(token);
|
||||
|
||||
const getFilesRecursively = (directory: string): string[] => {
|
||||
const files: string[] = [];
|
||||
const filesInDirectory = fs.readdirSync(directory);
|
||||
|
@ -13,8 +20,7 @@ const getFilesRecursively = (directory: string): string[] => {
|
|||
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
files.push(...getFilesRecursively(filePath));
|
||||
}
|
||||
else if (file.endsWith('.js')) {
|
||||
} else if (file.endsWith('.js')) {
|
||||
files.push(filePath);
|
||||
}
|
||||
}
|
||||
|
@ -27,9 +33,13 @@ const commandFiles = getFilesRecursively(commandsPath);
|
|||
export const deployCommands = async () => {
|
||||
try {
|
||||
console.log(
|
||||
`Started refreshing ${commandFiles.length} application (/) commands.`,
|
||||
`Started refreshing ${commandFiles.length} application (/) commands...`,
|
||||
);
|
||||
|
||||
const existingCommands = (await rest.get(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
)) as any[];
|
||||
|
||||
const commands = commandFiles.map(async (file) => {
|
||||
const commandModule = await import(`file://${file}`);
|
||||
const command = commandModule.default;
|
||||
|
@ -40,8 +50,7 @@ export const deployCommands = async () => {
|
|||
'execute' in command
|
||||
) {
|
||||
return command;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
console.warn(
|
||||
`[WARNING] The command at ${file} is missing a required "data" or "execute" property.`,
|
||||
);
|
||||
|
@ -53,9 +62,31 @@ export const deployCommands = async () => {
|
|||
commands.filter((command) => command !== null),
|
||||
);
|
||||
|
||||
const apiCommands = validCommands.map((command) => command.data.toJSON());
|
||||
|
||||
const commandsToRemove = existingCommands.filter(
|
||||
(existingCmd) =>
|
||||
!apiCommands.some((newCmd) => newCmd.name === existingCmd.name),
|
||||
);
|
||||
|
||||
for (const cmdToRemove of commandsToRemove) {
|
||||
await rest.delete(
|
||||
Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id),
|
||||
);
|
||||
console.log(`Removed command: ${cmdToRemove.name}`);
|
||||
}
|
||||
|
||||
const data: any = await rest.put(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
{ body: apiCommands },
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Successfully registered ${data.length} application (/) commands with the Discord API.`,
|
||||
);
|
||||
|
||||
return validCommands;
|
||||
}
|
||||
catch (error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
|
45
src/util/eventLoader.ts
Normal file
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
Add a link
Reference in a new issue