Enhance bot functionality with new features and improvements

- Updated package dependencies to include `@sentry/node` for error tracking.
- Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety.
- Enhanced health server metrics to include queue waiting times, providing better insights into system performance.
- Introduced confirmation handling for guild backup commands, streamlining user interactions.
- Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities.
- Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features.
- Updated localization files to reflect new command structures and user guidance improvements.
This commit is contained in:
smueller
2026-07-23 09:53:44 +02:00
parent 4e135bcf43
commit ffaa1e26fd
31 changed files with 1831 additions and 363 deletions

View File

@@ -2,14 +2,14 @@ import {
ChannelType,
PermissionFlagsBits,
TextChannel,
type ChatInputCommandInteraction,
type GuildMember
type ChatInputCommandInteraction
} from 'discord.js';
import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js';
import { assertModerableMember } from '../../hierarchy.js';
import { applyWarnEscalation, createCase } from './service.js';
import { parseDuration } from './duration.js';
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js';
import { promptDestructiveConfirmation } from './confirmations.js';
import {
@@ -51,6 +51,17 @@ async function ensureMod(
return requirePermission(interaction, permission, locale);
}
async function replySuccessCase(
interaction: ChatInputCommandInteraction,
locale: 'de' | 'en',
caseNumber: number
): Promise<void> {
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
ephemeral: true
});
}
const banCommand: SlashCommand = {
data: banCommandData,
async execute(interaction, context) {
@@ -59,7 +70,22 @@ const banCommand: SlashCommand = {
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const duration = interaction.options.getString('duration');
await promptDestructiveConfirmation(interaction, locale, {
if (duration) {
const durationMs = tryParseDuration(duration);
if (durationMs === null || durationMs <= 0) {
await interaction.reply({
content: t(locale, 'moderation.duration.invalid'),
ephemeral: true
});
return;
}
}
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'ban');
if (!hierarchy.ok) return;
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'ban',
payload: {
userId: user.id,
@@ -78,7 +104,7 @@ const unbanCommand: SlashCommand = {
const userId = interaction.options.getString('user_id', true);
const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.unban(userId, reason);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: userId,
moderatorId: interaction.user.id,
@@ -86,7 +112,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -97,9 +123,10 @@ const kickCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
await member.kick(reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.kick(reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -107,7 +134,7 @@ const kickCommand: SlashCommand = {
action: 'KICK',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -117,11 +144,21 @@ const timeoutCommand: SlashCommand = {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const duration = parseDuration(interaction.options.getString('duration', true));
let duration: number;
try {
duration = parseTimeoutDuration(interaction.options.getString('duration', true));
} catch {
await interaction.reply({
content: t(locale, 'moderation.duration.invalidTimeout'),
ephemeral: true
});
return;
}
const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(duration, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(duration, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -130,7 +167,7 @@ const timeoutCommand: SlashCommand = {
reason,
metadata: { durationMs: duration }
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -141,9 +178,10 @@ const untimeoutCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(null, reason);
await createCase(context, {
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'timeout');
if (!hierarchy.ok || !hierarchy.member) return;
await hierarchy.member.timeout(null, reason);
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -151,7 +189,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT',
reason
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -166,7 +204,19 @@ const warnCommand: SlashCommand = {
const action = interaction.options.getString('action', true);
const durationInput = interaction.options.getString('duration');
const reason = interaction.options.getString('reason');
const durationMs = durationInput ? parseDuration(durationInput) : null;
let durationMs: number | null = null;
if (durationInput) {
try {
durationMs =
action === 'TIMEOUT' ? parseTimeoutDuration(durationInput) : parseDuration(durationInput);
} catch {
await interaction.reply({
content: t(locale, 'moderation.duration.invalid'),
ephemeral: true
});
return;
}
}
if (action === 'TIMEOUT' && !durationMs) {
await interaction.reply({
content: t(locale, 'moderation.escalation.durationRequired'),
@@ -267,7 +317,8 @@ const warnCommand: SlashCommand = {
interaction,
context,
user.id,
interaction.user.id
interaction.user.id,
locale
);
await interaction.reply({
content: escalationResult
@@ -300,8 +351,18 @@ const warnCommand: SlashCommand = {
if (sub === 'remove') {
const warningId = interaction.options.getString('warning_id', true);
const warning = await context.prisma.warning.delete({ where: { id: warningId } });
await createCase(context, {
const warning = await context.prisma.warning.findFirst({
where: { id: warningId, guildId: interaction.guildId! }
});
if (!warning) {
await interaction.reply({
content: t(locale, 'moderation.warning.notFound'),
ephemeral: true
});
return;
}
await context.prisma.warning.delete({ where: { id: warning.id } });
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: warning.userId,
moderatorId: interaction.user.id,
@@ -309,13 +370,15 @@ const warnCommand: SlashCommand = {
action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const user = interaction.options.getUser('user', true);
await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } });
await createCase(context, {
await context.prisma.warning.deleteMany({
where: { guildId: interaction.guildId!, userId: user.id }
});
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -323,7 +386,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_CLEAR',
reason: 'Warnings cleared'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -352,7 +415,7 @@ const purgeCommand: SlashCommand = {
return;
}
await promptDestructiveConfirmation(interaction, locale, {
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'purge',
payload: {
channelId: interaction.channel.id,
@@ -376,7 +439,7 @@ const slowmodeCommand: SlashCommand = {
if (interaction.channel instanceof TextChannel) {
await interaction.channel.setRateLimitPerUser(seconds);
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -384,7 +447,7 @@ const slowmodeCommand: SlashCommand = {
action: 'SLOWMODE',
reason: `Set slowmode to ${seconds}s`
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -398,7 +461,7 @@ const lockCommand: SlashCommand = {
SendMessages: false
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -406,7 +469,7 @@ const lockCommand: SlashCommand = {
action: 'LOCK',
reason: 'Channel locked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -420,7 +483,7 @@ const unlockCommand: SlashCommand = {
SendMessages: null
});
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -428,7 +491,7 @@ const unlockCommand: SlashCommand = {
action: 'UNLOCK',
reason: 'Channel unlocked'
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
};
@@ -439,11 +502,13 @@ const nickCommand: SlashCommand = {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true);
const member = await interaction.guild!.members.fetch(user.id);
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'nick');
if (!hierarchy.ok || !hierarchy.member) return;
const member = hierarchy.member;
if (sub === 'set') {
const nickname = interaction.options.getString('nickname', true);
await member.setNickname(nickname);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -451,9 +516,10 @@ const nickCommand: SlashCommand = {
action: 'NICK_SET',
reason: nickname
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
} else {
await member.setNickname(null);
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -461,8 +527,8 @@ const nickCommand: SlashCommand = {
action: 'NICK_RESET',
reason: 'Nickname reset'
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
}
};
@@ -474,8 +540,12 @@ const caseCommand: SlashCommand = {
const sub = interaction.options.getSubcommand();
const caseNumber = interaction.options.getInteger('id', true);
if (sub === 'view') {
const found = await context.prisma.case.findUnique({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
const found = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
await interaction.reply({
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
@@ -485,14 +555,31 @@ const caseCommand: SlashCommand = {
}
if (sub === 'edit') {
const reason = interaction.options.getString('reason', true);
const existing = await context.prisma.case.findFirst({
where: {
guildId: interaction.guildId!,
caseNumber,
deletedAt: null
}
});
if (!existing) {
await interaction.reply({
content: t(locale, 'moderation.case.notFound'),
ephemeral: true
});
return;
}
await context.prisma.case.update({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
where: { id: existing.id },
data: { reason }
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await interaction.reply({
content: tf(locale, 'moderation.success.case', { caseNumber }),
ephemeral: true
});
return;
}
await promptDestructiveConfirmation(interaction, locale, {
await promptDestructiveConfirmation(interaction, context, locale, {
action: 'case_delete',
payload: { caseNumber }
});
@@ -517,7 +604,7 @@ const modNoteCommand: SlashCommand = {
note
}
});
await createCase(context, {
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: user.id,
moderatorId: interaction.user.id,
@@ -525,7 +612,7 @@ const modNoteCommand: SlashCommand = {
action: 'MODNOTE_ADD',
reason: note
});
await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
await replySuccessCase(interaction, locale, modCase.caseNumber);
return;
}
const notes = await context.prisma.modNote.findMany({