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,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
});
}