diff --git a/apps/bot/src/i18n.ts b/apps/bot/src/i18n.ts new file mode 100644 index 0000000..970815d --- /dev/null +++ b/apps/bot/src/i18n.ts @@ -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 { + 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; +} diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index b56e8de..29db9c8 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -6,22 +6,27 @@ import { type ChatInputCommandInteraction, type GuildMember } from 'discord.js'; -import { t } from '@nexumi/shared'; +import { t, tf } 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'; +import { getGuildLocale } from '../../i18n.js'; -function reasonFrom(i: ChatInputCommandInteraction): string { - return i.options.getString('reason') ?? t('de', 'moderation.reason.none'); +function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string { + return i.options.getString('reason') ?? t(locale, 'moderation.reason.none'); } -async function ensureMod(interaction: ChatInputCommandInteraction, permission: bigint): Promise { +async function ensureMod( + interaction: ChatInputCommandInteraction, + permission: bigint, + locale: 'de' | 'en' +): Promise { 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 requirePermission(interaction, permission); + return requirePermission(interaction, permission, locale); } const banCommand: SlashCommand = { @@ -32,9 +37,10 @@ const banCommand: SlashCommand = { .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 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); + const reason = reasonFrom(interaction, locale); await interaction.guild!.members.ban(user.id, { reason }); await createCase(context, { guildId: interaction.guildId!, @@ -47,7 +53,7 @@ const banCommand: SlashCommand = { if (duration) { 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('reason').setDescription('Reason')), 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 reason = reasonFrom(interaction); + const reason = reasonFrom(interaction, locale); await interaction.guild!.members.unban(userId, reason); await createCase(context, { guildId: interaction.guildId!, @@ -69,7 +76,7 @@ const unbanCommand: SlashCommand = { action: 'UNBAN', 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)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), 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 reason = reasonFrom(interaction); + const reason = reasonFrom(interaction, locale); const member = (await interaction.guild!.members.fetch(user.id)) as GuildMember; await member.kick(reason); await createCase(context, { @@ -92,7 +100,7 @@ const kickCommand: SlashCommand = { action: 'KICK', 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')), 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 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); await member.timeout(duration, reason); await createCase(context, { @@ -120,7 +129,7 @@ const timeoutCommand: SlashCommand = { reason, 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)) .addStringOption((opt) => opt.setName('reason').setDescription('Reason')), 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 reason = reasonFrom(interaction); + const reason = reasonFrom(interaction, locale); const member = await interaction.guild!.members.fetch(user.id); await member.timeout(null, reason); await createCase(context, { @@ -143,7 +153,7 @@ const untimeoutCommand: SlashCommand = { action: 'UN_TIMEOUT', 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)) ), 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(); if (sub === 'escalation-set') { const warnCount = interaction.options.getInteger('count', true); @@ -212,7 +223,10 @@ const warnCommand: SlashCommand = { 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 }); + await interaction.reply({ + content: t(locale, 'moderation.escalation.durationRequired'), + ephemeral: true + }); return; } await context.prisma.escalationRule.upsert({ @@ -237,8 +251,13 @@ const warnCommand: SlashCommand = { enabled: true } }); + const durationText = durationMs ? ` (${durationMs} ms)` : ''; 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 }); return; @@ -251,7 +270,7 @@ const warnCommand: SlashCommand = { }); const content = rules.length === 0 - ? 'No escalation rules configured.' + ? t(locale, 'moderation.escalation.noneConfigured') : rules .map( (rule) => @@ -267,7 +286,10 @@ const warnCommand: SlashCommand = { await context.prisma.escalationRule.delete({ 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; } @@ -303,8 +325,8 @@ const warnCommand: SlashCommand = { ); await interaction.reply({ content: escalationResult - ? `Warning created: ${warning.id}\n${escalationResult}` - : `Warning created: ${warning.id}`, + ? `${tf(locale, 'moderation.warning.created', { warningId: warning.id })}\n${escalationResult}` + : tf(locale, 'moderation.warning.created', { warningId: warning.id }), ephemeral: true }); return; @@ -319,8 +341,13 @@ const warnCommand: SlashCommand = { }); const content = warnings.length === 0 - ? 'No warnings.' - : warnings.map((w: { id: string; reason: string | null }) => `- ${w.id}: ${w.reason ?? 'No reason'}`).join('\n'); + ? t(locale, 'moderation.warning.none') + : 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 }); return; } @@ -335,7 +362,7 @@ const warnCommand: SlashCommand = { action: 'WARN_REMOVE', 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; } @@ -348,7 +375,7 @@ const warnCommand: SlashCommand = { action: 'WARN_CLEAR', 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') ), 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 targetUser = interaction.options.getUser('user'); const botsOnly = interaction.options.getBoolean('bots') ?? false; @@ -381,7 +409,7 @@ const purgeCommand: SlashCommand = { try { regex = new RegExp(regexInput, 'i'); } catch { - await interaction.reply({ content: 'Invalid regex pattern.', ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.invalidRegex'), ephemeral: true }); return; } } @@ -408,7 +436,7 @@ const purgeCommand: SlashCommand = { action: 'PURGE', 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') .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 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); @@ -430,14 +459,15 @@ const slowmodeCommand: SlashCommand = { action: 'SLOWMODE', 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 = { data: new SlashCommandBuilder().setName('lock').setDescription('Lock current text channel'), 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) { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { SendMessages: false @@ -450,14 +480,15 @@ const lockCommand: SlashCommand = { action: 'LOCK', 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 = { data: new SlashCommandBuilder().setName('unlock').setDescription('Unlock current text channel'), 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) { await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, { SendMessages: null @@ -470,7 +501,7 @@ const unlockCommand: SlashCommand = { action: 'UNLOCK', 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)) ), 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 user = interaction.options.getUser('user', true); const member = await interaction.guild!.members.fetch(user.id); @@ -516,7 +548,7 @@ const nickCommand: SlashCommand = { 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)) ), 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 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 }); + await interaction.reply({ + content: found ? JSON.stringify(found, null, 2) : t(locale, 'moderation.case.notFound'), + ephemeral: true + }); return; } if (sub === 'edit') { @@ -554,14 +590,14 @@ const caseCommand: SlashCommand = { where: { guildId_caseNumber: { guildId: interaction.guildId!, caseNumber } }, data: { reason } }); - await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + await interaction.reply({ content: t(locale, '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 }); + 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)) ), 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 user = interaction.options.getUser('user', true); await context.prisma.user.upsert({ where: { id: user.id }, update: {}, create: { id: user.id } }); @@ -604,7 +641,7 @@ const modNoteCommand: SlashCommand = { action: 'MODNOTE_ADD', reason: note }); - await interaction.reply({ content: t('de', 'moderation.success'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'moderation.success'), ephemeral: true }); return; } const notes = await context.prisma.modNote.findMany({ @@ -614,7 +651,7 @@ const modNoteCommand: SlashCommand = { }); const content = notes.length === 0 - ? 'No notes.' + ? t(locale, 'moderation.note.none') : notes.map((n: { note: string }) => `- ${n.note}`).join('\n'); await interaction.reply({ content, ephemeral: true }); } diff --git a/apps/bot/src/permissions.ts b/apps/bot/src/permissions.ts index 75e4b46..1773960 100644 --- a/apps/bot/src/permissions.ts +++ b/apps/bot/src/permissions.ts @@ -1,11 +1,12 @@ import { type ChatInputCommandInteraction } from 'discord.js'; import { t } from '@nexumi/shared'; +import type { Locale } from '@nexumi/shared'; export async function requirePermission( interaction: ChatInputCommandInteraction, - permission: bigint + permission: bigint, + locale: Locale ): Promise { - const locale = 'de'; const member = interaction.memberPermissions; if (!member?.has(permission)) { await interaction.reply({ @@ -17,7 +18,10 @@ export async function requirePermission( if (!interaction.guild?.members.me?.permissions.has(permission)) { await interaction.reply({ - content: `Bot lacks required permission (${permission.toString()}).`, + content: t(locale, 'generic.botMissingPermission').replace( + '{permission}', + permission.toString() + ), ephemeral: true }); return false; diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 657d70d..d16925d 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -39,6 +39,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - Command-Routing - zentraler Permission-Check - 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 - BullMQ-Integration: - Moderation-Queue @@ -54,6 +55,7 @@ Dieses Dokument hält den aktuellen Implementierungsstand fest. Es wird bei jede - `/nick set|reset` - `/case view|edit|delete` - `/modnote add|list` + - user-facing Reply-Strings im Moderationsfluss auf i18n-Keys umgestellt (`de`/`en`) - Tests (aktuell vorhanden): - `packages/shared/src/i18n.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 - 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: - Ban, Purge und weitere destruktive Flows sollen explizite Bestätigungsschritte erhalten. - Audit-/Case-System weiter härten: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4607005..b380026 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -34,13 +34,41 @@ export type ModerationAction = z.infer; type Dictionary = Record; const de: Dictionary = { '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.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 = { '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.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 = { de, en }; @@ -48,3 +76,11 @@ const locales: Record = { de, en }; export function t(locale: Locale, key: string): string { return locales[locale][key] ?? key; } + +export function tf(locale: Locale, key: string, vars: Record): string { + const base = t(locale, key); + return Object.entries(vars).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + base + ); +}