import { ChannelType, PermissionFlagsBits, SlashCommandBuilder, TextChannel, type ChatInputCommandInteraction, type GuildMember } from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js'; import { parseDuration } from './duration.js'; function reasonFrom(i: ChatInputCommandInteraction): string { return i.options.getString('reason') ?? t('de', 'moderation.reason.none'); } async function ensureMod(interaction: ChatInputCommandInteraction, permission: bigint): Promise { if (!interaction.inGuild()) { await interaction.reply({ content: 'Guild only command.', ephemeral: true }); return false; } return requirePermission(interaction, permission); } const banCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('ban') .setDescription('Ban a user') .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')) .addStringOption((opt) => opt.setName('duration').setDescription('Optional temp-ban duration (e.g. 7d)')), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction); await interaction.guild!.members.ban(user.id, { reason }); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'BAN', reason }); const duration = interaction.options.getString('duration'); if (duration) { await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason); } await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const unbanCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('unban') .setDescription('Unban a user') .addStringOption((opt) => opt.setName('user_id').setDescription('Discord user id').setRequired(true)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; const userId = interaction.options.getString('user_id', true); const reason = reasonFrom(interaction); await interaction.guild!.members.unban(userId, reason); await createCase(context, { guildId: interaction.guildId!, targetUserId: userId, moderatorId: interaction.user.id, action: 'UNBAN', reason }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const kickCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('kick') .setDescription('Kick a user') .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction); const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; await member.kick(reason); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'KICK', reason }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const timeoutCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('timeout') .setDescription('Timeout a user') .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addStringOption((opt) => opt.setName('duration').setDescription('Duration (e.g. 15m)').setRequired(true) ) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const user = interaction.options.getUser('user', true); const duration = parseDuration(interaction.options.getString('duration', true)); const reason = reasonFrom(interaction); const member = await interaction.guild!.members.fetch(user.id); await member.timeout(duration, reason); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'TIMEOUT', reason, metadata: { durationMs: duration } }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const untimeoutCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('untimeout') .setDescription('Remove timeout from a user') .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const user = interaction.options.getUser('user', true); const reason = reasonFrom(interaction); const member = await interaction.guild!.members.fetch(user.id); await member.timeout(null, reason); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'UN_TIMEOUT', reason }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const warnCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('warn') .setDescription('Manage user warnings') .addSubcommand((s) => s .setName('add') .setDescription('Add warning') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) .addStringOption((o) => o.setName('reason').setDescription('Reason').setRequired(true)) ) .addSubcommand((s) => s .setName('list') .setDescription('List warnings') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) ) .addSubcommand((s) => s .setName('remove') .setDescription('Remove warning') .addStringOption((o) => o.setName('warning_id').setDescription('Warning ID').setRequired(true)) ) .addSubcommand((s) => s .setName('clear') .setDescription('Clear warnings for user') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) ) .addSubcommand((s) => s .setName('escalation-set') .setDescription('Create or update escalation rule') .addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50)) .addStringOption((o) => o .setName('action') .setDescription('Escalation action') .setRequired(true) .addChoices( { name: 'timeout', value: 'TIMEOUT' }, { name: 'kick', value: 'KICK' }, { name: 'ban', value: 'BAN' } ) ) .addStringOption((o) => o.setName('duration').setDescription('Timeout duration (required for TIMEOUT)')) .addStringOption((o) => o.setName('reason').setDescription('Optional escalation reason')) ) .addSubcommand((s) => s.setName('escalation-list').setDescription('List escalation rules')) .addSubcommand((s) => s .setName('escalation-remove') .setDescription('Remove escalation rule') .addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50)) ), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) 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'); const durationMs = durationInput ? parseDuration(durationInput) : null; if (action === 'TIMEOUT' && !durationMs) { await interaction.reply({ content: 'Duration is required for TIMEOUT escalation.', 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 } }); await interaction.reply({ content: `Escalation rule saved: ${warnCount} warnings => ${action}${durationMs ? ` (${durationMs} ms)` : ''}.`, 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 ? 'No escalation rules configured.' : 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: `Escalation rule removed for ${warnCount} warnings.`, 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, action: 'WARN_ADD', reason }); await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } }); const warning = await context.prisma.warning.create({ data: { guildId: interaction.guildId!, userId: user.id, moderatorId: interaction.user.id, reason, caseId: modCase.id } }); const escalationResult = await applyWarnEscalation( interaction, context, user.id, interaction.user.id ); await interaction.reply({ content: escalationResult ? `Warning created: ${warning.id}\n${escalationResult}` : `Warning created: ${warning.id}`, 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 ? 'No warnings.' : warnings.map((w: { id: string; reason: string | null }) => `- ${w.id}: ${w.reason ?? 'No reason'}`).join('\n'); await interaction.reply({ content, ephemeral: true }); return; } if (sub === 'remove') { const warningId = interaction.options.getString('warning_id', true); const warning = await context.prisma.warning.delete({ where: { id: warningId } }); await createCase(context, { guildId: interaction.guildId!, targetUserId: warning.userId, moderatorId: interaction.user.id, action: 'WARN_REMOVE', reason: `Warning ${warningId} removed` }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); return; } const user = interaction.options.getUser('user', true); await context.prisma.warning.deleteMany({ where: { guildId: interaction.guildId!, userId: user.id } }); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'WARN_CLEAR', reason: 'Warnings cleared' }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const purgeCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('purge') .setDescription('Bulk delete messages') .addIntegerOption((o) => o.setName('amount').setDescription('2-100').setRequired(true).setMinValue(2).setMaxValue(100)), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages))) return; const amount = interaction.options.getInteger('amount', true); let deletedCount = 0; if (interaction.channel instanceof TextChannel) { const deleted = await interaction.channel.bulkDelete(amount, true); deletedCount = deleted.size; } await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, action: 'PURGE', reason: `Deleted ${deletedCount} messages` }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const slowmodeCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('slowmode') .setDescription('Set channel slowmode') .addIntegerOption((o) => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; const seconds = interaction.options.getInteger('seconds', true); if (interaction.channel instanceof TextChannel) { await interaction.channel.setRateLimitPerUser(seconds); } await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, action: 'SLOWMODE', reason: `Set slowmode to ${seconds}s` }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const lockCommand: SlashCommand = { data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; if (interaction.channel?.type === ChannelType.GuildText) { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { SendMessages: false }); } await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, action: 'LOCK', reason: 'Channel locked' }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const unlockCommand: SlashCommand = { data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; if (interaction.channel?.type === ChannelType.GuildText) { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { SendMessages: null }); } await createCase(context, { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, action: 'UNLOCK', reason: 'Channel unlocked' }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const nickCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('nick') .setDescription('Manage nicknames') .addSubcommand((s) => s .setName('set') .setDescription('Set nickname') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) .addStringOption((o) => o.setName('nickname').setDescription('New nickname').setRequired(true)) ) .addSubcommand((s) => s .setName('reset') .setDescription('Reset nickname') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) ), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames))) return; const sub = interaction.options.getSubcommand(); const user = interaction.options.getUser('user', true); const member = await interaction.guild!.members.fetch(user.id); if (sub === 'set') { const nickname = interaction.options.getString('nickname', true); await member.setNickname(nickname); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'NICK_SET', reason: nickname }); } else { await member.setNickname(null); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'NICK_RESET', reason: 'Nickname reset' }); } await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const caseCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('case') .setDescription('Manage moderation cases') .addSubcommand((s) => s.setName('view').setDescription('View case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) ) .addSubcommand((s) => s .setName('edit') .setDescription('Edit case reason') .addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) .addStringOption((o) => o.setName('reason').setDescription('New reason').setRequired(true)) ) .addSubcommand((s) => s.setName('delete').setDescription('Soft-delete case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) ), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; 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 } } }); await interaction.reply({ content: found ? JSON.stringify(found, null, 2) : 'Case not found.', ephemeral: true }); return; } if (sub === 'edit') { const reason = interaction.options.getString('reason', true); await context.prisma.case.update({ where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, data: { reason } }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); return; } await context.prisma.case.update({ where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, data: { deletedAt: new Date() } }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); } }; const modNoteCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('modnote') .setDescription('Manage mod notes') .addSubcommand((s) => s .setName('add') .setDescription('Add note') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) .addStringOption((o) => o.setName('note').setDescription('Note').setRequired(true)) ) .addSubcommand((s) => s .setName('list') .setDescription('List notes') .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) ), async execute(interaction, context) { if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) 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 } }); await createCase(context, { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, action: 'MODNOTE_ADD', reason: note }); await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); 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 ? 'No notes.' : 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 ];