mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-07 15:39:30 +00:00
Added code coments, refactored db.ts and redis.ts, and added two new commands
This commit is contained in:
parent
b3fbd2358b
commit
890ca26c78
30 changed files with 1899 additions and 462 deletions
|
@ -2,6 +2,10 @@ import { Config } from '../types/ConfigTypes.js';
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Loads the config file from the root directory
|
||||
* @returns - The loaded config object
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
try {
|
||||
const configPath = path.join(process.cwd(), './config.json');
|
||||
|
|
|
@ -16,6 +16,10 @@ const MILESTONE_REACTIONS = {
|
|||
multiples100: '🎉',
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the counting data if it doesn't exist
|
||||
* @returns - The initialized counting data
|
||||
*/
|
||||
export async function initializeCountingData(): Promise<CountingData> {
|
||||
const exists = await getJson<CountingData>('counting');
|
||||
if (exists) return exists;
|
||||
|
@ -31,6 +35,10 @@ export async function initializeCountingData(): Promise<CountingData> {
|
|||
return initialData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current counting data
|
||||
* @returns - The current counting data
|
||||
*/
|
||||
export async function getCountingData(): Promise<CountingData> {
|
||||
const data = await getJson<CountingData>('counting');
|
||||
if (!data) {
|
||||
|
@ -39,6 +47,10 @@ export async function getCountingData(): Promise<CountingData> {
|
|||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the counting data with new data
|
||||
* @param data - The data to update the counting data with
|
||||
*/
|
||||
export async function updateCountingData(
|
||||
data: Partial<CountingData>,
|
||||
): Promise<void> {
|
||||
|
@ -47,6 +59,10 @@ export async function updateCountingData(
|
|||
await setJson<CountingData>('counting', updatedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the counting data to the initial state
|
||||
* @returns - The current count
|
||||
*/
|
||||
export async function resetCounting(): Promise<void> {
|
||||
await updateCountingData({
|
||||
currentCount: 0,
|
||||
|
@ -55,6 +71,11 @@ export async function resetCounting(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a counting message to determine if it is valid
|
||||
* @param message - The message to process
|
||||
* @returns - An object with information about the message
|
||||
*/
|
||||
export async function processCountingMessage(message: Message): Promise<{
|
||||
isValid: boolean;
|
||||
expectedCount?: number;
|
||||
|
@ -125,6 +146,11 @@ export async function processCountingMessage(message: Message): Promise<{
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds counting reactions to a message based on the milestone type
|
||||
* @param message - The message to add counting reactions to
|
||||
* @param milestoneType - The type of milestone to add reactions for
|
||||
*/
|
||||
export async function addCountingReactions(
|
||||
message: Message,
|
||||
milestoneType: keyof typeof MILESTONE_REACTIONS,
|
||||
|
@ -140,11 +166,19 @@ export async function addCountingReactions(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current counting status
|
||||
* @returns - A string with the current counting status
|
||||
*/
|
||||
export async function getCountingStatus(): Promise<string> {
|
||||
const data = await getCountingData();
|
||||
return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current count to a specific number
|
||||
* @param count - The number to set as the current count
|
||||
*/
|
||||
export async function setCount(count: number): Promise<void> {
|
||||
if (!Number.isInteger(count) || count < 0) {
|
||||
throw new Error('Count must be a non-negative integer.');
|
||||
|
|
|
@ -11,6 +11,11 @@ const commandsPath = path.join(__dirname, 'target', 'commands');
|
|||
|
||||
const rest = new REST({ version: '10' }).setToken(token);
|
||||
|
||||
/**
|
||||
* Gets all files in the command directory and its subdirectories
|
||||
* @param directory - The directory to get files from
|
||||
* @returns - An array of file paths
|
||||
*/
|
||||
const getFilesRecursively = (directory: string): string[] => {
|
||||
const files: string[] = [];
|
||||
const filesInDirectory = fs.readdirSync(directory);
|
||||
|
@ -30,15 +35,21 @@ const getFilesRecursively = (directory: string): string[] => {
|
|||
|
||||
const commandFiles = getFilesRecursively(commandsPath);
|
||||
|
||||
/**
|
||||
* Registers all commands in the command directory with the Discord API
|
||||
* @returns - An array of valid command objects
|
||||
*/
|
||||
export const deployCommands = async () => {
|
||||
try {
|
||||
console.log(
|
||||
`Started refreshing ${commandFiles.length} application (/) commands...`,
|
||||
);
|
||||
|
||||
const existingCommands = (await rest.get(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
)) as any[];
|
||||
console.log('Undeploying all existing commands...');
|
||||
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
|
||||
body: [],
|
||||
});
|
||||
console.log('Successfully undeployed all commands');
|
||||
|
||||
const commands = commandFiles.map(async (file) => {
|
||||
const commandModule = await import(`file://${file}`);
|
||||
|
@ -64,18 +75,6 @@ export const deployCommands = async () => {
|
|||
|
||||
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 },
|
||||
|
|
|
@ -7,6 +7,10 @@ import { dirname } from 'path';
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* Registers all event handlers in the events directory
|
||||
* @param client - The Discord client
|
||||
*/
|
||||
export async function registerEvents(client: Client): Promise<void> {
|
||||
try {
|
||||
const eventsPath = join(__dirname, '..', 'events');
|
||||
|
|
|
@ -3,8 +3,22 @@ import { EmbedBuilder, Client } from 'discord.js';
|
|||
import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
|
||||
export async function scheduleFactOfTheDay(client: Client) {
|
||||
let isFactScheduled = false;
|
||||
|
||||
/**
|
||||
* Schedule the fact of the day to be posted daily
|
||||
* @param client - The Discord client
|
||||
*/
|
||||
export async function scheduleFactOfTheDay(client: Client): Promise<void> {
|
||||
if (isFactScheduled) {
|
||||
console.log(
|
||||
'Fact of the day already scheduled, skipping duplicate schedule',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isFactScheduled = true;
|
||||
const now = new Date();
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
|
@ -14,6 +28,7 @@ export async function scheduleFactOfTheDay(client: Client) {
|
|||
|
||||
setTimeout(() => {
|
||||
postFactOfTheDay(client);
|
||||
isFactScheduled = false;
|
||||
scheduleFactOfTheDay(client);
|
||||
}, timeUntilMidnight);
|
||||
|
||||
|
@ -22,11 +37,16 @@ export async function scheduleFactOfTheDay(client: Client) {
|
|||
);
|
||||
} catch (error) {
|
||||
console.error('Error scheduling fact of the day:', error);
|
||||
isFactScheduled = false;
|
||||
setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postFactOfTheDay(client: Client) {
|
||||
/**
|
||||
* Post the fact of the day to the configured channel
|
||||
* @param client - The Discord client
|
||||
*/
|
||||
export async function postFactOfTheDay(client: Client): Promise<void> {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const guild = client.guilds.cache.get(config.guildId);
|
||||
|
|
|
@ -5,11 +5,16 @@ 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 { db, handleDbError, updateMember } from '../db/db.js';
|
||||
import logAction from './logging/logAction.js';
|
||||
|
||||
const __dirname = path.resolve();
|
||||
|
||||
/**
|
||||
* Turns a duration string into milliseconds
|
||||
* @param duration - The duration to parse
|
||||
* @returns - The parsed duration in milliseconds
|
||||
*/
|
||||
export function parseDuration(duration: string): number {
|
||||
const regex = /^(\d+)(s|m|h|d)$/;
|
||||
const match = duration.match(regex);
|
||||
|
@ -30,17 +35,27 @@ export function parseDuration(duration: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Member banner types
|
||||
*/
|
||||
interface generateMemberBannerTypes {
|
||||
member: GuildMember;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a welcome banner for a member
|
||||
* @param member - The member to generate a banner for
|
||||
* @param width - The width of the banner
|
||||
* @param height - The height of the banner
|
||||
* @returns - The generated banner
|
||||
*/
|
||||
export async function generateMemberBanner({
|
||||
member,
|
||||
width,
|
||||
height,
|
||||
}: generateMemberBannerTypes) {
|
||||
}: generateMemberBannerTypes): Promise<AttachmentBuilder> {
|
||||
const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png');
|
||||
const canvas = Canvas.createCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
@ -92,12 +107,19 @@ export async function generateMemberBanner({
|
|||
return attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules an unban for a user
|
||||
* @param client - The client to use
|
||||
* @param guildId - The guild ID to unban the user from
|
||||
* @param userId - The user ID to unban
|
||||
* @param expiresAt - The date to unban the user at
|
||||
*/
|
||||
export async function scheduleUnban(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
expiresAt: Date,
|
||||
) {
|
||||
): Promise<void> {
|
||||
const timeUntilUnban = expiresAt.getTime() - Date.now();
|
||||
if (timeUntilUnban > 0) {
|
||||
setTimeout(async () => {
|
||||
|
@ -106,12 +128,19 @@ export async function scheduleUnban(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an unban for a user
|
||||
* @param client - The client to use
|
||||
* @param guildId - The guild ID to unban the user from
|
||||
* @param userId - The user ID to unban
|
||||
* @param reason - The reason for the unban
|
||||
*/
|
||||
export async function executeUnban(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(guildId);
|
||||
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||
|
@ -140,26 +169,96 @@ export async function executeUnban(
|
|||
reason: reason ?? 'Temporary ban expired',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to unban user ${userId}:`, error);
|
||||
handleDbError(`Failed to unban user ${userId}`, error as 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)),
|
||||
);
|
||||
/**
|
||||
* Loads all active bans and schedules unban events
|
||||
* @param client - The client to use
|
||||
* @param guild - The guild to load bans for
|
||||
*/
|
||||
export async function loadActiveBans(
|
||||
client: Client,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
try {
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
handleDbError('Failed to load active bans', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Types for the roundRect function
|
||||
*/
|
||||
interface roundRectTypes {
|
||||
ctx: Canvas.SKRSContext2D;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: boolean;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rounded rectangle
|
||||
* @param ctx - The canvas context to use
|
||||
* @param x - The x position of the rectangle
|
||||
* @param y - The y position of the rectangle
|
||||
* @param width - The width of the rectangle
|
||||
* @param height - The height of the rectangle
|
||||
* @param radius - The radius of the corners
|
||||
* @param fill - Whether to fill the rectangle
|
||||
*/
|
||||
export function roundRect({
|
||||
ctx,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
fill,
|
||||
}: roundRectTypes): void {
|
||||
if (typeof radius === 'undefined') {
|
||||
radius = 5;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
|
||||
if (fill) {
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +1,41 @@
|
|||
import path from 'path';
|
||||
import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
|
||||
import Canvas, { GlobalFonts } from '@napi-rs/canvas';
|
||||
import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
|
||||
|
||||
import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js';
|
||||
import {
|
||||
addXpToUser,
|
||||
db,
|
||||
getUserLevel,
|
||||
getUserRank,
|
||||
handleDbError,
|
||||
} from '../db/db.js';
|
||||
import * as schema from '../db/schema.js';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
import { roundRect } from './helpers.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const XP_COOLDOWN = 60 * 1000;
|
||||
const MIN_XP = 15;
|
||||
const MAX_XP = 25;
|
||||
const XP_COOLDOWN = config.leveling.xpCooldown * 1000;
|
||||
const MIN_XP = config.leveling.minXpAwarded;
|
||||
const MAX_XP = config.leveling.maxXpAwarded;
|
||||
|
||||
const __dirname = path.resolve();
|
||||
|
||||
/**
|
||||
* Calculates the amount of XP required to reach the given level
|
||||
* @param level - The level to calculate the XP for
|
||||
* @returns - The amount of XP required to reach the given level
|
||||
*/
|
||||
export const calculateXpForLevel = (level: number): number => {
|
||||
if (level === 0) return 0;
|
||||
return (5 / 6) * level * (2 * level * level + 27 * level + 91);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the level that corresponds to the given amount of XP
|
||||
* @param xp - The amount of XP to calculate the level for
|
||||
* @returns - The level that corresponds to the given amount of XP
|
||||
*/
|
||||
export const calculateLevelFromXp = (xp: number): number => {
|
||||
if (xp < calculateXpForLevel(1)) return 0;
|
||||
|
||||
|
@ -30,6 +47,12 @@ export const calculateLevelFromXp = (xp: number): number => {
|
|||
return level;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the amount of XP required to reach the next level
|
||||
* @param level - The level to calculate the XP for
|
||||
* @param currentXp - The current amount of XP
|
||||
* @returns - The amount of XP required to reach the next level
|
||||
*/
|
||||
export const getXpToNextLevel = (level: number, currentXp: number): number => {
|
||||
if (level === 0) return calculateXpForLevel(1) - currentXp;
|
||||
|
||||
|
@ -37,14 +60,26 @@ export const getXpToNextLevel = (level: number, currentXp: number): number => {
|
|||
return nextLevelXp - currentXp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recalculates the levels for all users in the database
|
||||
*/
|
||||
export async function recalculateUserLevels() {
|
||||
const users = await db.select().from(schema.levelTable);
|
||||
try {
|
||||
const users = await db.select().from(schema.levelTable);
|
||||
|
||||
for (const user of users) {
|
||||
await addXpToUser(user.discordId, 0);
|
||||
for (const user of users) {
|
||||
await addXpToUser(user.discordId, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
handleDbError('Failed to recalculate user levels', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a message for XP
|
||||
* @param message - The message to process for XP
|
||||
* @returns - The result of processing the message
|
||||
*/
|
||||
export async function processMessage(message: Message) {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
|
||||
|
@ -71,38 +106,12 @@ export async function processMessage(message: Message) {
|
|||
}
|
||||
}
|
||||
|
||||
function roundRect(
|
||||
ctx: Canvas.SKRSContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
fill: boolean,
|
||||
) {
|
||||
if (typeof radius === 'undefined') {
|
||||
radius = 5;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
|
||||
if (fill) {
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a rank card for the given member
|
||||
* @param member - The member to generate a rank card for
|
||||
* @param userData - The user's level data
|
||||
* @returns - The rank card as an attachment
|
||||
*/
|
||||
export async function generateRankCard(
|
||||
member: GuildMember,
|
||||
userData: schema.levelTableTypes,
|
||||
|
@ -125,7 +134,15 @@ export async function generateRankCard(
|
|||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
context.fillStyle = '#2C2F33';
|
||||
roundRect(context, 22, 22, 890, 238, 20, true);
|
||||
roundRect({
|
||||
ctx: context,
|
||||
x: 22,
|
||||
y: 22,
|
||||
width: 890,
|
||||
height: 238,
|
||||
radius: 20,
|
||||
fill: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const avatar = await Canvas.loadImage(
|
||||
|
@ -183,19 +200,27 @@ export async function generateRankCard(
|
|||
);
|
||||
|
||||
context.fillStyle = '#484b4E';
|
||||
roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true);
|
||||
roundRect({
|
||||
ctx: context,
|
||||
x: barX,
|
||||
y: barY,
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
radius: barHeight / 2,
|
||||
fill: true,
|
||||
});
|
||||
|
||||
if (progress > 0) {
|
||||
context.fillStyle = '#5865F2';
|
||||
roundRect(
|
||||
context,
|
||||
barX,
|
||||
barY,
|
||||
barWidth * progress,
|
||||
barHeight,
|
||||
barHeight / 2,
|
||||
true,
|
||||
);
|
||||
roundRect({
|
||||
ctx: context,
|
||||
x: barX,
|
||||
y: barY,
|
||||
width: barWidth * progress,
|
||||
height: barHeight,
|
||||
radius: barHeight / 2,
|
||||
fill: true,
|
||||
});
|
||||
}
|
||||
|
||||
context.textAlign = 'center';
|
||||
|
@ -212,6 +237,13 @@ export async function generateRankCard(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns level roles to a user based on their new level
|
||||
* @param guild - The guild to assign roles in
|
||||
* @param userId - The userId of the user to assign roles to
|
||||
* @param newLevel - The new level of the user
|
||||
* @returns - The highest role that was assigned
|
||||
*/
|
||||
export async function checkAndAssignLevelRoles(
|
||||
guild: Guild,
|
||||
userId: string,
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { ChannelType } from 'discord.js';
|
||||
import { LogActionType } from './types';
|
||||
|
||||
/**
|
||||
* Colors for different actions
|
||||
*/
|
||||
export const ACTION_COLORS: Record<string, number> = {
|
||||
// Danger actions - Red
|
||||
ban: 0xff0000,
|
||||
|
@ -31,6 +34,9 @@ export const ACTION_COLORS: Record<string, number> = {
|
|||
default: 0x0099ff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Emojis for different actions
|
||||
*/
|
||||
export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
||||
roleCreate: '⭐',
|
||||
roleDelete: '🗑️',
|
||||
|
@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
|||
roleRemove: '➖',
|
||||
};
|
||||
|
||||
/**
|
||||
* Types of channels
|
||||
*/
|
||||
export const CHANNEL_TYPES: Record<number, string> = {
|
||||
[ChannelType.GuildText]: 'Text Channel',
|
||||
[ChannelType.GuildVoice]: 'Voice Channel',
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
TextChannel,
|
||||
ButtonStyle,
|
||||
ButtonBuilder,
|
||||
ActionRowBuilder,
|
||||
|
@ -25,7 +24,13 @@ import {
|
|||
} from './utils.js';
|
||||
import { loadConfig } from '../configLoader.js';
|
||||
|
||||
export default async function logAction(payload: LogActionPayload) {
|
||||
/**
|
||||
* Logs an action to the log channel
|
||||
* @param payload - The payload to log
|
||||
*/
|
||||
export default async function logAction(
|
||||
payload: LogActionPayload,
|
||||
): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const logChannel = payload.guild.channels.cache.get(config.channels.logs);
|
||||
if (!logChannel?.isTextBased()) {
|
||||
|
|
|
@ -7,6 +7,9 @@ import {
|
|||
PermissionsBitField,
|
||||
} from 'discord.js';
|
||||
|
||||
/**
|
||||
* Moderation log action types
|
||||
*/
|
||||
export type ModerationActionType =
|
||||
| 'ban'
|
||||
| 'kick'
|
||||
|
@ -14,23 +17,38 @@ export type ModerationActionType =
|
|||
| 'unban'
|
||||
| 'unmute'
|
||||
| 'warn';
|
||||
/**
|
||||
* Message log action types
|
||||
*/
|
||||
export type MessageActionType = 'messageDelete' | 'messageEdit';
|
||||
/**
|
||||
* Member log action types
|
||||
*/
|
||||
export type MemberActionType =
|
||||
| 'memberJoin'
|
||||
| 'memberLeave'
|
||||
| 'memberUsernameUpdate'
|
||||
| 'memberNicknameUpdate';
|
||||
/**
|
||||
* Role log action types
|
||||
*/
|
||||
export type RoleActionType =
|
||||
| 'roleAdd'
|
||||
| 'roleRemove'
|
||||
| 'roleCreate'
|
||||
| 'roleDelete'
|
||||
| 'roleUpdate';
|
||||
/**
|
||||
* Channel log action types
|
||||
*/
|
||||
export type ChannelActionType =
|
||||
| 'channelCreate'
|
||||
| 'channelDelete'
|
||||
| 'channelUpdate';
|
||||
|
||||
/**
|
||||
* All log action types
|
||||
*/
|
||||
export type LogActionType =
|
||||
| ModerationActionType
|
||||
| MessageActionType
|
||||
|
@ -38,6 +56,9 @@ export type LogActionType =
|
|||
| RoleActionType
|
||||
| ChannelActionType;
|
||||
|
||||
/**
|
||||
* Properties of a role
|
||||
*/
|
||||
export type RoleProperties = {
|
||||
name: string;
|
||||
color: string;
|
||||
|
@ -45,6 +66,9 @@ export type RoleProperties = {
|
|||
mentionable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base log action properties
|
||||
*/
|
||||
export interface BaseLogAction {
|
||||
guild: Guild;
|
||||
action: LogActionType;
|
||||
|
@ -53,6 +77,9 @@ export interface BaseLogAction {
|
|||
duration?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for moderation actions
|
||||
*/
|
||||
export interface ModerationLogAction extends BaseLogAction {
|
||||
action: ModerationActionType;
|
||||
target: GuildMember;
|
||||
|
@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction {
|
|||
duration?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for message actions
|
||||
*/
|
||||
export interface MessageLogAction extends BaseLogAction {
|
||||
action: MessageActionType;
|
||||
message: Message<true>;
|
||||
|
@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction {
|
|||
newContent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for member actions
|
||||
*/
|
||||
export interface MemberLogAction extends BaseLogAction {
|
||||
action: 'memberJoin' | 'memberLeave';
|
||||
member: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for member username or nickname updates
|
||||
*/
|
||||
export interface MemberUpdateAction extends BaseLogAction {
|
||||
action: 'memberUsernameUpdate' | 'memberNicknameUpdate';
|
||||
member: GuildMember;
|
||||
|
@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction {
|
|||
newValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role actions
|
||||
*/
|
||||
export interface RoleLogAction extends BaseLogAction {
|
||||
action: 'roleAdd' | 'roleRemove';
|
||||
member: GuildMember;
|
||||
|
@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role updates
|
||||
*/
|
||||
export interface RoleUpdateAction extends BaseLogAction {
|
||||
action: 'roleUpdate';
|
||||
role: Role;
|
||||
|
@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role creation or deletion
|
||||
*/
|
||||
export interface RoleCreateDeleteAction extends BaseLogAction {
|
||||
action: 'roleCreate' | 'roleDelete';
|
||||
role: Role;
|
||||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for channel actions
|
||||
*/
|
||||
export interface ChannelLogAction extends BaseLogAction {
|
||||
action: ChannelActionType;
|
||||
channel: GuildChannel;
|
||||
|
@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for a log action
|
||||
*/
|
||||
export type LogActionPayload =
|
||||
| ModerationLogAction
|
||||
| MessageLogAction
|
||||
|
|
|
@ -5,9 +5,15 @@ import {
|
|||
EmbedField,
|
||||
PermissionsBitField,
|
||||
} from 'discord.js';
|
||||
|
||||
import { LogActionPayload, LogActionType, RoleProperties } from './types.js';
|
||||
import { ACTION_EMOJIS } from './constants.js';
|
||||
|
||||
/**
|
||||
* Formats a permission name to be more readable
|
||||
* @param perm - The permission to format
|
||||
* @returns - The formatted permission name
|
||||
*/
|
||||
export const formatPermissionName = (perm: string): string => {
|
||||
return perm
|
||||
.split('_')
|
||||
|
@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => {
|
|||
.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a field for a user
|
||||
* @param user - The user to create a field for
|
||||
* @param label - The label for the field
|
||||
* @returns - The created field
|
||||
*/
|
||||
export const createUserField = (
|
||||
user: User | GuildMember,
|
||||
label = 'User',
|
||||
|
@ -24,6 +36,12 @@ export const createUserField = (
|
|||
inline: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a field for a moderator
|
||||
* @param moderator - The moderator to create a field for
|
||||
* @param label - The label for the field
|
||||
* @returns - The created field
|
||||
*/
|
||||
export const createModeratorField = (
|
||||
moderator?: GuildMember,
|
||||
label = 'Moderator',
|
||||
|
@ -36,12 +54,23 @@ export const createModeratorField = (
|
|||
}
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Creates a field for a channel
|
||||
* @param channel - The channel to create a field for
|
||||
* @returns - The created field
|
||||
*/
|
||||
export const createChannelField = (channel: GuildChannel): EmbedField => ({
|
||||
name: 'Channel',
|
||||
value: `<#${channel.id}>`,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a field for changed permissions
|
||||
* @param oldPerms - The old permissions
|
||||
* @param newPerms - The new permissions
|
||||
* @returns - The created fields
|
||||
*/
|
||||
export const createPermissionChangeFields = (
|
||||
oldPerms: Readonly<PermissionsBitField>,
|
||||
newPerms: Readonly<PermissionsBitField>,
|
||||
|
@ -84,6 +113,11 @@ export const createPermissionChangeFields = (
|
|||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the names of the permissions in a bitfield
|
||||
* @param permissions - The permissions to get the names of
|
||||
* @returns - The names of the permissions
|
||||
*/
|
||||
export const getPermissionNames = (
|
||||
permissions: Readonly<PermissionsBitField>,
|
||||
): string[] => {
|
||||
|
@ -98,6 +132,12 @@ export const getPermissionNames = (
|
|||
return names;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares two bitfields and returns the names of the permissions that are in the first bitfield but not the second
|
||||
* @param a - The first bitfield
|
||||
* @param b - The second bitfield
|
||||
* @returns - The names of the permissions that are in the first bitfield but not the second
|
||||
*/
|
||||
export const getPermissionDifference = (
|
||||
a: Readonly<PermissionsBitField>,
|
||||
b: Readonly<PermissionsBitField>,
|
||||
|
@ -114,6 +154,12 @@ export const getPermissionDifference = (
|
|||
return names;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a field for a role
|
||||
* @param oldRole - The old role
|
||||
* @param newRole - The new role
|
||||
* @returns - The fields for the role changes
|
||||
*/
|
||||
export const createRoleChangeFields = (
|
||||
oldRole: Partial<RoleProperties>,
|
||||
newRole: Partial<RoleProperties>,
|
||||
|
@ -153,6 +199,11 @@ export const createRoleChangeFields = (
|
|||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the ID of the item that was logged
|
||||
* @param payload - The payload to get the log item ID from
|
||||
* @returns - The ID of the log item
|
||||
*/
|
||||
export const getLogItemId = (payload: LogActionPayload): string => {
|
||||
switch (payload.action) {
|
||||
case 'roleCreate':
|
||||
|
@ -188,6 +239,11 @@ export const getLogItemId = (payload: LogActionPayload): string => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the emoji for an action
|
||||
* @param action - The action to get an emoji for
|
||||
* @returns - The emoji for the action
|
||||
*/
|
||||
export const getEmojiForAction = (action: LogActionType): string => {
|
||||
return ACTION_EMOJIS[action] || '📝';
|
||||
};
|
||||
|
|
151
src/util/notificationHandler.ts
Normal file
151
src/util/notificationHandler.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { Client, Guild, GuildMember } from 'discord.js';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
|
||||
/**
|
||||
* Types of notifications that can be sent
|
||||
*/
|
||||
export enum NotificationType {
|
||||
// Redis notifications
|
||||
REDIS_CONNECTION_LOST = 'REDIS_CONNECTION_LOST',
|
||||
REDIS_CONNECTION_RESTORED = 'REDIS_CONNECTION_RESTORED',
|
||||
|
||||
// Database notifications
|
||||
DATABASE_CONNECTION_LOST = 'DATABASE_CONNECTION_LOST',
|
||||
DATABASE_CONNECTION_RESTORED = 'DATABASE_CONNECTION_RESTORED',
|
||||
|
||||
// Bot notifications
|
||||
BOT_RESTARTING = 'BOT_RESTARTING',
|
||||
BOT_ERROR = 'BOT_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps notification types to their messages
|
||||
*/
|
||||
const NOTIFICATION_MESSAGES = {
|
||||
[NotificationType.REDIS_CONNECTION_LOST]:
|
||||
'⚠️ **Redis Connection Lost**\n\nThe bot has lost connection to Redis after multiple retry attempts. Caching functionality is disabled until the connection is restored.',
|
||||
[NotificationType.REDIS_CONNECTION_RESTORED]:
|
||||
'✅ **Redis Connection Restored**\n\nThe bot has successfully reconnected to Redis. All caching functionality has been restored.',
|
||||
|
||||
[NotificationType.DATABASE_CONNECTION_LOST]:
|
||||
'🚨 **Database Connection Lost**\n\nThe bot has lost connection to the database after multiple retry attempts. The bot cannot function properly without database access and will shut down.',
|
||||
[NotificationType.DATABASE_CONNECTION_RESTORED]:
|
||||
'✅ **Database Connection Restored**\n\nThe bot has successfully reconnected to the database.',
|
||||
|
||||
[NotificationType.BOT_RESTARTING]:
|
||||
'🔄 **Bot Restarting**\n\nThe bot is being restarted. Services will be temporarily unavailable.',
|
||||
[NotificationType.BOT_ERROR]:
|
||||
'🚨 **Critical Bot Error**\n\nThe bot has encountered a critical error and may not function correctly.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a Discord-friendly timestamp string
|
||||
* @returns Formatted Discord timestamp string
|
||||
*/
|
||||
function createDiscordTimestamp(): string {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
return `<t:${timestamp}:F> (<t:${timestamp}:R>)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all managers with the Manager role
|
||||
* @param guild - The guild to search in
|
||||
* @returns Array of members with the Manager role
|
||||
*/
|
||||
async function getManagers(guild: Guild): Promise<GuildMember[]> {
|
||||
const config = loadConfig();
|
||||
const managerRoleId = config.roles?.staffRoles?.find(
|
||||
(role) => role.name === 'Manager',
|
||||
)?.roleId;
|
||||
|
||||
if (!managerRoleId) {
|
||||
console.warn('Manager role not found in config');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
await guild.members.fetch();
|
||||
|
||||
return Array.from(
|
||||
guild.members.cache
|
||||
.filter(
|
||||
(member) => member.roles.cache.has(managerRoleId) && !member.user.bot,
|
||||
)
|
||||
.values(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching managers:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification to users with the Manager role
|
||||
* @param client - Discord client instance
|
||||
* @param type - Type of notification to send
|
||||
* @param customMessage - Optional custom message to append
|
||||
*/
|
||||
export async function notifyManagers(
|
||||
client: Client,
|
||||
type: NotificationType,
|
||||
customMessage?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const guild = client.guilds.cache.get(config.guildId);
|
||||
|
||||
if (!guild) {
|
||||
console.error(`Guild with ID ${config.guildId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const managers = await getManagers(guild);
|
||||
|
||||
if (managers.length === 0) {
|
||||
console.warn('No managers found to notify');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMessage = NOTIFICATION_MESSAGES[type];
|
||||
const timestamp = createDiscordTimestamp();
|
||||
const fullMessage = customMessage
|
||||
? `${baseMessage}\n\n${customMessage}`
|
||||
: baseMessage;
|
||||
|
||||
let successCount = 0;
|
||||
for (const manager of managers) {
|
||||
try {
|
||||
await manager.send({
|
||||
content: `${fullMessage}\n\nTimestamp: ${timestamp}`,
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send DM to manager ${manager.user.tag}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Sent ${type} notification to ${successCount}/${managers.length} managers`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error sending manager notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a manager-level notification to the console
|
||||
* @param type - Type of notification
|
||||
* @param details - Additional details
|
||||
*/
|
||||
export function logManagerNotification(
|
||||
type: NotificationType,
|
||||
details?: string,
|
||||
): void {
|
||||
const baseMessage = NOTIFICATION_MESSAGES[type].split('\n')[0];
|
||||
console.warn(
|
||||
`MANAGER NOTIFICATION: ${baseMessage}${details ? ` | ${details}` : ''}`,
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue