mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-06-09 16:39:30 +00:00
Compare commits
No commits in common. "84bf5b272cbd94bf6f80c541101da462cdc70ee6" and "7af6d5914dd050355421335163682b5f2de3bdf2" have entirely different histories.
84bf5b272c
...
7af6d5914d
15 changed files with 39 additions and 970 deletions
Binary file not shown.
Binary file not shown.
|
@ -9,41 +9,12 @@
|
|||
"logs": "LOG_CHANNEL_ID",
|
||||
"counting": "COUNTING_CHANNEL_ID",
|
||||
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
|
||||
"advancements": "ADVANCEMENTS_CHANNEL_ID"
|
||||
"factApproval": "FACT_APPROVAL_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,12 +74,6 @@ 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();
|
||||
|
||||
|
@ -118,15 +112,13 @@ const command: SubcommandCommand = {
|
|||
)
|
||||
.setTimestamp();
|
||||
|
||||
const factId = await getLastInsertedFactId();
|
||||
|
||||
const approveButton = new ButtonBuilder()
|
||||
.setCustomId(`approve_fact_${factId}`)
|
||||
.setCustomId(`approve_fact_${await getLastInsertedFactId()}`)
|
||||
.setLabel('Approve')
|
||||
.setStyle(ButtonStyle.Success);
|
||||
|
||||
const rejectButton = new ButtonBuilder()
|
||||
.setCustomId(`reject_fact_${factId}`)
|
||||
.setCustomId(`reject_fact_${await getLastInsertedFactId()}`)
|
||||
.setLabel('Reject')
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
|
@ -144,10 +136,11 @@ const command: SubcommandCommand = {
|
|||
}
|
||||
}
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
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 (
|
||||
|
@ -155,8 +148,9 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to approve facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -164,8 +158,9 @@ const command: SubcommandCommand = {
|
|||
const id = interaction.options.getInteger('id', true);
|
||||
await approveFact(id);
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: `Fact #${id} has been approved!`,
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'delete') {
|
||||
if (
|
||||
|
@ -173,8 +168,9 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to delete facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -182,8 +178,9 @@ const command: SubcommandCommand = {
|
|||
const id = interaction.options.getInteger('id', true);
|
||||
await deleteFact(id);
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: `Fact #${id} has been deleted!`,
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'pending') {
|
||||
if (
|
||||
|
@ -191,8 +188,9 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.ModerateMembers,
|
||||
)
|
||||
) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to view pending facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -200,8 +198,9 @@ const command: SubcommandCommand = {
|
|||
const pendingFacts = await getPendingFacts();
|
||||
|
||||
if (pendingFacts.length === 0) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'There are no pending facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -218,8 +217,9 @@ const command: SubcommandCommand = {
|
|||
)
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
embeds: [embed],
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else if (subcommand === 'post') {
|
||||
if (
|
||||
|
@ -227,16 +227,18 @@ const command: SubcommandCommand = {
|
|||
PermissionsBitField.Flags.Administrator,
|
||||
)
|
||||
) {
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to manually post facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await postFactOfTheDay(interaction.client);
|
||||
|
||||
await interaction.editReply({
|
||||
await interaction.reply({
|
||||
content: 'Fact of the day has been posted!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,171 +0,0 @@
|
|||
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;
|
|
@ -1,49 +0,0 @@
|
|||
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;
|
|
@ -1,36 +0,0 @@
|
|||
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;
|
|
@ -1,132 +0,0 @@
|
|||
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,11 +1,10 @@
|
|||
import pkg from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, 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();
|
||||
|
@ -163,179 +162,6 @@ 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,24 +25,6 @@ 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(0),
|
||||
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
||||
});
|
||||
|
||||
export interface moderationTableTypes {
|
||||
id?: number;
|
||||
discordId: string;
|
||||
|
@ -69,19 +51,8 @@ export const moderationTable = pgTable('moderations', {
|
|||
active: boolean('active').notNull().default(true),
|
||||
});
|
||||
|
||||
export const memberRelations = relations(memberTable, ({ many, one }) => ({
|
||||
export const memberRelations = relations(memberTable, ({ many }) => ({
|
||||
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,40 +20,20 @@ export default {
|
|||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${interaction.commandName}`);
|
||||
console.error(error);
|
||||
|
||||
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);
|
||||
}
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
'Interaction expired before response could be sent (code 10062)',
|
||||
);
|
||||
await interaction.reply({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (interaction.isButton()) {
|
||||
|
@ -93,7 +73,7 @@ export default {
|
|||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('Unhandled interaction type:', interaction);
|
||||
console.log('Unhandled interaction type:', interaction);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -8,10 +8,6 @@ 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,
|
||||
|
@ -76,38 +72,7 @@ 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!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (message.author.bot) return;
|
||||
|
||||
const countingChannelId = loadConfig().channels.counting;
|
||||
const countingChannel =
|
||||
|
@ -150,7 +115,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,18 +10,9 @@ 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();
|
||||
|
|
|
@ -1,249 +0,0 @@
|
|||
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
Add a link
Reference in a new issue