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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user