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 { 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 { 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 { 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 { 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 { 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 { 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 { return setXp(context, guild, member, 0, channelId); } export async function getLeaderboard( prisma: BotContext['prisma'], guildId: string, limit = 10 ): Promise { 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 { 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 { 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 { 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 { 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 { 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 }); }