import { ChannelType, PermissionFlagsBits, TextChannel, 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, createWarning, notifyModerationTargetDm } from './service.js'; import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; import { promptDestructiveConfirmation } from './confirmations.js'; import { banCommandData, caseCommandData, kickCommandData, lockCommandData, modNoteCommandData, nickCommandData, purgeCommandData, slowmodeCommandData, timeoutCommandData, unbanCommandData, unlockCommandData, untimeoutCommandData, warnCommandData } from './command-definitions.js'; function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string { return i.options.getString('reason') ?? t(locale, 'moderation.reason.none'); } function auditFrom(interaction: ChatInputCommandInteraction) { return { channelId: interaction.channelId ?? undefined, source: 'slash_command' as const }; } async function ensureMod( interaction: ChatInputCommandInteraction, permission: bigint, locale: 'de' | 'en' ): Promise { if (!interaction.inGuild()) { await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); return false; } return requirePermission(interaction, permission, locale, { botPermissions: permission }); } async function replySuccessCase( interaction: ChatInputCommandInteraction, locale: 'de' | 'en', caseNumber: number, reason?: string ): Promise { const content = reason ? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason }) : tf(locale, 'moderation.success.case', { caseNumber }); if (interaction.deferred || interaction.replied) { await interaction.editReply({ content }); return; } await interaction.reply({ content, ephemeral: true }); } const banCommand: SlashCommand = { data: banCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); const duration = interaction.options.getString('duration'); 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, reason, duration } }); } }; const unbanCommand: SlashCommand = { data: unbanCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return; const userId = interaction.options.getString('user_id', true); const reason = reasonFrom(interaction, locale); await interaction.guild!.members.unban(userId, reason); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: userId, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'UNBAN', reason }); await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; const kickCommand: SlashCommand = { data: kickCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick'); if (!hierarchy.ok || !hierarchy.member) return; await notifyModerationTargetDm({ user, guildName: interaction.guild!.name, reason, locale, action: 'kick' }); await hierarchy.member.kick(reason); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'KICK', reason }); await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; const timeoutCommand: SlashCommand = { data: timeoutCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', 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 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, ...auditFrom(interaction), action: 'TIMEOUT', reason, metadata: { durationMs: duration } }); await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; const untimeoutCommand: SlashCommand = { data: untimeoutCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction, locale); 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, ...auditFrom(interaction), action: 'UN_TIMEOUT', reason }); await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; const warnCommand: SlashCommand = { data: warnCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const sub = interaction.options.getSubcommand(); if (sub === 'escalation-set') { const warnCount = interaction.options.getInteger('count', true); const action = interaction.options.getString('action', true); const durationInput = interaction.options.getString('duration'); const reason = interaction.options.getString('reason'); 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'), ephemeral: true }); return; } await context.prisma.escalationRule.upsert({ where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } }, update: { action, durationMs, reason, enabled: true }, create: { guildId: interaction.guildId!, warnCount, action, durationMs, reason, enabled: true } }); const durationText = durationMs ? ` (${durationMs} ms)` : ''; await interaction.reply({ content: tf(locale, 'moderation.escalation.saved', { warnCount, action, duration: durationText }), ephemeral: true }); return; } if (sub === 'escalation-list') { const rules = await context.prisma.escalationRule.findMany({ where: { guildId: interaction.guildId!, enabled: true }, orderBy: { warnCount: 'asc' } }); const content = rules.length === 0 ? t(locale, 'moderation.escalation.noneConfigured') : rules .map( (rule) => `- ${rule.warnCount} warnings => ${rule.action}${rule.durationMs ? ` (${rule.durationMs} ms)` : ''}${rule.reason ? ` | ${rule.reason}` : ''}` ) .join('\n'); await interaction.reply({ content, ephemeral: true }); return; } if (sub === 'escalation-remove') { const warnCount = interaction.options.getInteger('count', true); await context.prisma.escalationRule.delete({ where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } } }); await interaction.reply({ content: tf(locale, 'moderation.escalation.removed', { warnCount }), ephemeral: true }); return; } if (sub === 'add') { const user = interaction.options.getUser('user', true); const reason = interaction.options.getString('reason', true); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'WARN_ADD', reason }); const { warning } = await createWarning(context, { guildId: interaction.guildId!, userId: user.id, moderatorId: interaction.user.id, reason, caseId: modCase.id, guildName: interaction.guild!.name, locale, notifyUser: user }); const escalationResult = await applyWarnEscalation( interaction, context, user.id, interaction.user.id, locale ); await interaction.reply({ content: escalationResult ? `${tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber })}\n${escalationResult}` : tf(locale, 'moderation.warning.created', { warningNumber: warning.warningNumber }), ephemeral: true }); return; } if (sub === 'list') { const user = interaction.options.getUser('user', true); const warnings = await context.prisma.warning.findMany({ where: { guildId: interaction.guildId!, userId: user.id }, orderBy: { createdAt: 'desc' }, take: 20 }); const content = warnings.length === 0 ? t(locale, 'moderation.warning.none') : warnings .map( (w: { warningNumber: number; reason: string | null }) => `- #${w.warningNumber}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}` ) .join('\n'); await interaction.reply({ content, ephemeral: true }); return; } if (sub === 'remove') { const warningNumber = interaction.options.getInteger('id', true); const warning = await context.prisma.warning.findFirst({ where: { warningNumber, 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, ...auditFrom(interaction), action: 'WARN_REMOVE', reason: `Warning #${warningNumber} removed` }); 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 } }); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'WARN_CLEAR', reason: 'Warnings cleared' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } }; const purgeCommand: SlashCommand = { data: purgeCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages, locale))) return; const amount = interaction.options.getInteger('amount', true); const targetUser = interaction.options.getUser('user'); const botsOnly = interaction.options.getBoolean('bots') ?? false; const linksOnly = interaction.options.getBoolean('links') ?? false; const attachmentsOnly = interaction.options.getBoolean('attachments') ?? false; const regexInput = interaction.options.getString('regex'); if (regexInput) { try { new RegExp(regexInput, 'i'); } catch { await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true }); return; } } if (!(interaction.channel instanceof TextChannel)) { await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); return; } await promptDestructiveConfirmation(interaction, context, locale, { action: 'purge', payload: { channelId: interaction.channel.id, amount, targetUserId: targetUser?.id ?? null, botsOnly, linksOnly, attachmentsOnly, regex: regexInput } }); } }; const slowmodeCommand: SlashCommand = { data: slowmodeCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; const seconds = interaction.options.getInteger('seconds', true); if (interaction.channel instanceof TextChannel) { await interaction.channel.setRateLimitPerUser(seconds); } const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'SLOWMODE', reason: `Set slowmode to ${seconds}s` }); await replySuccessCase(interaction, locale, modCase.caseNumber); } }; const lockCommand: SlashCommand = { data: lockCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; const serverWide = interaction.options.getBoolean('server') ?? false; const guild = interaction.guild!; if (serverWide) { await interaction.deferReply({ ephemeral: true }); for (const channel of guild.channels.cache.values()) { if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { continue; } await channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); } } else if (interaction.channel?.type === ChannelType.GuildText) { await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: false }); } const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'LOCK', reason: serverWide ? 'Server locked' : 'Channel locked' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } }; const unlockCommand: SlashCommand = { data: unlockCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return; const serverWide = interaction.options.getBoolean('server') ?? false; const guild = interaction.guild!; if (serverWide) { await interaction.deferReply({ ephemeral: true }); for (const channel of guild.channels.cache.values()) { if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { continue; } await channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); } } else if (interaction.channel?.type === ChannelType.GuildText) { await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, { SendMessages: null }); } const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'UNLOCK', reason: serverWide ? 'Server unlocked' : 'Channel unlocked' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } }; const nickCommand: SlashCommand = { data: nickCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return; const sub = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); 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); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'NICK_SET', reason: nickname }); await replySuccessCase(interaction, locale, modCase.caseNumber); } else { await member.setNickname(null); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'NICK_RESET', reason: 'Nickname reset' }); await replySuccessCase(interaction, locale, modCase.caseNumber); } } }; const caseCommand: SlashCommand = { data: caseCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const sub = interaction.options.getSubcommand(); const caseNumber = interaction.options.getInteger('id', true); if (sub === 'view') { 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'), ephemeral: true }); return; } 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: { id: existing.id }, data: { reason } }); await interaction.reply({ content: tf(locale, 'moderation.success.case', { caseNumber }), ephemeral: true }); return; } await promptDestructiveConfirmation(interaction, context, locale, { action: 'case_delete', payload: { caseNumber } }); } }; const modNoteCommand: SlashCommand = { data: modNoteCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return; const sub = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } }); if (sub === 'add') { const note = interaction.options.getString('note', true); await context.prisma.modNote.create({ data: { guildId: interaction.guildId!, userId: user.id, moderatorId: interaction.user.id, note } }); const modCase = await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, ...auditFrom(interaction), action: 'MODNOTE_ADD', reason: note }); await replySuccessCase(interaction, locale, modCase.caseNumber); return; } const notes = await context.prisma.modNote.findMany({ where: { guildId: interaction.guildId!, userId: user.id }, orderBy: { createdAt: 'desc' }, take: 20 }); const content = notes.length === 0 ? t(locale, 'moderation.note.none') : notes.map((n: { note: string }) => `- ${n.note}`).join('\n'); await interaction.reply({ content, ephemeral: true }); } }; export const moderationCommands: SlashCommand[] = [ banCommand, unbanCommand, kickCommand, timeoutCommand, untimeoutCommand, warnCommand, purgeCommand, slowmodeCommand, lockCommand, unlockCommand, nickCommand, caseCommand, modNoteCommand ];