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:
@@ -154,7 +154,7 @@ const backupCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await promptDeleteConfirmation(interaction, locale, backupId);
|
||||
await promptDeleteConfirmation(interaction, context, locale, backupId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ const backupCommand: SlashCommand = {
|
||||
return;
|
||||
}
|
||||
|
||||
await promptRestoreConfirmation(interaction, locale, backupId);
|
||||
await promptRestoreConfirmation(interaction, context, locale, backupId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,12 @@ 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 {
|
||||
deleteGuildBackup,
|
||||
enqueueGuildBackupRestore,
|
||||
@@ -22,7 +28,8 @@ import {
|
||||
const DELETE_CONFIRM_PREFIX = 'gbackup:confirm:delete:';
|
||||
const RESTORE_CONFIRM_PREFIX = 'gbackup:confirm:restore:';
|
||||
const CANCEL_PREFIX = 'gbackup:cancel:';
|
||||
const TTL_MS = 120_000;
|
||||
const NAMESPACE = 'guildbackup';
|
||||
const TTL_SECONDS = 120;
|
||||
|
||||
type PendingDelete = {
|
||||
action: 'delete';
|
||||
@@ -30,7 +37,6 @@ type PendingDelete = {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
locale: Locale;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type PendingRestore = {
|
||||
@@ -40,22 +46,10 @@ type PendingRestore = {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
locale: Locale;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type PendingEntry = PendingDelete | PendingRestore;
|
||||
|
||||
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 buildRows(token: string, locale: Locale, confirmPrefix: string): ActionRowBuilder<ButtonBuilder>[] {
|
||||
return [
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
@@ -73,19 +67,24 @@ function buildRows(token: string, locale: Locale, confirmPrefix: string): Action
|
||||
|
||||
export async function promptDeleteConfirmation(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext,
|
||||
locale: Locale,
|
||||
backupId: string
|
||||
): Promise<void> {
|
||||
pruneExpired();
|
||||
const token = randomUUID();
|
||||
pending.set(token, {
|
||||
action: 'delete',
|
||||
backupId,
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId!,
|
||||
locale,
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
await setConfirmation(
|
||||
context.redis,
|
||||
NAMESPACE,
|
||||
token,
|
||||
{
|
||||
action: 'delete',
|
||||
backupId,
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId!,
|
||||
locale
|
||||
} satisfies PendingDelete,
|
||||
TTL_SECONDS
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'guildbackup.confirm.delete', { id: backupId }),
|
||||
@@ -96,20 +95,25 @@ export async function promptDeleteConfirmation(
|
||||
|
||||
export async function promptRestoreConfirmation(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext,
|
||||
locale: Locale,
|
||||
backupId: string
|
||||
): Promise<void> {
|
||||
pruneExpired();
|
||||
const token = randomUUID();
|
||||
pending.set(token, {
|
||||
action: 'restore',
|
||||
step: 1,
|
||||
backupId,
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId!,
|
||||
locale,
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
await setConfirmation(
|
||||
context.redis,
|
||||
NAMESPACE,
|
||||
token,
|
||||
{
|
||||
action: 'restore',
|
||||
step: 1,
|
||||
backupId,
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId!,
|
||||
locale
|
||||
} satisfies PendingRestore,
|
||||
TTL_SECONDS
|
||||
);
|
||||
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'guildbackup.confirm.restore.step1', { id: backupId }),
|
||||
@@ -122,8 +126,6 @@ export async function handleGuildBackupButton(
|
||||
interaction: ButtonInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
pruneExpired();
|
||||
|
||||
const customId = interaction.customId;
|
||||
const isDeleteConfirm = customId.startsWith(DELETE_CONFIRM_PREFIX);
|
||||
const isRestoreConfirm = customId.startsWith(RESTORE_CONFIRM_PREFIX);
|
||||
@@ -141,48 +143,57 @@ export async function handleGuildBackupButton(
|
||||
: 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, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.user.id !== entry.userId) {
|
||||
if (interaction.user.id !== peeked.userId) {
|
||||
await interaction.reply({
|
||||
content: t(entry.locale, 'guildbackup.confirm.unauthorized'),
|
||||
content: t(peeked.locale, 'guildbackup.confirm.unauthorized'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCancel) {
|
||||
pending.delete(token);
|
||||
await deleteConfirmation(context.redis, NAMESPACE, token);
|
||||
await interaction.update({
|
||||
content: t(entry.locale, 'guildbackup.confirm.cancelled'),
|
||||
content: t(peeked.locale, 'guildbackup.confirm.cancelled'),
|
||||
components: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.action === 'delete') {
|
||||
pending.delete(token);
|
||||
if (peeked.action === 'delete') {
|
||||
const entry = await takeConfirmation<PendingDelete>(context.redis, NAMESPACE, token);
|
||||
if (!entry) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await executeDelete(interaction, context, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.step === 1) {
|
||||
entry.step = 2;
|
||||
entry.expiresAt = Date.now() + TTL_MS;
|
||||
pending.set(token, entry);
|
||||
if (peeked.step === 1) {
|
||||
const next: PendingRestore = { ...peeked, step: 2 };
|
||||
await setConfirmation(context.redis, NAMESPACE, token, next, TTL_SECONDS);
|
||||
await interaction.update({
|
||||
content: tf(entry.locale, 'guildbackup.confirm.restore.step2', { id: entry.backupId }),
|
||||
components: buildRows(token, entry.locale, RESTORE_CONFIRM_PREFIX)
|
||||
content: tf(peeked.locale, 'guildbackup.confirm.restore.step2', { id: peeked.backupId }),
|
||||
components: buildRows(token, peeked.locale, RESTORE_CONFIRM_PREFIX)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pending.delete(token);
|
||||
const entry = await takeConfirmation<PendingRestore>(context.redis, NAMESPACE, token);
|
||||
if (!entry) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
await interaction.reply({ content: t(locale, 'guildbackup.confirm.expired'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
await executeRestore(interaction, context, entry);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: []
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user