Implement logging for verification failures and enhance moderation responses

- Introduced a new logging event type `VERIFICATION_FAIL` to capture details of failed verification attempts, including reasons and actions taken.
- Updated moderation commands to include reasons in success messages for actions like ban, kick, and timeout.
- Enhanced the logging of ban and kick actions to include moderator information and reasons fetched from the audit log.
- Improved localization for new logging messages in both English and German.
- Documented changes in the phase tracking documentation to reflect the new logging capabilities and verification failure handling.
This commit is contained in:
TheOnlyMace
2026-07-25 16:14:41 +02:00
parent 1eec10ba80
commit dbee58cd81
11 changed files with 193 additions and 23 deletions

View File

@@ -10,6 +10,9 @@ import type { LogEventType } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.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) { export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: string) {
await ensureGuild(prisma, guildId); await ensureGuild(prisma, guildId);
let config = await prisma.loggingConfig.findUnique({ where: { guildId } }); let config = await prisma.loggingConfig.findUnique({ where: { guildId } });
@@ -19,6 +22,36 @@ export async function getLoggingConfig(prisma: BotContext['prisma'], guildId: st
return config; 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( export async function getLogChannelId(
prisma: BotContext['prisma'], prisma: BotContext['prisma'],
guildId: string, guildId: string,
@@ -124,6 +157,45 @@ export async function logModAction(
await sendLogEmbed(context, guild, 'MOD_ACTION', 'Moderation Action', description, 0xef4444); 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<void> {
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( export async function handleChannelDeleteForAntiNuke(
context: BotContext, context: BotContext,
guild: Guild, guild: Guild,
@@ -253,6 +325,29 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
if (memberIgnored(config, member as GuildMember, member.user.bot)) { if (memberIgnored(config, member as GuildMember, member.user.bot)) {
return; 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( await sendLogEmbed(
context, context,
member.guild, member.guild,
@@ -265,12 +360,19 @@ export async function registerLoggingEvents(context: BotContext): Promise<void>
client.on('guildBanAdd', async (ban) => { client.on('guildBanAdd', async (ban) => {
const guild = ban.guild; const guild = ban.guild;
await handleBanForAntiNuke(context, 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( await sendLogEmbed(
context, context,
guild, guild,
'MEMBER_BAN', 'MEMBER_BAN',
'Member Banned', 'Member Banned',
`**User:** ${ban.user.tag} (<@${ban.user.id}>)\n**Reason:** ${ban.reason ?? '—'}` `**User:** ${ban.user.tag} (<@${ban.user.id}>)${executorLine}\n**Reason:** ${reason}`
); );
}); });

View File

@@ -76,7 +76,10 @@ export async function executeBan(
} }
await interaction.editReply({ 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: [] components: []
}); });
} }

View File

@@ -54,9 +54,12 @@ async function ensureMod(
async function replySuccessCase( async function replySuccessCase(
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
locale: 'de' | 'en', locale: 'de' | 'en',
caseNumber: number caseNumber: number,
reason?: string
): Promise<void> { ): Promise<void> {
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) { if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content }); await interaction.editReply({ content });
return; return;
@@ -117,7 +120,7 @@ const unbanCommand: SlashCommand = {
action: 'UNBAN', action: 'UNBAN',
reason reason
}); });
await replySuccessCase(interaction, locale, modCase.caseNumber); await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
} }
}; };
@@ -139,7 +142,7 @@ const kickCommand: SlashCommand = {
action: 'KICK', action: 'KICK',
reason reason
}); });
await replySuccessCase(interaction, locale, modCase.caseNumber); await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
} }
}; };
@@ -172,7 +175,7 @@ const timeoutCommand: SlashCommand = {
reason, reason,
metadata: { durationMs: duration } 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', action: 'UN_TIMEOUT',
reason reason
}); });
await replySuccessCase(interaction, locale, modCase.caseNumber); await replySuccessCase(interaction, locale, modCase.caseNumber, reason);
} }
}; };

View File

@@ -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<void> {
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( export async function completeVerification(
context: BotContext, context: BotContext,
guildId: string, guildId: string,
@@ -90,11 +112,13 @@ export async function completeVerification(
const ageDays = accountAgeDays(member.user.createdAt); const ageDays = accountAgeDays(member.user.createdAt);
if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) { if (config.minAccountAgeDays > 0 && ageDays < config.minAccountAgeDays) {
await applyFailAction( const failReason = `Verification failed: account too young (${ageDays}d)`;
member, await logVerificationFailure(context, member, {
config.failAction, reason: 'account_too_young',
`Verification failed: account too young (${ageDays}d)` 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' }; return { ok: false, reason: 'account_too_young' };
} }
@@ -170,13 +194,23 @@ export async function completeVerification(
}); });
if (config.altBlockOnMatch) { if (config.altBlockOnMatch) {
await applyFailAction( const failReason = `Verification failed: possible alt account (${match.reason})`;
member, await logVerificationFailure(context, member, {
config.failAction, reason: 'alt_detected',
`Verification failed: possible alt account (${match.reason})` detail: `Signal: ${match.reason}`,
); failAction: config.failAction,
matchedUserId
});
await applyFailAction(member, config.failAction, failReason);
return { ok: false, reason: 'alt_detected' }; 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
});
} }
} }

View File

@@ -42,7 +42,8 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
'THREAD_UPDATE', 'THREAD_UPDATE',
'THREAD_DELETE', 'THREAD_DELETE',
'GUILD_UPDATE', 'GUILD_UPDATE',
'MOD_ACTION' 'MOD_ACTION',
'VERIFICATION_FAIL'
]; ];
interface LocalLogChannelGroup { interface LocalLogChannelGroup {

View File

@@ -518,7 +518,8 @@
"THREAD_UPDATE": "Thread aktualisiert", "THREAD_UPDATE": "Thread aktualisiert",
"THREAD_DELETE": "Thread gelöscht", "THREAD_DELETE": "Thread gelöscht",
"GUILD_UPDATE": "Server aktualisiert", "GUILD_UPDATE": "Server aktualisiert",
"MOD_ACTION": "Moderations-Aktion" "MOD_ACTION": "Moderations-Aktion",
"VERIFICATION_FAIL": "Verifizierung fehlgeschlagen"
} }
}, },
"welcome": { "welcome": {

View File

@@ -518,7 +518,8 @@
"THREAD_UPDATE": "Thread updated", "THREAD_UPDATE": "Thread updated",
"THREAD_DELETE": "Thread deleted", "THREAD_DELETE": "Thread deleted",
"GUILD_UPDATE": "Server updated", "GUILD_UPDATE": "Server updated",
"MOD_ACTION": "Moderation action" "MOD_ACTION": "Moderation action",
"VERIFICATION_FAIL": "Verification failed"
} }
}, },
"welcome": { "welcome": {

View File

@@ -499,3 +499,22 @@
- [ ] Kategorie mit Emoji-Picker (Unicode + Server-Emoji) speichern → Button zeigt Emoji - [ ] Kategorie mit Emoji-Picker (Unicode + Server-Emoji) speichern → Button zeigt Emoji
- [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich - [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich
- [ ] Panel-Kanal wechseln → altes Panel weg, neues im Zielkanal - [ ] 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

View File

@@ -193,7 +193,8 @@ export const LogEventTypeDashboardSchema = z.enum([
'THREAD_UPDATE', 'THREAD_UPDATE',
'THREAD_DELETE', 'THREAD_DELETE',
'GUILD_UPDATE', 'GUILD_UPDATE',
'MOD_ACTION' 'MOD_ACTION',
'VERIFICATION_FAIL'
]); ]);
export type LogEventTypeDashboard = z.infer<typeof LogEventTypeDashboardSchema>; export type LogEventTypeDashboard = z.infer<typeof LogEventTypeDashboardSchema>;

View File

@@ -119,6 +119,8 @@ const de: Dictionary = {
'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.', 'Gelöscht/anonymisiert: {warnings} Verwarnungen, {levels} Level-Einträge, {economy} Economy-Einträge, {cases} Cases anonymisiert.',
'moderation.success': 'Aktion erfolgreich ausgeführt.', 'moderation.success': 'Aktion erfolgreich ausgeführt.',
'moderation.success.case': 'Aktion erfolgreich ausgeführt (Fall #{caseNumber}).', '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.invalid': 'Ungültige Dauer. Nutze z. B. 30m, 12h oder 7d.',
'moderation.duration.invalidTimeout': 'moderation.duration.invalidTimeout':
'Ungültige Timeout-Dauer. Nutze z. B. 30m oder 1d (max. 28 Tage).', '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.', 'Deleted/anonymized: {warnings} warnings, {levels} level rows, {economy} economy rows, {cases} cases anonymized.',
'moderation.success': 'Action executed successfully.', 'moderation.success': 'Action executed successfully.',
'moderation.success.case': 'Action executed successfully (case #{caseNumber}).', '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.invalid': 'Invalid duration. Use e.g. 30m, 12h, or 7d.',
'moderation.duration.invalidTimeout': 'moderation.duration.invalidTimeout':
'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).', 'Invalid timeout duration. Use e.g. 30m or 1d (max 28 days).',

View File

@@ -123,7 +123,8 @@ export const LogEventTypeSchema = z.enum([
'THREAD_UPDATE', 'THREAD_UPDATE',
'THREAD_DELETE', 'THREAD_DELETE',
'GUILD_UPDATE', 'GUILD_UPDATE',
'MOD_ACTION' 'MOD_ACTION',
'VERIFICATION_FAIL'
]); ]);
export type LogEventType = z.infer<typeof LogEventTypeSchema>; export type LogEventType = z.infer<typeof LogEventTypeSchema>;