Files
Nexumi/apps/bot/src/modules/moderation/commands.ts
smueller dd3b132f73 Add backup functionality with escalation rules for moderation commands
- Updated .env.example to include backup configuration options.
- Modified docker-compose.yml to mount backup volume.
- Enhanced Dockerfile to install PostgreSQL client.
- Added EscalationRule model to Prisma schema for managing warning escalation.
- Updated environment validation in env.ts to include backup settings.
- Refactored job handling in jobs.ts to implement backup jobs and cleanup.
- Introduced escalation commands in moderation service for managing user warnings.
- Updated moderation commands to apply escalation rules based on warning count.
2026-07-22 11:16:39 +02:00

602 lines
22 KiB
TypeScript

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<boolean> {
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
];