mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-04-01 01:04:16 +00:00
Added Basic Leveling System and QoL Updates
This commit is contained in:
parent
7af6d5914d
commit
b5ce514397
15 changed files with 970 additions and 39 deletions
BIN
assets/fonts/Manrope-Bold.ttf
Normal file
BIN
assets/fonts/Manrope-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Manrope-Regular.ttf
Normal file
BIN
assets/fonts/Manrope-Regular.ttf
Normal file
Binary file not shown.
|
@ -9,12 +9,41 @@
|
|||
"logs": "LOG_CHANNEL_ID",
|
||||
"counting": "COUNTING_CHANNEL_ID",
|
||||
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||
"factApproval": "FACT_APPROVAL_CHANNEL_ID"
|
||||
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
|
||||
"advancements": "ADVANCEMENTS_CHANNEL_ID"
|
||||
},
|
||||
"roles": {
|
||||
"joinRoles": [
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,6 +74,12 @@ const command: SubcommandCommand = {
|
|||
|
||||
execute: async (interaction) => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
|
||||
await interaction.deferReply({
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
await interaction.editReply('Processing...');
|
||||
|
||||
const config = loadConfig();
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
|
||||
|
@ -112,13 +118,15 @@ const command: SubcommandCommand = {
|
|||
)
|
||||
.setTimestamp();
|
||||
|
||||
const factId = await getLastInsertedFactId();
|
||||
|
||||
const approveButton = new ButtonBuilder()
|
||||
.setCustomId(`approve_fact_${await getLastInsertedFactId()}`)
|
||||
.setCustomId(`approve_fact_${factId}`)
|
||||
.setLabel('Approve')
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const rejectButton = new ButtonBuilder()
|
||||
.setCustomId(`reject_fact_${await getLastInsertedFactId()}`)
|
||||
.setCustomId(`reject_fact_${factId}`)
|
||||
.setLabel('Reject')
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
|
@ -136,11 +144,10 @@ const command: SubcommandCommand = {
|
|||
}
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: isAdmin
|
||||
? 'Your fact has been automatically approved and added to the database!'
|
||||
: 'Your fact has been submitted for approval!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'approve') {
|
||||
if (
|
||||
|
@ -148,9 +155,8 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'You do not have permission to approve facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -158,9 +164,8 @@ const command: SubcommandCommand = {
|
|||
const id = interaction.options.getInteger('id', true);
|
||||
await approveFact(id);
|
||||
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: `Fact #${id} has been approved!`,
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'delete') {
|
||||
if (
|
||||
|
@ -168,9 +173,8 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'You do not have permission to delete facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -178,9 +182,8 @@ const command: SubcommandCommand = {
|
|||
const id = interaction.options.getInteger('id', true);
|
||||
await deleteFact(id);
|
||||
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: `Fact #${id} has been deleted!`,
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'pending') {
|
||||
if (
|
||||
|
@ -188,9 +191,8 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'You do not have permission to view pending facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -198,9 +200,8 @@ const command: SubcommandCommand = {
|
|||
const pendingFacts = await getPendingFacts();
|
||||
|
||||
if (pendingFacts.length === 0) {
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'There are no pending facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -217,9 +218,8 @@ const command: SubcommandCommand = {
|
|||
)
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
embeds: [embed],
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'post') {
|
||||
if (
|
||||
|
@ -227,18 +227,16 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.Administrator,
|
||||
)
|
||||
) {
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'You do not have permission to manually post facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await postFactOfTheDay(interaction.client);
|
||||
|
||||
await interaction.reply({
|
||||
await interaction.editReply({
|
||||
content: 'Fact of the day has been posted!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
171
src/commands/fun/leaderboard.ts
Normal file
171
src/commands/fun/leaderboard.ts
Normal 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
49
src/commands/fun/rank.ts
Normal 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;
|
36
src/commands/util/recalculatelevels.ts
Normal file
36
src/commands/util/recalculatelevels.ts
Normal 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
132
src/commands/util/xp.ts
Normal 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;
|
176
src/db/db.ts
176
src/db/db.ts
|
@ -1,10 +1,11 @@
|
|||
import pkg from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import * as schema from './schema.js';
|
||||
import { loadConfig } from '../util/configLoader.js';
|
||||
import { del, exists, getJson, setJson } from './redis.js';
|
||||
import { calculateLevelFromXp } from '../util/levelingSystem.js';
|
||||
|
||||
const { Pool } = pkg;
|
||||
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({
|
||||
discordId,
|
||||
moderatorDiscordId,
|
||||
|
|
|
@ -25,6 +25,24 @@ export const memberTable = pgTable('members', {
|
|||
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(1),
|
||||
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
||||
});
|
||||
|
||||
export interface moderationTableTypes {
|
||||
id?: number;
|
||||
discordId: string;
|
||||
|
@ -51,8 +69,19 @@ export const moderationTable = pgTable('moderations', {
|
|||
active: boolean('active').notNull().default(true),
|
||||
});
|
||||
|
||||
export const memberRelations = relations(memberTable, ({ many }) => ({
|
||||
export const memberRelations = relations(memberTable, ({ many, one }) => ({
|
||||
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 }) => ({
|
||||
|
|
|
@ -20,20 +20,40 @@ export default {
|
|||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`Error executing ${interaction.commandName}`);
|
||||
console.error(error);
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
const isUnknownInteractionError =
|
||||
error.code === 10062 ||
|
||||
(error.message && error.message.includes('Unknown interaction'));
|
||||
|
||||
if (!isUnknownInteractionError) {
|
||||
try {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
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 {
|
||||
await interaction.reply({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
console.warn(
|
||||
'Interaction expired before response could be sent (code 10062)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (interaction.isButton()) {
|
||||
|
@ -73,7 +93,7 @@ export default {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
console.log('Unhandled interaction type:', interaction);
|
||||
console.warn('Unhandled interaction type:', interaction);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -8,6 +8,10 @@ import {
|
|||
resetCounting,
|
||||
} from '../util/countingManager.js';
|
||||
import logAction from '../util/logging/logAction.js';
|
||||
import {
|
||||
checkAndAssignLevelRoles,
|
||||
processMessage,
|
||||
} from '../util/levelingSystem.js';
|
||||
|
||||
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
||||
name: Events.MessageDelete,
|
||||
|
@ -72,7 +76,38 @@ export const messageCreate: Event<typeof Events.MessageCreate> = {
|
|||
name: Events.MessageCreate,
|
||||
execute: async (message: Message) => {
|
||||
try {
|
||||
if (message.author.bot) return;
|
||||
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 =
|
||||
|
@ -115,7 +150,7 @@ export const messageCreate: Event<typeof Events.MessageCreate> = {
|
|||
await message.react('❌');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling message create:', error);
|
||||
console.error('Error handling message create: ', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -10,9 +10,18 @@ export interface Config {
|
|||
counting: string;
|
||||
factOfTheDay: string;
|
||||
factApproval: string;
|
||||
advancements: string;
|
||||
};
|
||||
roles: {
|
||||
joinRoles: string[];
|
||||
levelRoles: {
|
||||
level: number;
|
||||
roleId: string;
|
||||
}[];
|
||||
staffRoles: {
|
||||
name: string;
|
||||
roleId: string;
|
||||
}[];
|
||||
factPingRole: string;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { REST, Routes } from 'discord.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { REST, Routes } from 'discord.js';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
|
249
src/util/levelingSystem.ts
Normal file
249
src/util/levelingSystem.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue