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

@@ -1,8 +1,9 @@
import { TextChannel, type ButtonInteraction } from 'discord.js';
import { type Locale, t } from '@nexumi/shared';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { assertBannableForConfirm } from '../../hierarchy.js';
import { createCase, scheduleTempBanExpire } from './service.js';
import { parseDuration } from './duration.js';
import { tryParseDuration } from './duration.js';
type BanPayload = {
userId: string;
@@ -36,8 +37,24 @@ export async function executeBan(
return;
}
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
return;
}
let durationMs: number | null = null;
if (payload.duration) {
durationMs = tryParseDuration(payload.duration);
if (durationMs === null || durationMs <= 0) {
await interaction.update({
content: t(locale, 'moderation.duration.invalid'),
components: []
});
return;
}
}
await guild.members.ban(payload.userId, { reason: payload.reason });
await createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: payload.userId,
moderatorId: interaction.user.id,
@@ -47,21 +64,17 @@ export async function executeBan(
source: 'button_confirmation',
metadata: {
confirmed: true,
duration: payload.duration
duration: payload.duration,
durationMs
}
});
if (payload.duration) {
await scheduleTempBanExpire(
guild.id,
payload.userId,
parseDuration(payload.duration),
payload.reason
);
if (durationMs !== null) {
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
}
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}
@@ -97,7 +110,7 @@ export async function executePurge(
deletedCount = deleted.size;
}
await createCase(context, {
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
@@ -122,7 +135,10 @@ export async function executePurge(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber
}),
components: []
});
}
@@ -143,7 +159,7 @@ export async function executeCaseDelete(
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
});
if (!existing) {
if (!existing || existing.deletedAt) {
await interaction.update({
content: t(locale, 'moderation.case.notFound'),
components: []
@@ -156,7 +172,7 @@ export async function executeCaseDelete(
data: { deletedAt: new Date() }
});
await createCase(context, {
const modCase = await createCase(context, {
guildId,
targetUserId: existing.targetUserId,
moderatorId: interaction.user.id,
@@ -172,7 +188,7 @@ export async function executeCaseDelete(
});
await interaction.update({
content: t(locale, 'moderation.success'),
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}

View File

@@ -1,4 +1,5 @@
import {
PermissionFlagsBits,
SlashCommandBuilder,
type SlashCommandStringOption,
type SlashCommandUserOption
@@ -26,7 +27,9 @@ function reasonOpt(o: SlashCommandStringOption, required = false) {
}
export const banCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('ban'),
new SlashCommandBuilder()
.setName('ban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.ban.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -36,7 +39,9 @@ export const banCommandData = applyCommandDescription(
);
export const unbanCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unban'),
new SlashCommandBuilder()
.setName('unban')
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers),
'moderation.unban.description'
)
.addStringOption((o) =>
@@ -45,14 +50,18 @@ export const unbanCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const kickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('kick'),
new SlashCommandBuilder()
.setName('kick')
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
'moderation.kick.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const timeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('timeout'),
new SlashCommandBuilder()
.setName('timeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.timeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
@@ -62,14 +71,18 @@ export const timeoutCommandData = applyCommandDescription(
.addStringOption((o) => reasonOpt(o));
export const untimeoutCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('untimeout'),
new SlashCommandBuilder()
.setName('untimeout')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.untimeout.description'
)
.addUserOption((o) => userOpt(o, 'moderation.ban.options.user'))
.addStringOption((o) => reasonOpt(o));
export const warnCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('warn'),
new SlashCommandBuilder()
.setName('warn')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.warn.description'
)
.addSubcommand((s) =>
@@ -123,7 +136,9 @@ export const warnCommandData = applyCommandDescription(
);
export const purgeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('purge'),
new SlashCommandBuilder()
.setName('purge')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
'moderation.purge.description'
)
.addIntegerOption((o) =>
@@ -138,24 +153,32 @@ export const purgeCommandData = applyCommandDescription(
.addStringOption((o) => applyOptionDescription(o.setName('regex'), 'moderation.purge.options.regex'));
export const slowmodeCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('slowmode'),
new SlashCommandBuilder()
.setName('slowmode')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.slowmode.description'
).addIntegerOption((o) =>
applyOptionDescription(o.setName('seconds').setRequired(true).setMinValue(0).setMaxValue(21600), 'moderation.slowmode.options.seconds')
);
export const lockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('lock'),
new SlashCommandBuilder()
.setName('lock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.lock.description'
);
export const unlockCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('unlock'),
new SlashCommandBuilder()
.setName('unlock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
'moderation.unlock.description'
);
export const nickCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('nick'),
new SlashCommandBuilder()
.setName('nick')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames),
'moderation.nick.description'
)
.addSubcommand((s) =>
@@ -172,7 +195,9 @@ export const nickCommandData = applyCommandDescription(
);
export const caseCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('case'),
new SlashCommandBuilder()
.setName('case')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.case.description'
)
.addSubcommand((s) =>
@@ -196,7 +221,9 @@ export const caseCommandData = applyCommandDescription(
);
export const modNoteCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('modnote'),
new SlashCommandBuilder()
.setName('modnote')
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
'moderation.modnote.description'
)
.addSubcommand((s) =>

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({

View File

@@ -9,11 +9,18 @@ import { randomUUID } from 'node:crypto';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import {
deleteConfirmation,
getConfirmation,
setConfirmation,
takeConfirmation
} from '../../confirmation-store.js';
import { executeBan, executeCaseDelete, executePurge } from './actions.js';
const CONFIRM_PREFIX = 'mod:confirm:';
const CANCEL_PREFIX = 'mod:cancel:';
const TTL_MS = 60_000;
const NAMESPACE = 'moderation';
const TTL_SECONDS = 60;
type BanPayload = {
userId: string;
@@ -44,20 +51,8 @@ type PendingEntry = PendingConfirmation & {
moderatorId: string;
guildId: string;
locale: Locale;
expiresAt: number;
};
const pending = new Map<string, PendingEntry>();
function pruneExpired(): void {
const now = Date.now();
for (const [token, entry] of pending) {
if (entry.expiresAt <= now) {
pending.delete(token);
}
}
}
function confirmationMessage(locale: Locale, entry: PendingConfirmation): string {
if (entry.action === 'ban') {
return tf(locale, 'moderation.confirm.ban', {
@@ -89,18 +84,23 @@ function buildRows(token: string, locale: Locale): ActionRowBuilder<ButtonBuilde
export async function promptDestructiveConfirmation(
interaction: ChatInputCommandInteraction,
context: BotContext,
locale: Locale,
entry: PendingConfirmation
): Promise<void> {
pruneExpired();
const token = randomUUID();
pending.set(token, {
...entry,
moderatorId: interaction.user.id,
guildId: interaction.guildId!,
locale,
expiresAt: Date.now() + TTL_MS
});
await setConfirmation(
context.redis,
NAMESPACE,
token,
{
...entry,
moderatorId: interaction.user.id,
guildId: interaction.guildId!,
locale
} satisfies PendingEntry,
TTL_SECONDS
);
await interaction.reply({
content: confirmationMessage(locale, entry),
@@ -113,8 +113,6 @@ export async function handleModerationConfirmation(
interaction: ButtonInteraction,
context: BotContext
): Promise<void> {
pruneExpired();
const customId = interaction.customId;
const isConfirm = customId.startsWith(CONFIRM_PREFIX);
const isCancel = customId.startsWith(CANCEL_PREFIX);
@@ -123,31 +121,37 @@ export async function handleModerationConfirmation(
}
const token = customId.slice(isConfirm ? CONFIRM_PREFIX.length : CANCEL_PREFIX.length);
const entry = pending.get(token);
if (!entry) {
const peeked = await getConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!peeked) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return;
}
if (interaction.user.id !== entry.moderatorId) {
if (interaction.user.id !== peeked.moderatorId) {
await interaction.reply({
content: t(entry.locale, 'moderation.confirm.unauthorized'),
content: t(peeked.locale, 'moderation.confirm.unauthorized'),
ephemeral: true
});
return;
}
pending.delete(token);
if (isCancel) {
await deleteConfirmation(context.redis, NAMESPACE, token);
await interaction.update({
content: t(entry.locale, 'moderation.confirm.cancelled'),
content: t(peeked.locale, 'moderation.confirm.cancelled'),
components: []
});
return;
}
const entry = await takeConfirmation<PendingEntry>(context.redis, NAMESPACE, token);
if (!entry) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
await interaction.reply({ content: t(locale, 'moderation.confirm.expired'), ephemeral: true });
return;
}
if (entry.action === 'ban') {
await executeBan(interaction, context, entry.locale, entry.payload);
return;

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { parseDuration } from './duration.js';
import {
MAX_TIMEOUT_MS,
parseDuration,
parseTimeoutDuration,
tryParseDuration
} from './duration.js';
describe('parseDuration', () => {
it('parses minutes', () => {
@@ -14,3 +19,27 @@ describe('parseDuration', () => {
expect(() => parseDuration('tomorrow')).toThrow();
});
});
describe('parseTimeoutDuration', () => {
it('accepts durations within Discord limit', () => {
expect(parseTimeoutDuration('1d')).toBe(86_400_000);
});
it('rejects durations over 28 days', () => {
expect(() => parseTimeoutDuration('30d')).toThrow(/28/);
});
it('exposes MAX_TIMEOUT_MS as 28 days', () => {
expect(MAX_TIMEOUT_MS).toBe(28 * 24 * 60 * 60 * 1000);
});
});
describe('tryParseDuration', () => {
it('returns null for invalid input', () => {
expect(tryParseDuration('nope')).toBeNull();
});
it('returns ms for valid input', () => {
expect(tryParseDuration('10s')).toBe(10_000);
});
});

View File

@@ -1,10 +1,39 @@
export const MAX_TIMEOUT_MS = 28 * 24 * 60 * 60 * 1000;
export class DurationParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'DurationParseError';
}
}
export function parseDuration(input: string): number {
const m = input.match(/^(\d+)([smhd])$/i);
if (!m) {
throw new Error('Duration must be like 30m, 12h, 7d');
throw new DurationParseError('Duration must be like 30m, 12h, 7d');
}
const amount = Number(m[1]);
const unit = m[2].toLowerCase();
const unit = m[2]!.toLowerCase();
const factor = unit === 's' ? 1000 : unit === 'm' ? 60000 : unit === 'h' ? 3600000 : 86400000;
return amount * factor;
}
/** Parse a duration and enforce Discord's 28-day timeout maximum. */
export function parseTimeoutDuration(input: string): number {
const ms = parseDuration(input);
if (ms <= 0) {
throw new DurationParseError('Duration must be positive');
}
if (ms > MAX_TIMEOUT_MS) {
throw new DurationParseError('Timeout duration cannot exceed 28 days');
}
return ms;
}
export function tryParseDuration(input: string): number | null {
try {
return parseDuration(input);
} catch {
return null;
}
}

View File

@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.js';
import type { BotContext } from '../../types.js';
import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js';
import type { Prisma } from '@prisma/client';
import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared';
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
import { MAX_TIMEOUT_MS } from './duration.js';
type CreateCaseInput = {
guildId: string;
@@ -22,39 +23,69 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
create: { id: input.guildId }
});
const lastCase = await context.prisma.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' }
});
const metadata = buildCaseMetadata({
source: input.source ?? 'slash_command',
channelId: input.channelId,
extras: input.metadata
});
return context.prisma.case.create({
data: {
guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
action: input.action,
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
let created = null;
for (let attempt = 0; attempt < 5; attempt++) {
try {
created = await context.prisma.$transaction(
async (tx) => {
const lastCase = await tx.case.findFirst({
where: { guildId: input.guildId },
orderBy: { caseNumber: 'desc' },
select: { caseNumber: true }
});
return tx.case.create({
data: {
guildId: input.guildId,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
action: input.action,
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
}
});
},
{ isolationLevel: 'Serializable' }
);
break;
} catch (error) {
const code =
typeof error === 'object' && error && 'code' in error
? String((error as { code: unknown }).code)
: '';
if (code !== 'P2002' && attempt === 4) {
throw error;
}
if (attempt === 4) {
throw error;
}
}
}).then(async (created) => {
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
caseNumber: created.caseNumber,
action: input.action,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
reason: input.reason
});
return created;
}
if (!created) {
throw new Error('Failed to create case');
}
const { logModAction } = await import('../logging/events.js');
await logModAction(context, {
guildId: input.guildId,
caseNumber: created.caseNumber,
action: input.action,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
reason: input.reason
});
return created;
}
export function tempBanJobId(guildId: string, userId: string): string {
return `tempBan:${guildId}:${userId}`;
}
export async function scheduleTempBanExpire(
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
durationMs: number,
reason?: string
) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await moderationQueue.add(
'tempBanExpire',
{ guildId, userId, reason: reason ?? null },
{
jobId,
delay: durationMs,
removeOnComplete: 1000,
removeOnFail: 500
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
interaction: ChatInputCommandInteraction,
context: BotContext,
userId: string,
moderatorId: string
moderatorId: string,
locale: Locale
): Promise<string | null> {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) {
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
}
const member = await interaction.guild.members.fetch(userId);
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
const reason =
rule.reason ??
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
if (rule.action === 'TIMEOUT') {
if (!rule.durationMs) {
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
return t(locale, 'moderation.escalation.skippedDuration').replace(
'{warnCount}',
String(warningCount)
);
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.timeout(rule.durationMs, reason);
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
await member.timeout(durationMs, reason);
await createCase(context, {
guildId,
targetUserId: userId,
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
reason,
channelId: interaction.channelId ?? undefined,
source: 'escalation',
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
metadata: { escalation: true, durationMs, warningCount }
});
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
return t(locale, 'moderation.escalation.appliedTimeout').replace(
'{durationMs}',
String(durationMs)
);
}
if (rule.action === 'KICK') {
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.kick(reason);
await createCase(context, {
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: kick.';
return t(locale, 'moderation.escalation.appliedKick');
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await interaction.guild.members.ban(userId, { reason });
await createCase(context, {
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: ban.';
return t(locale, 'moderation.escalation.appliedBan');
}