diff --git a/src/commands/admin/ban.ts b/src/commands/admin/ban.ts index fdc100f..94e9a95 100644 --- a/src/commands/admin/ban.ts +++ b/src/commands/admin/ban.ts @@ -23,7 +23,7 @@ const command: SlashCommand = { await member.ban({ reason }).catch(() => null); await interaction.reply({ content: `${user.tag} wurde gebannt. Grund: ${reason}` }); - context.logging.logAction(user, 'Ban', reason); + context.logging.logAction(user, 'Ban', reason, interaction.guild); } }; diff --git a/src/commands/admin/kick.ts b/src/commands/admin/kick.ts index 0cfbb6b..595ecf7 100644 --- a/src/commands/admin/kick.ts +++ b/src/commands/admin/kick.ts @@ -21,7 +21,7 @@ const command: SlashCommand = { } await member.kick(reason); await interaction.reply({ content: `${user.tag} wurde gekickt.` }); - context.logging.logAction(user, 'Kick', reason); + context.logging.logAction(user, 'Kick', reason, interaction.guild); } }; diff --git a/src/commands/admin/mute.ts b/src/commands/admin/mute.ts index c148241..a63ea7f 100644 --- a/src/commands/admin/mute.ts +++ b/src/commands/admin/mute.ts @@ -23,7 +23,7 @@ const command: SlashCommand = { } await member.timeout(minutes * 60 * 1000, reason).catch(() => null); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gemutet.` }); - context.logging.logAction(user, 'Mute', reason); + context.logging.logAction(user, 'Mute', reason, interaction.guild); } }; diff --git a/src/commands/admin/tempban.ts b/src/commands/admin/tempban.ts index 409f8a6..f1c4679 100644 --- a/src/commands/admin/tempban.ts +++ b/src/commands/admin/tempban.ts @@ -25,7 +25,7 @@ const command: SlashCommand = { await member.ban({ reason: `${reason} | ${minutes} Minuten` }); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten gebannt.` }); - context.logging.logAction(user, 'Tempban', reason); + context.logging.logAction(user, 'Tempban', reason, interaction.guild); setTimeout(async () => { await interaction.guild?.members.unban(user.id, 'Tempban abgelaufen').catch(() => null); diff --git a/src/commands/admin/timeout.ts b/src/commands/admin/timeout.ts index 5bed07a..b09f8f8 100644 --- a/src/commands/admin/timeout.ts +++ b/src/commands/admin/timeout.ts @@ -23,7 +23,7 @@ const command: SlashCommand = { } await member.timeout(minutes * 60 * 1000, reason).catch(() => null); await interaction.reply({ content: `${user.tag} wurde für ${minutes} Minuten in Timeout gesetzt.` }); - context.logging.logAction(user, 'Timeout', reason); + context.logging.logAction(user, 'Timeout', reason, interaction.guild); } }; diff --git a/src/commands/admin/unmute.ts b/src/commands/admin/unmute.ts index 10e73d9..82dcf99 100644 --- a/src/commands/admin/unmute.ts +++ b/src/commands/admin/unmute.ts @@ -19,7 +19,7 @@ const command: SlashCommand = { } await member.timeout(null).catch(() => null); await interaction.reply({ content: `${user.tag} ist nun entmuted.` }); - context.logging.logAction(user, 'Unmute'); + context.logging.logAction(user, 'Unmute', undefined, interaction.guild); } }; diff --git a/src/config/context.ts b/src/config/context.ts index 301d006..d3fece6 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -17,11 +17,13 @@ import { KnowledgeBaseService } from '../services/knowledgeBaseService'; import { RegisterService } from '../services/registerService'; import { StatsService } from '../services/statsService'; +const logging = new LoggingService(); + export const context = { client: null as Client | null, commandHandler: null as CommandHandler | null, - automod: new AutoModService(true, true), - logging: new LoggingService(), + logging, + automod: new AutoModService(logging, true, true), music: new MusicService(), tickets: new TicketService(), leveling: new LevelService(), diff --git a/src/events/guildBanAdd.ts b/src/events/guildBanAdd.ts index b1f62d7..8b01493 100644 --- a/src/events/guildBanAdd.ts +++ b/src/events/guildBanAdd.ts @@ -5,7 +5,7 @@ import { context } from '../config/context'; const event: EventHandler = { name: 'guildBanAdd', execute(ban: GuildBan) { - context.logging.logAction(ban.user, 'Ban'); + context.logging.logAction(ban.user, 'Ban', undefined, ban.guild); } }; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index d133104..374481e 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -8,7 +8,7 @@ const event: EventHandler = { async execute(message: Message) { const cfg = message.guildId ? settingsStore.get(message.guildId) : undefined; if (message.guildId) context.admin.trackEvent('message', message.guildId); - if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig); + if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg); if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message); // Ticket SLA + KB await context.tickets.trackFirstResponse(message); diff --git a/src/services/automodService.ts b/src/services/automodService.ts index 78d0adf..36e9911 100644 --- a/src/services/automodService.ts +++ b/src/services/automodService.ts @@ -1,5 +1,7 @@ import { Collection, Message, PermissionFlagsBits } from 'discord.js'; import { logger } from '../utils/logger'; +import { GuildSettings } from '../config/state'; +import { LoggingService } from './loggingService'; export interface AutomodConfig { spamThreshold?: number; @@ -37,11 +39,13 @@ export class AutoModService { }; private defaultBadwords = ['badword', 'spamword']; - constructor(private linkFilterEnabled = true, private antiSpamEnabled = true) {} + constructor(private logging?: LoggingService, private linkFilterEnabled = true, private antiSpamEnabled = true) {} - public async checkMessage(message: Message, cfg?: AutomodConfig) { - if (message.author.bot) return; - const config = { ...this.defaults, ...(cfg ?? {}) }; + public async checkMessage(message: Message, cfg?: AutomodConfig | GuildSettings) { + if (message.author.bot || message.webhookId) return; + if (!message.inGuild()) return; + const guildConfig = (cfg as GuildSettings)?.automodConfig ? (cfg as GuildSettings).automodConfig : cfg; + const config = { ...this.defaults, ...(guildConfig ?? {}) }; const member = message.member; if (member?.roles.cache.size && Array.isArray(config.whitelistRoles) && config.whitelistRoles.length) { @@ -51,22 +55,17 @@ export class AutoModService { if (this.linkFilterEnabled && config.deleteLinks !== false && this.containsLink(message.content, config.linkWhitelist)) { if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; - message.delete().catch(() => undefined); - message.channel - .send({ content: `${message.author}, Links sind hier nicht erlaubt.` }) - .then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)); + await this.deleteMessageWithReason(message, `${message.author}, Links sind hier nicht erlaubt.`); + const reason = `Link gefunden (nicht freigegeben)${config.linkWhitelist?.length ? ` | Whitelist: ${config.linkWhitelist.join(', ')}` : ''}`; logger.info(`Deleted link from ${message.author.tag}`); - await this.logAutomodAction(message, config, 'link_filter'); + await this.logAutomodAction(message, config, 'link_filter', reason); return true; } if (config.badWordFilter !== false && this.containsBadword(message.content, config.customBadwords)) { if (message.member?.permissions.has(PermissionFlagsBits.ManageGuild)) return false; - message.delete().catch(() => undefined); - message.channel - .send({ content: `${message.author}, bitte auf deine Wortwahl achten.` }) - .then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)); - await this.logAutomodAction(message, config, 'badword', message.content); + await this.deleteMessageWithReason(message, `${message.author}, bitte auf deine Wortwahl achten.`); + await this.logAutomodAction(message, config, 'badword', 'Badword erkannt', message.content); return true; } @@ -74,11 +73,9 @@ export class AutoModService { const letters = message.content.replace(/[^a-zA-Z]/g, ''); const upper = letters.replace(/[^A-Z]/g, ''); if (letters.length >= 10 && upper.length / letters.length > 0.7) { - message.delete().catch(() => undefined); - message.channel - .send({ content: `${message.author}, bitte weniger Capslock nutzen.` }) - .then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)); - await this.logAutomodAction(message, config, 'capslock', message.content); + await this.deleteMessageWithReason(message, `${message.author}, bitte weniger Capslock nutzen.`); + const ratio = Math.round((upper.length / letters.length) * 100); + await this.logAutomodAction(message, config, 'capslock', `Caps Anteil ${ratio}%`, message.content); return true; } } @@ -98,12 +95,11 @@ export class AutoModService { if (tracker.count >= threshold) { const timeoutMs = (config.spamTimeoutMinutes ?? this.defaults.spamTimeoutMinutes!) * 60 * 1000; message.member?.timeout(timeoutMs, 'Automod: Spam').catch(() => undefined); - message.channel - .send({ content: `${message.author}, bitte langsamer schreiben (Spam-Schutz).` }) - .then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)); + await this.deleteMessageWithReason(message, `${message.author}, bitte langsamer schreiben (Spam-Schutz).`); logger.warn(`Timed out ${message.author.tag} for spam`); this.spamTracker.delete(message.author.id); - await this.logAutomodAction(message, config, 'spam', `Count ${tracker.count}`); + const reason = `Spam erkannt (${tracker.count}/${threshold} Nachrichten innerhalb ${config.windowMs ?? this.windowMs}ms)`; + await this.logAutomodAction(message, config, 'spam', reason); return true; } } @@ -125,10 +121,30 @@ export class AutoModService { return !normalized.some((w) => url.includes(w)); } - private async logAutomodAction(message: Message, config: AutomodConfig, action: string, details?: string) { + private async deleteMessageWithReason(message: Message, response: string) { + await message.delete().catch(() => undefined); + await message.channel + .send({ content: response }) + .then((m) => setTimeout(() => m.delete().catch(() => undefined), 5000)) + .catch(() => undefined); + } + + private async logAutomodAction(message: Message, config: AutomodConfig, action: string, reason: string, content?: string) { try { const guild = message.guild; if (!guild) return; + if (this.logging) { + this.logging.logAutomodAction(guild, { + userTag: message.author.tag, + userId: message.author.id, + action, + reason, + content, + channel: guild.channels.cache.get(message.channelId) ?? null, + messageUrl: message.url + }); + return; + } const loggingCfg = config.loggingConfig || {}; const flags = loggingCfg.categories || {}; if (flags.automodActions === false) return; @@ -136,8 +152,8 @@ export class AutoModService { if (!channelId) return; const channel = await guild.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased()) return; - const content = `[Automod] ${action} by ${message.author.tag}${details ? ` | ${details}` : ''}`; - await channel.send({ content }); + const body = `[Automod] ${action} by ${message.author.tag} | ${reason}${content ? ` | ${content.slice(0, 1800)}` : ''}`; + await channel.send({ content: body }); } catch (err) { logger.error('Automod log failed', err); } diff --git a/src/services/loggingService.ts b/src/services/loggingService.ts index a2af72a..379277a 100644 --- a/src/services/loggingService.ts +++ b/src/services/loggingService.ts @@ -45,7 +45,7 @@ export class LoggingService { private resolve(guild: Guild) { const cfg = settingsStore.get(guild.id); const loggingCfg = cfg?.loggingConfig || cfg?.automodConfig?.loggingConfig || {}; - const logChannelId = loggingCfg.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId; + const logChannelId = loggingCfg.logChannelId || cfg?.automodConfig?.logChannelId || cfg?.logChannelId || this.fallbackLogChannelId; const flags = loggingCfg.categories || {}; const channel = logChannelId ? guild.channels.cache.get(logChannelId) : null; return { channel: channel && channel.type === 0 ? (channel as TextChannel) : null, flags }; @@ -128,11 +128,11 @@ export class LoggingService { }); } - logAction(user: User, action: string, reason?: string) { - const guild = user instanceof GuildMember ? user.guild : null; - if (!guild) return; - if (!this.shouldLog(guild, 'automodActions')) return; - const { channel } = this.resolve(guild); + logAction(user: User | GuildMember, action: string, reason?: string, guild?: Guild) { + const resolvedGuild = guild ?? (user instanceof GuildMember ? user.guild : null); + if (!resolvedGuild) return; + if (!this.shouldLog(resolvedGuild, 'automodActions')) return; + const { channel } = this.resolve(resolvedGuild); if (!channel) return; const embed = new EmbedBuilder() .setTitle('Moderation') @@ -141,7 +141,7 @@ export class LoggingService { .setColor(0x7289da) .setTimestamp(); channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log action', err)); - const guildId = (user as GuildMember)?.guild?.id; + const guildId = resolvedGuild.id; if (guildId) { adminSink?.pushGuildLog({ guildId, @@ -154,6 +154,36 @@ export class LoggingService { } } + logAutomodAction(guild: Guild, options: { userTag: string; userId: string; action: string; reason: string; content?: string; channel?: GuildChannel | null; messageUrl?: string }) { + if (!this.shouldLog(guild, 'automodActions')) return; + const { channel } = this.resolve(guild); + if (!channel) return; + const embed = new EmbedBuilder() + .setTitle('Automod') + .setDescription(`${options.userTag} (${options.userId}) -> ${options.action}`) + .addFields( + { name: 'Grund', value: this.safeField(options.reason) }, + { name: 'Kanal', value: options.channel ? `<#${options.channel.id}>` : 'Unbekannt' } + ) + .setColor(0xff006e) + .setTimestamp(); + if (options.content) { + embed.addFields({ name: 'Nachricht', value: this.safeField(options.content) }); + } + if (options.messageUrl) { + embed.addFields({ name: 'Link', value: options.messageUrl }); + } + channel.send({ embeds: [embed] }).catch((err) => logger.error('Failed to log automod action', err)); + adminSink?.pushGuildLog({ + guildId: guild.id, + level: 'INFO', + message: `Automod: ${options.action} (${options.userTag})`, + timestamp: Date.now(), + category: 'automodActions' + }); + adminSink?.trackGuildEvent(guild.id, 'automod'); + } + logRoleUpdate(member: GuildMember, added: string[], removed: string[]) { const guildId = member.guild.id; adminSink?.pushGuildLog({