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:
415
apps/bot/src/modules/leveling/service.ts
Normal file
415
apps/bot/src/modules/leveling/service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user