This commit is contained in:
Ahmad 2025-03-09 19:55:30 +00:00 committed by GitHub
commit 6869e463b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1823 additions and 32 deletions

View file

@ -3,6 +3,9 @@
> [!WARNING] > [!WARNING]
> This Discord bot is not production ready and everything is subject to change > This Discord bot is not production ready and everything is subject to change
> [!TIP]
> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY).
## Development Commands ## Development Commands
Install Dependencies: ``yarn install`` Install Dependencies: ``yarn install``

Binary file not shown.

Binary file not shown.

View file

@ -6,11 +6,44 @@
"redisConnectionString": "REDIS_CONNECTION_STRING", "redisConnectionString": "REDIS_CONNECTION_STRING",
"channels": { "channels": {
"welcome": "WELCOME_CHANNEL_ID", "welcome": "WELCOME_CHANNEL_ID",
"logs": "LOG_CHAANNEL_ID" "logs": "LOG_CHANNEL_ID",
"counting": "COUNTING_CHANNEL_ID",
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
"advancements": "ADVANCEMENTS_CHANNEL_ID"
}, },
"roles": { "roles": {
"joinRoles": [ "joinRoles": [
"JOIN_ROLE_IDS" "JOIN_ROLE_IDS"
] ],
"levelRoles": [
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
},
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
},
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
}
],
"staffRoles": [
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
},
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
},
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
}
],
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
} }
} }

View file

@ -38,5 +38,5 @@
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"packageManager": "yarn@4.6.0" "packageManager": "yarn@4.7.0"
} }

View file

@ -0,0 +1,117 @@
import {
SlashCommandBuilder,
EmbedBuilder,
PermissionsBitField,
} from 'discord.js';
import { SubcommandCommand } from '../../types/CommandTypes.js';
import { getCountingData, setCount } from '../../util/countingManager.js';
import { loadConfig } from '../../util/configLoader.js';
const command: SubcommandCommand = {
data: new SlashCommandBuilder()
.setName('counting')
.setDescription('Commands related to the counting channel')
.addSubcommand((subcommand) =>
subcommand
.setName('status')
.setDescription('Check the current counting status'),
)
.addSubcommand((subcommand) =>
subcommand
.setName('setcount')
.setDescription(
'Set the current count to a specific number (Admin only)',
)
.addIntegerOption((option) =>
option
.setName('count')
.setDescription('The number to set as the current count')
.setRequired(true)
.setMinValue(0),
),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'status') {
const countingData = await getCountingData();
const countingChannelId = loadConfig().channels.counting;
const embed = new EmbedBuilder()
.setTitle('Counting Channel Status')
.setColor(0x0099ff)
.addFields(
{
name: 'Current Count',
value: countingData.currentCount.toString(),
inline: true,
},
{
name: 'Next Number',
value: (countingData.currentCount + 1).toString(),
inline: true,
},
{
name: 'Highest Count',
value: countingData.highestCount.toString(),
inline: true,
},
{
name: 'Total Correct Counts',
value: countingData.totalCorrect.toString(),
inline: true,
},
{
name: 'Counting Channel',
value: `<#${countingChannelId}>`,
inline: true,
},
)
.setFooter({ text: 'Remember: No user can count twice in a row!' })
.setTimestamp();
if (countingData.lastUserId) {
embed.addFields({
name: 'Last Counter',
value: `<@${countingData.lastUserId}>`,
inline: true,
});
}
await interaction.reply({ embeds: [embed] });
} else if (subcommand === 'setcount') {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
)
) {
await interaction.reply({
content: 'You need administrator permissions to use this command.',
flags: ['Ephemeral'],
});
return;
}
const count = interaction.options.getInteger('count');
if (count === null) {
await interaction.reply({
content: 'Invalid count specified.',
flags: ['Ephemeral'],
});
return;
}
await setCount(count);
await interaction.reply({
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
flags: ['Ephemeral'],
});
}
},
};
export default command;

245
src/commands/fun/fact.ts Normal file
View file

@ -0,0 +1,245 @@
import {
SlashCommandBuilder,
PermissionsBitField,
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
} from 'discord.js';
import {
addFact,
getPendingFacts,
approveFact,
deleteFact,
getLastInsertedFactId,
} from '../../db/db.js';
import { postFactOfTheDay } from '../../util/factManager.js';
import { loadConfig } from '../../util/configLoader.js';
import { SubcommandCommand } from '../../types/CommandTypes.js';
const command: SubcommandCommand = {
data: new SlashCommandBuilder()
.setName('fact')
.setDescription('Manage facts of the day')
.addSubcommand((subcommand) =>
subcommand
.setName('submit')
.setDescription('Submit a new fact for approval')
.addStringOption((option) =>
option
.setName('content')
.setDescription('The fact content')
.setRequired(true),
)
.addStringOption((option) =>
option
.setName('source')
.setDescription('Source of the fact (optional)')
.setRequired(false),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('approve')
.setDescription('Approve a pending fact (Mod only)')
.addIntegerOption((option) =>
option
.setName('id')
.setDescription('The ID of the fact to approve')
.setRequired(true),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('delete')
.setDescription('Delete a fact (Mod only)')
.addIntegerOption((option) =>
option
.setName('id')
.setDescription('The ID of the fact to delete')
.setRequired(true),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('pending')
.setDescription('List all pending facts (Mod only)'),
)
.addSubcommand((subcommand) =>
subcommand
.setName('post')
.setDescription('Post a fact of the day manually (Admin only)'),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return;
await interaction.deferReply({
flags: ['Ephemeral'],
});
await interaction.editReply('Processing...');
const config = loadConfig();
const subcommand = interaction.options.getSubcommand();
if (subcommand === 'submit') {
const content = interaction.options.getString('content', true);
const source = interaction.options.getString('source') || undefined;
const isAdmin = interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
);
await addFact({
content,
source,
addedBy: interaction.user.id,
approved: isAdmin ? true : false,
});
if (!isAdmin) {
const approvalChannel = interaction.guild?.channels.cache.get(
config.channels.factApproval,
);
if (approvalChannel?.isTextBased()) {
const embed = new EmbedBuilder()
.setTitle('New Fact Submission')
.setDescription(content)
.setColor(0x0099ff)
.addFields(
{
name: 'Submitted By',
value: `<@${interaction.user.id}>`,
inline: true,
},
{ name: 'Source', value: source || 'Not provided', inline: true },
)
.setTimestamp();
const factId = await getLastInsertedFactId();
const approveButton = new ButtonBuilder()
.setCustomId(`approve_fact_${factId}`)
.setLabel('Approve')
.setStyle(ButtonStyle.Success);
const rejectButton = new ButtonBuilder()
.setCustomId(`reject_fact_${factId}`)
.setLabel('Reject')
.setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
approveButton,
rejectButton,
);
await approvalChannel.send({
embeds: [embed],
components: [row],
});
} else {
console.error('Approval channel not found or is not a text channel');
}
}
await interaction.editReply({
content: isAdmin
? 'Your fact has been automatically approved and added to the database!'
: 'Your fact has been submitted for approval!',
});
} else if (subcommand === 'approve') {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to approve facts.',
});
return;
}
const id = interaction.options.getInteger('id', true);
await approveFact(id);
await interaction.editReply({
content: `Fact #${id} has been approved!`,
});
} else if (subcommand === 'delete') {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to delete facts.',
});
return;
}
const id = interaction.options.getInteger('id', true);
await deleteFact(id);
await interaction.editReply({
content: `Fact #${id} has been deleted!`,
});
} else if (subcommand === 'pending') {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers,
)
) {
await interaction.editReply({
content: 'You do not have permission to view pending facts.',
});
return;
}
const pendingFacts = await getPendingFacts();
if (pendingFacts.length === 0) {
await interaction.editReply({
content: 'There are no pending facts.',
});
return;
}
const embed = new EmbedBuilder()
.setTitle('Pending Facts')
.setColor(0x0099ff)
.setDescription(
pendingFacts
.map((fact) => {
return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`;
})
.join('\n\n'),
)
.setTimestamp();
await interaction.editReply({
embeds: [embed],
});
} else if (subcommand === 'post') {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
)
) {
await interaction.editReply({
content: 'You do not have permission to manually post facts.',
});
return;
}
await postFactOfTheDay(interaction.client);
await interaction.editReply({
content: 'Fact of the day has been posted!',
});
}
},
};
export default command;

View file

@ -0,0 +1,171 @@
import {
SlashCommandBuilder,
EmbedBuilder,
ButtonBuilder,
ActionRowBuilder,
ButtonStyle,
StringSelectMenuBuilder,
APIEmbed,
JSONEncodable,
} from 'discord.js';
import { OptionsCommand } from '../../types/CommandTypes.js';
import { getLevelLeaderboard } from '../../db/db.js';
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('leaderboard')
.setDescription('Shows the server XP leaderboard')
.addIntegerOption((option) =>
option
.setName('limit')
.setDescription('Number of users per page (default: 10)')
.setRequired(false),
),
execute: async (interaction) => {
if (!interaction.guild) return;
await interaction.deferReply();
try {
const usersPerPage =
(interaction.options.get('limit')?.value as number) || 10;
const allUsers = await getLevelLeaderboard(100);
if (allUsers.length === 0) {
const embed = new EmbedBuilder()
.setTitle('🏆 Server Leaderboard')
.setColor(0x5865f2)
.setDescription('No users found on the leaderboard yet.')
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
}
const pages: (APIEmbed | JSONEncodable<APIEmbed>)[] = [];
for (let i = 0; i < allUsers.length; i += usersPerPage) {
const pageUsers = allUsers.slice(i, i + usersPerPage);
let leaderboardText = '';
for (let j = 0; j < pageUsers.length; j++) {
const user = pageUsers[j];
const position = i + j + 1;
try {
const member = await interaction.guild.members.fetch(
user.discordId,
);
leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`;
} catch (error) {
leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`;
}
}
const embed = new EmbedBuilder()
.setTitle('🏆 Server Leaderboard')
.setColor(0x5865f2)
.setDescription(leaderboardText)
.setTimestamp()
.setFooter({
text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`,
});
pages.push(embed);
}
let currentPage = 0;
const getButtonActionRow = () =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('previous')
.setLabel('Previous')
.setStyle(ButtonStyle.Primary)
.setDisabled(currentPage === 0),
new ButtonBuilder()
.setCustomId('next')
.setLabel('Next')
.setStyle(ButtonStyle.Primary)
.setDisabled(currentPage === pages.length - 1),
);
const getSelectMenuRow = () => {
const options = pages.map((_, index) => ({
label: `Page ${index + 1}`,
value: index.toString(),
default: index === currentPage,
}));
const select = new StringSelectMenuBuilder()
.setCustomId('select_page')
.setPlaceholder('Jump to a page')
.addOptions(options);
return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
select,
);
};
const components =
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
const message = await interaction.editReply({
embeds: [pages[currentPage]],
components,
});
if (pages.length <= 1) return;
const collector = message.createMessageComponentCollector({
time: 60000,
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) {
await i.reply({
content: 'These controls are not for you!',
flags: ['Ephemeral'],
});
return;
}
if (i.isButton()) {
if (i.customId === 'previous' && currentPage > 0) {
currentPage--;
} else if (i.customId === 'next' && currentPage < pages.length - 1) {
currentPage++;
}
}
if (i.isStringSelectMenu()) {
const selected = parseInt(i.values[0]);
if (!isNaN(selected) && selected >= 0 && selected < pages.length) {
currentPage = selected;
}
}
await i.update({
embeds: [pages[currentPage]],
components: [getButtonActionRow(), getSelectMenuRow()],
});
});
collector.on('end', async () => {
if (message) {
try {
await interaction.editReply({ components: [] });
} catch (error) {
console.error('Error removing components:', error);
}
}
});
} catch (error) {
console.error('Error getting leaderboard:', error);
await interaction.editReply('Failed to get leaderboard information.');
}
},
};
export default command;

49
src/commands/fun/rank.ts Normal file
View file

@ -0,0 +1,49 @@
import { GuildMember, SlashCommandBuilder } from 'discord.js';
import { OptionsCommand } from '../../types/CommandTypes.js';
import {
generateRankCard,
getXpToNextLevel,
} from '../../util/levelingSystem.js';
import { getUserLevel } from '../../db/db.js';
const command: OptionsCommand = {
data: new SlashCommandBuilder()
.setName('rank')
.setDescription('Shows your current rank and level')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to check rank for (defaults to yourself)')
.setRequired(false),
),
execute: async (interaction) => {
const member = await interaction.guild?.members.fetch(
(interaction.options.get('user')?.value as string) || interaction.user.id,
);
if (!member) {
await interaction.reply('User not found in this server.');
return;
}
await interaction.deferReply();
try {
const userData = await getUserLevel(member.id);
const rankCard = await generateRankCard(member, userData);
const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp);
await interaction.editReply({
content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`,
files: [rankCard],
});
} catch (error) {
console.error('Error getting rank:', error);
await interaction.editReply('Failed to get rank information.');
}
},
};
export default command;

View file

@ -0,0 +1,36 @@
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
import { Command } from '../../types/CommandTypes.js';
import { recalculateUserLevels } from '../../util/levelingSystem.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('recalculatelevels')
.setDescription('(Admin Only) Recalculate all user levels'),
execute: async (interaction) => {
if (
!interaction.memberPermissions?.has(
PermissionsBitField.Flags.Administrator,
)
) {
await interaction.reply({
content: 'You do not have permission to use this command.',
flags: ['Ephemeral'],
});
return;
}
await interaction.deferReply();
await interaction.editReply('Recalculating levels...');
try {
await recalculateUserLevels();
await interaction.editReply('Levels recalculated successfully!');
} catch (error) {
console.error('Error recalculating levels:', error);
await interaction.editReply('Failed to recalculate levels.');
}
},
};
export default command;

132
src/commands/util/xp.ts Normal file
View file

@ -0,0 +1,132 @@
import { SlashCommandBuilder } from 'discord.js';
import { SubcommandCommand } from '../../types/CommandTypes.js';
import { addXpToUser, getUserLevel } from '../../db/db.js';
import { loadConfig } from '../../util/configLoader.js';
const command: SubcommandCommand = {
data: new SlashCommandBuilder()
.setName('xp')
.setDescription('(Manager only) Manage user XP')
.addSubcommand((subcommand) =>
subcommand
.setName('add')
.setDescription('Add XP to a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to add XP to')
.setRequired(true),
)
.addIntegerOption((option) =>
option
.setName('amount')
.setDescription('The amount of XP to add')
.setRequired(true),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('remove')
.setDescription('Remove XP from a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to remove XP from')
.setRequired(true),
)
.addIntegerOption((option) =>
option
.setName('amount')
.setDescription('The amount of XP to remove')
.setRequired(true),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('set')
.setDescription('Set XP for a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to set XP for')
.setRequired(true),
)
.addIntegerOption((option) =>
option
.setName('amount')
.setDescription('The amount of XP to set')
.setRequired(true),
),
)
.addSubcommand((subcommand) =>
subcommand
.setName('reset')
.setDescription('Reset XP for a member')
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to reset XP for')
.setRequired(true),
),
),
execute: async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const commandUser = interaction.guild?.members.cache.get(
interaction.user.id,
);
const config = loadConfig();
const managerRoleId = config.roles.staffRoles.find(
(role) => role.name === 'Manager',
)?.roleId;
if (
!commandUser ||
!managerRoleId ||
commandUser.roles.highest.comparePositionTo(managerRoleId) < 0
) {
await interaction.reply({
content: 'You do not have permission to use this command',
flags: ['Ephemeral'],
});
return;
}
await interaction.deferReply({
flags: ['Ephemeral'],
});
await interaction.editReply('Processing...');
const subcommand = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
const amount = interaction.options.getInteger('amount', false);
const userData = await getUserLevel(user.id);
if (subcommand === 'add') {
await addXpToUser(user.id, amount!);
await interaction.editReply({
content: `Added ${amount} XP to <@${user.id}>`,
});
} else if (subcommand === 'remove') {
await addXpToUser(user.id, -amount!);
await interaction.editReply({
content: `Removed ${amount} XP from <@${user.id}>`,
});
} else if (subcommand === 'set') {
await addXpToUser(user.id, amount! - userData.xp);
await interaction.editReply({
content: `Set ${amount} XP for <@${user.id}>`,
});
} else if (subcommand === 'reset') {
await addXpToUser(user.id, userData.xp * -1);
await interaction.editReply({
content: `Reset XP for <@${user.id}>`,
});
}
},
};
export default command;

View file

@ -1,10 +1,11 @@
import pkg from 'pg'; import pkg from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres'; import { drizzle } from 'drizzle-orm/node-postgres';
import { eq } from 'drizzle-orm'; import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import * as schema from './schema.js'; import * as schema from './schema.js';
import { loadConfig } from '../util/configLoader.js'; import { loadConfig } from '../util/configLoader.js';
import { del, exists, getJson, setJson } from './redis.js'; import { del, exists, getJson, setJson } from './redis.js';
import { calculateLevelFromXp } from '../util/levelingSystem.js';
const { Pool } = pkg; const { Pool } = pkg;
const config = loadConfig(); const config = loadConfig();
@ -162,6 +163,179 @@ export async function updateMember({
} }
} }
export async function getUserLevel(
discordId: string,
): Promise<schema.levelTableTypes> {
try {
if (await exists(`level-${discordId}`)) {
const cachedLevel = await getJson<schema.levelTableTypes>(
`level-${discordId}`,
);
if (cachedLevel !== null) {
return cachedLevel;
}
await del(`level-${discordId}`);
}
const level = await db
.select()
.from(schema.levelTable)
.where(eq(schema.levelTable.discordId, discordId))
.then((rows) => rows[0]);
if (level) {
const typedLevel: schema.levelTableTypes = {
...level,
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
};
await setJson(`level-${discordId}`, typedLevel);
return typedLevel;
}
const newLevel = {
discordId,
xp: 0,
level: 0,
lastMessageTimestamp: new Date(),
};
await db.insert(schema.levelTable).values(newLevel);
await setJson(`level-${discordId}`, newLevel);
return newLevel;
} catch (error) {
console.error('Error getting user level:', error);
throw error;
}
}
export async function addXpToUser(discordId: string, amount: number) {
try {
const userData = await getUserLevel(discordId);
const currentLevel = userData.level;
userData.xp += amount;
userData.lastMessageTimestamp = new Date();
const newLevel = calculateLevelFromXp(userData.xp);
userData.level = newLevel;
await db
.update(schema.levelTable)
.set({
xp: userData.xp,
level: newLevel,
lastMessageTimestamp: userData.lastMessageTimestamp,
})
.where(eq(schema.levelTable.discordId, discordId));
await setJson(`level-${discordId}`, userData);
await invalidateLeaderboardCache();
return {
leveledUp: newLevel > currentLevel,
newLevel,
oldLevel: currentLevel,
};
} catch (error) {
console.error('Error adding XP to user:', error);
throw error;
}
}
export async function getUserRank(discordId: string): Promise<number> {
try {
if (await exists('xp-leaderboard-cache')) {
const leaderboardCache = await getJson<
Array<{ discordId: string; xp: number }>
>('xp-leaderboard-cache');
if (leaderboardCache) {
const userIndex = leaderboardCache.findIndex(
(member) => member.discordId === discordId,
);
if (userIndex !== -1) {
return userIndex + 1;
}
}
}
const allMembers = await db
.select({
discordId: schema.levelTable.discordId,
xp: schema.levelTable.xp,
})
.from(schema.levelTable)
.orderBy(desc(schema.levelTable.xp));
await setJson('xp-leaderboard-cache', allMembers, 300);
const userIndex = allMembers.findIndex(
(member) => member.discordId === discordId,
);
return userIndex !== -1 ? userIndex + 1 : 1;
} catch (error) {
console.error('Error getting user rank:', error);
return 1;
}
}
export async function invalidateLeaderboardCache() {
try {
if (await exists('xp-leaderboard-cache')) {
await del('xp-leaderboard-cache');
}
} catch (error) {
console.error('Error invalidating leaderboard cache:', error);
}
}
export async function getLevelLeaderboard(limit = 10) {
try {
if (await exists('xp-leaderboard-cache')) {
const leaderboardCache = await getJson<
Array<{ discordId: string; xp: number }>
>('xp-leaderboard-cache');
if (leaderboardCache) {
const limitedCache = leaderboardCache.slice(0, limit);
const fullLeaderboard = await Promise.all(
limitedCache.map(async (entry) => {
const userData = await getUserLevel(entry.discordId);
return userData;
}),
);
return fullLeaderboard;
}
}
const leaderboard = await db
.select()
.from(schema.levelTable)
.orderBy(desc(schema.levelTable.xp))
.limit(limit);
const allMembers = await db
.select({
discordId: schema.levelTable.discordId,
xp: schema.levelTable.xp,
})
.from(schema.levelTable)
.orderBy(desc(schema.levelTable.xp));
await setJson('xp-leaderboard-cache', allMembers, 300);
return leaderboard;
} catch (error) {
console.error('Error getting leaderboard:', error);
throw error;
}
}
export async function updateMemberModerationHistory({ export async function updateMemberModerationHistory({
discordId, discordId,
moderatorDiscordId, moderatorDiscordId,
@ -230,3 +404,132 @@ export async function getMemberModerationHistory(discordId: string) {
); );
} }
} }
export async function addFact({
content,
source,
addedBy,
approved = false,
}: schema.factTableTypes) {
try {
const result = await db.insert(schema.factTable).values({
content,
source,
addedBy,
approved,
});
await del('unusedFacts');
return result;
} catch (error) {
console.error('Error adding fact:', error);
throw new DatabaseError('Failed to add fact:', error as Error);
}
}
export async function getLastInsertedFactId(): Promise<number> {
try {
const result = await db
.select({ id: sql<number>`MAX(${schema.factTable.id})` })
.from(schema.factTable);
return result[0]?.id ?? 0;
} catch (error) {
console.error('Error getting last inserted fact ID:', error);
throw new DatabaseError(
'Failed to get last inserted fact ID:',
error as Error,
);
}
}
export async function getRandomUnusedFact() {
try {
if (await exists('unusedFacts')) {
const facts =
await getJson<(typeof schema.factTable.$inferSelect)[]>('unusedFacts');
if (facts && facts.length > 0) {
return facts[Math.floor(Math.random() * facts.length)];
}
}
const facts = await db
.select()
.from(schema.factTable)
.where(
and(
eq(schema.factTable.approved, true),
isNull(schema.factTable.usedOn),
),
);
if (facts.length === 0) {
await db
.update(schema.factTable)
.set({ usedOn: null })
.where(eq(schema.factTable.approved, true));
return await getRandomUnusedFact();
}
await setJson<(typeof schema.factTable.$inferSelect)[]>(
'unusedFacts',
facts,
);
return facts[Math.floor(Math.random() * facts.length)];
} catch (error) {
console.error('Error getting random fact:', error);
throw new DatabaseError('Failed to get random fact:', error as Error);
}
}
export async function markFactAsUsed(id: number) {
try {
await db
.update(schema.factTable)
.set({ usedOn: new Date() })
.where(eq(schema.factTable.id, id));
await del('unusedFacts');
} catch (error) {
console.error('Error marking fact as used:', error);
throw new DatabaseError('Failed to mark fact as used:', error as Error);
}
}
export async function getPendingFacts() {
try {
return await db
.select()
.from(schema.factTable)
.where(eq(schema.factTable.approved, false));
} catch (error) {
console.error('Error getting pending facts:', error);
throw new DatabaseError('Failed to get pending facts:', error as Error);
}
}
export async function approveFact(id: number) {
try {
await db
.update(schema.factTable)
.set({ approved: true })
.where(eq(schema.factTable.id, id));
await del('unusedFacts');
} catch (error) {
console.error('Error approving fact:', error);
throw new DatabaseError('Failed to approve fact:', error as Error);
}
}
export async function deleteFact(id: number) {
try {
await db.delete(schema.factTable).where(eq(schema.factTable.id, id));
await del('unusedFacts');
} catch (error) {
console.error('Error deleting fact:', error);
throw new DatabaseError('Failed to delete fact:', error as Error);
}
}

View file

@ -14,7 +14,7 @@ class RedisError extends Error {
} }
} }
redis.on('error', (error) => { redis.on('error', (error: Error) => {
console.error('Redis connection error:', error); console.error('Redis connection error:', error);
throw new RedisError('Failed to connect to Redis instance: ', error); throw new RedisError('Failed to connect to Redis instance: ', error);
}); });

View file

@ -25,6 +25,24 @@ export const memberTable = pgTable('members', {
currentlyMuted: boolean('currently_muted').notNull().default(false), currentlyMuted: boolean('currently_muted').notNull().default(false),
}); });
export interface levelTableTypes {
id?: number;
discordId: string;
xp: number;
level: number;
lastMessageTimestamp?: Date;
}
export const levelTable = pgTable('levels', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
discordId: varchar('discord_id')
.notNull()
.references(() => memberTable.discordId, { onDelete: 'cascade' }),
xp: integer('xp').notNull().default(0),
level: integer('level').notNull().default(0),
lastMessageTimestamp: timestamp('last_message_timestamp'),
});
export interface moderationTableTypes { export interface moderationTableTypes {
id?: number; id?: number;
discordId: string; discordId: string;
@ -51,8 +69,19 @@ export const moderationTable = pgTable('moderations', {
active: boolean('active').notNull().default(true), active: boolean('active').notNull().default(true),
}); });
export const memberRelations = relations(memberTable, ({ many }) => ({ export const memberRelations = relations(memberTable, ({ many, one }) => ({
moderations: many(moderationTable), moderations: many(moderationTable),
levels: one(levelTable, {
fields: [memberTable.discordId],
references: [levelTable.discordId],
}),
}));
export const levelRelations = relations(levelTable, ({ one }) => ({
member: one(memberTable, {
fields: [levelTable.discordId],
references: [memberTable.discordId],
}),
})); }));
export const moderationRelations = relations(moderationTable, ({ one }) => ({ export const moderationRelations = relations(moderationTable, ({ one }) => ({
@ -61,3 +90,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({
references: [memberTable.discordId], references: [memberTable.discordId],
}), }),
})); }));
export type factTableTypes = {
id?: number;
content: string;
source?: string;
addedBy: string;
addedAt?: Date;
approved?: boolean;
usedOn?: Date;
};
export const factTable = pgTable('facts', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
content: varchar('content').notNull(),
source: varchar('source'),
addedBy: varchar('added_by').notNull(),
addedAt: timestamp('added_at').defaultNow().notNull(),
approved: boolean('approved').default(false).notNull(),
usedOn: timestamp('used_on'),
});

View file

@ -2,39 +2,99 @@ import { Events, Interaction } from 'discord.js';
import { ExtendedClient } from '../structures/ExtendedClient.js'; import { ExtendedClient } from '../structures/ExtendedClient.js';
import { Event } from '../types/EventTypes.js'; import { Event } from '../types/EventTypes.js';
import { approveFact, deleteFact } from '../db/db.js';
export default { export default {
name: Events.InteractionCreate, name: Events.InteractionCreate,
execute: async (interaction: Interaction) => { execute: async (interaction: Interaction) => {
if (!interaction.isCommand()) return; if (interaction.isCommand()) {
const client = interaction.client as ExtendedClient;
const command = client.commands.get(interaction.commandName);
const client = interaction.client as ExtendedClient; if (!command) {
const command = client.commands.get(interaction.commandName); console.error(
`No command matching ${interaction.commandName} was found.`,
);
return;
}
if (!command) { try {
console.error( await command.execute(interaction);
`No command matching ${interaction.commandName} was found.`, } catch (error: any) {
); console.error(`Error executing ${interaction.commandName}`);
return; console.error(error);
}
try { const isUnknownInteractionError =
await command.execute(interaction); error.code === 10062 ||
} catch (error) { (error.message && error.message.includes('Unknown interaction'));
console.error(`Error executing ${interaction.commandName}`);
console.error(error);
if (interaction.replied || interaction.deferred) { if (!isUnknownInteractionError) {
await interaction.followUp({ try {
content: 'There was an error while executing this command!', if (interaction.replied || interaction.deferred) {
flags: ['Ephemeral'], await interaction
.followUp({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
})
.catch((e) =>
console.error('Failed to send error followup:', e),
);
} else {
await interaction
.reply({
content: 'There was an error while executing this command!',
flags: ['Ephemeral'],
})
.catch((e) => console.error('Failed to send error reply:', e));
}
} catch (replyError) {
console.error('Failed to respond with error message:', replyError);
}
} else {
console.warn(
'Interaction expired before response could be sent (code 10062)',
);
}
}
} else if (interaction.isButton()) {
const { customId } = interaction;
if (customId.startsWith('approve_fact_')) {
if (!interaction.memberPermissions?.has('ModerateMembers')) {
await interaction.reply({
content: 'You do not have permission to approve facts.',
ephemeral: true,
});
return;
}
const factId = parseInt(customId.replace('approve_fact_', ''), 10);
await approveFact(factId);
await interaction.update({
content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`,
components: [],
}); });
} else { } else if (customId.startsWith('reject_fact_')) {
await interaction.reply({ if (!interaction.memberPermissions?.has('ModerateMembers')) {
content: 'There was an error while executing this command!', await interaction.reply({
flags: ['Ephemeral'], content: 'You do not have permission to reject facts.',
ephemeral: true,
});
return;
}
const factId = parseInt(customId.replace('reject_fact_', ''), 10);
await deleteFact(factId);
await interaction.update({
content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`,
components: [],
}); });
} }
} else {
console.warn('Unhandled interaction type:', interaction);
return;
} }
}, },
} as Event<typeof Events.InteractionCreate>; } as Event<typeof Events.InteractionCreate>;

View file

@ -1,7 +1,17 @@
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
import { Event } from '../types/EventTypes.js'; import { Event } from '../types/EventTypes.js';
import { loadConfig } from '../util/configLoader.js';
import {
addCountingReactions,
processCountingMessage,
resetCounting,
} from '../util/countingManager.js';
import logAction from '../util/logging/logAction.js'; import logAction from '../util/logging/logAction.js';
import {
checkAndAssignLevelRoles,
processMessage,
} from '../util/levelingSystem.js';
export const messageDelete: Event<typeof Events.MessageDelete> = { export const messageDelete: Event<typeof Events.MessageDelete> = {
name: Events.MessageDelete, name: Events.MessageDelete,
@ -62,4 +72,87 @@ export const messageUpdate: Event<typeof Events.MessageUpdate> = {
}, },
}; };
export default [messageDelete, messageUpdate]; export const messageCreate: Event<typeof Events.MessageCreate> = {
name: Events.MessageCreate,
execute: async (message: Message) => {
try {
if (message.author.bot || !message.guild) return;
const levelResult = await processMessage(message);
const advancementsChannelId = loadConfig().channels.advancements;
const advancementsChannel = message.guild?.channels.cache.get(
advancementsChannelId,
);
if (!advancementsChannel || !advancementsChannel.isTextBased()) {
console.error(
'Advancements channel not found or is not a text channel',
);
return;
}
if (levelResult?.leveledUp) {
await advancementsChannel.send(
`🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`,
);
const assignedRole = await checkAndAssignLevelRoles(
message.guild,
message.author.id,
levelResult.newLevel,
);
if (assignedRole) {
await advancementsChannel.send(
`<@${message.author.id}> You've earned the <@&${assignedRole}> role!`,
);
}
}
const countingChannelId = loadConfig().channels.counting;
const countingChannel =
message.guild?.channels.cache.get(countingChannelId);
if (!countingChannel || message.channel.id !== countingChannelId) return;
if (!countingChannel.isTextBased()) {
console.error('Counting channel not found or is not a text channel');
return;
}
const result = await processCountingMessage(message);
if (result.isValid) {
await addCountingReactions(message, result.milestoneType || 'normal');
} else {
let errorMessage: string;
switch (result.reason) {
case 'not_a_number':
errorMessage = `${message.author}, that's not a valid number! The count has been reset. The next number should be **1**.`;
break;
case 'too_high':
errorMessage = `${message.author}, too high! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
break;
case 'too_low':
errorMessage = `${message.author}, too low! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
break;
case 'same_user':
errorMessage = `${message.author}, you can't count twice in a row! The count has been reset. The next number should be **1**.`;
break;
default:
errorMessage = `${message.author}, something went wrong with the count. The count has been reset. The next number should be **1**.`;
}
await resetCounting();
await countingChannel.send(errorMessage);
await message.react('❌');
}
} catch (error) {
console.error('Error handling message create: ', error);
}
},
};
export default [messageCreate, messageDelete, messageUpdate];

View file

@ -3,6 +3,7 @@ import { Client, Events } from 'discord.js';
import { setMembers } from '../db/db.js'; import { setMembers } from '../db/db.js';
import { loadConfig } from '../util/configLoader.js'; import { loadConfig } from '../util/configLoader.js';
import { Event } from '../types/EventTypes.js'; import { Event } from '../types/EventTypes.js';
import { scheduleFactOfTheDay } from '../util/factManager.js';
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
@ -21,6 +22,8 @@ export default {
const members = await guild.members.fetch(); const members = await guild.members.fetch();
const nonBotMembers = members.filter((m) => !m.user.bot); const nonBotMembers = members.filter((m) => !m.user.bot);
await setMembers(nonBotMembers); await setMembers(nonBotMembers);
await scheduleFactOfTheDay(client);
} catch (error) { } catch (error) {
console.error('Failed to initialize members in database:', error); console.error('Failed to initialize members in database:', error);
} }

View file

@ -2,6 +2,7 @@ import {
CommandInteraction, CommandInteraction,
SlashCommandBuilder, SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder, SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
} from 'discord.js'; } from 'discord.js';
export interface Command { export interface Command {
@ -13,3 +14,8 @@ export interface OptionsCommand {
data: SlashCommandOptionsOnlyBuilder; data: SlashCommandOptionsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>; execute: (interaction: CommandInteraction) => Promise<void>;
} }
export interface SubcommandCommand {
data: SlashCommandSubcommandsOnlyBuilder;
execute: (interaction: CommandInteraction) => Promise<void>;
}

View file

@ -7,8 +7,21 @@ export interface Config {
channels: { channels: {
welcome: string; welcome: string;
logs: string; logs: string;
counting: string;
factOfTheDay: string;
factApproval: string;
advancements: string;
}; };
roles: { roles: {
joinRoles: string[]; joinRoles: string[];
levelRoles: {
level: number;
roleId: string;
}[];
staffRoles: {
name: string;
roleId: string;
}[];
factPingRole: string;
}; };
} }

157
src/util/countingManager.ts Normal file
View file

@ -0,0 +1,157 @@
import { Message } from 'discord.js';
import { getJson, setJson } from '../db/redis.js';
interface CountingData {
currentCount: number;
lastUserId: string | null;
highestCount: number;
totalCorrect: number;
}
const MILESTONE_REACTIONS = {
normal: '✅',
multiples25: '✨',
multiples50: '⭐',
multiples100: '🎉',
};
export async function initializeCountingData(): Promise<CountingData> {
const exists = await getJson<CountingData>('counting');
if (exists) return exists;
const initialData: CountingData = {
currentCount: 0,
lastUserId: null,
highestCount: 0,
totalCorrect: 0,
};
await setJson<CountingData>('counting', initialData);
return initialData;
}
export async function getCountingData(): Promise<CountingData> {
const data = await getJson<CountingData>('counting');
if (!data) {
return initializeCountingData();
}
return data;
}
export async function updateCountingData(
data: Partial<CountingData>,
): Promise<void> {
const currentData = await getCountingData();
const updatedData = { ...currentData, ...data };
await setJson<CountingData>('counting', updatedData);
}
export async function resetCounting(): Promise<void> {
await updateCountingData({
currentCount: 0,
lastUserId: null,
});
return;
}
export async function processCountingMessage(message: Message): Promise<{
isValid: boolean;
expectedCount?: number;
isMilestone?: boolean;
milestoneType?: keyof typeof MILESTONE_REACTIONS;
reason?: string;
}> {
try {
const countingData = await getCountingData();
const content = message.content.trim();
const count = Number(content);
if (isNaN(count) || !Number.isInteger(count)) {
return {
isValid: false,
expectedCount: countingData.currentCount + 1,
reason: 'not_a_number',
};
}
const expectedCount = countingData.currentCount + 1;
if (count !== expectedCount) {
return {
isValid: false,
expectedCount,
reason: count > expectedCount ? 'too_high' : 'too_low',
};
}
if (countingData.lastUserId === message.author.id) {
return { isValid: false, expectedCount, reason: 'same_user' };
}
const newCount = countingData.currentCount + 1;
const newHighestCount = Math.max(newCount, countingData.highestCount);
await updateCountingData({
currentCount: newCount,
lastUserId: message.author.id,
highestCount: newHighestCount,
totalCorrect: countingData.totalCorrect + 1,
});
let isMilestone = false;
let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal';
if (newCount % 100 === 0) {
isMilestone = true;
milestoneType = 'multiples100';
} else if (newCount % 50 === 0) {
isMilestone = true;
milestoneType = 'multiples50';
} else if (newCount % 25 === 0) {
isMilestone = true;
milestoneType = 'multiples25';
}
return {
isValid: true,
expectedCount: newCount + 1,
isMilestone,
milestoneType,
};
} catch (error) {
console.error('Error processing counting message:', error);
return { isValid: false, reason: 'error' };
}
}
export async function addCountingReactions(
message: Message,
milestoneType: keyof typeof MILESTONE_REACTIONS,
): Promise<void> {
try {
await message.react(MILESTONE_REACTIONS[milestoneType]);
if (milestoneType === 'multiples100') {
await message.react('💯');
}
} catch (error) {
console.error('Error adding counting reactions:', error);
}
}
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}`;
}
export async function setCount(count: number): Promise<void> {
if (!Number.isInteger(count) || count < 0) {
throw new Error('Count must be a non-negative integer.');
}
await updateCountingData({
currentCount: count,
lastUserId: null,
});
}

View file

@ -1,6 +1,6 @@
import { REST, Routes } from 'discord.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { REST, Routes } from 'discord.js';
import { loadConfig } from './configLoader.js'; import { loadConfig } from './configLoader.js';
const config = loadConfig(); const config = loadConfig();

69
src/util/factManager.ts Normal file
View file

@ -0,0 +1,69 @@
import { EmbedBuilder, Client } from 'discord.js';
import { getRandomUnusedFact, markFactAsUsed } from '../db/db.js';
import { loadConfig } from './configLoader.js';
export async function scheduleFactOfTheDay(client: Client) {
try {
const now = new Date();
const tomorrow = new Date();
tomorrow.setDate(now.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const timeUntilMidnight = tomorrow.getTime() - now.getTime();
setTimeout(() => {
postFactOfTheDay(client);
scheduleFactOfTheDay(client);
}, timeUntilMidnight);
console.log(
`Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`,
);
} catch (error) {
console.error('Error scheduling fact of the day:', error);
setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000);
}
}
export async function postFactOfTheDay(client: Client) {
try {
const config = loadConfig();
const guild = client.guilds.cache.get(config.guildId);
if (!guild) {
console.error('Guild not found');
return;
}
const factChannel = guild.channels.cache.get(config.channels.factOfTheDay);
if (!factChannel?.isTextBased()) {
console.error('Fact channel not found or is not a text channel');
return;
}
const fact = await getRandomUnusedFact();
if (!fact) {
console.error('No facts available');
return;
}
const embed = new EmbedBuilder()
.setTitle('🌟 Fact of the Day 🌟')
.setDescription(fact.content)
.setColor(0xffaa00)
.setTimestamp();
if (fact.source) {
embed.setFooter({ text: `Source: ${fact.source}` });
}
await factChannel.send({
content: `<@&${config.roles.factPingRole}>`,
embeds: [embed],
});
await markFactAsUsed(fact.id!);
} catch (error) {
console.error('Error posting fact of the day:', error);
}
}

249
src/util/levelingSystem.ts Normal file
View file

@ -0,0 +1,249 @@
import path from 'path';
import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
import Canvas, { GlobalFonts } from '@napi-rs/canvas';
import { addXpToUser, db, getUserLevel, getUserRank } from '../db/db.js';
import * as schema from '../db/schema.js';
import { loadConfig } from './configLoader.js';
const config = loadConfig();
const XP_COOLDOWN = 60 * 1000;
const MIN_XP = 15;
const MAX_XP = 25;
const __dirname = path.resolve();
export const calculateXpForLevel = (level: number): number => {
if (level === 0) return 0;
return (5 / 6) * level * (2 * level * level + 27 * level + 91);
};
export const calculateLevelFromXp = (xp: number): number => {
if (xp < calculateXpForLevel(1)) return 0;
let level = 0;
while (calculateXpForLevel(level + 1) <= xp) {
level++;
}
return level;
};
export const getXpToNextLevel = (level: number, currentXp: number): number => {
if (level === 0) return calculateXpForLevel(1) - currentXp;
const nextLevelXp = calculateXpForLevel(level + 1);
return nextLevelXp - currentXp;
};
export async function recalculateUserLevels() {
const users = await db.select().from(schema.levelTable);
for (const user of users) {
await addXpToUser(user.discordId, 0);
}
}
export async function processMessage(message: Message) {
if (message.author.bot || !message.guild) return;
try {
const userId = message.author.id;
const userData = await getUserLevel(userId);
if (userData.lastMessageTimestamp) {
const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime();
const currentTime = Date.now();
if (currentTime - lastMessageTime < XP_COOLDOWN) {
return null;
}
}
const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP;
const result = await addXpToUser(userId, xpToAdd);
return result;
} catch (error) {
console.error('Error processing message for XP:', error);
return null;
}
}
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();
}
}
export async function generateRankCard(
member: GuildMember,
userData: schema.levelTableTypes,
) {
GlobalFonts.registerFromPath(
path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'),
'Manrope Bold',
);
GlobalFonts.registerFromPath(
path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'),
'Manrope',
);
const userRank = await getUserRank(userData.discordId);
const canvas = Canvas.createCanvas(934, 282);
const context = canvas.getContext('2d');
context.fillStyle = '#23272A';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#2C2F33';
roundRect(context, 22, 22, 890, 238, 20, true);
try {
const avatar = await Canvas.loadImage(
member.user.displayAvatarURL({ extension: 'png', size: 256 }),
);
context.save();
context.beginPath();
context.arc(120, 141, 80, 0, Math.PI * 2);
context.closePath();
context.clip();
context.drawImage(avatar, 40, 61, 160, 160);
context.restore();
} catch (error) {
console.error('Error loading avatar image:', error);
context.fillStyle = '#5865F2';
context.beginPath();
context.arc(120, 141, 80, 0, Math.PI * 2);
context.fill();
}
context.font = '38px "Manrope Bold"';
context.fillStyle = '#FFFFFF';
context.fillText(member.user.username, 242, 142);
context.font = '24px "Manrope Bold"';
context.fillStyle = '#FFFFFF';
context.textAlign = 'end';
context.fillText(`LEVEL ${userData.level}`, 890, 82);
context.font = '24px "Manrope Bold"';
context.fillStyle = '#FFFFFF';
context.fillText(`RANK #${userRank}`, 890, 122);
const barWidth = 615;
const barHeight = 38;
const barX = 242;
const barY = 182;
const currentLevel = userData.level;
const currentLevelXp = calculateXpForLevel(currentLevel);
const nextLevelXp = calculateXpForLevel(currentLevel + 1);
const xpNeededForNextLevel = nextLevelXp - currentLevelXp;
let xpIntoCurrentLevel;
if (currentLevel === 0) {
xpIntoCurrentLevel = userData.xp;
} else {
xpIntoCurrentLevel = userData.xp - currentLevelXp;
}
const progress = Math.max(
0,
Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1),
);
context.fillStyle = '#484b4E';
roundRect(context, barX, barY, barWidth, barHeight, barHeight / 2, true);
if (progress > 0) {
context.fillStyle = '#5865F2';
roundRect(
context,
barX,
barY,
barWidth * progress,
barHeight,
barHeight / 2,
true,
);
}
context.textAlign = 'center';
context.font = '20px "Manrope"';
context.fillStyle = '#A0A0A0';
context.fillText(
`${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`,
barX + barWidth / 2,
barY + barHeight / 2 + 7,
);
return new AttachmentBuilder(canvas.toBuffer('image/png'), {
name: 'rank-card.png',
});
}
export async function checkAndAssignLevelRoles(
guild: Guild,
userId: string,
newLevel: number,
) {
try {
if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) {
return;
}
const member = await guild.members.fetch(userId);
if (!member) return;
const rolesToAdd = config.roles.levelRoles
.filter((role) => role.level <= newLevel)
.map((role) => role.roleId);
if (rolesToAdd.length === 0) return;
const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId);
const rolesToRemove = member.roles.cache.filter((role) =>
existingLevelRoles.includes(role.id),
);
if (rolesToRemove.size > 0) {
await member.roles.remove(rolesToRemove);
}
const highestRole = rolesToAdd[rolesToAdd.length - 1];
await member.roles.add(highestRole);
return highestRole;
} catch (error) {
console.error('Error assigning level roles:', error);
}
}

View file

@ -5,6 +5,7 @@ import {
ActionRowBuilder, ActionRowBuilder,
GuildChannel, GuildChannel,
} from 'discord.js'; } from 'discord.js';
import { import {
LogActionPayload, LogActionPayload,
ModerationLogAction, ModerationLogAction,
@ -22,10 +23,12 @@ import {
getPermissionDifference, getPermissionDifference,
getPermissionNames, getPermissionNames,
} from './utils.js'; } from './utils.js';
import { loadConfig } from '../configLoader.js';
export default async function logAction(payload: LogActionPayload) { export default async function logAction(payload: LogActionPayload) {
const logChannel = payload.guild.channels.cache.get('1007787977432383611'); const config = loadConfig();
if (!logChannel || !(logChannel instanceof TextChannel)) { const logChannel = payload.guild.channels.cache.get(config.channels.logs);
if (!logChannel?.isTextBased()) {
console.error('Log channel not found or is not a Text Channel.'); console.error('Log channel not found or is not a Text Channel.');
return; return;
} }