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:
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal file
49
apps/bot/src/modules/leveling/command-definitions.ts
Normal 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')
|
||||
)
|
||||
);
|
||||
160
apps/bot/src/modules/leveling/commands.ts
Normal file
160
apps/bot/src/modules/leveling/commands.ts
Normal 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];
|
||||
69
apps/bot/src/modules/leveling/events.ts
Normal file
69
apps/bot/src/modules/leveling/events.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
84
apps/bot/src/modules/leveling/rank-card.ts
Normal file
84
apps/bot/src/modules/leveling/rank-card.ts
Normal 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');
|
||||
}
|
||||
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