diff --git a/AGENTS-BOT.md b/AGENTS-BOT.md index c61f11f..208a7ec 100644 --- a/AGENTS-BOT.md +++ b/AGENTS-BOT.md @@ -158,3 +158,9 @@ if (process.env.AUTO_DEPLOY !== 'false') { 11. **Select Menu Emoji Handling:** Never pass an empty string `''` to `setEmoji()`. Only call `setEmoji()` when a valid, non-empty emoji string is provided. 12. **Team Application Owner Support:** When checking bot owner status, check `process.env.BOT_OWNER_ID` or test if `client.application.owner` is a `Team` object with `owner.members.has(userId)`. 13. **Stopwatch Duplicate Prevention & Custom Start Time:** When creating stopwatches, perform a case-insensitive name uniqueness check per user/guild (`LOWER(name)`). Validate custom start times (`startzeit`) to ensure they do not lie in the future. +14. **Public vs. Mod Subcommands & Ephemeral Output:** For commands offering both public query options and mod-only actions (e.g. `/trigger`, `/timer`, `/twitch`), do not set `defaultMemberPermissions` on the root SlashCommandBuilder if subcommands like `list` or `help` should be public. Check `isAdmin` inside mutating subcommands (`add`, `remove`), and deliver `list`/`help` subcommands with `ephemeral: true`. +15. **Server-Bound Commands & DM Isolation:** Commands relying on guild context (`guildId`, roles, settings) must enforce `.setDMPermission(false)` and validate `if (!interaction.guildId) return;` to prevent SQLite null constraint errors in direct messages. +16. **Guild Settings Provisioning:** Before issuing raw SQL updates on `guild_settings`, call `client.DB.getSettings(guildId)` to ensure the guild settings record exists in SQLite. +17. **Unicode-Safe Trigger Matching & URL Filtering:** In `messageCreate`, strip URLs before matching auto-responses and use Unicode property escapes (`(? ` – Timer hinzufügen @@ -99,8 +99,8 @@ npm run deploy ### Trigger (Auto-Antworten) - `/trigger add ` – Trigger hinzufügen (Mod) - `/trigger remove ` – Trigger entfernen (Mod) -- `/trigger list` – Alle Trigger anzeigen -- `/trigger help` – Trigger-Hilfe +- `/trigger list` – Alle Trigger anzeigen (Für alle User, ephemeral) +- `/trigger help` – Trigger-Hilfe (Für alle User, ephemeral) ### Twitch - `/twitch online ` – Prüft ob Kanal online ist @@ -119,12 +119,26 @@ npm run deploy ### Rollen-Auswahl - `/rolesetup create <#kanal> ` – Rollen-Auswahl Message erstellen - `/rolesetup add <@rolle> [emoji]` – Rolle zur Auswahl hinzufügen -- `/rolesetup edit [neues_emoji]` – Emoji ändern +- `/rolesetup edit [neue_rolle] [neues_emoji]` – Rolle oder Emoji ändern - `/rolesetup remove ` – Rolle entfernen - `/rolesetup delete ` – Kategorie löschen - `/rolesetup config max:1 exclusive:true` – Einstellungen - `/rolesetup list` – Alle Kategorien anzeigen +### Welcome & Goodbye +- `/welcome setchannel <#kanal>` – Kanal für Willkommen/Abschied setzen (Mod) +- `/welcome add ` – Nachricht setzen (Mod) +- `/welcome remove ` – Nachricht entfernen (Mod) +- `/welcome list` – Aktuelle Einstellungen anzeigen (Mod) +- `/welcome help` – Welcome-Hilfe + +### Log (Server-Logs) +- `/log setchannel <#kanal>` – Log-Kanal setzen (Mod) +- `/log enable ` – Event zum Loggen aktivieren (Mod) +- `/log disable ` – Event deaktivieren (Mod) +- `/log list` – Aktive Log-Events anzeigen (Mod) +- `/log help` – Log-Hilfe + ### Admin - `/admin kick ` – Nutzer kicken - `/admin warn ` – Nutzer verwarnen @@ -153,9 +167,9 @@ npm run deploy - ✅ **Centralized Event Logging:** Ein zentrales `EventLogger`-System zur strukturierten und verlässlichen Erfassung von Server-Ereignissen (Nachrichten, Rollen, Nicknames, Kanäle, Kicks, Bans, Warnungen). - ✅ **Optimized Twitch Polling:** Nutzt **Batch-Requests** (100 Kanäle pro Request), Datenbank-Transaktionen und ein 2-Minuten-Intervall für zeitnahe Benachrichtigungen. - ✅ **TwitchMonitor IRC:** Echtzeit IRC Chat-Logging (Mod-Events) via **wiederverwendbaren Webhooks** und FIFO-Cache. -- ✅ **Advanced Moderation & Safety:** Warn-System mit Auto-Actions, umfangreiches Purge-System, abgesicherte Timeout-Limits (max. 28 Tage gemäß Discord-API) und vollständiges Exception handling. -- ✅ **Role Selection:** Self-Service Rollen-System über Discord Select Menus mit automatischer Berechtigungsprüfung und Fehlerabfangung. -- ✅ **Smart Auto-Response System:** Präzises Trigger-Wort-Matching mit Wortgrenzen-Regex (`\b`), um Falsch-Positive zu vermeiden. +- ✅ **Advanced Moderation & Safety:** Warn-System mit Auto-Actions, umfangreiches Purge-System, Rollen-Hierarchie-Schutz (Moderatoren können sich oder höherrangige Rollen nicht kicken/bannen/muten), abgesicherte Timeout-Limits (max. 28 Tage gemäß Discord-API) und vollständiges Exception handling. +- ✅ **Role Selection:** Self-Service Rollen-System über Discord Select Menus mit automatischer Berechtigungsprüfung, Menü-Reset nach Auswahl und Fehlerabfangung. +- ✅ **Smart Auto-Response System:** Präzises Trigger-Wort-Matching mit Unicode-Wortgrenzen (`\p{L}`), URL-Filterung gegen False Positives und ephemeraler Trigger-Liste für alle Server-Mitglieder. - ✅ **Reminders:** Intelligentes Zeit-Parsing (z.B. "morgen 15:00", "30min"). - ✅ **Persistent Stopwatches:** Namentliche hochzählende Stoppuhren mit SQLite-Speicherung, Unterstützung für optionale vergangene Startzeiten (z.B. "14:30", "2h"), Duplikat-Schutz und relativer Discord-Zeitdarstellung. - ✅ **Developer & Team Support:** Unterstützung von Discord Developer Teams und `BOT_OWNER_ID` Fallback für Owner-Befehle. diff --git a/src/commands/utility/admin.ts b/src/commands/utility/admin.ts index 74ac9e6..15eb946 100644 --- a/src/commands/utility/admin.ts +++ b/src/commands/utility/admin.ts @@ -116,8 +116,9 @@ const command: Command = { .setDescription('Neuer Wert')) .addIntegerOption(option => option.setName('duration_value') - .setDescription('Dauer in Sekunden') - .setMinValue(0))) + .setDescription('Dauer in Sekunden (1 - 2419200)') + .setMinValue(1) + .setMaxValue(2419200))) .addSubcommandGroup(group => group .setName('purge') @@ -171,7 +172,8 @@ const command: Command = { subcommand .setName('help') .setDescription('Zeigt Hilfe zu den Admin-Befehlen an.')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setDMPermission(false), category: 'Admin', async execute(interaction: any) { const client = interaction.client as ExtendedClient; @@ -179,6 +181,14 @@ const command: Command = { const DB = client.DB; const guild = interaction.guild; const guildId = interaction.guildId; + if (!guildId || !guild) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); if (subcommand === 'help') { const embed = new EmbedBuilder() @@ -206,6 +216,25 @@ const command: Command = { return; } + if (!isAdmin) { + await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); + return; + } + + const checkTargetHierarchy = (target: GuildMember): string | null => { + if (target.id === interaction.user.id) { + return '❌ Du kannst diese Aktion nicht auf dich selbst anwenden.'; + } + if (target.id === guild.ownerId) { + return '❌ Diese Aktion kann nicht auf den Server-Owner angewendet werden.'; + } + const modMember = interaction.member as GuildMember | null; + if (modMember && interaction.user.id !== guild.ownerId && target.roles.highest.position >= modMember.roles.highest.position) { + return '❌ Du kannst keinen Nutzer moderieren, der eine gleich hohe oder höhere Rolle als du hat.'; + } + return null; + }; + // Get or create guild settings const settings = DB.getSettings(guildId); @@ -218,6 +247,12 @@ const command: Command = { return; } + const hierarchyError = checkTargetHierarchy(target); + if (hierarchyError) { + await interaction.reply({ content: hierarchyError, ephemeral: true }); + return; + } + if (!target.kickable) { await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', ephemeral: true }); return; @@ -239,7 +274,9 @@ const command: Command = { await EventLogger.sendLog(interaction.client, guildId, 'kicks', embed); } catch (error) { console.error(error); - await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true }); + } } return; } @@ -253,6 +290,12 @@ const command: Command = { return; } + const hierarchyError = checkTargetHierarchy(target); + if (hierarchyError) { + await interaction.reply({ content: hierarchyError, ephemeral: true }); + return; + } + DB.run( 'INSERT INTO mod_logs (guild_id, user_id, moderator_id, action, reason) VALUES (?, ?, ?, ?, ?)', guildId, @@ -284,9 +327,11 @@ const command: Command = { await target.kick(`Auto-Kick: ${warnThreshold} Warnungen`); message += `\n👢 **Automatisch gekickt!**`; } else if (warnAction === 'mute') { - const muteDuration = settings?.warn_mute_duration ?? 1800; // 30 min default + const MAX_TIMEOUT_SEC = 28 * 24 * 60 * 60; + const rawDuration = settings?.warn_mute_duration ?? 1800; // 30 min default + const muteDuration = Math.min(Math.max(rawDuration, 1), MAX_TIMEOUT_SEC); await target.timeout(muteDuration * 1000, `Auto-Mute: ${warnThreshold} Warnungen`); - const durationMins = Math.floor(muteDuration / 60); + const durationMins = Math.max(1, Math.floor(muteDuration / 60)); message += `\n🔇 **Automatisch gemuted (${durationMins} Min)!**`; } else if (warnAction === 'ban') { await target.ban({ reason: `Auto-Ban: ${warnThreshold} Warnungen` }); @@ -350,6 +395,12 @@ const command: Command = { return; } + const hierarchyError = checkTargetHierarchy(target); + if (hierarchyError) { + await interaction.reply({ content: hierarchyError, ephemeral: true }); + return; + } + // Parse duration let durationMs: number | null = null; let durationText = ''; @@ -413,7 +464,9 @@ const command: Command = { await EventLogger.sendLog(interaction.client, guildId, 'mutes', embed); } catch (error) { console.error(error); - await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true }); + } } return; } @@ -446,6 +499,12 @@ const command: Command = { return; } + const hierarchyError = checkTargetHierarchy(target); + if (hierarchyError) { + await interaction.reply({ content: hierarchyError, ephemeral: true }); + return; + } + if (!target.bannable) { await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', ephemeral: true }); return; @@ -468,7 +527,9 @@ const command: Command = { await EventLogger.sendLog(interaction.client, guildId, 'bans', embed); } catch (error) { console.error(error); - await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true }); + } } return; } @@ -510,35 +571,37 @@ const command: Command = { } if (setting === 'warn_threshold') { - const threshold = parseInt(value!); + const threshold = value !== null ? parseInt(value) : (durationValue ?? NaN); if (isNaN(threshold) || threshold < 1) { await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', ephemeral: true }); return; } DB.run('UPDATE guild_settings SET warn_threshold = ? WHERE guild_id = ?', threshold, guildId); - await interaction.reply(`✅ Warn-Limit auf ${threshold} gesetzt.`); + await interaction.reply({ content: `✅ Warn-Limit auf ${threshold} gesetzt.`, ephemeral: true }); return; } if (setting === 'warn_action') { const validActions = ['none', 'kick', 'mute', 'ban']; - if (!validActions.includes(value!)) { + if (!value || !validActions.includes(value)) { await interaction.reply({ content: `❌ Ungültige Aktion. Möglich: ${validActions.join(', ')}`, ephemeral: true }); return; } DB.run('UPDATE guild_settings SET warn_action = ? WHERE guild_id = ?', value, guildId); - await interaction.reply(`✅ Auto-Aktion auf ${value} gesetzt.`); + await interaction.reply({ content: `✅ Auto-Aktion auf ${value} gesetzt.`, ephemeral: true }); return; } if (setting === 'warn_mute_duration') { - const duration = durationValue ?? 1800; - if (duration < 0) { - await interaction.reply({ content: '❌ Ungültige Dauer.', ephemeral: true }); + const parsedValue = value !== null ? parseInt(value) : NaN; + const duration = durationValue ?? (!isNaN(parsedValue) ? parsedValue : 1800); + const MAX_TIMEOUT_SEC = 28 * 24 * 60 * 60; + if (duration < 1 || duration > MAX_TIMEOUT_SEC) { + await interaction.reply({ content: `❌ Ungültige Dauer. Erlaubt sind 1 bis ${MAX_TIMEOUT_SEC} Sekunden (max. 28 Tage).`, ephemeral: true }); return; } DB.run('UPDATE guild_settings SET warn_mute_duration = ? WHERE guild_id = ?', duration, guildId); - await interaction.reply(`✅ Mute-Dauer auf ${duration} Sekunden (${Math.floor(duration/60)} Min) gesetzt.`); + await interaction.reply({ content: `✅ Mute-Dauer auf ${duration} Sekunden (${Math.floor(duration/60)} Min) gesetzt.`, ephemeral: true }); return; } } diff --git a/src/commands/utility/log.ts b/src/commands/utility/log.ts index 188c52e..b310cdd 100644 --- a/src/commands/utility/log.ts +++ b/src/commands/utility/log.ts @@ -51,12 +51,19 @@ const command: Command = { subcommand .setName('help') .setDescription('Hilfe zu Log-Befehlen (Mod).')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setDMPermission(false), category: 'Admin', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; @@ -84,6 +91,9 @@ const command: Command = { return; } + // Ensure guild settings row exists before reading or updating + const settings = DB.getSettings(guildId); + if (subcommand === 'setchannel') { const channel = interaction.options.getChannel('channel'); @@ -98,8 +108,6 @@ const command: Command = { } if (subcommand === 'list') { - const settings = DB.getSettings(guildId); - const channel = settings?.log_channel ? `<#${settings.log_channel}>` : 'Nicht gesetzt'; diff --git a/src/commands/utility/rolesetup.ts b/src/commands/utility/rolesetup.ts index 325f5ac..dc7bf35 100644 --- a/src/commands/utility/rolesetup.ts +++ b/src/commands/utility/rolesetup.ts @@ -20,8 +20,11 @@ export async function updateRoleMessage(client: any, category: any) { } const roleOptions = options.map((opt: any) => { + const cleanLabel = opt.emoji && opt.label.startsWith(`${opt.emoji} `) + ? opt.label.slice(opt.emoji.length + 1) + : opt.label; const menuOption = new StringSelectMenuOptionBuilder() - .setLabel(opt.label.replace(/^[^\s]+\s/, '')) + .setLabel(cleanLabel) .setValue(opt.role_id); if (opt.emoji && opt.emoji.trim().length > 0) { menuOption.setEmoji(opt.emoji.trim()); @@ -95,7 +98,7 @@ const command: Command = { option.setName('rolle') .setDescription('Die Discord-Rolle zum Ändern') .setRequired(true)) - .addStringOption(option => + .addRoleOption(option => option.setName('neue_rolle') .setDescription('Die neue Discord-Rolle') .setRequired(false)) @@ -148,14 +151,26 @@ const command: Command = { subcommand .setName('list') .setDescription('Listet alle Rollen-Kategorien auf')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setDMPermission(false), category: 'Admin', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; + if (!isAdmin) { + await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); + return; + } + if (subcommand === 'list') { const categories: any[] = DB.all('SELECT * FROM role_categories WHERE guild_id = ? ORDER BY category_name ASC', guildId); @@ -323,7 +338,7 @@ const command: Command = { if (discordRole) { const members = guild.members.cache.filter((m: any) => m.roles.cache.has(role.id)); for (const member of members.values()) { - await member.roles.remove(role.id); + await member.roles.remove(role.id).catch(() => null); } } } @@ -356,7 +371,7 @@ const command: Command = { if (discordRole) { const members = guild.members.cache.filter((m: any) => m.roles.cache.has(opt.role_id)); for (const member of members.values()) { - await member.roles.remove(opt.role_id); + await member.roles.remove(opt.role_id).catch(() => null); } } } diff --git a/src/commands/utility/stopwatch.ts b/src/commands/utility/stopwatch.ts index 6a93c07..34f91d3 100644 --- a/src/commands/utility/stopwatch.ts +++ b/src/commands/utility/stopwatch.ts @@ -125,12 +125,16 @@ const command: Command = { subcommand .setName('help') .setDescription('Zeigt Hilfe zu den Stoppuhr-Befehlen an.')) - .setDMPermission(true), + .setDMPermission(false), category: 'Public', async execute(interaction: any) { const client = interaction.client as ExtendedClient; const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } const userId = interaction.user.id; const channelId = interaction.channelId; const DB = client.DB; diff --git a/src/commands/utility/timer.ts b/src/commands/utility/timer.ts index 5b36851..2d12315 100644 --- a/src/commands/utility/timer.ts +++ b/src/commands/utility/timer.ts @@ -134,14 +134,21 @@ const command: Command = { .addSubcommand(subcommand => subcommand .setName('help') - .setDescription('Zeigt Hilfe zu den Timer-Befehlen an.')), + .setDescription('Zeigt Hilfe zu den Timer-Befehlen an.')) + .setDMPermission(false), category: 'Public', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } const channelId = interaction.channelId; const userId = interaction.user.id; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; if (subcommand === 'help') { diff --git a/src/commands/utility/trigger.ts b/src/commands/utility/trigger.ts index 5b7d69e..911431b 100644 --- a/src/commands/utility/trigger.ts +++ b/src/commands/utility/trigger.ts @@ -33,12 +33,18 @@ const command: Command = { subcommand .setName('help') .setDescription('Zeigt Hilfe zu den Trigger-Befehlen an.')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - category: 'Admin', + .setDMPermission(false), + category: 'Public', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; if (subcommand === 'help') { @@ -99,9 +105,14 @@ const command: Command = { } if (subcommand === 'add') { - const triggerWord = interaction.options.getString('trigger')!.toLowerCase(); + const triggerWord = interaction.options.getString('trigger')!.trim().toLowerCase(); const responseText = interaction.options.getString('antwort')!; + if (!triggerWord) { + await interaction.reply({ content: '❌ Das Trigger-Wort darf nicht leer sein.', ephemeral: true }); + return; + } + DB.run( 'INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?) ON CONFLICT(guild_id, trigger_word) DO UPDATE SET response_text = ?', guildId, @@ -115,7 +126,7 @@ const command: Command = { } if (subcommand === 'remove') { - const triggerWord = interaction.options.getString('trigger')!.toLowerCase(); + const triggerWord = interaction.options.getString('trigger')!.trim().toLowerCase(); const result = DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord); diff --git a/src/commands/utility/twitch.ts b/src/commands/utility/twitch.ts index 6826bd1..6be6f89 100644 --- a/src/commands/utility/twitch.ts +++ b/src/commands/utility/twitch.ts @@ -45,13 +45,20 @@ const command: Command = { .addSubcommand(subcommand => subcommand .setName('help') - .setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')), + .setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')) + .setDMPermission(false), category: 'Public', async execute(interaction: any) { const client = interaction.client as ExtendedClient; const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = client.DB; if (subcommand === 'help') { @@ -65,7 +72,7 @@ const command: Command = { { name: '`/twitch remove streamer1, streamer2, ...`', value: 'Entfernt Twitch-Kanäle (Mod).', inline: false }, { name: '`/twitch list`', value: 'Zeigt überwachte Kanäle in diesem Kanal.', inline: false }, { name: '`/twitch listall`', value: 'Zeigt alle überwachten Kanäle des Servers (Mod).', inline: false }, - { name: 'Beispiele', value: '`/twitch add shroud, ninja`\n`/twitch add #general shroud, ninja "Live!"`\n`/twitch remove shroud, ninja`', inline: false } + { name: 'Beispiele', value: '`/twitch add streamers: shroud, ninja`\n`/twitch add streamers: shroud, ninja message: Live!`\n`/twitch remove streamers: shroud, ninja`', inline: false } ) .setTimestamp(); @@ -184,13 +191,19 @@ const command: Command = { return; } + await interaction.deferReply({ ephemeral: true }); + const results = { added: 0, updated: 0, errors: 0 }; const errors: string[] = []; for (const streamer of streamers) { try { - // Check if stream exists - await TwitchManager.fetchStreamData(streamer); + const isValid = await TwitchManager.validateUser(streamer); + if (!isValid) { + errors.push(streamer); + results.errors++; + continue; + } // Check if already exists const existing = DB.get('SELECT id FROM twitch_monitors WHERE guild_id = ? AND channel_name = ?', guildId, streamer); @@ -211,12 +224,12 @@ const command: Command = { } } - let reply = `✅ **${streamers.length}** Twitch-Kanal/Kanäle für <#${discordChannelId}> konfiguriert.`; + let reply = `✅ **${results.added + results.updated}** Twitch-Kanal/Kanäle für <#${discordChannelId}> konfiguriert.`; if (results.added > 0) reply += `\n🆕 Neu: ${results.added}`; if (results.updated > 0) reply += `\n🔄 Aktualisiert: ${results.updated}`; if (results.errors > 0) reply += `\n❌ Fehler: ${results.errors} (${errors.join(', ')})`; - await interaction.reply({ content: reply, ephemeral: true }); + await interaction.editReply({ content: reply }); return; } diff --git a/src/commands/utility/twitchmonitor.ts b/src/commands/utility/twitchmonitor.ts index 2a39f00..eb32c3c 100644 --- a/src/commands/utility/twitchmonitor.ts +++ b/src/commands/utility/twitchmonitor.ts @@ -35,12 +35,19 @@ const command: Command = { subcommand .setName('help') .setDescription('Zeigt Hilfe zu den TwitchMonitor-Befehlen')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setDMPermission(false), category: 'Admin', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; if (subcommand === 'help') { @@ -59,6 +66,11 @@ const command: Command = { return; } + if (!isAdmin) { + await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); + return; + } + if (subcommand === 'list') { const monitors: any[] = DB.all('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? ORDER BY twitch_channel ASC', guildId); @@ -79,13 +91,8 @@ const command: Command = { return; } - if (!isAdmin) { - await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); - return; - } - if (subcommand === 'add') { - const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, ''); + const twitchChannel = interaction.options.getString('twitch').trim().toLowerCase().replace(/^[@#]/, ''); const discordChannel = interaction.options.getChannel('discord'); const existing = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel); @@ -94,34 +101,41 @@ const command: Command = { return; } - const webhook = await discordChannel.createWebhook({ - name: `${twitchChannel} | Mod-Log`, - avatar: undefined, - }).catch(async () => { - const existing_webhook = await discordChannel.fetchWebhooks().then((webhooks: any) => - webhooks.find((w: any) => w.name?.includes(twitchChannel)) + await interaction.deferReply({ ephemeral: true }); + + try { + const webhook = await discordChannel.createWebhook({ + name: `${twitchChannel} | Mod-Log`, + avatar: undefined, + }).catch(async () => { + const existing_webhook = await discordChannel.fetchWebhooks().then((webhooks: any) => + webhooks.find((w: any) => w.name?.includes(twitchChannel)) + ); + if (existing_webhook) return existing_webhook; + throw new Error('Could not create webhook'); + }); + + DB.run( + 'INSERT INTO twitch_monitor_channels (guild_id, twitch_channel, discord_channel_id, webhook_url) VALUES (?, ?, ?, ?)', + guildId, + twitchChannel, + discordChannel.id, + webhook.url ); - if (existing_webhook) return existing_webhook; - throw new Error('Could not create webhook'); - }); - DB.run( - 'INSERT INTO twitch_monitor_channels (guild_id, twitch_channel, discord_channel_id, webhook_url) VALUES (?, ?, ?, ?)', - guildId, - twitchChannel, - discordChannel.id, - webhook.url - ); + const monitor = TwitchMonitor.getInstance(); + await monitor.addMonitor(guildId, twitchChannel, discordChannel.id, webhook.url); - const monitor = TwitchMonitor.getInstance(); - await monitor.addMonitor(guildId, twitchChannel, discordChannel.id, webhook.url); - - await interaction.reply({ content: `${twitchChannel} wird jetzt überwacht.`, ephemeral: true }); + await interaction.editReply({ content: `✅ **${twitchChannel}** wird jetzt in ${discordChannel} überwacht.` }); + } catch (error) { + console.error('[TWITCHMONITOR] Error adding monitor:', error); + await interaction.editReply({ content: '❌ Konnte keinen Webhook erstellen. Bitte prüfe, ob der Bot die Berechtigung "Webhooks verwalten" im Ziel-Kanal hat.' }); + } return; } if (subcommand === 'remove') { - const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, ''); + const twitchChannel = interaction.options.getString('twitch').trim().toLowerCase().replace(/^[@#]/, ''); const monitor: any = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel); if (!monitor) { diff --git a/src/commands/utility/welcome.ts b/src/commands/utility/welcome.ts index ebac5a8..b1f3098 100644 --- a/src/commands/utility/welcome.ts +++ b/src/commands/utility/welcome.ts @@ -49,15 +49,21 @@ const command: Command = { subcommand .setName('help') .setDescription('Hilfe zu Welcome-Befehlen.')) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .setDMPermission(false), category: 'Admin', async execute(interaction: any) { const subcommand = interaction.options.getSubcommand(); const guildId = interaction.guildId; - const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); + if (!guildId) { + await interaction.reply({ content: '❌ Dieser Befehl kann nur auf einem Server verwendet werden.', ephemeral: true }); + return; + } + const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers) || + interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || + interaction.memberPermissions?.has(PermissionFlagsBits.Administrator); const DB = interaction.client.DB; - if (subcommand === 'help') { const embed = new EmbedBuilder() .setTitle('👋 Welcome Hilfe') @@ -78,9 +84,16 @@ const command: Command = { return; } - if (subcommand === 'list') { - const settings = DB.getSettings(guildId); + // Admin only for all commands + if (!isAdmin) { + await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); + return; + } + // Ensure guild settings row exists before reading or updating + const settings = DB.getSettings(guildId); + + if (subcommand === 'list') { const channel = settings?.welcome_channel ? `<#${settings.welcome_channel}>` : 'Nicht gesetzt'; @@ -103,12 +116,6 @@ const command: Command = { return; } - // Admin only for modify commands - if (!isAdmin) { - await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true }); - return; - } - if (subcommand === 'setchannel') { const channel = interaction.options.getChannel('channel'); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 871d7e3..03988b3 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -50,6 +50,7 @@ export default { ephemeral: true }); } + await interaction.message.edit({ components: interaction.message.components }).catch(() => null); return; } @@ -116,6 +117,7 @@ export default { }); } } + await interaction.message.edit({ components: interaction.message.components }).catch(() => null); } catch (error) { console.error('[ROLESETUP] Role assignment error:', error); const errorMsg = '❌ Fehler beim Ändern der Rolle. Mir fehlen eventuell die Rechte dazu.'; @@ -126,7 +128,6 @@ export default { } } return; - return; } } diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 0838d8f..80c951a 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -14,13 +14,20 @@ export default { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + // Strip URLs before checking triggers so domain names don't trigger auto-responses + const contentWithoutUrls = message.content.replace(/https?:\/\/\S+/gi, ' '); + for (const trigger of triggers) { const word = trigger.trigger_word.trim().toLowerCase(); if (!word) continue; - const pattern = new RegExp(`(?:^|\\s|\\b)${escapeRegex(word)}(?:$|\\s|\\b)`, 'i'); - if (pattern.test(message.content)) { - await message.channel.send(trigger.response_text); + const pattern = new RegExp(`(? 0; if (webhookExists) { diff --git a/src/structures/TwitchManager.ts b/src/structures/TwitchManager.ts index 8784414..71c2ad3 100644 --- a/src/structures/TwitchManager.ts +++ b/src/structures/TwitchManager.ts @@ -63,6 +63,31 @@ export class TwitchManager { return streams[0] || null; } + /** + * Validates whether a Twitch user/channel exists via the Helix Users API. + */ + static async validateUser(channelName: string): Promise { + if (!/^[a-z0-9_]{1,25}$/i.test(channelName)) return false; + + const token = await this.getAccessToken(); + if (!token) return true; // Fallback if Twitch API credentials are not configured locally + + try { + const response = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(channelName)}`, { + headers: { + 'Client-ID': process.env.TWITCH_CLIENT_ID!, + 'Authorization': `Bearer ${token}` + } + }); + if (!response.ok) return false; + const data: any = await response.json(); + return Array.isArray(data.data) && data.data.length > 0; + } catch (error) { + console.error(`[TWITCH] Error validating user ${channelName}:`, error); + return false; + } + } + static async checkStreams(client: ExtendedClient) { const monitors: any[] = client.DB?.all('SELECT * FROM twitch_monitors') || []; if (monitors.length === 0) return; diff --git a/src/structures/TwitchMonitor.ts b/src/structures/TwitchMonitor.ts index efbef59..61f2959 100644 --- a/src/structures/TwitchMonitor.ts +++ b/src/structures/TwitchMonitor.ts @@ -121,87 +121,89 @@ export class TwitchMonitor { this.client.on('messagedeleted', (channel: string, username: string, deletedMessage: string, userstate: DeleteUserstate) => { const msgId = userstate['target-msg-id'] || ''; const cached = this.cache.getByMsgId(msgId); - const result = this.findMonitor(channel); - if (!result) return; - - this.sendWebhook(result.monitor.webhookUrl, { - title: '🗑️ Nachricht gelöscht', - description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`, - color: 0xff0000, - }, result.key); + const results = this.findMonitors(channel); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '🗑️ Nachricht gelöscht', + description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`, + color: 0xff0000, + }, result.key); + } }); this.client.on('ban', (channel: string, username: string, reason: string) => { - const result = this.findMonitor(channel); - if (!result) return; + const results = this.findMonitors(channel); + if (results.length === 0) return; const lastMessages = this.cache.getLastN(username, channel, 5); const context = lastMessages.length > 0 ? `\n\n**Letzte Nachrichten:**\n${lastMessages.map(m => `• ${m.text}`).join('\n')}` : ''; - this.sendWebhook(result.monitor.webhookUrl, { - title: '🔨 User gebannt', - description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`, - color: 0x8b0000, - }, result.key); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '🔨 User gebannt', + description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`, + color: 0x8b0000, + }, result.key); + } }); this.client.on('timeout', (channel: string, username: string, reason: string) => { - const result = this.findMonitor(channel); - if (!result) return; - - this.sendWebhook(result.monitor.webhookUrl, { - title: '⏱️ User getimeouted', - description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`, - color: 0xffa500, - }, result.key); + const results = this.findMonitors(channel); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '⏱️ User getimeouted', + description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`, + color: 0xffa500, + }, result.key); + } }); this.client.on('clearchat', (channel: string) => { - const result = this.findMonitor(channel); - if (!result) return; - - this.sendWebhook(result.monitor.webhookUrl, { - title: '🧹 Chat geleert', - description: `Der Chat in **${channel}** wurde geleert.`, - color: 0xffa500, - }, result.key); + const results = this.findMonitors(channel); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '🧹 Chat geleert', + description: `Der Chat in **${channel}** wurde geleert.`, + color: 0xffa500, + }, result.key); + } }); this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => { - const result = this.findMonitor(channel); - if (!result) return; - + const results = this.findMonitors(channel); const amount = userstate.bits || 0; - this.sendWebhook(result.monitor.webhookUrl, { - title: '💰 Bit-Cheer', - description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`, - color: 0x9b59b6, - }, result.key); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '💰 Bit-Cheer', + description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`, + color: 0x9b59b6, + }, result.key); + } }); this.client.on('subscription', (channel: string, username: string, methods: SubMethods, message: string, userstate: SubUserstate) => { - const result = this.findMonitor(channel); - if (!result) return; - - this.sendWebhook(result.monitor.webhookUrl, { - title: '🅿️ Subscription', - description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`, - color: 0x9146ff, - }, result.key); + const results = this.findMonitors(channel); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '🅿️ Subscription', + description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`, + color: 0x9146ff, + }, result.key); + } }); this.client.on('resub', (channel: string, username: string, months: number, message: string, userstate: SubUserstate, methods: SubMethods) => { - const result = this.findMonitor(channel); - if (!result) return; - - this.sendWebhook(result.monitor.webhookUrl, { - title: '🅿️ Resubscription', - description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`, - color: 0x9146ff, - }, result.key); + const results = this.findMonitors(channel); + for (const result of results) { + this.sendWebhook(result.monitor.webhookUrl, { + title: '🅿️ Resubscription', + description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`, + color: 0x9146ff, + }, result.key); + } }); this.client.on('disconnected', async () => { @@ -231,13 +233,14 @@ export class TwitchMonitor { (this as any).discordClient = discordClient; } - private findMonitor(channel: string): { monitor: MonitorChannel; key: string } | undefined { + private findMonitors(channel: string): { monitor: MonitorChannel; key: string }[] { const normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`; - const entry = Array.from(this.monitors.entries()).find( - ([_, m]) => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() || - m.twitchChannel.toLowerCase() === channel.toLowerCase() - ); - return entry ? { key: entry[0], monitor: entry[1] } : undefined; + return Array.from(this.monitors.entries()) + .filter( + ([_, m]) => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() || + m.twitchChannel.toLowerCase() === channel.toLowerCase() + ) + .map(([key, monitor]) => ({ key, monitor })); } async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise { @@ -254,21 +257,26 @@ export class TwitchMonitor { this.webhookClients.set(key, new WebhookClient({ url: webhookUrl })); if (this.client) { - await this.client.join(normalizedChannel); + await this.client.join(normalizedChannel).catch(() => null); + } else if (process.env.TWITCH_OAUTH_TOKEN && process.env.TWITCH_USERNAME && !this.isTokenInvalid) { + await this.connect(); } } async removeMonitor(guildId: string, twitchChannel: string): Promise { const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`; const key = `${guildId}:${normalizedChannel}`; - const monitor = this.monitors.get(key); - - if (monitor && this.client) { - await this.client.part(normalizedChannel); - } this.monitors.delete(key); this.webhookClients.delete(key); + + const stillMonitored = Array.from(this.monitors.values()).some( + m => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() + ); + + if (!stillMonitored && this.client) { + await this.client.part(normalizedChannel).catch(() => null); + } } getMonitors(guildId?: string): MonitorChannel[] {