Enhance moderation commands with locale support and permission checks

- Updated moderation commands to utilize locale for user-facing messages, improving internationalization.
- Refactored permission checks to include locale as a parameter, ensuring accurate responses based on the server's language settings.
- Added new translation keys for bot permissions and command restrictions in both German and English.
This commit is contained in:
smueller
2026-07-22 11:23:10 +02:00
parent 39a47074ae
commit a17d161c06
5 changed files with 153 additions and 56 deletions

18
apps/bot/src/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import { LocaleSchema, type Locale } from '@nexumi/shared';
import type { PrismaClient } from '@prisma/client';
import { env } from './env.js';
export async function getGuildLocale(
prisma: PrismaClient,
guildId: string | null
): Promise<Locale> {
if (!guildId) {
return env.DEFAULT_LOCALE;
}
const settings = await prisma.guildSettings.findUnique({
where: { guildId },
select: { locale: true }
});
const parsed = LocaleSchema.safeParse(settings?.locale);
return parsed.success ? parsed.data : env.DEFAULT_LOCALE;
}

View File

@@ -6,22 +6,27 @@ import {
type ChatInputCommandInteraction, type ChatInputCommandInteraction,
type GuildMember type GuildMember
} from 'discord.js'; } from 'discord.js';
import { t } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js'; import { applyWarnEscalation, createCase, scheduleTempBanExpire } from './service.js';
import { parseDuration } from './duration.js'; import { parseDuration } from './duration.js';
import { getGuildLocale } from '../../i18n.js';
function reasonFrom(i: ChatInputCommandInteraction): string { function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string {
return i.options.getString('reason') ?? t('de', 'moderation.reason.none'); return i.options.getString('reason') ?? t(locale, 'moderation.reason.none');
} }
async function ensureMod(interaction: ChatInputCommandInteraction, permission: bigint): Promise<boolean> { async function ensureMod(
interaction: ChatInputCommandInteraction,
permission: bigint,
locale: 'de' | 'en'
): Promise<boolean> {
if (!interaction.inGuild()) { if (!interaction.inGuild()) {
await interaction.reply({ content: 'Guild only command.', ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return false; return false;
} }
return requirePermission(interaction, permission); return requirePermission(interaction, permission, locale);
} }
const banCommand: SlashCommand = { const banCommand: SlashCommand = {
@@ -32,9 +37,10 @@ const banCommand: SlashCommand = {
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')) .addStringOption((opt) => opt.setName('reason').setDescription('Reason'))
.addStringOption((opt) => opt.setName('duration').setDescription('Optional temp-ban duration (e.g. 7d)')), .addStringOption((opt) => opt.setName('duration').setDescription('Optional temp-ban duration (e.g. 7d)')),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction); const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.ban(user.id, { reason }); await interaction.guild!.members.ban(user.id, { reason });
await createCase(context, { await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
@@ -47,7 +53,7 @@ const banCommand: SlashCommand = {
if (duration) { if (duration) {
await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason); await scheduleTempBanExpire(interaction.guildId!, user.id, parseDuration(duration), reason);
} }
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -58,9 +64,10 @@ const unbanCommand: SlashCommand = {
.addStringOption((opt) => opt.setName('user_id').setDescription('Discord user id').setRequired(true)) .addStringOption((opt) => opt.setName('user_id').setDescription('Discord user id').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')), .addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.BanMembers))) return; 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 userId = interaction.options.getString('user_id', true);
const reason = reasonFrom(interaction); const reason = reasonFrom(interaction, locale);
await interaction.guild!.members.unban(userId, reason); await interaction.guild!.members.unban(userId, reason);
await createCase(context, { await createCase(context, {
guildId: interaction.guildId!, guildId: interaction.guildId!,
@@ -69,7 +76,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN', action: 'UNBAN',
reason reason
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -80,9 +87,10 @@ const kickCommand: SlashCommand = {
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')), .addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.KickMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction); const reason = reasonFrom(interaction, locale);
const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember;
await member.kick(reason); await member.kick(reason);
await createCase(context, { await createCase(context, {
@@ -92,7 +100,7 @@ const kickCommand: SlashCommand = {
action: 'KICK', action: 'KICK',
reason reason
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -106,10 +114,11 @@ const timeoutCommand: SlashCommand = {
) )
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')), .addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const duration = parseDuration(interaction.options.getString('duration', true)); const duration = parseDuration(interaction.options.getString('duration', true));
const reason = reasonFrom(interaction); const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id); const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(duration, reason); await member.timeout(duration, reason);
await createCase(context, { await createCase(context, {
@@ -120,7 +129,7 @@ const timeoutCommand: SlashCommand = {
reason, reason,
metadata: { durationMs: duration } metadata: { durationMs: duration }
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -131,9 +140,10 @@ const untimeoutCommand: SlashCommand = {
.addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true))
.addStringOption((opt) => opt.setName('reason').setDescription('Reason')), .addStringOption((opt) => opt.setName('reason').setDescription('Reason')),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = reasonFrom(interaction); const reason = reasonFrom(interaction, locale);
const member = await interaction.guild!.members.fetch(user.id); const member = await interaction.guild!.members.fetch(user.id);
await member.timeout(null, reason); await member.timeout(null, reason);
await createCase(context, { await createCase(context, {
@@ -143,7 +153,7 @@ const untimeoutCommand: SlashCommand = {
action: 'UN_TIMEOUT', action: 'UN_TIMEOUT',
reason reason
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -203,7 +213,8 @@ const warnCommand: SlashCommand = {
.addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50)) .addIntegerOption((o) => o.setName('count').setDescription('Warning count threshold').setRequired(true).setMinValue(1).setMaxValue(50))
), ),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
if (sub === 'escalation-set') { if (sub === 'escalation-set') {
const warnCount = interaction.options.getInteger('count', true); const warnCount = interaction.options.getInteger('count', true);
@@ -212,7 +223,10 @@ const warnCommand: SlashCommand = {
const reason = interaction.options.getString('reason'); const reason = interaction.options.getString('reason');
const durationMs = durationInput ? parseDuration(durationInput) : null; const durationMs = durationInput ? parseDuration(durationInput) : null;
if (action === 'TIMEOUT' && !durationMs) { if (action === 'TIMEOUT' && !durationMs) {
await interaction.reply({ content: 'Duration is required for TIMEOUT escalation.', ephemeral: true }); await interaction.reply({
content: t(locale, 'moderation.escalation.durationRequired'),
ephemeral: true
});
return; return;
} }
await context.prisma.escalationRule.upsert({ await context.prisma.escalationRule.upsert({
@@ -237,8 +251,13 @@ const warnCommand: SlashCommand = {
enabled: true enabled: true
} }
}); });
const durationText = durationMs ? ` (${durationMs} ms)` : '';
await interaction.reply({ await interaction.reply({
content: `Escalation rule saved: ${warnCount} warnings => ${action}${durationMs ? ` (${durationMs} ms)` : ''}.`, content: tf(locale, 'moderation.escalation.saved', {
warnCount,
action,
duration: durationText
}),
ephemeral: true ephemeral: true
}); });
return; return;
@@ -251,7 +270,7 @@ const warnCommand: SlashCommand = {
}); });
const content = const content =
rules.length === 0 rules.length === 0
? 'No escalation rules configured.' ? t(locale, 'moderation.escalation.noneConfigured')
: rules : rules
.map( .map(
(rule) => (rule) =>
@@ -267,7 +286,10 @@ const warnCommand: SlashCommand = {
await context.prisma.escalationRule.delete({ await context.prisma.escalationRule.delete({
where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } } where: { guildId_warnCount: { guildId: interaction.guildId!, warnCount } }
}); });
await interaction.reply({ content: `Escalation rule removed for ${warnCount} warnings.`, ephemeral: true }); await interaction.reply({
content: tf(locale, 'moderation.escalation.removed', { warnCount }),
ephemeral: true
});
return; return;
} }
@@ -303,8 +325,8 @@ const warnCommand: SlashCommand = {
); );
await interaction.reply({ await interaction.reply({
content: escalationResult content: escalationResult
? `Warning created: ${warning.id}\n${escalationResult}` ? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}`
: `Warning created: ${warning.id}`, : tf(locale, 'moderation.warning.created', { warningId: warning.id }),
ephemeral: true ephemeral: true
}); });
return; return;
@@ -319,8 +341,13 @@ const warnCommand: SlashCommand = {
}); });
const content = const content =
warnings.length === 0 warnings.length === 0
? 'No warnings.' ? t(locale, 'moderation.warning.none')
: warnings.map((w: { id: string; reason: string | null }) => `- ${w.id}: ${w.reason ?? 'No reason'}`).join('\n'); : warnings
.map(
(w: { id: string; reason: string | null }) =>
`- ${w.id}: ${w.reason ?? t(locale, 'moderation.warning.defaultReason')}`
)
.join('\n');
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ephemeral: true });
return; return;
} }
@@ -335,7 +362,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_REMOVE', action: 'WARN_REMOVE',
reason: `Warning ${warningId} removed` reason: `Warning ${warningId} removed`
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
return; return;
} }
@@ -348,7 +375,7 @@ const warnCommand: SlashCommand = {
action: 'WARN_CLEAR', action: 'WARN_CLEAR',
reason: 'Warnings cleared' reason: 'Warnings cleared'
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -369,7 +396,8 @@ const purgeCommand: SlashCommand = {
o.setName('regex').setDescription('Only purge messages matching regex') o.setName('regex').setDescription('Only purge messages matching regex')
), ),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageMessages, locale))) return;
const amount = interaction.options.getInteger('amount', true); const amount = interaction.options.getInteger('amount', true);
const targetUser = interaction.options.getUser('user'); const targetUser = interaction.options.getUser('user');
const botsOnly = interaction.options.getBoolean('bots') ?? false; const botsOnly = interaction.options.getBoolean('bots') ?? false;
@@ -381,7 +409,7 @@ const purgeCommand: SlashCommand = {
try { try {
regex = new RegExp(regexInput, 'i'); regex = new RegExp(regexInput, 'i');
} catch { } catch {
await interaction.reply({ content: 'Invalid regex pattern.', ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true });
return; return;
} }
} }
@@ -408,7 +436,7 @@ const purgeCommand: SlashCommand = {
action: 'PURGE', action: 'PURGE',
reason: `Deleted ${deletedCount} messages` reason: `Deleted ${deletedCount} messages`
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -418,7 +446,8 @@ const slowmodeCommand: SlashCommand = {
.setDescription('Set channel slowmode') .setDescription('Set channel slowmode')
.addIntegerOption((o) => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)), .addIntegerOption((o) => o.setName('seconds').setDescription('0-21600').setRequired(true).setMinValue(0).setMaxValue(21600)),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
const seconds = interaction.options.getInteger('seconds', true); const seconds = interaction.options.getInteger('seconds', true);
if (interaction.channel instanceof TextChannel) { if (interaction.channel instanceof TextChannel) {
await interaction.channel.setRateLimitPerUser(seconds); await interaction.channel.setRateLimitPerUser(seconds);
@@ -430,14 +459,15 @@ const slowmodeCommand: SlashCommand = {
action: 'SLOWMODE', action: 'SLOWMODE',
reason: `Set slowmode to ${seconds}s` reason: `Set slowmode to ${seconds}s`
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
const lockCommand: SlashCommand = { const lockCommand: SlashCommand = {
data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'), data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) { if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
SendMessages: false SendMessages: false
@@ -450,14 +480,15 @@ const lockCommand: SlashCommand = {
action: 'LOCK', action: 'LOCK',
reason: 'Channel locked' reason: 'Channel locked'
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
const unlockCommand: SlashCommand = { const unlockCommand: SlashCommand = {
data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'), data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) { if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
SendMessages: null SendMessages: null
@@ -470,7 +501,7 @@ const unlockCommand: SlashCommand = {
action: 'UNLOCK', action: 'UNLOCK',
reason: 'Channel unlocked' reason: 'Channel unlocked'
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -492,7 +523,8 @@ const nickCommand: SlashCommand = {
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
), ),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageNicknames, locale))) return;
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const member = await interaction.guild!.members.fetch(user.id); const member = await interaction.guild!.members.fetch(user.id);
@@ -516,7 +548,7 @@ const nickCommand: SlashCommand = {
reason: 'Nickname reset' reason: 'Nickname reset'
}); });
} }
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -538,14 +570,18 @@ const caseCommand: SlashCommand = {
s.setName('delete').setDescription('Soft-delete case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true)) s.setName('delete').setDescription('Soft-delete case').addIntegerOption((o) => o.setName('id').setDescription('Case number').setRequired(true))
), ),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const caseNumber = interaction.options.getInteger('id', true); const caseNumber = interaction.options.getInteger('id', true);
if (sub === 'view') { if (sub === 'view') {
const found = await context.prisma.case.findUnique({ const found = await context.prisma.case.findUnique({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } } where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }
}); });
await interaction.reply({ content: found ? JSON.stringify(found, null, 2) : 'Case not found.', ephemeral: true }); await interaction.reply({
content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'),
ephemeral: true
});
return; return;
} }
if (sub === 'edit') { if (sub === 'edit') {
@@ -554,14 +590,14 @@ const caseCommand: SlashCommand = {
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
data: { reason } data: { reason }
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
return; return;
} }
await context.prisma.case.update({ await context.prisma.case.update({
where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } },
data: { deletedAt: new Date() } data: { deletedAt: new Date() }
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
} }
}; };
@@ -583,7 +619,8 @@ const modNoteCommand: SlashCommand = {
.addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true)) .addUserOption((o) => o.setName('user').setDescription('Target').setRequired(true))
), ),
async execute(interaction, context) { async execute(interaction, context) {
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers))) return; const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ModerateMembers, locale))) return;
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } }); await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } });
@@ -604,7 +641,7 @@ const modNoteCommand: SlashCommand = {
action: 'MODNOTE_ADD', action: 'MODNOTE_ADD',
reason: note reason: note
}); });
await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true });
return; return;
} }
const notes = await context.prisma.modNote.findMany({ const notes = await context.prisma.modNote.findMany({
@@ -614,7 +651,7 @@ const modNoteCommand: SlashCommand = {
}); });
const content = const content =
notes.length === 0 notes.length === 0
? 'No notes.' ? t(locale, 'moderation.note.none')
: notes.map((n: { note: string }) => `- ${n.note}`).join('\n'); : notes.map((n: { note: string }) => `- ${n.note}`).join('\n');
await interaction.reply({ content, ephemeral: true }); await interaction.reply({ content, ephemeral: true });
} }

View File

@@ -1,11 +1,12 @@
import { type ChatInputCommandInteraction } from 'discord.js'; import { type ChatInputCommandInteraction } from 'discord.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
export async function requirePermission( export async function requirePermission(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
permission: bigint permission: bigint,
locale: Locale
): Promise<boolean> { ): Promise<boolean> {
const locale = 'de';
const member = interaction.memberPermissions; const member = interaction.memberPermissions;
if (!member?.has(permission)) { if (!member?.has(permission)) {
await interaction.reply({ await interaction.reply({
@@ -17,7 +18,10 @@ export async function requirePermission(
if (!interaction.guild?.members.me?.permissions.has(permission)) { if (!interaction.guild?.members.me?.permissions.has(permission)) {
await interaction.reply({ await interaction.reply({
content: `Bot lacks required permission (${permission.toString()}).`, content: t(locale, 'generic.botMissingPermission').replace(
'{permission}',
permission.toString()
),
ephemeral: true ephemeral: true
}); });
return false; return false;

View File

@@ -39,6 +39,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
- Command-Routing - Command-Routing
- zentraler Permission-Check - zentraler Permission-Check
- Basis-i18n in `@nexumi/shared` - Basis-i18n in `@nexumi/shared`
- Guild-Locale-Auflösung über DB-Settings mit Fallback auf Env-Default
- Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold - Health-Endpoint (`/health`) und token-geschütztes `/metrics`-Scaffold
- BullMQ-Integration: - BullMQ-Integration:
- Moderation-Queue - Moderation-Queue
@@ -54,6 +55,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
- `/nick set|reset` - `/nick set|reset`
- `/case view|edit|delete` - `/case view|edit|delete`
- `/modnote add|list` - `/modnote add|list`
- user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`)
- Tests (aktuell vorhanden): - Tests (aktuell vorhanden):
- `packages/shared/src/i18n.test.ts` - `packages/shared/src/i18n.test.ts`
- `apps/bot/src/modules/moderation/duration.test.ts` - `apps/bot/src/modules/moderation/duration.test.ts`
@@ -61,7 +63,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede
### Noch offen für Phase 1 ### Noch offen für Phase 1
- Vollständige i18n-Auslagerung: - Vollständige i18n-Auslagerung:
- Alle user-facing Moderations-Strings in Locale-Dateien verschieben (de/en parallel). - Restarbeiten: Command-Beschreibungen/-Optionstexte ebenfalls in zentralen Locale-Strukturen führen.
- Slash-Command-Bestätigungen für destruktive Aktionen gemäß SPEC-Regel: - Slash-Command-Bestätigungen für destruktive Aktionen gemäß SPEC-Regel:
- Ban, Purge und weitere destruktive Flows sollen explizite Bestätigungsschritte erhalten. - Ban, Purge und weitere destruktive Flows sollen explizite Bestätigungsschritte erhalten.
- Audit-/Case-System weiter härten: - Audit-/Case-System weiter härten:

View File

@@ -34,13 +34,41 @@ export type ModerationAction = z.infer<typeof ModerationActionSchema>;
type Dictionary = Record<string, string>; type Dictionary = Record<string, string>;
const de: Dictionary = { const de: Dictionary = {
'generic.noPermission': 'Du hast keine Berechtigung für diese Aktion.', 'generic.noPermission': 'Du hast keine Berechtigung für diese Aktion.',
'generic.guildOnly': 'Dieser Command kann nur auf einem Server verwendet werden.',
'generic.botMissingPermission': 'Dem Bot fehlt die erforderliche Berechtigung ({permission}).',
'generic.invalidRegex': 'Ungültiges Regex-Muster.',
'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.reason.none': 'Kein Grund angegeben' 'moderation.reason.none': 'Kein Grund angegeben',
'moderation.warning.created': 'Verwarnung erstellt: {warningId}',
'moderation.warning.none': 'Keine Verwarnungen vorhanden.',
'moderation.warning.defaultReason': 'Kein Grund',
'moderation.note.none': 'Keine Notizen vorhanden.',
'moderation.case.notFound': 'Fall nicht gefunden.',
'moderation.escalation.durationRequired':
'Für TIMEOUT-Eskalation ist eine Dauer erforderlich.',
'moderation.escalation.noneConfigured': 'Keine Eskalationsregeln konfiguriert.',
'moderation.escalation.saved':
'Eskalationsregel gespeichert: {warnCount} Verwarnungen => {action}{duration}.',
'moderation.escalation.removed':
'Eskalationsregel für {warnCount} Verwarnungen entfernt.'
}; };
const en: Dictionary = { const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.', 'generic.noPermission': 'You do not have permission for this action.',
'generic.guildOnly': 'This command can only be used in a server.',
'generic.botMissingPermission': 'Bot lacks required permission ({permission}).',
'generic.invalidRegex': 'Invalid regex pattern.',
'moderation.success': 'Action executed successfully.', 'moderation.success': 'Action executed successfully.',
'moderation.reason.none': 'No reason provided' 'moderation.reason.none': 'No reason provided',
'moderation.warning.created': 'Warning created: {warningId}',
'moderation.warning.none': 'No warnings.',
'moderation.warning.defaultReason': 'No reason',
'moderation.note.none': 'No notes.',
'moderation.case.notFound': 'Case not found.',
'moderation.escalation.durationRequired': 'Duration is required for TIMEOUT escalation.',
'moderation.escalation.noneConfigured': 'No escalation rules configured.',
'moderation.escalation.saved':
'Escalation rule saved: {warnCount} warnings => {action}{duration}.',
'moderation.escalation.removed': 'Escalation rule removed for {warnCount} warnings.'
}; };
const locales: Record<Locale, Dictionary> = { de, en }; const locales: Record<Locale, Dictionary> = { de, en };
@@ -48,3 +76,11 @@ const locales: Record<Locale, Dictionary> = { de, en };
export function t(locale: Locale, key: string): string { export function t(locale: Locale, key: string): string {
return locales[locale][key] ?? key; return locales[locale][key] ?? key;
} }
export function tf(locale: Locale, key: string, vars: Record<string, string | number>): string {
const base = t(locale, key);
return Object.entries(vars).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
base
);
}