diff --git a/apps/bot/src/modules/logging/events.ts b/apps/bot/src/modules/logging/events.ts index 448cab6..26630f0 100644 --- a/apps/bot/src/modules/logging/events.ts +++ b/apps/bot/src/modules/logging/events.ts @@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; +/** Audit-log entries can lag a moment behind the gateway event. */ +const AUDIT_LOG_MAX_AGE_MS = 15_000; + export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) { await ensureGuild(prisma, guildId); let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); @@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st return config; } +/** + * Discord does not send ban/kick reasons over the gateway. + * Reasons must be read from the audit log (requires View Audit Log). + */ +async function fetchRecentAuditEntry( + guild: Guild, + type: AuditLogEvent, + targetId: string +): Promise<{ reason: string | null; executorId: string | null } | null> { + try { + const auditLogs = await guild.fetchAuditLogs({ type, limit: 5 }); + const entry = auditLogs.entries.find((item) => { + if (item.targetId !== targetId) { + return false; + } + return Date.now() - item.createdTimestamp <= AUDIT_LOG_MAX_AGE_MS; + }); + if (!entry) { + return null; + } + return { + reason: entry.reason ?? null, + executorId: entry.executorId ?? entry.executor?.id ?? null + }; + } catch (error) { + logger.warn({ error, guildId: guild.id, type }, 'Failed to fetch audit log for logging'); + return null; + } +} + export async function getLogChannelId( prisma: BotContext['prisma'], guildId: string, @@ -124,6 +157,45 @@ export async function logModAction( await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); } +export async function logVerificationFail( + context: BotContext, + input: { + guildId: string; + userId: string; + userTag?: string; + reason: string; + detail?: string; + failAction?: string; + matchedUserId?: string | null; + } +): Promise { + const guild = await context.client.guilds.fetch(input.guildId).catch(() => null); + if (!guild) { + return; + } + const lines = [ + `**User:** ${input.userTag ? `${input.userTag} ` : ''}(<@${input.userId}>) (\`${input.userId}\`)`, + `**Reason:** ${input.reason}` + ]; + if (input.detail) { + lines.push(`**Detail:** ${input.detail}`); + } + if (input.matchedUserId) { + lines.push(`**Matched user:** <@${input.matchedUserId}> (\`${input.matchedUserId}\`)`); + } + if (input.failAction) { + lines.push(`**Fail action:** ${input.failAction}`); + } + await sendLogEmbed( + context, + guild, + 'VERIFICATION_FAIL', + 'Verification Failed', + lines.join('\n'), + 0xf97316 + ); +} + export async function handleChannelDeleteForAntiNuke( context: BotContext, guild: Guild, @@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise if (memberIgnored(config, member as GuildMember, member.user.bot)) { return; } + + const kickEntry = await fetchRecentAuditEntry( + member.guild, + AuditLogEvent.MemberKick, + member.id + ); + + if (kickEntry) { + await sendLogEmbed( + context, + member.guild, + 'MEMBER_LEAVE', + 'Member Kicked', + [ + `**User:** ${member.user.tag} (<@${member.id}>)`, + `**Moderator:** ${kickEntry.executorId ? `<@${kickEntry.executorId}>` : '—'}`, + `**Reason:** ${kickEntry.reason ?? '—'}` + ].join('\n'), + 0xf59e0b + ); + return; + } + await sendLogEmbed( context, member.guild, @@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise client.on('guildBanAdd', async (ban) => { const guild = ban.guild; await handleBanForAntiNuke(context, guild); + + const audit = await fetchRecentAuditEntry(guild, AuditLogEvent.MemberBanAdd, ban.user.id); + const reason = audit?.reason ?? ban.reason ?? '—'; + const executorLine = audit?.executorId + ? `\n**Moderator:** <@${audit.executorId}>` + : ''; + await sendLogEmbed( context, guild, 'MEMBER_BAN', 'Member Banned', - `**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}` + `**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}` ); }); diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index 3747ad7..9177b11 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -76,7 +76,10 @@ export async function executeBan( } await interaction.editReply({ - content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }), + content: tf(locale, 'moderation.success.caseWithReason', { + caseNumber: modCase.caseNumber, + reason: payload.reason + }), components: [] }); } diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index 6f0c49b..bf614d8 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -54,9 +54,12 @@ async function ensureMod( async function replySuccessCase( interaction: ChatInputCommandInteraction, locale: 'de' | 'en', - caseNumber: number + caseNumber: number, + reason?: string ): Promise { - const content = tf(locale, 'moderation.success.case', { caseNumber }); + const content = reason + ? tf(locale, 'moderation.success.caseWithReason', { caseNumber, reason }) + : tf(locale, 'moderation.success.case', { caseNumber }); if (interaction.deferred || interaction.replied) { await interaction.editReply({ content }); return; @@ -117,7 +120,7 @@ const unbanCommand: SlashCommand = { action: 'UNBAN', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -139,7 +142,7 @@ const kickCommand: SlashCommand = { action: 'KICK', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -172,7 +175,7 @@ const timeoutCommand: SlashCommand = { reason, metadata: { durationMs: duration } }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; @@ -194,7 +197,7 @@ const untimeoutCommand: SlashCommand = { action: 'UN_TIMEOUT', reason }); - await replySuccessCase(interaction, locale, modCase.caseNumber); + await replySuccessCase(interaction, locale, modCase.caseNumber, reason); } }; diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index fdec680..15a8e45 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -71,6 +71,28 @@ async function applyFailAction( } } +async function logVerificationFailure( + context: BotContext, + member: GuildMember, + input: { + reason: string; + detail?: string; + failAction?: string; + matchedUserId?: string | null; + } +): Promise { + const { logVerificationFail } = await import('../logging/events.js'); + await logVerificationFail(context, { + guildId: member.guild.id, + userId: member.id, + userTag: member.user.tag, + reason: input.reason, + detail: input.detail, + failAction: input.failAction, + matchedUserId: input.matchedUserId + }); +} + export async function completeVerification( context: BotContext, guildId: string, @@ -90,11 +112,13 @@ export async function completeVerification( const ageDays = accountAgeDays(member.user.createdAt); if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { - await applyFailAction( - member, - config.failAction, - `Verification failed: account too young (${ageDays}d)` - ); + const failReason = `Verification failed: account too young (${ageDays}d)`; + await logVerificationFailure(context, member, { + reason: 'account_too_young', + detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`, + failAction: config.failAction + }); + await applyFailAction(member, config.failAction, failReason); return { ok: false, reason: 'account_too_young' }; } @@ -170,13 +194,23 @@ export async function completeVerification( }); if (config.altBlockOnMatch) { - await applyFailAction( - member, - config.failAction, - `Verification failed: possible alt account (${match.reason})` - ); + const failReason = `Verification failed: possible alt account (${match.reason})`; + await logVerificationFailure(context, member, { + reason: 'alt_detected', + detail: `Signal: ${match.reason}`, + failAction: config.failAction, + matchedUserId + }); + await applyFailAction(member, config.failAction, failReason); return { ok: false, reason: 'alt_detected' }; } + + await logVerificationFailure(context, member, { + reason: 'alt_detected_flagged', + detail: `Signal: ${match.reason} (flagged only, not blocked)`, + failAction: 'NONE', + matchedUserId + }); } } diff --git a/apps/webui/src/components/modules/logging-form.tsx b/apps/webui/src/components/modules/logging-form.tsx index d41742e..0008858 100644 --- a/apps/webui/src/components/modules/logging-form.tsx +++ b/apps/webui/src/components/modules/logging-form.tsx @@ -42,7 +42,8 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [ 'THREAD_UPDATE', 'THREAD_DELETE', 'GUILD_UPDATE', - 'MOD_ACTION' + 'MOD_ACTION', + 'VERIFICATION_FAIL' ]; interface LocalLogChannelGroup { diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index ec9f867..3fe991c 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -518,7 +518,8 @@ "THREAD_UPDATE": "Thread aktualisiert", "THREAD_DELETE": "Thread gelöscht", "GUILD_UPDATE": "Server aktualisiert", - "MOD_ACTION": "Moderations-Aktion" + "MOD_ACTION": "Moderations-Aktion", + "VERIFICATION_FAIL": "Verifizierung fehlgeschlagen" } }, "welcome": { diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index fc46001..778ec8e 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -518,7 +518,8 @@ "THREAD_UPDATE": "Thread updated", "THREAD_DELETE": "Thread deleted", "GUILD_UPDATE": "Server updated", - "MOD_ACTION": "Moderation action" + "MOD_ACTION": "Moderation action", + "VERIFICATION_FAIL": "Verification failed" } }, "welcome": { diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 0cba9ff..34e088f 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -499,3 +499,22 @@ - [ ] Kategorie mit Emoji-Picker (Unicode + Server-Emoji) speichern → Button zeigt Emoji - [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich - [ ] Panel-Kanal wechseln → altes Panel weg, neues im Zielkanal + +## Post-Phase – Ban/Kick-Reason Logging + Verification Fail Event (Status: implementiert) + +### Abgeschlossen (Code) + +- Root cause Ban/Kick-Reason im Log: Discord sendet Reasons nicht über Gateway (`ban.reason` war immer leer) +- `MEMBER_BAN` / Kick-`MEMBER_LEAVE` lesen Reason + Moderator jetzt aus dem Audit-Log +- Ephemere Success-Antworten für Ban/Kick/Timeout zeigen den Grund mit +- Neuer Log-Event-Typ `VERIFICATION_FAIL` (Shared + WebUI-Picker DE/EN) +- Alt-Check / Account-zu-jung schreiben ins Logging (`logVerificationFail`); auch Flag-only ohne Block + +### Manuell testen + +- [ ] `/kick` mit Reason → Log-Kanal (MEMBER_LEAVE) zeigt Reason + Moderator +- [ ] `/ban` mit Reason → Log-Kanal (MEMBER_BAN) zeigt Reason + Moderator; Bot braucht „Audit-Log anzeigen“ +- [ ] Logging: `VERIFICATION_FAIL` einem Kanal zuordnen +- [ ] Alt-Check mit Block → User sieht Fehler-Ephemeral; Log-Kanal bekommt Embed +- [ ] Alt-Check nur Flag (ohne Block) → User wird verifiziert, Log trotzdem + diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 914ca7c..1923a9a 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -193,7 +193,8 @@ export const LogEventTypeDashboardSchema = z.enum([ 'THREAD_UPDATE', 'THREAD_DELETE', 'GUILD_UPDATE', - 'MOD_ACTION' + 'MOD_ACTION', + 'VERIFICATION_FAIL' ]); export type LogEventTypeDashboard = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a1134f4..9b252a2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -119,6 +119,8 @@ const de: Dictionary = { 'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.', 'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.success.case': 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).', + 'moderation.success.caseWithReason': + 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).\nGrund: {reason}', 'moderation.duration.invalid': 'Ungültige Dauer. Nutze z. B. 30m, 12h oder 7d.', 'moderation.duration.invalidTimeout': 'Ungültige Timeout-Dauer. Nutze z. B. 30m oder 1d (max. 28 Tage).', @@ -718,6 +720,8 @@ const en: Dictionary = { 'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.', 'moderation.success': 'Action executed successfully.', 'moderation.success.case': 'Action executed successfully (case #{caseNumber}).', + 'moderation.success.caseWithReason': + 'Action executed successfully (case #{caseNumber}).\nReason: {reason}', 'moderation.duration.invalid': 'Invalid duration. Use e.g. 30m, 12h, or 7d.', 'moderation.duration.invalidTimeout': 'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).', diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts index d6acd27..bae3ab1 100644 --- a/packages/shared/src/phase2.ts +++ b/packages/shared/src/phase2.ts @@ -123,7 +123,8 @@ export const LogEventTypeSchema = z.enum([ 'THREAD_UPDATE', 'THREAD_DELETE', 'GUILD_UPDATE', - 'MOD_ACTION' + 'MOD_ACTION', + 'VERIFICATION_FAIL' ]); export type LogEventType = z.infer;