- 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.
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
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
|
|
});
|
|
}
|