fix: make trigger list public and ephemeral, fix rolesetup types, and improve bot safety
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 28s

- Make /trigger list and help public subcommands with ephemeral replies
- Restrict /trigger add and remove to moderators/admins
- Use Unicode-aware regex and strip URLs before trigger word matching
- Ensure guild_settings exist before UPDATE queries in welcome and log commands
- Fix rolesetup edit neue_rolle type to addRoleOption and prevent label truncation
- Reset select menu components after selection so users can re-click same options
- Validate Twitch streamers via Helix Users API and prevent interaction timeouts with deferReply
- Connect TwitchMonitor automatically on startup and when first channel is added
- Add moderation role-hierarchy checks to admin commands and enforce timeout limits
- Disable DM execution on server-bound commands to prevent SQLite null constraint errors
- Update README.md and AGENTS-BOT.md with new constraints and command documentation
This commit is contained in:
sarah
2026-11-01 12:34:54 +01:00
parent 8690e1e80a
commit 390297936d
16 changed files with 366 additions and 163 deletions

View File

@@ -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. 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)`. 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. 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 (`(?<![\p{L}\p{N}_])...(?![\p{L}\p{N}_])` with `iu`) to handle umlauts and avoid URL false positives.
18. **Role Option Select Menus & Typing:** Subcommand options taking a Discord role to edit must use `.addRoleOption()`. After role select interactions, re-render/reset components so users can click the same option again to toggle or remove it.
19. **Twitch Helix User Validation & Interaction Deferral:** Commands validating Twitch streamers must use `interaction.deferReply()` to avoid 3-second interaction timeouts and validate user accounts via the Helix Users API (`/helix/users`).

View File

@@ -79,8 +79,8 @@ npm run deploy
- `/twitch help` – Twitch-Hilfe - `/twitch help` – Twitch-Hilfe
- `/timer list` – Timer dieses Kanals - `/timer list` – Timer dieses Kanals
- `/timer help` – Timer-Hilfe - `/timer help` – Timer-Hilfe
- `/trigger list` – Alle Trigger anzeigen - `/trigger list` – Alle Trigger anzeigen (ephemeral)
- `/trigger help` – Trigger-Hilfe - `/trigger help` – Trigger-Hilfe (ephemeral)
### Timer (Erinnerungen) ### Timer (Erinnerungen)
- `/timer add <zeit> <nachricht>` – Timer hinzufügen - `/timer add <zeit> <nachricht>` – Timer hinzufügen
@@ -99,8 +99,8 @@ npm run deploy
### Trigger (Auto-Antworten) ### Trigger (Auto-Antworten)
- `/trigger add <wort> <antwort>` – Trigger hinzufügen (Mod) - `/trigger add <wort> <antwort>` – Trigger hinzufügen (Mod)
- `/trigger remove <wort>` – Trigger entfernen (Mod) - `/trigger remove <wort>` – Trigger entfernen (Mod)
- `/trigger list` – Alle Trigger anzeigen - `/trigger list` – Alle Trigger anzeigen (Für alle User, ephemeral)
- `/trigger help` – Trigger-Hilfe - `/trigger help` – Trigger-Hilfe (Für alle User, ephemeral)
### Twitch ### Twitch
- `/twitch online <kanal>` – Prüft ob Kanal online ist - `/twitch online <kanal>` – Prüft ob Kanal online ist
@@ -119,12 +119,26 @@ npm run deploy
### Rollen-Auswahl ### Rollen-Auswahl
- `/rolesetup create <#kanal> <name>` – Rollen-Auswahl Message erstellen - `/rolesetup create <#kanal> <name>` – Rollen-Auswahl Message erstellen
- `/rolesetup add <kategorie> <@rolle> [emoji]` – Rolle zur Auswahl hinzufügen - `/rolesetup add <kategorie> <@rolle> [emoji]` – Rolle zur Auswahl hinzufügen
- `/rolesetup edit <kategorie> <rolle> [neues_emoji]` – Emoji ändern - `/rolesetup edit <kategorie> <rolle> [neue_rolle] [neues_emoji]` – Rolle oder Emoji ändern
- `/rolesetup remove <kategorie> <rolle>` – Rolle entfernen - `/rolesetup remove <kategorie> <rolle>` – Rolle entfernen
- `/rolesetup delete <kategorie>` – Kategorie löschen - `/rolesetup delete <kategorie>` – Kategorie löschen
- `/rolesetup config <kategorie> max:1 exclusive:true` – Einstellungen - `/rolesetup config <kategorie> max:1 exclusive:true` – Einstellungen
- `/rolesetup list` – Alle Kategorien anzeigen - `/rolesetup list` – Alle Kategorien anzeigen
### Welcome & Goodbye
- `/welcome setchannel <#kanal>` – Kanal für Willkommen/Abschied setzen (Mod)
- `/welcome add <welcome|goodbye> <nachricht>` – Nachricht setzen (Mod)
- `/welcome remove <welcome|goodbye>` – 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>` – Event zum Loggen aktivieren (Mod)
- `/log disable <event>` – Event deaktivieren (Mod)
- `/log list` – Aktive Log-Events anzeigen (Mod)
- `/log help` – Log-Hilfe
### Admin ### Admin
- `/admin kick <user>` – Nutzer kicken - `/admin kick <user>` – Nutzer kicken
- `/admin warn <user> <reason>` – Nutzer verwarnen - `/admin warn <user> <reason>` – 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). - ✅ **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. - ✅ **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. - ✅ **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. - ✅ **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 und Fehlerabfangung. - ✅ **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 Wortgrenzen-Regex (`\b`), um Falsch-Positive zu vermeiden. - ✅ **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"). - ✅ **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. - ✅ **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. - ✅ **Developer & Team Support:** Unterstützung von Discord Developer Teams und `BOT_OWNER_ID` Fallback für Owner-Befehle.

View File

@@ -116,8 +116,9 @@ const command: Command = {
.setDescription('Neuer Wert')) .setDescription('Neuer Wert'))
.addIntegerOption(option => .addIntegerOption(option =>
option.setName('duration_value') option.setName('duration_value')
.setDescription('Dauer in Sekunden') .setDescription('Dauer in Sekunden (1 - 2419200)')
.setMinValue(0))) .setMinValue(1)
.setMaxValue(2419200)))
.addSubcommandGroup(group => .addSubcommandGroup(group =>
group group
.setName('purge') .setName('purge')
@@ -171,7 +172,8 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den Admin-Befehlen an.')) .setDescription('Zeigt Hilfe zu den Admin-Befehlen an.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const client = interaction.client as ExtendedClient; const client = interaction.client as ExtendedClient;
@@ -179,6 +181,14 @@ const command: Command = {
const DB = client.DB; const DB = client.DB;
const guild = interaction.guild; const guild = interaction.guild;
const guildId = interaction.guildId; 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') { if (subcommand === 'help') {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
@@ -206,6 +216,25 @@ const command: Command = {
return; 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 // Get or create guild settings
const settings = DB.getSettings(guildId); const settings = DB.getSettings(guildId);
@@ -218,6 +247,12 @@ const command: Command = {
return; return;
} }
const hierarchyError = checkTargetHierarchy(target);
if (hierarchyError) {
await interaction.reply({ content: hierarchyError, ephemeral: true });
return;
}
if (!target.kickable) { if (!target.kickable) {
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', ephemeral: true }); await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', ephemeral: true });
return; return;
@@ -239,8 +274,10 @@ const command: Command = {
await EventLogger.sendLog(interaction.client, guildId, 'kicks', embed); await EventLogger.sendLog(interaction.client, guildId, 'kicks', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true }); await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true });
} }
}
return; return;
} }
@@ -253,6 +290,12 @@ const command: Command = {
return; return;
} }
const hierarchyError = checkTargetHierarchy(target);
if (hierarchyError) {
await interaction.reply({ content: hierarchyError, ephemeral: true });
return;
}
DB.run( DB.run(
'INSERT INTO mod_logs (guild_id, user_id, moderator_id, action, reason) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO mod_logs (guild_id, user_id, moderator_id, action, reason) VALUES (?, ?, ?, ?, ?)',
guildId, guildId,
@@ -284,9 +327,11 @@ const command: Command = {
await target.kick(`Auto-Kick: ${warnThreshold} Warnungen`); await target.kick(`Auto-Kick: ${warnThreshold} Warnungen`);
message += `\n👢 **Automatisch gekickt!**`; message += `\n👢 **Automatisch gekickt!**`;
} else if (warnAction === 'mute') { } 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`); 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)!**`; message += `\n🔇 **Automatisch gemuted (${durationMins} Min)!**`;
} else if (warnAction === 'ban') { } else if (warnAction === 'ban') {
await target.ban({ reason: `Auto-Ban: ${warnThreshold} Warnungen` }); await target.ban({ reason: `Auto-Ban: ${warnThreshold} Warnungen` });
@@ -350,6 +395,12 @@ const command: Command = {
return; return;
} }
const hierarchyError = checkTargetHierarchy(target);
if (hierarchyError) {
await interaction.reply({ content: hierarchyError, ephemeral: true });
return;
}
// Parse duration // Parse duration
let durationMs: number | null = null; let durationMs: number | null = null;
let durationText = ''; let durationText = '';
@@ -413,8 +464,10 @@ const command: Command = {
await EventLogger.sendLog(interaction.client, guildId, 'mutes', embed); await EventLogger.sendLog(interaction.client, guildId, 'mutes', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true }); await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true });
} }
}
return; return;
} }
@@ -446,6 +499,12 @@ const command: Command = {
return; return;
} }
const hierarchyError = checkTargetHierarchy(target);
if (hierarchyError) {
await interaction.reply({ content: hierarchyError, ephemeral: true });
return;
}
if (!target.bannable) { if (!target.bannable) {
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', ephemeral: true }); await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', ephemeral: true });
return; return;
@@ -468,8 +527,10 @@ const command: Command = {
await EventLogger.sendLog(interaction.client, guildId, 'bans', embed); await EventLogger.sendLog(interaction.client, guildId, 'bans', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true }); await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true });
} }
}
return; return;
} }
@@ -510,35 +571,37 @@ const command: Command = {
} }
if (setting === 'warn_threshold') { if (setting === 'warn_threshold') {
const threshold = parseInt(value!); const threshold = value !== null ? parseInt(value) : (durationValue ?? NaN);
if (isNaN(threshold) || threshold < 1) { if (isNaN(threshold) || threshold < 1) {
await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', ephemeral: true }); await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', ephemeral: true });
return; return;
} }
DB.run('UPDATE guild_settings SET warn_threshold = ? WHERE guild_id = ?', threshold, guildId); 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; return;
} }
if (setting === 'warn_action') { if (setting === 'warn_action') {
const validActions = ['none', 'kick', 'mute', 'ban']; 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 }); await interaction.reply({ content: `❌ Ungültige Aktion. Möglich: ${validActions.join(', ')}`, ephemeral: true });
return; return;
} }
DB.run('UPDATE guild_settings SET warn_action = ? WHERE guild_id = ?', value, guildId); 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; return;
} }
if (setting === 'warn_mute_duration') { if (setting === 'warn_mute_duration') {
const duration = durationValue ?? 1800; const parsedValue = value !== null ? parseInt(value) : NaN;
if (duration < 0) { const duration = durationValue ?? (!isNaN(parsedValue) ? parsedValue : 1800);
await interaction.reply({ content: '❌ Ungültige Dauer.', ephemeral: true }); 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; return;
} }
DB.run('UPDATE guild_settings SET warn_mute_duration = ? WHERE guild_id = ?', duration, guildId); 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; return;
} }
} }

View File

@@ -51,12 +51,19 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Hilfe zu Log-Befehlen (Mod).')) .setDescription('Hilfe zu Log-Befehlen (Mod).'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = interaction.client.DB;
@@ -84,6 +91,9 @@ const command: Command = {
return; return;
} }
// Ensure guild settings row exists before reading or updating
const settings = DB.getSettings(guildId);
if (subcommand === 'setchannel') { if (subcommand === 'setchannel') {
const channel = interaction.options.getChannel('channel'); const channel = interaction.options.getChannel('channel');
@@ -98,8 +108,6 @@ const command: Command = {
} }
if (subcommand === 'list') { if (subcommand === 'list') {
const settings = DB.getSettings(guildId);
const channel = settings?.log_channel const channel = settings?.log_channel
? `<#${settings.log_channel}>` ? `<#${settings.log_channel}>`
: 'Nicht gesetzt'; : 'Nicht gesetzt';

View File

@@ -20,8 +20,11 @@ export async function updateRoleMessage(client: any, category: any) {
} }
const roleOptions = options.map((opt: 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() const menuOption = new StringSelectMenuOptionBuilder()
.setLabel(opt.label.replace(/^[^\s]+\s/, '')) .setLabel(cleanLabel)
.setValue(opt.role_id); .setValue(opt.role_id);
if (opt.emoji && opt.emoji.trim().length > 0) { if (opt.emoji && opt.emoji.trim().length > 0) {
menuOption.setEmoji(opt.emoji.trim()); menuOption.setEmoji(opt.emoji.trim());
@@ -95,7 +98,7 @@ const command: Command = {
option.setName('rolle') option.setName('rolle')
.setDescription('Die Discord-Rolle zum Ändern') .setDescription('Die Discord-Rolle zum Ändern')
.setRequired(true)) .setRequired(true))
.addStringOption(option => .addRoleOption(option =>
option.setName('neue_rolle') option.setName('neue_rolle')
.setDescription('Die neue Discord-Rolle') .setDescription('Die neue Discord-Rolle')
.setRequired(false)) .setRequired(false))
@@ -148,14 +151,26 @@ const command: Command = {
subcommand subcommand
.setName('list') .setName('list')
.setDescription('Listet alle Rollen-Kategorien auf')) .setDescription('Listet alle Rollen-Kategorien auf'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = interaction.client.DB;
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'list') { if (subcommand === 'list') {
const categories: any[] = DB.all('SELECT * FROM role_categories WHERE guild_id = ? ORDER BY category_name ASC', guildId); 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) { if (discordRole) {
const members = guild.members.cache.filter((m: any) => m.roles.cache.has(role.id)); const members = guild.members.cache.filter((m: any) => m.roles.cache.has(role.id));
for (const member of members.values()) { 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) { if (discordRole) {
const members = guild.members.cache.filter((m: any) => m.roles.cache.has(opt.role_id)); const members = guild.members.cache.filter((m: any) => m.roles.cache.has(opt.role_id));
for (const member of members.values()) { for (const member of members.values()) {
await member.roles.remove(opt.role_id); await member.roles.remove(opt.role_id).catch(() => null);
} }
} }
} }

View File

@@ -125,12 +125,16 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den Stoppuhr-Befehlen an.')) .setDescription('Zeigt Hilfe zu den Stoppuhr-Befehlen an.'))
.setDMPermission(true), .setDMPermission(false),
category: 'Public', category: 'Public',
async execute(interaction: any) { async execute(interaction: any) {
const client = interaction.client as ExtendedClient; const client = interaction.client as ExtendedClient;
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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 userId = interaction.user.id;
const channelId = interaction.channelId; const channelId = interaction.channelId;
const DB = client.DB; const DB = client.DB;

View File

@@ -134,14 +134,21 @@ const command: Command = {
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den Timer-Befehlen an.')), .setDescription('Zeigt Hilfe zu den Timer-Befehlen an.'))
.setDMPermission(false),
category: 'Public', category: 'Public',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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 channelId = interaction.channelId;
const userId = interaction.user.id; 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; const DB = interaction.client.DB;
if (subcommand === 'help') { if (subcommand === 'help') {

View File

@@ -33,12 +33,18 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den Trigger-Befehlen an.')) .setDescription('Zeigt Hilfe zu den Trigger-Befehlen an.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDMPermission(false),
category: 'Admin', category: 'Public',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = interaction.client.DB;
if (subcommand === 'help') { if (subcommand === 'help') {
@@ -99,9 +105,14 @@ const command: Command = {
} }
if (subcommand === 'add') { if (subcommand === 'add') {
const triggerWord = interaction.options.getString('trigger')!.toLowerCase(); const triggerWord = interaction.options.getString('trigger')!.trim().toLowerCase();
const responseText = interaction.options.getString('antwort')!; const responseText = interaction.options.getString('antwort')!;
if (!triggerWord) {
await interaction.reply({ content: '❌ Das Trigger-Wort darf nicht leer sein.', ephemeral: true });
return;
}
DB.run( DB.run(
'INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?) ON CONFLICT(guild_id, trigger_word) DO UPDATE SET response_text = ?', 'INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?) ON CONFLICT(guild_id, trigger_word) DO UPDATE SET response_text = ?',
guildId, guildId,
@@ -115,7 +126,7 @@ const command: Command = {
} }
if (subcommand === 'remove') { 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); const result = DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord);

View File

@@ -45,13 +45,20 @@ const command: Command = {
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')), .setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.'))
.setDMPermission(false),
category: 'Public', category: 'Public',
async execute(interaction: any) { async execute(interaction: any) {
const client = interaction.client as ExtendedClient; const client = interaction.client as ExtendedClient;
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = client.DB;
if (subcommand === 'help') { 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 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 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: '`/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(); .setTimestamp();
@@ -184,13 +191,19 @@ const command: Command = {
return; return;
} }
await interaction.deferReply({ ephemeral: true });
const results = { added: 0, updated: 0, errors: 0 }; const results = { added: 0, updated: 0, errors: 0 };
const errors: string[] = []; const errors: string[] = [];
for (const streamer of streamers) { for (const streamer of streamers) {
try { try {
// Check if stream exists const isValid = await TwitchManager.validateUser(streamer);
await TwitchManager.fetchStreamData(streamer); if (!isValid) {
errors.push(streamer);
results.errors++;
continue;
}
// Check if already exists // Check if already exists
const existing = DB.get('SELECT id FROM twitch_monitors WHERE guild_id = ? AND channel_name = ?', guildId, streamer); 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.added > 0) reply += `\n🆕 Neu: ${results.added}`;
if (results.updated > 0) reply += `\n🔄 Aktualisiert: ${results.updated}`; if (results.updated > 0) reply += `\n🔄 Aktualisiert: ${results.updated}`;
if (results.errors > 0) reply += `\n❌ Fehler: ${results.errors} (${errors.join(', ')})`; if (results.errors > 0) reply += `\n❌ Fehler: ${results.errors} (${errors.join(', ')})`;
await interaction.reply({ content: reply, ephemeral: true }); await interaction.editReply({ content: reply });
return; return;
} }

View File

@@ -35,12 +35,19 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Zeigt Hilfe zu den TwitchMonitor-Befehlen')) .setDescription('Zeigt Hilfe zu den TwitchMonitor-Befehlen'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = interaction.client.DB;
if (subcommand === 'help') { if (subcommand === 'help') {
@@ -59,6 +66,11 @@ const command: Command = {
return; return;
} }
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'list') { if (subcommand === 'list') {
const monitors: any[] = DB.all('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? ORDER BY twitch_channel ASC', guildId); 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; return;
} }
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'add') { 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 discordChannel = interaction.options.getChannel('discord');
const existing = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel); const existing = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
@@ -94,6 +101,9 @@ const command: Command = {
return; return;
} }
await interaction.deferReply({ ephemeral: true });
try {
const webhook = await discordChannel.createWebhook({ const webhook = await discordChannel.createWebhook({
name: `${twitchChannel} | Mod-Log`, name: `${twitchChannel} | Mod-Log`,
avatar: undefined, avatar: undefined,
@@ -116,12 +126,16 @@ const command: Command = {
const monitor = TwitchMonitor.getInstance(); const monitor = TwitchMonitor.getInstance();
await monitor.addMonitor(guildId, twitchChannel, discordChannel.id, webhook.url); 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; return;
} }
if (subcommand === 'remove') { 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); const monitor: any = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
if (!monitor) { if (!monitor) {

View File

@@ -49,15 +49,21 @@ const command: Command = {
subcommand subcommand
.setName('help') .setName('help')
.setDescription('Hilfe zu Welcome-Befehlen.')) .setDescription('Hilfe zu Welcome-Befehlen.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId; 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; const DB = interaction.client.DB;
if (subcommand === 'help') { if (subcommand === 'help') {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('👋 Welcome Hilfe') .setTitle('👋 Welcome Hilfe')
@@ -78,9 +84,16 @@ const command: Command = {
return; return;
} }
if (subcommand === 'list') { // 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); const settings = DB.getSettings(guildId);
if (subcommand === 'list') {
const channel = settings?.welcome_channel const channel = settings?.welcome_channel
? `<#${settings.welcome_channel}>` ? `<#${settings.welcome_channel}>`
: 'Nicht gesetzt'; : 'Nicht gesetzt';
@@ -103,12 +116,6 @@ const command: Command = {
return; return;
} }
// Admin only for modify commands
if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'setchannel') { if (subcommand === 'setchannel') {
const channel = interaction.options.getChannel('channel'); const channel = interaction.options.getChannel('channel');

View File

@@ -50,6 +50,7 @@ export default {
ephemeral: true ephemeral: true
}); });
} }
await interaction.message.edit({ components: interaction.message.components }).catch(() => null);
return; return;
} }
@@ -116,6 +117,7 @@ export default {
}); });
} }
} }
await interaction.message.edit({ components: interaction.message.components }).catch(() => null);
} catch (error) { } catch (error) {
console.error('[ROLESETUP] Role assignment error:', error); console.error('[ROLESETUP] Role assignment error:', error);
const errorMsg = '❌ Fehler beim Ändern der Rolle. Mir fehlen eventuell die Rechte dazu.'; const errorMsg = '❌ Fehler beim Ändern der Rolle. Mir fehlen eventuell die Rechte dazu.';
@@ -126,7 +128,6 @@ export default {
} }
} }
return; return;
return;
} }
} }

View File

@@ -14,13 +14,20 @@ export default {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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) { for (const trigger of triggers) {
const word = trigger.trigger_word.trim().toLowerCase(); const word = trigger.trigger_word.trim().toLowerCase();
if (!word) continue; if (!word) continue;
const pattern = new RegExp(`(?:^|\\s|\\b)${escapeRegex(word)}(?:$|\\s|\\b)`, 'i'); const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(word)}(?![\\p{L}\\p{N}_])`, 'iu');
if (pattern.test(message.content)) { if (pattern.test(contentWithoutUrls)) {
try {
await message.channel.send(trigger.response_text); await message.channel.send(trigger.response_text);
} catch (error) {
console.error(`[TRIGGER] Failed to send auto-response in guild ${message.guildId}:`, error);
}
return; return;
} }
} }

View File

@@ -47,6 +47,9 @@ async function validateRoles(client: ExtendedClient) {
} }
async function initTwitchMonitor(client: ExtendedClient) { async function initTwitchMonitor(client: ExtendedClient) {
const monitor = TwitchMonitor.getInstance();
monitor.setDiscordClient(client);
if (!process.env.TWITCH_OAUTH_TOKEN || !process.env.TWITCH_USERNAME) { if (!process.env.TWITCH_OAUTH_TOKEN || !process.env.TWITCH_USERNAME) {
console.warn('[TWITCH] Missing TWITCH_OAUTH_TOKEN or TWITCH_USERNAME - moderation events will not be received'); console.warn('[TWITCH] Missing TWITCH_OAUTH_TOKEN or TWITCH_USERNAME - moderation events will not be received');
return; return;
@@ -59,9 +62,6 @@ async function initTwitchMonitor(client: ExtendedClient) {
return; return;
} }
const monitor = TwitchMonitor.getInstance();
monitor.setDiscordClient(client);
for (const m of monitors) { for (const m of monitors) {
const webhookExists = m.webhook_url && m.webhook_url.length > 0; const webhookExists = m.webhook_url && m.webhook_url.length > 0;
if (webhookExists) { if (webhookExists) {

View File

@@ -63,6 +63,31 @@ export class TwitchManager {
return streams[0] || null; return streams[0] || null;
} }
/**
* Validates whether a Twitch user/channel exists via the Helix Users API.
*/
static async validateUser(channelName: string): Promise<boolean> {
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) { static async checkStreams(client: ExtendedClient) {
const monitors: any[] = client.DB?.all('SELECT * FROM twitch_monitors') || []; const monitors: any[] = client.DB?.all('SELECT * FROM twitch_monitors') || [];
if (monitors.length === 0) return; if (monitors.length === 0) return;

View File

@@ -121,87 +121,89 @@ export class TwitchMonitor {
this.client.on('messagedeleted', (channel: string, username: string, deletedMessage: string, userstate: DeleteUserstate) => { this.client.on('messagedeleted', (channel: string, username: string, deletedMessage: string, userstate: DeleteUserstate) => {
const msgId = userstate['target-msg-id'] || ''; const msgId = userstate['target-msg-id'] || '';
const cached = this.cache.getByMsgId(msgId); const cached = this.cache.getByMsgId(msgId);
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '🗑️ Nachricht gelöscht', title: '🗑️ Nachricht gelöscht',
description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`, description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`,
color: 0xff0000, color: 0xff0000,
}, result.key); }, result.key);
}
}); });
this.client.on('ban', (channel: string, username: string, reason: string) => { this.client.on('ban', (channel: string, username: string, reason: string) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; if (results.length === 0) return;
const lastMessages = this.cache.getLastN(username, channel, 5); const lastMessages = this.cache.getLastN(username, channel, 5);
const context = lastMessages.length > 0 const context = lastMessages.length > 0
? `\n\n**Letzte Nachrichten:**\n${lastMessages.map(m => `• ${m.text}`).join('\n')}` ? `\n\n**Letzte Nachrichten:**\n${lastMessages.map(m => `• ${m.text}`).join('\n')}`
: ''; : '';
for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '🔨 User gebannt', title: '🔨 User gebannt',
description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`, description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`,
color: 0x8b0000, color: 0x8b0000,
}, result.key); }, result.key);
}
}); });
this.client.on('timeout', (channel: string, username: string, reason: string) => { this.client.on('timeout', (channel: string, username: string, reason: string) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '⏱️ User getimeouted', title: '⏱️ User getimeouted',
description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`, description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`,
color: 0xffa500, color: 0xffa500,
}, result.key); }, result.key);
}
}); });
this.client.on('clearchat', (channel: string) => { this.client.on('clearchat', (channel: string) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '🧹 Chat geleert', title: '🧹 Chat geleert',
description: `Der Chat in **${channel}** wurde geleert.`, description: `Der Chat in **${channel}** wurde geleert.`,
color: 0xffa500, color: 0xffa500,
}, result.key); }, result.key);
}
}); });
this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => { this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return;
const amount = userstate.bits || 0; const amount = userstate.bits || 0;
for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '💰 Bit-Cheer', title: '💰 Bit-Cheer',
description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`, description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`,
color: 0x9b59b6, color: 0x9b59b6,
}, result.key); }, result.key);
}
}); });
this.client.on('subscription', (channel: string, username: string, methods: SubMethods, message: string, userstate: SubUserstate) => { this.client.on('subscription', (channel: string, username: string, methods: SubMethods, message: string, userstate: SubUserstate) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '🅿️ Subscription', title: '🅿️ Subscription',
description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`, description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`,
color: 0x9146ff, color: 0x9146ff,
}, result.key); }, result.key);
}
}); });
this.client.on('resub', (channel: string, username: string, months: number, message: string, userstate: SubUserstate, methods: SubMethods) => { this.client.on('resub', (channel: string, username: string, months: number, message: string, userstate: SubUserstate, methods: SubMethods) => {
const result = this.findMonitor(channel); const results = this.findMonitors(channel);
if (!result) return; for (const result of results) {
this.sendWebhook(result.monitor.webhookUrl, { this.sendWebhook(result.monitor.webhookUrl, {
title: '🅿️ Resubscription', title: '🅿️ Resubscription',
description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`, description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`,
color: 0x9146ff, color: 0x9146ff,
}, result.key); }, result.key);
}
}); });
this.client.on('disconnected', async () => { this.client.on('disconnected', async () => {
@@ -231,13 +233,14 @@ export class TwitchMonitor {
(this as any).discordClient = discordClient; (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 normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`;
const entry = Array.from(this.monitors.entries()).find( return Array.from(this.monitors.entries())
.filter(
([_, m]) => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() || ([_, m]) => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() ||
m.twitchChannel.toLowerCase() === channel.toLowerCase() m.twitchChannel.toLowerCase() === channel.toLowerCase()
); )
return entry ? { key: entry[0], monitor: entry[1] } : undefined; .map(([key, monitor]) => ({ key, monitor }));
} }
async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise<void> { async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise<void> {
@@ -254,21 +257,26 @@ export class TwitchMonitor {
this.webhookClients.set(key, new WebhookClient({ url: webhookUrl })); this.webhookClients.set(key, new WebhookClient({ url: webhookUrl }));
if (this.client) { 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<void> { async removeMonitor(guildId: string, twitchChannel: string): Promise<void> {
const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`; const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`;
const key = `${guildId}:${normalizedChannel}`; const key = `${guildId}:${normalizedChannel}`;
const monitor = this.monitors.get(key);
if (monitor && this.client) {
await this.client.part(normalizedChannel);
}
this.monitors.delete(key); this.monitors.delete(key);
this.webhookClients.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[] { getMonitors(guildId?: string): MonitorChannel[] {