deploy #2
@@ -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}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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).',
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user