- 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.
640 lines
17 KiB
TypeScript
640 lines
17 KiB
TypeScript
import {
|
|
AuditLogEvent,
|
|
EmbedBuilder,
|
|
type Guild,
|
|
type GuildMember,
|
|
type TextChannel
|
|
} from 'discord.js';
|
|
import type { BotContext } from '../../types.js';
|
|
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 } });
|
|
if (!config) {
|
|
config = await prisma.loggingConfig.create({ data: { guildId } });
|
|
}
|
|
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,
|
|
eventType: LogEventType
|
|
): Promise<string | null> {
|
|
const entry = await prisma.logChannel.findUnique({
|
|
where: { guildId_eventType: { guildId, eventType } }
|
|
});
|
|
return entry?.channelId ?? null;
|
|
}
|
|
|
|
function shouldIgnoreMember(
|
|
config: Awaited<ReturnType<typeof getLoggingConfig>>,
|
|
member: GuildMember | null,
|
|
isBot: boolean
|
|
): boolean {
|
|
if (isBot && config.ignoreBots) {
|
|
return true;
|
|
}
|
|
if (!member) {
|
|
return false;
|
|
}
|
|
return member.roles.cache.some((role) => config.ignoredRoleIds.includes(role.id));
|
|
}
|
|
|
|
function shouldIgnoreChannel(
|
|
config: Awaited<ReturnType<typeof getLoggingConfig>>,
|
|
channelId: string | null
|
|
): boolean {
|
|
if (!channelId) {
|
|
return false;
|
|
}
|
|
return config.ignoredChannelIds.includes(channelId);
|
|
}
|
|
|
|
async function resolveLogChannel(
|
|
context: BotContext,
|
|
guild: Guild,
|
|
eventType: LogEventType
|
|
): Promise<TextChannel | null> {
|
|
const config = await getLoggingConfig(context.prisma, guild.id);
|
|
if (!config.enabled) {
|
|
return null;
|
|
}
|
|
const channelId = await getLogChannelId(context.prisma, guild.id, eventType);
|
|
if (!channelId) {
|
|
return null;
|
|
}
|
|
if (shouldIgnoreChannel(config, channelId)) {
|
|
return null;
|
|
}
|
|
const channel = await guild.channels.fetch(channelId).catch(() => null);
|
|
if (!channel?.isTextBased() || channel.isDMBased()) {
|
|
return null;
|
|
}
|
|
return channel as TextChannel;
|
|
}
|
|
|
|
export async function sendLogEmbed(
|
|
context: BotContext,
|
|
guild: Guild,
|
|
eventType: LogEventType,
|
|
title: string,
|
|
description: string,
|
|
color = 0x6366f1
|
|
): Promise<void> {
|
|
const channel = await resolveLogChannel(context, guild, eventType);
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(title)
|
|
.setDescription(description.slice(0, 4096))
|
|
.setColor(color)
|
|
.setTimestamp();
|
|
await channel.send({ embeds: [embed] }).catch((error) => {
|
|
logger.warn({ error, guildId: guild.id, eventType }, 'Failed to send log embed');
|
|
});
|
|
}
|
|
|
|
export async function logModAction(
|
|
context: BotContext,
|
|
input: {
|
|
guildId: string;
|
|
caseNumber: number;
|
|
action: string;
|
|
targetUserId: string;
|
|
moderatorId: string;
|
|
reason?: string | null;
|
|
}
|
|
): Promise<void> {
|
|
const guild = await context.client.guilds.fetch(input.guildId).catch(() => null);
|
|
if (!guild) {
|
|
return;
|
|
}
|
|
const description = [
|
|
`**Case:** #${input.caseNumber}`,
|
|
`**Action:** ${input.action}`,
|
|
`**Target:** <@${input.targetUserId}> (\`${input.targetUserId}\`)`,
|
|
`**Moderator:** <@${input.moderatorId}> (\`${input.moderatorId}\`)`,
|
|
`**Reason:** ${input.reason ?? '—'}`
|
|
].join('\n');
|
|
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(
|
|
context: BotContext,
|
|
guild: Guild,
|
|
channelId: string
|
|
): Promise<void> {
|
|
const config = await getLoggingConfig(context.prisma, guild.id);
|
|
if (shouldIgnoreChannel(config, channelId)) {
|
|
return;
|
|
}
|
|
|
|
const auditLogs = await guild.fetchAuditLogs({
|
|
type: AuditLogEvent.ChannelDelete,
|
|
limit: 1
|
|
});
|
|
const entry = auditLogs.entries.first();
|
|
if (!entry?.executor) {
|
|
return;
|
|
}
|
|
|
|
const { handleAntiNukeAction } = await import('../automod/actions.js');
|
|
await handleAntiNukeAction(context, guild.id, entry.executor.id, 'channel_delete');
|
|
}
|
|
|
|
export async function handleBanForAntiNuke(
|
|
context: BotContext,
|
|
guild: Guild
|
|
): Promise<void> {
|
|
const auditLogs = await guild.fetchAuditLogs({
|
|
type: AuditLogEvent.MemberBanAdd,
|
|
limit: 1
|
|
});
|
|
const entry = auditLogs.entries.first();
|
|
if (!entry?.executor) {
|
|
return;
|
|
}
|
|
|
|
const { handleAntiNukeAction } = await import('../automod/actions.js');
|
|
await handleAntiNukeAction(context, guild.id, entry.executor.id, 'ban');
|
|
}
|
|
|
|
export function memberIgnored(
|
|
config: Awaited<ReturnType<typeof getLoggingConfig>>,
|
|
member: GuildMember | null,
|
|
isBot: boolean,
|
|
channelId?: string | null
|
|
): boolean {
|
|
if (shouldIgnoreChannel(config, channelId ?? null)) {
|
|
return true;
|
|
}
|
|
return shouldIgnoreMember(config, member, isBot);
|
|
}
|
|
|
|
export async function registerLoggingEvents(context: BotContext): Promise<void> {
|
|
const { client } = context;
|
|
|
|
client.on('messageUpdate', async (oldMessage, newMessage) => {
|
|
if (!newMessage.guild || newMessage.author?.bot) {
|
|
return;
|
|
}
|
|
const config = await getLoggingConfig(context.prisma, newMessage.guild.id);
|
|
if (memberIgnored(config, newMessage.member, newMessage.author?.bot ?? false, newMessage.channel.id)) {
|
|
return;
|
|
}
|
|
const before = oldMessage.content ?? '—';
|
|
const after = newMessage.content ?? '—';
|
|
if (before === after) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newMessage.guild,
|
|
'MESSAGE_EDIT',
|
|
'Message Edited',
|
|
`**Author:** <@${newMessage.author?.id}>\n**Channel:** <#${newMessage.channel.id}>\n**Before:** ${before}\n**After:** ${after}`
|
|
);
|
|
});
|
|
|
|
client.on('messageDelete', async (message) => {
|
|
if (!message.guild || message.author?.bot) {
|
|
return;
|
|
}
|
|
const config = await getLoggingConfig(context.prisma, message.guild.id);
|
|
if (memberIgnored(config, message.member, message.author?.bot ?? false, message.channel.id)) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
message.guild,
|
|
'MESSAGE_DELETE',
|
|
'Message Deleted',
|
|
`**Author:** <@${message.author?.id}>\n**Channel:** <#${message.channel.id}>\n**Content:** ${message.content ?? '—'}`
|
|
);
|
|
});
|
|
|
|
client.on('messageDeleteBulk', async (messages, channel) => {
|
|
if (!channel.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
channel.guild,
|
|
'MESSAGE_BULK_DELETE',
|
|
'Bulk Delete',
|
|
`**Channel:** <#${channel.id}>\n**Count:** ${messages.size}`
|
|
);
|
|
});
|
|
|
|
client.on('guildMemberAdd', async (member) => {
|
|
const config = await getLoggingConfig(context.prisma, member.guild.id);
|
|
if (memberIgnored(config, member, member.user.bot)) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
member.guild,
|
|
'MEMBER_JOIN',
|
|
'Member Joined',
|
|
`**User:** ${member.user.tag} (<@${member.id}>)`
|
|
);
|
|
});
|
|
|
|
client.on('guildMemberRemove', async (member) => {
|
|
if (!member.guild) {
|
|
return;
|
|
}
|
|
const config = await getLoggingConfig(context.prisma, member.guild.id);
|
|
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,
|
|
'MEMBER_LEAVE',
|
|
'Member Left',
|
|
`**User:** ${member.user.tag} (<@${member.id}>)`
|
|
);
|
|
});
|
|
|
|
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}>)${executorLine}\n**Reason:** ${reason}`
|
|
);
|
|
});
|
|
|
|
client.on('guildBanRemove', async (ban) => {
|
|
await sendLogEmbed(
|
|
context,
|
|
ban.guild,
|
|
'MEMBER_UNBAN',
|
|
'Member Unbanned',
|
|
`**User:** ${ban.user.tag} (<@${ban.user.id}>)`
|
|
);
|
|
});
|
|
|
|
client.on('guildMemberUpdate', async (oldMember, newMember) => {
|
|
const config = await getLoggingConfig(context.prisma, newMember.guild.id);
|
|
if (memberIgnored(config, newMember, newMember.user.bot)) {
|
|
return;
|
|
}
|
|
const changes: string[] = [];
|
|
if (oldMember.nickname !== newMember.nickname) {
|
|
changes.push(`**Nickname:** ${oldMember.nickname ?? '—'} → ${newMember.nickname ?? '—'}`);
|
|
}
|
|
const addedRoles = newMember.roles.cache.filter((role) => !oldMember.roles.cache.has(role.id));
|
|
const removedRoles = oldMember.roles.cache.filter((role) => !newMember.roles.cache.has(role.id));
|
|
if (addedRoles.size > 0) {
|
|
changes.push(`**Roles added:** ${addedRoles.map((r) => r.name).join(', ')}`);
|
|
}
|
|
if (removedRoles.size > 0) {
|
|
changes.push(`**Roles removed:** ${removedRoles.map((r) => r.name).join(', ')}`);
|
|
}
|
|
if (changes.length === 0) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newMember.guild,
|
|
'MEMBER_UPDATE',
|
|
'Member Updated',
|
|
`**User:** <@${newMember.id}>\n${changes.join('\n')}`
|
|
);
|
|
});
|
|
|
|
client.on('channelCreate', async (channel) => {
|
|
if (!channel.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
channel.guild,
|
|
'CHANNEL_CREATE',
|
|
'Channel Created',
|
|
`**Channel:** ${channel.name} (<#${channel.id}>)`
|
|
);
|
|
});
|
|
|
|
client.on('channelDelete', async (channel) => {
|
|
if (!('guild' in channel) || !channel.guild) {
|
|
return;
|
|
}
|
|
await handleChannelDeleteForAntiNuke(context, channel.guild, channel.id);
|
|
await sendLogEmbed(
|
|
context,
|
|
channel.guild,
|
|
'CHANNEL_DELETE',
|
|
'Channel Deleted',
|
|
`**Channel:** ${'name' in channel ? channel.name : 'Unknown'} (\`${channel.id}\`)`
|
|
);
|
|
});
|
|
|
|
client.on('channelUpdate', async (oldChannel, newChannel) => {
|
|
if (!('guild' in newChannel) || !newChannel.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newChannel.guild,
|
|
'CHANNEL_UPDATE',
|
|
'Channel Updated',
|
|
`**Channel:** <#${newChannel.id}>\n**Before:** ${'name' in oldChannel ? oldChannel.name : '—'}\n**After:** ${'name' in newChannel ? newChannel.name : '—'}`
|
|
);
|
|
});
|
|
|
|
client.on('voiceStateUpdate', async (oldState, newState) => {
|
|
const guild = newState.guild;
|
|
const member = newState.member;
|
|
if (!member || member.user.bot) {
|
|
return;
|
|
}
|
|
const config = await getLoggingConfig(context.prisma, guild.id);
|
|
if (memberIgnored(config, member, member.user.bot)) {
|
|
return;
|
|
}
|
|
|
|
if (!oldState.channelId && newState.channelId) {
|
|
await sendLogEmbed(
|
|
context,
|
|
guild,
|
|
'VOICE_JOIN',
|
|
'Voice Join',
|
|
`**User:** <@${member.id}>\n**Channel:** <#${newState.channelId}>`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (oldState.channelId && !newState.channelId) {
|
|
await sendLogEmbed(
|
|
context,
|
|
guild,
|
|
'VOICE_LEAVE',
|
|
'Voice Leave',
|
|
`**User:** <@${member.id}>\n**Channel:** <#${oldState.channelId}>`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
|
|
await sendLogEmbed(
|
|
context,
|
|
guild,
|
|
'VOICE_MOVE',
|
|
'Voice Move',
|
|
`**User:** <@${member.id}>\n**From:** <#${oldState.channelId}>\n**To:** <#${newState.channelId}>`
|
|
);
|
|
}
|
|
});
|
|
|
|
client.on('inviteCreate', async (invite) => {
|
|
if (!invite.guild || !('members' in invite.guild)) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
invite.guild,
|
|
'INVITE_CREATE',
|
|
'Invite Created',
|
|
`**Code:** ${invite.code}\n**Channel:** <#${invite.channelId}>\n**Creator:** ${invite.inviter ? `<@${invite.inviter.id}>` : '—'}`
|
|
);
|
|
});
|
|
|
|
client.on('emojiCreate', async (emoji) => {
|
|
await sendLogEmbed(
|
|
context,
|
|
emoji.guild!,
|
|
'EMOJI_CREATE',
|
|
'Emoji Created',
|
|
`**Name:** ${emoji.name}`
|
|
);
|
|
});
|
|
|
|
client.on('emojiUpdate', async (oldEmoji, newEmoji) => {
|
|
await sendLogEmbed(
|
|
context,
|
|
newEmoji.guild!,
|
|
'EMOJI_UPDATE',
|
|
'Emoji Updated',
|
|
`**Before:** ${oldEmoji.name}\n**After:** ${newEmoji.name}`
|
|
);
|
|
});
|
|
|
|
client.on('emojiDelete', async (emoji) => {
|
|
await sendLogEmbed(
|
|
context,
|
|
emoji.guild!,
|
|
'EMOJI_DELETE',
|
|
'Emoji Deleted',
|
|
`**Name:** ${emoji.name}`
|
|
);
|
|
});
|
|
|
|
client.on('stickerCreate', async (sticker) => {
|
|
if (!sticker.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
sticker.guild,
|
|
'STICKER_CREATE',
|
|
'Sticker Created',
|
|
`**Name:** ${sticker.name}`
|
|
);
|
|
});
|
|
|
|
client.on('stickerUpdate', async (oldSticker, newSticker) => {
|
|
if (!newSticker.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newSticker.guild,
|
|
'STICKER_UPDATE',
|
|
'Sticker Updated',
|
|
`**Before:** ${oldSticker.name}\n**After:** ${newSticker.name}`
|
|
);
|
|
});
|
|
|
|
client.on('stickerDelete', async (sticker) => {
|
|
if (!sticker.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
sticker.guild,
|
|
'STICKER_DELETE',
|
|
'Sticker Deleted',
|
|
`**Name:** ${sticker.name}`
|
|
);
|
|
});
|
|
|
|
client.on('threadCreate', async (thread) => {
|
|
if (!thread.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
thread.guild,
|
|
'THREAD_CREATE',
|
|
'Thread Created',
|
|
`**Thread:** ${thread.name} (<#${thread.id}>)`
|
|
);
|
|
});
|
|
|
|
client.on('threadUpdate', async (oldThread, newThread) => {
|
|
if (!newThread.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newThread.guild,
|
|
'THREAD_UPDATE',
|
|
'Thread Updated',
|
|
`**Before:** ${oldThread.name}\n**After:** ${newThread.name}`
|
|
);
|
|
});
|
|
|
|
client.on('threadDelete', async (thread) => {
|
|
if (!thread.guild) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
thread.guild,
|
|
'THREAD_DELETE',
|
|
'Thread Deleted',
|
|
`**Thread:** ${thread.name} (\`${thread.id}\`)`
|
|
);
|
|
});
|
|
|
|
client.on('guildUpdate', async (oldGuild, newGuild) => {
|
|
const changes: string[] = [];
|
|
if (oldGuild.name !== newGuild.name) {
|
|
changes.push(`**Name:** ${oldGuild.name} → ${newGuild.name}`);
|
|
}
|
|
if (changes.length === 0) {
|
|
return;
|
|
}
|
|
await sendLogEmbed(
|
|
context,
|
|
newGuild,
|
|
'GUILD_UPDATE',
|
|
'Guild Updated',
|
|
changes.join('\n')
|
|
);
|
|
});
|
|
}
|