diff --git a/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql b/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql new file mode 100644 index 0000000..2c336c0 --- /dev/null +++ b/apps/bot/prisma/migrations/20260725190000_leave_announce_ban/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index f4a32e8..6c46d2c 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -254,6 +254,8 @@ model WelcomeConfig { leaveContent String? leaveEmbed Json? leaveComponents Json? + /// When true, leave channel also posts a notice if the member was banned. + leaveAnnounceBan Boolean @default(false) welcomeDmEnabled Boolean @default(false) welcomeDmContent String? userAutoroleIds String[] @default([]) diff --git a/apps/bot/src/modules/automod/actions.ts b/apps/bot/src/modules/automod/actions.ts index 0b75de1..0d533d2 100644 --- a/apps/bot/src/modules/automod/actions.ts +++ b/apps/bot/src/modules/automod/actions.ts @@ -1,6 +1,6 @@ import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js'; import type { BotContext } from '../../types.js'; -import { createCase, createWarning } from '../moderation/service.js'; +import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js'; import { getGuildLocale } from '../../i18n.js'; import { getAutoModConfig, @@ -85,6 +85,14 @@ async function applyAutoModAction( if (action === 'KICK') { if (me.permissions.has(PermissionFlagsBits.KickMembers)) { + const locale = await getGuildLocale(context.prisma, guild.id); + await notifyModerationTargetDm({ + user: message.author, + guildName: guild.name, + reason: baseReason, + locale, + action: 'kick' + }); await message.member.kick(baseReason); await createCase(context, { guildId: guild.id, @@ -102,6 +110,14 @@ async function applyAutoModAction( if (action === 'BAN') { if (me.permissions.has(PermissionFlagsBits.BanMembers)) { + const locale = await getGuildLocale(context.prisma, guild.id); + await notifyModerationTargetDm({ + user: message.author, + guildName: guild.name, + reason: baseReason, + locale, + action: 'ban' + }); await guild.members.ban(message.author.id, { reason: baseReason }); await createCase(context, { guildId: guild.id, diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index 9177b11..6b4fe13 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -2,7 +2,7 @@ import { TextChannel, type ButtonInteraction } from 'discord.js'; import { type Locale, t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; import { assertBannableForConfirm } from '../../hierarchy.js'; -import { createCase, scheduleTempBanExpire } from './service.js'; +import { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js'; import { tryParseDuration } from './duration.js'; type BanPayload = { @@ -55,6 +55,18 @@ export async function executeBan( await interaction.deferUpdate(); + const targetUser = await context.client.users.fetch(payload.userId).catch(() => null); + if (targetUser) { + await notifyModerationTargetDm({ + user: targetUser, + guildName: guild.name, + reason: payload.reason, + locale, + action: 'ban', + duration: payload.duration + }); + } + await guild.members.ban(payload.userId, { reason: payload.reason }); const modCase = await createCase(context, { guildId: guild.id, diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 9bb8851..3f0061b 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { requirePermission } from '../../permissions.js'; import { assertModerableMember } from '../../hierarchy.js'; -import { applyWarnEscalation, createCase, createWarning } from './service.js'; +import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js'; import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js'; import { getGuildLocale } from '../../i18n.js'; import { promptDestructiveConfirmation } from './confirmations.js'; @@ -133,6 +133,13 @@ const kickCommand: SlashCommand = { const reason = reasonFrom(interaction, locale); const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick'); if (!hierarchy.ok || !hierarchy.member) return; + await notifyModerationTargetDm({ + user, + guildName: interaction.guild!.name, + reason, + locale, + action: 'kick' + }); await hierarchy.member.kick(reason); const modCase = await createCase(context, { guildId: interaction.guildId!, diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index 0299cc4..d27aaa2 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -64,6 +64,37 @@ async function notifyWarnedUser(params: { } } +export async function notifyModerationTargetDm(params: { + user: User; + guildName: string; + reason: string; + locale: Locale; + action: 'ban' | 'kick'; + duration?: string | null; +}): Promise { + const reason = params.reason || t(params.locale, 'moderation.reason.none'); + const key = + params.action === 'kick' + ? 'moderation.kick.dm' + : params.duration + ? 'moderation.ban.dm.temporary' + : 'moderation.ban.dm'; + try { + await params.user.send( + tf(params.locale, key, { + guild: params.guildName, + reason, + ...(params.duration ? { duration: params.duration } : {}) + }) + ); + } catch (error) { + logger.warn( + { error, userId: params.user.id, action: params.action }, + 'Failed to DM moderation target' + ); + } +} + export async function createWarning( context: BotContext, input: CreateWarningInput @@ -298,6 +329,13 @@ export async function applyWarnEscalation( String(warningCount) ); } + await notifyModerationTargetDm({ + user: member.user, + guildName: interaction.guild.name, + reason, + locale, + action: 'kick' + }); await member.kick(reason); await createCase(context, { guildId, @@ -318,6 +356,13 @@ export async function applyWarnEscalation( String(warningCount) ); } + await notifyModerationTargetDm({ + user: member.user, + guildName: interaction.guild.name, + reason, + locale, + action: 'ban' + }); await interaction.guild.members.ban(userId, { reason }); await createCase(context, { guildId, diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index 15a8e45..74b3da3 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -24,6 +24,7 @@ import { detectAltAccount } from './alt-detection.js'; import { env } from '../../env.js'; import { logger } from '../../logger.js'; import { ephemeral } from '../../interaction-reply.js'; +import { notifyModerationTargetDm } from '../moderation/service.js'; export class VerificationPanelError extends Error { constructor(public readonly code: 'not_configured' | 'missing_permission') { @@ -57,16 +58,35 @@ export type CompleteVerificationOptions = { }; async function applyFailAction( + context: BotContext, member: GuildMember, failAction: string, reason: string ): Promise { const me = member.guild.members.me; + if (failAction !== 'KICK' && failAction !== 'BAN') { + return; + } + const locale = await getGuildLocale(context.prisma, member.guild.id); if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) { + await notifyModerationTargetDm({ + user: member.user, + guildName: member.guild.name, + reason, + locale, + action: 'kick' + }); await member.kick(reason); return; } if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) { + await notifyModerationTargetDm({ + user: member.user, + guildName: member.guild.name, + reason, + locale, + action: 'ban' + }); await member.guild.members.ban(member.id, { reason }); } } @@ -118,7 +138,7 @@ export async function completeVerification( detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`, failAction: config.failAction }); - await applyFailAction(member, config.failAction, failReason); + await applyFailAction(context, member, config.failAction, failReason); return { ok: false, reason: 'account_too_young' }; } @@ -201,7 +221,7 @@ export async function completeVerification( failAction: config.failAction, matchedUserId }); - await applyFailAction(member, config.failAction, failReason); + await applyFailAction(context, member, config.failAction, failReason); return { ok: false, reason: 'alt_detected' }; } diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts index 11a9d50..7ccdc9c 100644 --- a/apps/bot/src/modules/welcome/events.ts +++ b/apps/bot/src/modules/welcome/events.ts @@ -1,5 +1,7 @@ import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js'; +import { t, tf } from '@nexumi/shared'; import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { logger } from '../../logger.js'; @@ -106,6 +108,33 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember return; } await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild); + + if (!config.leaveAnnounceBan || member.user.bot) { + return; + } + + const ban = await member.guild.bans.fetch(member.id).catch(() => null); + if (!ban) { + return; + } + + const locale = await getGuildLocale(context.prisma, member.guild.id); + const reason = ban.reason ?? t(locale, 'moderation.reason.none'); + if ('send' in channel) { + await channel + .send( + tf(locale, 'welcome.leave.bannedNotice', { + user: member.user.tag, + reason + }) + ) + .catch((error) => { + logger.warn( + { error, guildId: member.guild.id, userId: member.id }, + 'Failed to send leave ban notice' + ); + }); + } } export function registerWelcomeEvents(context: BotContext): void { diff --git a/apps/webui/src/components/modules/welcome-form.tsx b/apps/webui/src/components/modules/welcome-form.tsx index 4218d5c..66575b1 100644 --- a/apps/webui/src/components/modules/welcome-form.tsx +++ b/apps/webui/src/components/modules/welcome-form.tsx @@ -252,6 +252,22 @@ export function WelcomeForm({ guildId, initialValue, previewVars }: WelcomeFormP /> + +
+
+ +

+ {t('modulePages.welcome.leaveAnnounceBanHint')} +

+
+ + setValue((prev) => ({ ...prev, leaveAnnounceBan: checked })) + } + /> +
+
diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index d20a375..17dff38 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -309,6 +309,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [ href: 'welcome', hash: 'field-leave-enabled' }, + { + id: 'setting-leave-announce-ban', + kind: 'setting', + labelKey: 'modulePages.welcome.leaveAnnounceBanLabel', + href: 'welcome', + hash: 'field-leave-announce-ban', + keywords: ['ban', 'leave', 'moderation'] + }, { id: 'setting-leave-channel', kind: 'setting', diff --git a/apps/webui/src/lib/module-configs/welcome.ts b/apps/webui/src/lib/module-configs/welcome.ts index f8cebd4..7ff0f9a 100644 --- a/apps/webui/src/lib/module-configs/welcome.ts +++ b/apps/webui/src/lib/module-configs/welcome.ts @@ -42,6 +42,7 @@ function toDashboard(config: Awaited>): W leaveContent: config.leaveContent, leaveEmbed: parseEmbed(config.leaveEmbed), leaveComponents: parseComponents(config.leaveComponents), + leaveAnnounceBan: config.leaveAnnounceBan, welcomeDmEnabled: config.welcomeDmEnabled, welcomeDmContent: config.welcomeDmContent, userAutoroleIds: config.userAutoroleIds, diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 969f39a..5a2c59a 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -549,6 +549,9 @@ "leaveTitle": "Abschied", "leaveDescription": "Nachricht und Kanal, wenn ein Mitglied den Server verlässt.", "leaveEnabledLabel": "Abschiedsnachricht aktiviert", + "leaveAnnounceBanLabel": "Ban im Abschieds-Kanal melden", + "leaveAnnounceBanHint": + "Zusätzlich zur Abschiedsnachricht anzeigen, wenn das Mitglied gebannt wurde (inkl. Grund).", "leaveChannelId": "Abschieds-Kanal-ID", "leaveType": "Nachrichtentyp", "leaveTypes": { diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 122df19..0101e97 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -549,6 +549,9 @@ "leaveTitle": "Leave", "leaveDescription": "Message and channel when a member leaves the server.", "leaveEnabledLabel": "Leave message enabled", + "leaveAnnounceBanLabel": "Announce bans in leave channel", + "leaveAnnounceBanHint": + "Also post in the leave channel when the member was banned (including reason).", "leaveChannelId": "Leave channel ID", "leaveType": "Message type", "leaveTypes": { diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 33ab45a..6ebb4b9 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -634,6 +634,24 @@ - [ ] AutoMod-WARN → ebenfalls DM + Case verknüpft - [ ] Dashboard: Warn-Tabelle zeigt `#`-Spalte +## Post-Phase – Ban/Kick-DM + Leave-Ban-Hinweis (Status: implementiert) + +### Abgeschlossen (Code) + +- DM vor Ban/Kick (Slash, Confirm, AutoMod, Eskalation, Verifizierung) mit Server + Grund (+ Dauer bei Temp-Ban) +- WelcomeConfig `leaveAnnounceBan` (Default aus); WebUI-Toggle unter Abschied +- Bei Leave + Toggle: Ban-Check via `guild.bans.fetch` → Zusatzzeile im Leave-Kanal +- Migration `20260725190000_leave_announce_ban` + +### Manuell testen + +- [ ] `/kick` → Ziel erhält DM; Moderator sieht Case-Erfolg +- [ ] `/ban` (Confirm) → DM vor Ban; Temp-Ban zeigt Dauer in der DM +- [ ] Leave-Kanal: normales Leave ohne Ban → nur Abschiedsnachricht +- [ ] Leave-Kanal: Ban mit Toggle an → Abschied + „wurde gebannt“ inkl. Grund +- [ ] Leave-Kanal: Ban mit Toggle aus → nur Abschiedsnachricht +- [ ] User mit geschlossenen DMs → Ban/Kick läuft trotzdem + ## Post-Phase – AutoMod Standard-Wortliste DE/EN (Status: implementiert) ### Abgeschlossen (Code) diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index d4d49ff..cfe25e0 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -254,6 +254,7 @@ export const WelcomeConfigDashboardSchema = z.object({ leaveContent: z.string().max(2000).nullable().optional(), leaveEmbed: DashboardEmbedSchema.nullable().optional(), leaveComponents: MessageComponentsV2Schema.nullable().optional(), + leaveAnnounceBan: z.boolean().default(false), welcomeDmEnabled: z.boolean(), welcomeDmContent: z.string().max(2000).nullable().optional(), userAutoroleIds: z.array(z.string()), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 71fc0e9..5cf2de7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -139,6 +139,11 @@ const de: Dictionary = { 'moderation.warning.notFound': 'Verwarnung nicht gefunden.', 'moderation.warning.dm': 'Du hast eine Verwarnung auf **{guild}** erhalten.\nGrund: {reason}\nVerwarnungen: {count} (Nr. #{warningNumber})', + 'moderation.kick.dm': 'Du wurdest von **{guild}** gekickt.\nGrund: {reason}', + 'moderation.ban.dm': 'Du wurdest von **{guild}** gebannt.\nGrund: {reason}', + 'moderation.ban.dm.temporary': + 'Du wurdest von **{guild}** temporär gebannt ({duration}).\nGrund: {reason}', + 'welcome.leave.bannedNotice': '**{user}** wurde gebannt.\nGrund: {reason}', 'moderation.purge.done': '{deletedCount} Nachrichten gelöscht (Fall #{caseNumber}).', 'moderation.note.none': 'Keine Notizen vorhanden.', 'moderation.case.notFound': 'Fall nicht gefunden.', @@ -758,6 +763,11 @@ const en: Dictionary = { 'moderation.warning.notFound': 'Warning not found.', 'moderation.warning.dm': 'You received a warning on **{guild}**.\nReason: {reason}\nWarnings: {count} (No. #{warningNumber})', + 'moderation.kick.dm': 'You were kicked from **{guild}**.\nReason: {reason}', + 'moderation.ban.dm': 'You were banned from **{guild}**.\nReason: {reason}', + 'moderation.ban.dm.temporary': + 'You were temporarily banned from **{guild}** ({duration}).\nReason: {reason}', + 'welcome.leave.bannedNotice': '**{user}** was banned.\nReason: {reason}', 'moderation.purge.done': 'Deleted {deletedCount} messages (case #{caseNumber}).', 'moderation.note.none': 'No notes.', 'moderation.case.notFound': 'Case not found.',