Implement leveling and economy features with associated models and commands

- Added new models for leveling and economy functionalities in the Prisma schema, including LevelingConfig, MemberLevel, LevelReward, EconomyConfig, MemberEconomy, ShopItem, and InventoryItem.
- Introduced commands for managing XP, economy transactions, and shop interactions, enhancing user engagement.
- Updated job handling to include reminders and poll closing functionalities.
- Enhanced localization support for new commands and features in both German and English.
- Improved the bot's job processing capabilities with a dedicated reminder worker for scheduled tasks.
This commit is contained in:
smueller
2026-07-22 12:49:17 +02:00
parent 2518119257
commit fa5910ec43
38 changed files with 6759 additions and 144 deletions

View File

@@ -0,0 +1,160 @@
import { AttachmentBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js';
import {
addXp,
formatLeaderboardLine,
getConfig,
getLeaderboard,
getOrCreateMemberLevel,
getRankPosition,
memberProgress,
removeXp,
resetXp
} from './service.js';
import { renderRankCard } from './rank-card.js';
import {
leaderboardCommandData,
rankCommandData,
xpCommandData
} from './command-definitions.js';
const rankCommand: SlashCommand = {
data: rankCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const member = await interaction.guild.members.fetch(targetUser.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true });
return;
}
const config = await getConfig(context.prisma, interaction.guild.id);
const memberLevel = await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id);
const rank = await getRankPosition(context.prisma, interaction.guild.id, member.id);
const progress = memberProgress(memberLevel);
const buffer = await renderRankCard({
displayName: member.displayName,
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 256 }),
level: progress.level,
rank,
intoLevel: progress.intoLevel,
needed: progress.needed,
totalXp: memberLevel.xp,
accentColor: config.cardAccentColor,
backgroundColor: config.cardBackgroundColor
});
const attachment = new AttachmentBuilder(buffer, { name: 'rank.png' });
await interaction.reply({ files: [attachment] });
}
};
const leaderboardCommand: SlashCommand = {
data: leaderboardCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const entries = await getLeaderboard(context.prisma, interaction.guild.id, 10);
if (entries.length === 0) {
await interaction.reply({ content: t(locale, 'leveling.leaderboard.empty'), ephemeral: true });
return;
}
const lines = await Promise.all(
entries.map(async (entry, index) => {
const user = await context.client.users.fetch(entry.userId).catch(() => null);
const label = user ? `<@${entry.userId}>` : entry.userId;
return formatLeaderboardLine(locale, index + 1, label, entry.level, entry.xp);
})
);
const embed = new EmbedBuilder()
.setTitle(t(locale, 'leveling.leaderboard.title'))
.setDescription(lines.join('\n'))
.setFooter({ text: tf(locale, 'leveling.leaderboard.footer', { count: entries.length }) })
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const xpCommand: SlashCommand = {
data: xpCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
const member = await interaction.guild.members.fetch(user.id).catch(() => null);
if (!member) {
await interaction.reply({ content: t(locale, 'leveling.user.notFound'), ephemeral: true });
return;
}
const channelId = interaction.channelId ?? undefined;
if (sub === 'give') {
const amount = interaction.options.getInteger('amount', true);
const result = await addXp(context, interaction.guild, member, amount, {
channelId,
force: true
});
const level = result?.newLevel ?? (await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level;
await interaction.reply({
content: tf(locale, 'leveling.xp.give.success', {
amount,
user: `<@${member.id}>`,
level
}),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const amount = interaction.options.getInteger('amount', true);
const result = await removeXp(context, interaction.guild, member, amount, channelId);
const level =
result?.newLevel ??
(await getOrCreateMemberLevel(context.prisma, interaction.guild.id, member.id)).level;
await interaction.reply({
content: tf(locale, 'leveling.xp.remove.success', {
amount,
user: `<@${member.id}>`,
level
}),
ephemeral: true
});
return;
}
await resetXp(context, interaction.guild, member, channelId);
await interaction.reply({
content: tf(locale, 'leveling.xp.reset.success', { user: `<@${member.id}>` }),
ephemeral: true
});
}
};
export const levelingCommands: SlashCommand[] = [rankCommand, leaderboardCommand, xpCommand];