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,438 @@
import {
EmbedBuilder,
PermissionFlagsBits,
type ChatInputCommandInteraction,
type GuildBasedChannel
} 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 { showEmbedBuilderModal } from './buttons.js';
import {
afkCommandData,
avatarCommandData,
bannerCommandData,
channelinfoCommandData,
editsnipeCommandData,
embedCommandData,
emojiCommandData,
pollCommandData,
remindmeCommandData,
remindersCommandData,
roleinfoCommandData,
serverinfoCommandData,
snipeCommandData,
stickerCommandData,
timestampCommandData,
userinfoCommandData
} from './command-definitions.js';
import { createPoll } from './poll.js';
import { createReminder, deleteReminder, listReminders } from './reminders.js';
import { setAfk } from './afk.js';
import { getDeletedSnipe, getEditedSnipe } from './snipe.js';
import {
UtilityError,
buildChannelInfoEmbed,
buildRoleInfoEmbed,
buildServerInfoEmbed,
buildUserInfoEmbed,
extractFirstCustomEmoji,
formatDiscordTimestamps,
parseTimestampInput
} from './service.js';
async function ensureGuildCommand(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false;
}
return true;
}
async function handleUtilityError(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
error: unknown
): Promise<void> {
if (error instanceof UtilityError) {
await interaction.reply({
content: t(locale, `utility.error.${error.code}` as 'utility.error.invalid_when'),
ephemeral: true
});
return;
}
throw error;
}
const userinfoCommand: SlashCommand = {
data: userinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const user = interaction.options.getUser('user') ?? interaction.user;
const member = await interaction.guild!.members.fetch(user.id).catch(() => null);
const embed = buildUserInfoEmbed(user, member, locale);
await interaction.reply({ embeds: [embed] });
}
};
const serverinfoCommand: SlashCommand = {
data: serverinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const embed = buildServerInfoEmbed(interaction.guild!, locale);
await interaction.reply({ embeds: [embed] });
}
};
const roleinfoCommand: SlashCommand = {
data: roleinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const role = interaction.options.getRole('role', true);
const guildRole =
interaction.guild!.roles.cache.get(role.id) ?? (await interaction.guild!.roles.fetch(role.id));
if (!guildRole) {
await interaction.reply({ content: t(locale, 'utility.error.role_not_found'), ephemeral: true });
return;
}
await interaction.reply({ embeds: [buildRoleInfoEmbed(guildRole, locale)] });
}
};
const channelinfoCommand: SlashCommand = {
data: channelinfoCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const channel = interaction.options.getChannel('channel') ?? interaction.channel;
if (!channel || !('guild' in channel)) {
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
return;
}
await interaction.reply({ embeds: [buildChannelInfoEmbed(channel as GuildBasedChannel, locale)] });
}
};
const avatarCommand: SlashCommand = {
data: avatarCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const user = interaction.options.getUser('user') ?? interaction.user;
const embed = new EmbedBuilder()
.setTitle(tf(locale, 'utility.avatar.title', { user: user.tag }))
.setImage(user.displayAvatarURL({ size: 512 }))
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const bannerCommand: SlashCommand = {
data: bannerCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const user = interaction.options.getUser('user') ?? interaction.user;
const fetched = await context.client.users.fetch(user.id, { force: true });
const banner = fetched.bannerURL({ size: 512 });
if (!banner) {
await interaction.reply({ content: t(locale, 'utility.banner.none'), ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setTitle(tf(locale, 'utility.banner.title', { user: fetched.tag }))
.setImage(banner)
.setColor(0x6366f1);
await interaction.reply({ embeds: [embed] });
}
};
const pollCommand: SlashCommand = {
data: pollCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const question = interaction.options.getString('question', true);
const optionsInput = interaction.options.getString('options', true);
const multiSelect = interaction.options.getBoolean('multiselect') ?? false;
const anonymous = interaction.options.getBoolean('anonymous') ?? false;
const duration = interaction.options.getString('duration');
try {
await createPoll(
context,
{
guildId: interaction.guildId!,
channelId: interaction.channelId!,
creatorId: interaction.user.id,
question,
optionsInput,
multiSelect,
anonymous,
durationInput: duration
},
locale
);
await interaction.reply({ content: t(locale, 'utility.poll.created'), ephemeral: true });
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const remindmeCommand: SlashCommand = {
data: remindmeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const whenInput = interaction.options.getString('when', true);
const content = interaction.options.getString('content', true);
const recurringCron = interaction.options.getString('recurring_cron');
try {
const id = await createReminder(context, {
guildId: interaction.guildId,
userId: interaction.user.id,
channelId: interaction.channelId!,
content,
whenInput,
recurringCron
});
await interaction.reply({
content: tf(locale, 'utility.remindme.created', { id: id.slice(0, 8) }),
ephemeral: true
});
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const remindersCommand: SlashCommand = {
data: remindersCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const sub = interaction.options.getSubcommand();
if (sub === 'list') {
const content = await listReminders(context, interaction.user.id, locale);
await interaction.reply({ content, ephemeral: true });
return;
}
const id = interaction.options.getString('id', true);
const deleted = await deleteReminder(context, interaction.user.id, id);
await interaction.reply({
content: t(locale, deleted ? 'utility.reminders.deleted' : 'utility.reminders.notFound'),
ephemeral: true
});
}
};
const afkCommand: SlashCommand = {
data: afkCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const reason = interaction.options.getString('reason');
await setAfk(context, interaction.guildId!, interaction.user.id, reason);
await interaction.reply({
content: tf(locale, 'utility.afk.set', { reason: reason ?? '—' }),
ephemeral: true
});
}
};
const emojiCommand: SlashCommand = {
data: emojiCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
return;
}
const sub = interaction.options.getSubcommand();
if (sub === 'add') {
const name = interaction.options.getString('name', true);
const attachment = interaction.options.getAttachment('attachment');
const url = interaction.options.getString('url') ?? attachment?.url;
if (!url) {
await interaction.reply({ content: t(locale, 'utility.emoji.noSource'), ephemeral: true });
return;
}
const emoji = await interaction.guild!.emojis.create({ attachment: url, name });
await interaction.reply({
content: tf(locale, 'utility.emoji.added', { emoji: emoji.toString() }),
ephemeral: true
});
return;
}
if (sub === 'remove') {
const input = interaction.options.getString('emoji', true);
const match = input.match(/:(\d+)>$/) ?? input.match(/^(\d+)$/);
const emojiId = match?.[1] ?? input;
const emoji = interaction.guild!.emojis.cache.get(emojiId);
if (!emoji) {
await interaction.reply({ content: t(locale, 'utility.emoji.notFound'), ephemeral: true });
return;
}
await emoji.delete();
await interaction.reply({ content: t(locale, 'utility.emoji.removed'), ephemeral: true });
return;
}
const messageId = interaction.options.getString('message_id', true);
const channel = interaction.channel;
if (!channel?.isTextBased()) {
await interaction.reply({ content: t(locale, 'utility.error.channel_not_found'), ephemeral: true });
return;
}
const message = await channel.messages.fetch(messageId).catch(() => null);
if (!message) {
await interaction.reply({ content: t(locale, 'utility.emoji.messageNotFound'), ephemeral: true });
return;
}
const stolen = extractFirstCustomEmoji(message.content);
if (!stolen) {
await interaction.reply({ content: t(locale, 'utility.emoji.noEmojiInMessage'), ephemeral: true });
return;
}
const isAnimated = message.content.includes(`<a:${stolen.name}:${stolen.id}>`);
const emoji = await interaction.guild!.emojis.create({
attachment: `https://cdn.discordapp.com/emojis/${stolen.id}.${isAnimated ? 'gif' : 'png'}`,
name: stolen.name
});
await interaction.reply({
content: tf(locale, 'utility.emoji.stolen', { emoji: emoji.toString() }),
ephemeral: true
});
}
};
const stickerCommand: SlashCommand = {
data: stickerCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuildExpressions, locale))) {
return;
}
const name = interaction.options.getString('name', true);
const attachment = interaction.options.getAttachment('attachment');
const url = interaction.options.getString('url') ?? attachment?.url;
if (!url) {
await interaction.reply({ content: t(locale, 'utility.sticker.noSource'), ephemeral: true });
return;
}
const sticker = await interaction.guild!.stickers.create({
file: url,
name,
tags: name
});
await interaction.reply({
content: tf(locale, 'utility.sticker.added', { name: sticker.name }),
ephemeral: true
});
}
};
const timestampCommand: SlashCommand = {
data: timestampCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const input = interaction.options.getString('datetime', true);
try {
const ms = parseTimestampInput(input);
const unix = Math.floor(ms / 1000);
await interaction.reply({
content: formatDiscordTimestamps(unix),
ephemeral: true
});
} catch (error) {
await handleUtilityError(interaction, locale, error);
}
}
};
const snipeCommand: SlashCommand = {
data: snipeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const result = await getDeletedSnipe(context, interaction.channelId!, locale);
if (!result) {
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
return;
}
await interaction.reply({
embeds: [result.embed],
content: result.privacyNote,
ephemeral: true
});
}
};
const editsnipeCommand: SlashCommand = {
data: editsnipeCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
const result = await getEditedSnipe(context, interaction.channelId!, locale);
if (!result) {
await interaction.reply({ content: t(locale, 'utility.snipe.none'), ephemeral: true });
return;
}
await interaction.reply({
embeds: [result.embed],
content: result.privacyNote,
ephemeral: true
});
}
};
const embedCommand: SlashCommand = {
data: embedCommandData,
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureGuildCommand(interaction, locale))) return;
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
return;
}
await showEmbedBuilderModal(interaction);
}
};
export const utilityCommands: SlashCommand[] = [
userinfoCommand,
serverinfoCommand,
roleinfoCommand,
channelinfoCommand,
avatarCommand,
bannerCommand,
pollCommand,
remindmeCommand,
remindersCommand,
afkCommand,
emojiCommand,
stickerCommand,
timestampCommand,
snipeCommand,
editsnipeCommand,
embedCommand
];