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,49 @@
import { SlashCommandBuilder } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
export const rankCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('rank'),
'leveling.rank.description'
).addUserOption((option) =>
applyOptionDescription(option.setName('user'), 'leveling.rank.options.user')
);
export const leaderboardCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('leaderboard'),
'leveling.leaderboard.description'
);
export const xpCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('xp'),
'leveling.xp.description'
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('give'), 'leveling.xp.give.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('remove'), 'leveling.xp.remove.description')
.addUserOption((option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
.addIntegerOption((option) =>
applyOptionDescription(
option.setName('amount').setRequired(true).setMinValue(1).setMaxValue(1_000_000),
'leveling.xp.options.amount'
)
)
)
.addSubcommand((sub) =>
applyCommandDescription(sub.setName('reset'), 'leveling.xp.reset.description').addUserOption(
(option) =>
applyOptionDescription(option.setName('user').setRequired(true), 'leveling.xp.options.user')
)
);

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];

View File

@@ -0,0 +1,69 @@
import type { BotContext } from '../../types.js';
import { logger } from '../../logger.js';
import {
awardVoiceXpForSession,
handleTextXpMessage,
voiceKey
} from './service.js';
async function handleVoiceJoin(context: BotContext, guildId: string, userId: string): Promise<void> {
await context.redis.set(voiceKey(guildId, userId), String(Date.now()));
}
async function handleVoiceLeave(
context: BotContext,
guildId: string,
userId: string,
channelId: string
): Promise<void> {
const key = voiceKey(guildId, userId);
const joinedAt = await context.redis.get(key);
await context.redis.del(key);
if (!joinedAt) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
const member = await guild.members.fetch(userId).catch(() => null);
if (!member) {
return;
}
await awardVoiceXpForSession(context, guild, member, Number(joinedAt), channelId);
}
export function registerLevelingEvents(context: BotContext): void {
context.client.on('messageCreate', async (message) => {
try {
if (!message.guild || message.author.bot || !message.member) {
return;
}
await handleTextXpMessage(context, message.member, message.channel.id);
} catch (error) {
logger.error({ error, messageId: message.id }, 'Leveling message handler failed');
}
});
context.client.on('voiceStateUpdate', async (oldState, newState) => {
try {
const guildId = newState.guild.id;
const userId = newState.id;
const oldChannelId = oldState.channelId;
const newChannelId = newState.channelId;
if (oldChannelId && oldChannelId !== newChannelId) {
await handleVoiceLeave(context, guildId, userId, oldChannelId);
}
if (newChannelId && newChannelId !== oldChannelId) {
await handleVoiceJoin(context, guildId, userId);
}
} catch (error) {
logger.error({ error, userId: newState.id }, 'Leveling voice handler failed');
}
});
}

View File

@@ -0,0 +1,84 @@
import { createCanvas, loadImage } from '@napi-rs/canvas';
export type RankCardInput = {
displayName: string;
avatarUrl: string;
level: number;
rank: number;
intoLevel: number;
needed: number;
totalXp: number;
accentColor: string;
backgroundColor: string;
};
function parseHexColor(color: string, fallback: string): string {
return /^#[0-9A-Fa-f]{6}$/.test(color) ? color : fallback;
}
export async function renderRankCard(input: RankCardInput): Promise<Buffer> {
const width = 934;
const height = 282;
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
const background = parseHexColor(input.backgroundColor, '#111827');
const accent = parseHexColor(input.accentColor, '#6366F1');
ctx.fillStyle = background;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = accent;
ctx.fillRect(0, 0, width, 6);
const avatarSize = 128;
const avatarX = 36;
const avatarY = 77;
const avatar = await loadImage(input.avatarUrl);
ctx.save();
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize);
ctx.restore();
ctx.strokeStyle = accent;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2 + 2, 0, Math.PI * 2);
ctx.stroke();
const textX = 200;
ctx.fillStyle = '#F9FAFB';
ctx.font = 'bold 32px sans-serif';
const name = input.displayName.slice(0, 28);
ctx.fillText(name, textX, 110);
ctx.fillStyle = '#D1D5DB';
ctx.font = '22px sans-serif';
ctx.fillText(`Rank #${input.rank}`, textX, 145);
ctx.fillText(`Level ${input.level}`, textX, 175);
ctx.fillText(`${input.totalXp.toLocaleString()} XP`, textX, 205);
const barX = 520;
const barY = 120;
const barWidth = 370;
const barHeight = 28;
const progress = input.needed > 0 ? Math.min(1, input.intoLevel / input.needed) : 0;
ctx.fillStyle = '#1F2937';
ctx.fillRect(barX, barY, barWidth, barHeight);
if (progress > 0) {
ctx.fillStyle = accent;
ctx.fillRect(barX, barY, Math.max(1, barWidth * progress), barHeight);
}
ctx.fillStyle = '#E5E7EB';
ctx.font = '18px sans-serif';
ctx.fillText(`${input.intoLevel.toLocaleString()} / ${input.needed.toLocaleString()} XP`, barX, barY + 52);
return canvas.toBuffer('image/png');
}

View File

@@ -0,0 +1,415 @@
import type { LevelingConfig, MemberLevel } from '@prisma/client';
import {
levelFromXp,
parseMultiplierMap,
progressInLevel,
randomInt,
t,
tf,
type LevelUpMode
} from '@nexumi/shared';
import {
PermissionFlagsBits,
type Guild,
type GuildMember,
type SendableChannels
} from 'discord.js';
import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
const COOLDOWN_PREFIX = 'leveling:cd:';
const VOICE_PREFIX = 'leveling:voice:';
export function cooldownKey(guildId: string, userId: string): string {
return `${COOLDOWN_PREFIX}${guildId}:${userId}`;
}
export function voiceKey(guildId: string, userId: string): string {
return `${VOICE_PREFIX}${guildId}:${userId}`;
}
export async function getConfig(
prisma: BotContext['prisma'],
guildId: string
): Promise<LevelingConfig> {
await ensureGuild(prisma, guildId);
return prisma.levelingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getOrCreateMemberLevel(
prisma: BotContext['prisma'],
guildId: string,
userId: string
): Promise<MemberLevel> {
await ensureGuild(prisma, guildId);
return prisma.memberLevel.upsert({
where: { guildId_userId: { guildId, userId } },
update: {},
create: { guildId, userId, xp: 0, level: 0 }
});
}
function resolveRoleMultiplier(config: LevelingConfig, member: GuildMember): number {
const multipliers = parseMultiplierMap(config.roleMultipliers);
let highest = 1;
for (const role of member.roles.cache.values()) {
const value = multipliers[role.id];
if (value !== undefined && value > highest) {
highest = value;
}
}
return highest;
}
function resolveChannelMultiplier(config: LevelingConfig, channelId: string): number {
const multipliers = parseMultiplierMap(config.channelMultipliers);
return multipliers[channelId] ?? 1;
}
export function calculateTextXp(
config: LevelingConfig,
member: GuildMember,
channelId: string
): number {
const base = randomInt(config.textXpMin, config.textXpMax);
const roleMultiplier = resolveRoleMultiplier(config, member);
const channelMultiplier = resolveChannelMultiplier(config, channelId);
return Math.max(1, Math.round(base * roleMultiplier * channelMultiplier));
}
export function calculateVoiceXp(config: LevelingConfig, minutes: number): number {
if (minutes <= 0) {
return 0;
}
return config.voiceXpPerMinute * minutes;
}
export function isNoXpMember(config: LevelingConfig, member: GuildMember): boolean {
return config.noXpRoleIds.some((roleId) => member.roles.cache.has(roleId));
}
export function isNoXpChannel(config: LevelingConfig, channelId: string): boolean {
return config.noXpChannelIds.includes(channelId);
}
export type XpChangeResult = {
memberLevel: MemberLevel;
previousLevel: number;
newLevel: number;
xpDelta: number;
leveledUp: boolean;
};
async function persistMemberXp(
prisma: BotContext['prisma'],
guildId: string,
userId: string,
xp: number
): Promise<MemberLevel> {
const level = levelFromXp(xp);
return prisma.memberLevel.upsert({
where: { guildId_userId: { guildId, userId } },
update: { xp, level },
create: { guildId, userId, xp, level }
});
}
export async function addXp(
context: BotContext,
guild: Guild,
member: GuildMember,
amount: number,
options?: { channelId?: string; skipNotifications?: boolean; force?: boolean }
): Promise<XpChangeResult | null> {
if (amount <= 0) {
return null;
}
const config = await getConfig(context.prisma, guild.id);
if (!config.enabled && !options?.force) {
return null;
}
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
const previousLevel = existing.level;
const newXp = existing.xp + amount;
const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, newXp);
const leveledUp = memberLevel.level > previousLevel;
if (leveledUp && !options?.skipNotifications) {
await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config);
await sendLevelUpNotification(context, guild, member, memberLevel.level, config, options?.channelId);
}
return {
memberLevel,
previousLevel,
newLevel: memberLevel.level,
xpDelta: amount,
leveledUp
};
}
export async function setXp(
context: BotContext,
guild: Guild,
member: GuildMember,
xp: number,
channelId?: string
): Promise<XpChangeResult> {
const config = await getConfig(context.prisma, guild.id);
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
const previousLevel = existing.level;
const clampedXp = Math.max(0, xp);
const memberLevel = await persistMemberXp(context.prisma, guild.id, member.id, clampedXp);
const leveledUp = memberLevel.level > previousLevel;
if (leveledUp) {
await applyLevelRewards(context, guild, member, previousLevel, memberLevel.level, config);
await sendLevelUpNotification(context, guild, member, memberLevel.level, config, channelId);
}
return {
memberLevel,
previousLevel,
newLevel: memberLevel.level,
xpDelta: clampedXp - existing.xp,
leveledUp
};
}
export async function removeXp(
context: BotContext,
guild: Guild,
member: GuildMember,
amount: number,
channelId?: string
): Promise<XpChangeResult | null> {
if (amount <= 0) {
return null;
}
const existing = await getOrCreateMemberLevel(context.prisma, guild.id, member.id);
return setXp(context, guild, member, Math.max(0, existing.xp - amount), channelId);
}
export async function resetXp(
context: BotContext,
guild: Guild,
member: GuildMember,
channelId?: string
): Promise<XpChangeResult> {
return setXp(context, guild, member, 0, channelId);
}
export async function getLeaderboard(
prisma: BotContext['prisma'],
guildId: string,
limit = 10
): Promise<MemberLevel[]> {
return prisma.memberLevel.findMany({
where: { guildId },
orderBy: [{ xp: 'desc' }, { updatedAt: 'asc' }],
take: limit
});
}
export async function getRankPosition(
prisma: BotContext['prisma'],
guildId: string,
userId: string
): Promise<number> {
const member = await getOrCreateMemberLevel(prisma, guildId, userId);
const higher = await prisma.memberLevel.count({
where: { guildId, xp: { gt: member.xp } }
});
return higher + 1;
}
export async function applyLevelRewards(
context: BotContext,
guild: Guild,
member: GuildMember,
previousLevel: number,
newLevel: number,
config?: LevelingConfig
): Promise<void> {
if (newLevel <= previousLevel) {
return;
}
const levelingConfig = config ?? (await getConfig(context.prisma, guild.id));
const me = guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const rewards = await context.prisma.levelReward.findMany({
where: { guildId: guild.id },
orderBy: { level: 'asc' }
});
if (rewards.length === 0) {
return;
}
const rewardRoleIds = new Set(rewards.map((reward) => reward.roleId));
const assignable = (roleId: string) => {
const role = guild.roles.cache.get(roleId);
return role && role.id !== guild.id && role.position < me.roles.highest.position;
};
const rolesToAdd: string[] = [];
if (levelingConfig.stackRewards) {
for (let level = previousLevel + 1; level <= newLevel; level += 1) {
const reward = rewards.find((entry) => entry.level === level);
if (reward && assignable(reward.roleId)) {
rolesToAdd.push(reward.roleId);
}
}
} else {
const reward = rewards.find((entry) => entry.level === newLevel);
const removable = rewards
.map((entry) => entry.roleId)
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
if (removable.length > 0) {
await member.roles.remove(removable).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to remove level reward roles');
});
}
if (reward && assignable(reward.roleId)) {
rolesToAdd.push(reward.roleId);
}
}
if (rolesToAdd.length > 0) {
await member.roles.add([...new Set(rolesToAdd)]).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to assign level reward roles');
});
}
}
function renderLevelUpMessage(
template: string,
member: GuildMember,
level: number
): string {
return template
.replaceAll('{user}', `<@${member.id}>`)
.replaceAll('{username}', member.displayName)
.replaceAll('{level}', String(level));
}
async function sendLevelUpNotification(
context: BotContext,
guild: Guild,
member: GuildMember,
level: number,
config: LevelingConfig,
fallbackChannelId?: string
): Promise<void> {
const mode = config.levelUpMode as LevelUpMode;
if (mode === 'OFF') {
return;
}
const locale = await getGuildLocale(context.prisma, guild.id);
const template = config.levelUpMessage ?? t(locale, 'leveling.levelUp.default');
const content = renderLevelUpMessage(template, member, level);
if (mode === 'DM') {
await member.send({ content }).catch(() => undefined);
return;
}
const channelId = config.levelUpChannelId ?? fallbackChannelId;
if (!channelId) {
return;
}
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased() || channel.isDMBased()) {
return;
}
await (channel as SendableChannels).send({ content }).catch((error) => {
logger.warn({ error, channelId }, 'Failed to send level-up message');
});
}
export async function awardVoiceXpForSession(
context: BotContext,
guild: Guild,
member: GuildMember,
joinedAtMs: number,
channelId: string
): Promise<void> {
const config = await getConfig(context.prisma, guild.id);
if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) {
return;
}
const minutes = Math.floor((Date.now() - joinedAtMs) / 60_000);
const baseXp = calculateVoiceXp(config, minutes);
if (baseXp <= 0) {
return;
}
const roleMultiplier = resolveRoleMultiplier(config, member);
const channelMultiplier = resolveChannelMultiplier(config, channelId);
const amount = Math.max(1, Math.round(baseXp * roleMultiplier * channelMultiplier));
await addXp(context, guild, member, amount, { channelId });
}
export async function handleTextXpMessage(
context: BotContext,
member: GuildMember,
channelId: string
): Promise<void> {
if (member.user.bot) {
return;
}
const config = await getConfig(context.prisma, member.guild.id);
if (!config.enabled || isNoXpMember(config, member) || isNoXpChannel(config, channelId)) {
return;
}
const key = cooldownKey(member.guild.id, member.id);
const onCooldown = await context.redis.get(key);
if (onCooldown) {
return;
}
const amount = calculateTextXp(config, member, channelId);
const result = await addXp(context, member.guild, member, amount, { channelId });
if (result) {
await context.redis.set(key, '1', 'EX', config.textCooldownSeconds);
}
}
export function memberProgress(member: MemberLevel) {
return progressInLevel(member.xp);
}
export function formatLeaderboardLine(
locale: 'de' | 'en',
rank: number,
userLabel: string,
level: number,
xp: number
): string {
return tf(locale, 'leveling.leaderboard.entry', {
rank,
user: userLabel,
level,
xp
});
}