Added code coments, refactored db.ts and redis.ts, and added two new commands

This commit is contained in:
Ahmad 2025-03-16 20:31:43 -04:00
parent b3fbd2358b
commit 890ca26c78
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
30 changed files with 1899 additions and 462 deletions

View file

@ -12,7 +12,7 @@ jobs:
strategy:
matrix:
node-version: [21.x]
node-version: [23.x]
steps:
- uses: actions/checkout@v4

View file

@ -20,4 +20,8 @@ Compile: ``yarn compile``
Start: ``yarn target``
Build & Start: ``yarn start``
Build & Start (dev): ``yarn start:dev``
Build & Start (prod): ``yarn start:prod``
Restart (works only when the bot is started with ``yarn start:prod``): ``yarn restart``

View file

@ -2,8 +2,16 @@
"token": "DISCORD_BOT_API_KEY",
"clientId": "DISCORD_BOT_ID",
"guildId": "DISCORD_SERVER_ID",
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
"redisConnectionString": "REDIS_CONNECTION_STRING",
"database": {
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
"retryDelay": "RETRY_DELAY_IN_MS"
},
"redis": {
"redisConnectionString": "REDIS_CONNECTION_STRING",
"retryAttempts": "RETRY_ATTEMPTS",
"initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
},
"channels": {
"welcome": "WELCOME_CHANNEL_ID",
"logs": "LOG_CHANNEL_ID",
@ -45,5 +53,10 @@
}
],
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
},
"leveling": {
"xpCooldown": "XP_COOLDOWN_IN_SECONDS",
"minXpAwarded": "MINIMUM_XP_AWARDED",
"maxXpAwarded": "MAXIMUM_XP_AWARDED"
}
}

View file

@ -9,7 +9,9 @@
"scripts": {
"compile": "npx tsc",
"target": "node ./target/discord-bot.js",
"start": "yarn run compile && yarn run target",
"start:dev": "yarn run compile && yarn run target",
"start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot",
"restart": "pm2 restart poixpixel-discord-bot",
"lint": "npx eslint ./src && npx tsc --noEmit",
"format": "prettier --check --ignore-path .prettierignore .",
"format:fix": "prettier --write --ignore-path .prettierignore ."

View file

@ -19,7 +19,7 @@ const command: Command = {
execute: async (interaction) => {
let members = await getAllMembers();
members = members.sort((a, b) =>
a.discordUsername.localeCompare(b.discordUsername),
(a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
);
const ITEMS_PER_PAGE = 15;

View file

@ -0,0 +1,203 @@
import {
CommandInteraction,
PermissionsBitField,
SlashCommandBuilder,
} from 'discord.js';
import { SubcommandCommand } from '../../types/CommandTypes.js';
import { loadConfig } from '../../util/configLoader.js';
import {
initializeDatabaseConnection,
ensureDbInitialized,
} from '../../db/db.js';
import { isRedisConnected } from '../../db/redis.js';
import {
NotificationType,
notifyManagers,
} from '../../util/notificationHandler.js';
const command: SubcommandCommand = {
data: new SlashCommandBuilder()
.setName('reconnect')
.setDescription('(Manager Only) Force reconnection to database or Redis')
.addSubcommand((subcommand) =>
subcommand
.setName('database')
.setDescription('(Manager Only) Force reconnection to the database'),
)
.addSubcommand((subcommand) =>
subcommand
.setName('redis')
.setDescription('(Manager Only) Force reconnection to Redis cache'),
)
.addSubcommand((subcommand) =>
subcommand
.setName('status')
.setDescription(
'(Manager Only) Check connection status of database and Redis',
),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Manager',
)?.roleId;
const member = await interaction.guild?.members.fetch(interaction.user.id);
const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
if (
!hasManagerRole &&
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
)
) {
await interaction.reply({
content:
'You do not have permission to use this command. This command is restricted to users with the Manager role.',
flags: ['Ephemeral'],
});
return;
}
const subcommand = interaction.options.getSubcommand();
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
if (subcommand === 'database') {
await handleDatabaseReconnect(interaction);
} else if (subcommand === 'redis') {
await handleRedisReconnect(interaction);
} else if (subcommand === 'status') {
await handleStatusCheck(interaction);
}
} catch (error) {
console.error(`Error in reconnect command (${subcommand}):`, error);
await interaction.editReply({
content: `An error occurred while processing the reconnect command: \`${error}\``,
});
}
},
};
/**
* Handle database reconnection
*/
async function handleDatabaseReconnect(interaction: CommandInteraction) {
await interaction.editReply('Attempting to reconnect to the database...');
try {
const success = await initializeDatabaseConnection();
if (success) {
await interaction.editReply(
'✅ **Database reconnection successful!** All database functions should now be operational.',
);
notifyManagers(
interaction.client,
NotificationType.DATABASE_CONNECTION_RESTORED,
`Database connection manually restored by ${interaction.user.tag}`,
);
} else {
await interaction.editReply(
'❌ **Database reconnection failed.** Check the logs for more details.',
);
}
} catch (error) {
console.error('Error reconnecting to database:', error);
await interaction.editReply(
`❌ **Database reconnection failed with error:** \`${error}\``,
);
}
}
/**
* Handle Redis reconnection
*/
async function handleRedisReconnect(interaction: CommandInteraction) {
await interaction.editReply('Attempting to reconnect to Redis...');
try {
const redisModule = await import('../../db/redis.js');
await redisModule.ensureRedisConnection();
const isConnected = redisModule.isRedisConnected();
if (isConnected) {
await interaction.editReply(
'✅ **Redis reconnection successful!** Cache functionality is now available.',
);
notifyManagers(
interaction.client,
NotificationType.REDIS_CONNECTION_RESTORED,
`Redis connection manually restored by ${interaction.user.tag}`,
);
} else {
await interaction.editReply(
'❌ **Redis reconnection failed.** The bot will continue to function without caching capabilities.',
);
}
} catch (error) {
console.error('Error reconnecting to Redis:', error);
await interaction.editReply(
`❌ **Redis reconnection failed with error:** \`${error}\``,
);
}
}
/**
* Handle status check for both services
*/
async function handleStatusCheck(interaction: any) {
await interaction.editReply('Checking connection status...');
try {
const dbStatus = await (async () => {
try {
await ensureDbInitialized();
return true;
} catch {
return false;
}
})();
const redisStatus = isRedisConnected();
const statusEmbed = {
title: '🔌 Service Connection Status',
fields: [
{
name: 'Database',
value: dbStatus ? '✅ Connected' : '❌ Disconnected',
inline: true,
},
{
name: 'Redis Cache',
value: redisStatus
? '✅ Connected'
: '⚠️ Disconnected (caching disabled)',
inline: true,
},
],
color:
dbStatus && redisStatus ? 0x00ff00 : dbStatus ? 0xffaa00 : 0xff0000,
timestamp: new Date().toISOString(),
};
await interaction.editReply({ content: '', embeds: [statusEmbed] });
} catch (error) {
console.error('Error checking connection status:', error);
await interaction.editReply(
`❌ **Error checking connection status:** \`${error}\``,
);
}
}
export default command;

View file

@ -0,0 +1,93 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Command } from '../../types/CommandTypes.js';
import { loadConfig } from '../../util/configLoader.js';
import {
NotificationType,
notifyManagers,
} from '../../util/notificationHandler.js';
import { isRedisConnected } from '../../db/redis.js';
import { ensureDatabaseConnection } from '../../db/db.js';
const execAsync = promisify(exec);
const command: Command = {
data: new SlashCommandBuilder()
.setName('restart')
.setDescription('(Manager Only) Restart the bot'),
execute: async (interaction) => {
const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Manager',
)?.roleId;
const member = await interaction.guild?.members.fetch(interaction.user.id);
const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
if (
!hasManagerRole &&
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
)
) {
await interaction.reply({
content:
'You do not have permission to restart the bot. This command is restricted to users with the Manager role.',
flags: ['Ephemeral'],
});
return;
}
await interaction.reply({
content: 'Restarting the bot... This may take a few moments.',
flags: ['Ephemeral'],
});
const dbConnected = await ensureDatabaseConnection();
const redisConnected = isRedisConnected();
let statusInfo = '';
if (!dbConnected) {
statusInfo += '⚠️ Database is currently disconnected\n';
}
if (!redisConnected) {
statusInfo += '⚠️ Redis caching is currently unavailable\n';
}
if (dbConnected && redisConnected) {
statusInfo = '✅ All services are operational\n';
}
await notifyManagers(
interaction.client,
NotificationType.BOT_RESTARTING,
`Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`,
);
setTimeout(async () => {
try {
console.log(
`Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`,
);
await execAsync('yarn restart');
} catch (error) {
console.error('Failed to restart the bot:', error);
try {
await interaction.followUp({
content:
'Failed to restart the bot. Check the console for details.',
flags: ['Ephemeral'],
});
} catch {
// If this fails too, we can't do much
}
}
}, 1000);
},
};
export default command;

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,31 @@
import Redis from 'ioredis';
import { Client } from 'discord.js';
import { loadConfig } from '../util/configLoader.js';
import {
logManagerNotification,
NotificationType,
notifyManagers,
} from '../util/notificationHandler.js';
const config = loadConfig();
const redis = new Redis(config.redisConnectionString);
// Redis connection state
let isRedisAvailable = false;
let redis: Redis;
let connectionAttempts = 0;
const MAX_RETRY_ATTEMPTS = config.redis.retryAttempts;
const INITIAL_RETRY_DELAY = config.redis.initialRetryDelay;
let hasNotifiedDisconnect = false;
let discordClient: Client | null = null;
// ========================
// Redis Utility Classes and Helper Functions
// ========================
/**
* Custom error class for Redis errors
*/
class RedisError extends Error {
constructor(
message: string,
@ -14,77 +36,271 @@ class RedisError extends Error {
}
}
redis.on('error', (error: Error) => {
console.error('Redis connection error:', error);
throw new RedisError('Failed to connect to Redis instance: ', error);
});
/**
* Redis error handler
* @param errorMessage - The error message to log
* @param error - The error object
*/
const handleRedisError = (errorMessage: string, error: Error): null => {
console.error(`${errorMessage}:`, error);
throw new RedisError(errorMessage, error);
};
redis.on('connect', () => {
console.log('Successfully connected to Redis');
});
/**
* Sets the Discord client for sending notifications
* @param client - The Discord client
*/
export function setDiscordClient(client: Client): void {
discordClient = client;
}
/**
* Initializes the Redis connection with retry logic
*/
async function initializeRedisConnection() {
try {
if (redis && redis.status !== 'end' && redis.status !== 'close') {
return;
}
redis = new Redis(config.redis.redisConnectionString, {
retryStrategy(times) {
connectionAttempts = times;
if (times >= MAX_RETRY_ATTEMPTS) {
const message = `Failed to connect to Redis after ${times} attempts. Caching will be disabled.`;
console.warn(message);
if (!hasNotifiedDisconnect && discordClient) {
logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
notifyManagers(
discordClient,
NotificationType.REDIS_CONNECTION_LOST,
`Connection attempts exhausted after ${times} tries. Caching is now disabled.`,
);
hasNotifiedDisconnect = true;
}
return null;
}
const delay = Math.min(INITIAL_RETRY_DELAY * Math.pow(2, times), 30000);
console.log(
`Retrying Redis connection in ${delay}ms... (Attempt ${times + 1}/${MAX_RETRY_ATTEMPTS})`,
);
return delay;
},
maxRetriesPerRequest: 3,
enableOfflineQueue: true,
});
// ========================
// Redis Events
// ========================
redis.on('error', (error: Error) => {
console.error('Redis Connection Error:', error);
isRedisAvailable = false;
});
redis.on('connect', () => {
console.info('Successfully connected to Redis');
isRedisAvailable = true;
connectionAttempts = 0;
if (hasNotifiedDisconnect && discordClient) {
logManagerNotification(NotificationType.REDIS_CONNECTION_RESTORED);
notifyManagers(
discordClient,
NotificationType.REDIS_CONNECTION_RESTORED,
);
hasNotifiedDisconnect = false;
}
});
redis.on('close', () => {
console.warn('Redis connection closed');
isRedisAvailable = false;
// Try to reconnect after some time if we've not exceeded max attempts
if (connectionAttempts < MAX_RETRY_ATTEMPTS) {
const delay = Math.min(
INITIAL_RETRY_DELAY * Math.pow(2, connectionAttempts),
30000,
);
setTimeout(initializeRedisConnection, delay);
} else if (!hasNotifiedDisconnect && discordClient) {
logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
notifyManagers(
discordClient,
NotificationType.REDIS_CONNECTION_LOST,
'Connection closed and max retry attempts reached.',
);
hasNotifiedDisconnect = true;
}
});
redis.on('reconnecting', () => {
console.info('Attempting to reconnect to Redis...');
});
} catch (error) {
console.error('Failed to initialize Redis:', error);
isRedisAvailable = false;
if (!hasNotifiedDisconnect && discordClient) {
logManagerNotification(
NotificationType.REDIS_CONNECTION_LOST,
`Error: ${error}`,
);
notifyManagers(
discordClient,
NotificationType.REDIS_CONNECTION_LOST,
`Initialization error: ${error}`,
);
hasNotifiedDisconnect = true;
}
}
}
// Initialize Redis connection
initializeRedisConnection();
/**
* Check if Redis is currently available, and attempt to reconnect if not
* @returns - True if Redis is connected and available
*/
export async function ensureRedisConnection(): Promise<boolean> {
if (!isRedisAvailable) {
await initializeRedisConnection();
}
return isRedisAvailable;
}
// ========================
// Redis Functions
// ========================
/**
* Function to set a key in Redis
* @param key - The key to set
* @param value - The value to set
* @param ttl - The time to live for the key
* @returns - 'OK' if successful
*/
export async function set(
key: string,
value: string,
ttl?: number,
): Promise<'OK'> {
try {
await redis.set(key, value);
if (ttl) await redis.expire(key, ttl);
} catch (error) {
console.error('Redis set error: ', error);
throw new RedisError(`Failed to set key: ${key}, `, error as Error);
): Promise<'OK' | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping set operation');
return null;
}
try {
await redis.set(`bot:${key}`, value);
if (ttl) await redis.expire(`bot:${key}`, ttl);
return 'OK';
} catch (error) {
return handleRedisError(`Failed to set key: ${key}`, error as Error);
}
return Promise.resolve('OK');
}
/**
* Function to set a key in Redis with a JSON value
* @param key - The key to set
* @param value - The value to set
* @param ttl - The time to live for the key
* @returns - 'OK' if successful
*/
export async function setJson<T>(
key: string,
value: T,
ttl?: number,
): Promise<'OK'> {
): Promise<'OK' | null> {
return await set(key, JSON.stringify(value), ttl);
}
export async function incr(key: string): Promise<number> {
/**
* Increments a key in Redis
* @param key - The key to increment
* @returns - The new value of the key, or null if Redis is unavailable
*/
export async function incr(key: string): Promise<number | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping increment operation');
return null;
}
try {
return await redis.incr(key);
return await redis.incr(`bot:${key}`);
} catch (error) {
console.error('Redis increment error: ', error);
throw new RedisError(`Failed to increment key: ${key}, `, error as Error);
return handleRedisError(`Failed to increment key: ${key}`, error as Error);
}
}
export async function exists(key: string): Promise<boolean> {
/**
* Checks if a key exists in Redis
* @param key - The key to check
* @returns - True if the key exists, false otherwise, or null if Redis is unavailable
*/
export async function exists(key: string): Promise<boolean | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping exists operation');
return null;
}
try {
return (await redis.exists(key)) === 1;
return (await redis.exists(`bot:${key}`)) === 1;
} catch (error) {
console.error('Redis exists error: ', error);
throw new RedisError(
`Failed to check if key exists: ${key}, `,
return handleRedisError(
`Failed to check if key exists: ${key}`,
error as Error,
);
}
}
/**
* Gets the value of a key in Redis
* @param key - The key to get
* @returns - The value of the key, or null if the key does not exist or Redis is unavailable
*/
export async function get(key: string): Promise<string | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping get operation');
return null;
}
try {
return await redis.get(key);
return await redis.get(`bot:${key}`);
} catch (error) {
console.error('Redis get error: ', error);
throw new RedisError(`Failed to get key: ${key}, `, error as Error);
return handleRedisError(`Failed to get key: ${key}`, error as Error);
}
}
export async function mget(...keys: string[]): Promise<(string | null)[]> {
/**
* Gets the values of multiple keys in Redis
* @param keys - The keys to get
* @returns - The values of the keys, or null if Redis is unavailable
*/
export async function mget(
...keys: string[]
): Promise<(string | null)[] | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping mget operation');
return null;
}
try {
return await redis.mget(keys);
return await redis.mget(...keys.map((key) => `bot:${key}`));
} catch (error) {
console.error('Redis mget error: ', error);
throw new RedisError(`Failed to get keys: ${keys}, `, error as Error);
return handleRedisError('Failed to get keys', error as Error);
}
}
/**
* Gets the value of a key in Redis and parses it as a JSON object
* @param key - The key to get
* @returns - The parsed JSON value of the key, or null if the key does not exist or Redis is unavailable
*/
export async function getJson<T>(key: string): Promise<T | null> {
const value = await get(key);
if (!value) return null;
@ -95,11 +311,28 @@ export async function getJson<T>(key: string): Promise<T | null> {
}
}
export async function del(key: string): Promise<number> {
/**
* Deletes a key in Redis
* @param key - The key to delete
* @returns - The number of keys that were deleted, or null if Redis is unavailable
*/
export async function del(key: string): Promise<number | null> {
if (!(await ensureRedisConnection())) {
console.warn('Redis unavailable, skipping delete operation');
return null;
}
try {
return await redis.del(key);
return await redis.del(`bot:${key}`);
} catch (error) {
console.error('Redis del error: ', error);
throw new RedisError(`Failed to delete key: ${key}, `, error as Error);
return handleRedisError(`Failed to delete key: ${key}`, error as Error);
}
}
/**
* Check if Redis is currently available
* @returns - True if Redis is connected and available
*/
export function isRedisConnected(): boolean {
return isRedisAvailable;
}

View file

@ -75,6 +75,7 @@ export const memberRelations = relations(memberTable, ({ many, one }) => ({
fields: [memberTable.discordId],
references: [levelTable.discordId],
}),
facts: many(factTable),
}));
export const levelRelations = relations(levelTable, ({ one }) => ({

View file

@ -63,7 +63,7 @@ export default {
if (!interaction.memberPermissions?.has('ModerateMembers')) {
await interaction.reply({
content: 'You do not have permission to approve facts.',
ephemeral: true,
flags: ['Ephemeral'],
});
return;
}
@ -79,7 +79,7 @@ export default {
if (!interaction.memberPermissions?.has('ModerateMembers')) {
await interaction.reply({
content: 'You do not have permission to reject facts.',
ephemeral: true,
flags: ['Ephemeral'],
});
return;
}

View file

@ -1,4 +1,10 @@
import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js';
import {
Collection,
Events,
Guild,
GuildMember,
PartialGuildMember,
} from 'discord.js';
import { updateMember, setMembers } from '../db/db.js';
import { generateMemberBanner } from '../util/helpers.js';
@ -19,12 +25,9 @@ export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
}
try {
await setMembers([
{
discordId: member.user.id,
discordUsername: member.user.username,
},
]);
const memberCollection = new Collection<string, GuildMember>();
memberCollection.set(member.user.id, member);
await setMembers(memberCollection);
if (!member.user.bot) {
const attachment = await generateMemberBanner({

View file

@ -84,7 +84,7 @@ export const messageCreate: Event<typeof Events.MessageCreate> = {
advancementsChannelId,
);
if (!advancementsChannel || !advancementsChannel.isTextBased()) {
if (!advancementsChannel?.isTextBased()) {
console.error(
'Advancements channel not found or is not a text channel',
);

View file

@ -1,16 +1,28 @@
import { Client, Events } from 'discord.js';
import { setMembers } from '../db/db.js';
import { ensureDbInitialized, setMembers } from '../db/db.js';
import { loadConfig } from '../util/configLoader.js';
import { Event } from '../types/EventTypes.js';
import { scheduleFactOfTheDay } from '../util/factManager.js';
import {
ensureRedisConnection,
setDiscordClient as setRedisDiscordClient,
} from '../db/redis.js';
import { setDiscordClient as setDbDiscordClient } from '../db/db.js';
export default {
name: Events.ClientReady,
once: true,
execute: async (client: Client) => {
const config = loadConfig();
try {
setRedisDiscordClient(client);
setDbDiscordClient(client);
await ensureDbInitialized();
await ensureRedisConnection();
const guild = client.guilds.cache.find(
(guilds) => guilds.id === config.guildId,
);
@ -25,7 +37,7 @@ export default {
await scheduleFactOfTheDay(client);
} catch (error) {
console.error('Failed to initialize members in database:', error);
console.error('Failed to initialize the bot:', error);
}
console.log(`Ready! Logged in as ${client.user?.tag}`);

View file

@ -4,6 +4,9 @@ import { Config } from '../types/ConfigTypes.js';
import { deployCommands } from '../util/deployCommand.js';
import { registerEvents } from '../util/eventLoader.js';
/**
* Extended client class that extends the default Client class
*/
export class ExtendedClient extends Client {
public commands: Collection<string, Command>;
private config: Config;

View file

@ -5,16 +5,25 @@ import {
SlashCommandSubcommandsOnlyBuilder,
} from 'discord.js';
/**
* Command interface for normal commands
*/
export interface Command {
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
execute: (interaction: CommandInteraction) => Promise<void>;
}
/**
* Command interface for options commands
*/
export interface OptionsCommand {
data: SlashCommandOptionsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;
}
/**
* Command interface for subcommand commands
*/
export interface SubcommandCommand {
data: SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;

View file

@ -1,9 +1,20 @@
/**
* Config interface for the bot
*/
export interface Config {
token: string;
clientId: string;
guildId: string;
dbConnectionString: string;
redisConnectionString: string;
database: {
dbConnectionString: string;
maxRetryAttempts: number;
retryDelay: number;
};
redis: {
redisConnectionString: string;
retryAttempts: number;
initialRetryDelay: number;
};
channels: {
welcome: string;
logs: string;
@ -24,4 +35,9 @@ export interface Config {
}[];
factPingRole: string;
};
leveling: {
xpCooldown: number;
minXpAwarded: number;
maxXpAwarded: number;
};
}

View file

@ -1,5 +1,8 @@
import { ClientEvents } from 'discord.js';
/**
* Event interface for events
*/
export interface Event<K extends keyof ClientEvents> {
name: K;
once?: boolean;

View file

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

View file

@ -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.');

View file

@ -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 },

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View 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}` : ''}`,
);
}