From d546035bb77772b854c071683d3d285476e5fead Mon Sep 17 00:00:00 2001 From: coding Date: Wed, 29 Apr 2026 19:50:50 +0200 Subject: [PATCH] Optimize database, implement automatic command deployment, and improve project-wide type safety --- .env.example | 5 +- AGENTS-BOT.md | 138 +++++++++++++++--------------- README.md | 16 ++-- src/commands/utility/admin.ts | 10 ++- src/commands/utility/twitch.ts | 4 +- src/events/interactionCreate.ts | 36 +++++--- src/events/ready.ts | 11 +-- src/index.ts | 10 ++- src/structures/Database.ts | 27 ++++-- src/structures/Deployer.ts | 31 ++++--- src/structures/ExtendedClient.ts | 2 + src/structures/ReminderManager.ts | 13 +-- src/structures/TwitchManager.ts | 54 +++++++----- src/structures/TwitchMonitor.ts | 78 +++++++++-------- 14 files changed, 258 insertions(+), 177 deletions(-) diff --git a/.env.example b/.env.example index a514dfa..b4a2e69 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,7 @@ TWITCH_CLIENT_ID=deine_twitch_id TWITCH_CLIENT_SECRET=dein_twitch_secret TWITCH_USERNAME=dein_twitch_username TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx -BOT_OWNER_ID=deine_discord_user_id \ No newline at end of file +BOT_OWNER_ID=deine_discord_user_id + +# Optional: Automatische Slash-Command Registrierung bei Bot-Start (default: true) +AUTO_DEPLOY=true \ No newline at end of file diff --git a/AGENTS-BOT.md b/AGENTS-BOT.md index 3c6e668..04e9412 100644 --- a/AGENTS-BOT.md +++ b/AGENTS-BOT.md @@ -4,42 +4,42 @@ This document is intended for AI agents to understand and recreate the `pixelpö ## 🏗 Architecture Overview -The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScript Modules)** and **TypeScript**. +The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScript Modules)** and **TypeScript**, emphasizing strict typing and performance. ### Key Design Patterns: -1. **Extended Client Pattern:** Extend `discord.js` `Client` class to hold global state. +1. **Extended Client Pattern:** Extend `discord.js` `Client` class to hold global state and a typed `DB` instance. 2. **Dynamic Discovery:** `index.ts` uses `readdirSync` and dynamic `import()` to register commands/events. -3. **Interface-Driven Commands:** All commands implement `Command` interface. -4. **Optimized SQLite Database:** Persistent storage with `better-sqlite3`, utilizing **WAL (Write-Ahead Logging)** and **In-Memory Caching** for frequently accessed data. -5. **Twitch Monitoring:** Periodic API polling using **Batch-Requests** (100 channels per request) with Stream ID tracking. -6. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events. -7. **Reminder System:** Periodic reminder checking with target_time tracking. -8. **Auto-Response System:** Trigger word detection for automatic replies (cached). -9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. -10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). -11. **Role Selection System:** Self-service role assignment via select menus. -12. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. +3. **Automatic Command Deployment:** Commands are automatically registered with Discord on startup via `Deployer.deployCommands()`, unless disabled via `AUTO_DEPLOY=false`. +4. **Interface-Driven Commands:** All commands implement the `Command` interface. +5. **Optimized SQLite Database:** Persistent storage with `better-sqlite3`, utilizing **WAL (Write-Ahead Logging)**, **Foreign Key support**, and **surgical In-Memory Caching** (clearing specific guild data on updates). +6. **Twitch Monitoring:** Periodic API polling using **Batch-Requests** (100 channels per request) with Stream ID tracking and **database transactions** for status updates. +7. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events, using **reusable WebhookClient instances** for efficiency. +8. **Reminder System:** Periodic reminder checking with `target_time` tracking. +9. **Auto-Response System:** Trigger word detection for automatic replies (cached). +10. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. +11. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). +12. **Role Selection System:** Self-service role assignment via select menus with support for exclusive categories and max-role limits. ## 📁 Project Structure ``` pixelpoebel/ ├── src/ -│ ├── index.ts # Entry point, loads commands/events -│ ├── deploy-commands.ts # Deploy script for Discord +│ ├── index.ts # Entry point, loads commands/events/auto-deploy +│ ├── deploy-commands.ts # Manual deploy script for Discord │ ├── commands/ │ │ └── utility/ # All commands grouped here │ ├── events/ │ │ ├── ... # All Discord event handlers │ └── structures/ -│ ├── ExtendedClient.ts +│ ├── ExtendedClient.ts # Typed client with DB property │ ├── Command.ts -│ ├── Database.ts # Database with Caching logic -│ ├── TwitchManager.ts # Batch Polling logic -│ ├── TwitchMonitor.ts # IRC Monitoring logic +│ ├── Database.ts # Database with Transaction & Surgical Caching +│ ├── TwitchManager.ts # Batch Polling & Transaction logic +│ ├── TwitchMonitor.ts # IRC Monitoring & Webhook reuse │ ├── TwitchCache.ts # IRC Message FIFO Cache │ ├── ReminderManager.ts -│ └── Deployer.ts +│ └── Deployer.ts # Centralized deployment logic ├── data/ # SQLite database (volume mounted) ├── package.json ├── tsconfig.json @@ -51,96 +51,96 @@ pixelpoebel/ ## 📝 Implementation Steps -### 1. Database with Caching +### 1. Database with Surgical Caching -The database uses WAL mode for performance and an in-memory Map for caching settings and triggers. +The database uses WAL mode and foreign keys. Caching is surgical (clears specific guild entries when possible). ```typescript // src/structures/Database.ts export class DB { private static settingsCache = new Map(); - private static triggersCache = new Map(); - + static init() { db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); // ... Table creation ... } - static getSettings(guildId: string) { - if (this.settingsCache.has(guildId)) return this.settingsCache.get(guildId); - const settings = db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId); - if (settings) this.settingsCache.set(guildId, settings); - return settings; - } - static run(query: string, ...params: any[]) { const result = db.prepare(query).run(...params); - if (query.toLowerCase().includes('guild_settings') || query.toLowerCase().includes('auto_responses')) { - this.settingsCache.clear(); - this.triggersCache.clear(); + if (query.toLowerCase().includes('guild_settings')) { + const guildId = params.find(p => typeof p === 'string' && /^\d{17,20}$/.test(p)); + if (guildId) this.settingsCache.delete(guildId); + else this.settingsCache.clear(); } return result; } + + static transaction(fn: () => T): T { + return db.transaction(fn)(); + } } ``` -### 2. Twitch Batch Polling +### 2. Twitch Batch Polling with Transactions -To stay within API limits and provide faster updates, the bot polls Twitch in batches of 100. +Updates are collected and saved in a single transaction to reduce disk I/O. ```typescript // src/structures/TwitchManager.ts -static async checkStreams(client: any) { - const monitors = client.DB.all('SELECT * FROM twitch_monitors'); - const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))]; - - for (let i = 0; i < uniqueChannels.length; i += 100) { - const chunk = uniqueChannels.slice(i, i + 100); - const query = chunk.map(name => `user_login=${encodeURIComponent(name)}`).join('&'); - // fetch https://api.twitch.tv/helix/streams?${query} - // ... process results ... +static async checkStreams(client: ExtendedClient) { + // ... fetch streams ... + const updates = []; // collect updates here + + if (updates.length > 0) { + client.DB.transaction(() => { + for (const update of updates) { + // ... client.DB.run update ... + } + }); } } - -static startPolling(client: any) { - setInterval(() => this.checkStreams(client), 2 * 60 * 1000); // 2 minute interval -} ``` -### 3. Event Logging Optimization +### 3. Webhook Reuse in TwitchMonitor -All logging events use a centralized `sendLog` pattern that utilizes the `DB.getSettings()` cache. +Avoid recreating `WebhookClient` instances for every message to improve performance. ```typescript -async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { - const settings = client.DB.getSettings(guildId); - if (!settings?.log_events || !settings?.log_channel) return; - - const activeEvents = settings.log_events.split(',').filter(Boolean); - if (!activeEvents.includes(event)) return; - - const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel); - if (channel?.isTextBased()) await channel.send({ embeds: [embed] }); +// src/structures/TwitchMonitor.ts +private async sendWebhook(webhookUrl: string, data: any, monitorKey?: string) { + let webhook = monitorKey ? this.webhookClients.get(monitorKey) : new WebhookClient({ url: webhookUrl }); + await webhook.send({ embeds: [embed] }); } ``` -### 4. TwitchMonitor IRC Chat Logging +### 4. Automatic Deployment -**Overview:** Uses TMI.js to connect to Twitch IRC and logs moderation events (bans, timeouts, deletes) via webhooks. +Commands are loaded into an array and deployed during the `index.ts` startup sequence. -**Key Feature:** FIFO Message Cache (`TwitchCache.ts`) allows restoring the text of deleted messages in the logs. +```typescript +// src/index.ts +const commandData = []; +// ... while loading commands ... +commandData.push(command.data.toJSON()); + +if (process.env.AUTO_DEPLOY !== 'false') { + await Deployer.deployCommands(process.env.CLIENT_ID, process.env.DISCORD_TOKEN, commandData); +} +``` ## 🚀 Performance Benchmarks (approx.) -| Metric | Old (Individual) | New (Batch/Cached) | -|--------|------------------|-------------------| +| Metric | Old (Individual) | New (Batch/Cached/Transaction) | +|--------|------------------|-------------------------------| | API Calls (500 channels) | 500 | 5 | -| DB Access (Triggers) | Disk I/O per msg | RAM access | +| DB Access (Polling) | Individual writes | Single Transaction | +| Webhook Overhead | New Connection | Connection Reuse | | Notification Delay | ~5-7 min | ~2 min | ## ⚠️ Critical Constraints -1. **Batching:** Never exceed 100 channels per Twitch API request. -2. **Caching:** Always invalidate cache (`clearCache`) when updating settings. -3. **WAL Mode:** Ensure the `data/` directory has proper permissions for `.shm` and `.wal` files. -4. **Webhook Safety:** Use `WebhookClient` for Twitch IRC logging to avoid Discord bot rate limits. +1. **Transaction Safety:** Never call async functions (like `fetch` or `channel.send`) inside a synchronous `db.transaction()`. +2. **Foreign Keys:** Always enable `foreign_keys = ON` to maintain data integrity. +3. **Type Safety:** Use `ExtendedClient` instead of `any` for the client instance. +4. **Webhook Reuse:** Map `WebhookClient` instances to their monitor keys to prevent leaks. diff --git a/README.md b/README.md index 2ccda25..5683354 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,13 @@ Erstelle `.env` Datei: DISCORD_TOKEN=dein_bot_token CLIENT_ID=deine_client_id TWITCH_CLIENT_ID=deine_twitch_id -TWITCH_CLIENT_SECRET=dein_twitch_secret +TWITCH_CLIENT_SECRET=deine_twitch_secret TWITCH_USERNAME=dein_twitch_username TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx BOT_OWNER_ID=deine_discord_user_id + +# Optional: Automatische Slash-Command Registrierung bei Bot-Start (default: true) +AUTO_DEPLOY=true ``` ### TwitchMonitor Setup @@ -57,6 +60,9 @@ docker-compose up -d --build --force-recreate ## 📋 Commands registrieren +Befehle werden standardmäßig **automatisch bei jedem Bot-Start** registriert. Du kannst dies über `AUTO_DEPLOY=false` in der `.env` deaktivieren. + +Für manuelle Updates oder im Notfall: ```bash npm run deploy ``` @@ -133,10 +139,10 @@ npm run deploy ## 📡 Features & Optimierungen -- ✅ **Modular Architecture:** TypeScript-basiert, ESM-Unterstützung. -- ✅ **High Performance Database:** SQLite mit **WAL-Modus** und **In-Memory Caching** für Guild-Settings und Trigger. -- ✅ **Optimized Twitch Polling:** Nutzt **Batch-Requests** (100 Kanäle pro Request) und ein 2-Minuten-Intervall für zeitnahe Benachrichtigungen. -- ✅ **TwitchMonitor IRC:** Echtzeit IRC Chat-Logging (Mod-Events) via Webhooks und FIFO-Cache. +- ✅ **Modular Architecture:** TypeScript-basiert, ESM-Unterstützung, strikte Typisierung. +- ✅ **High Performance Database:** SQLite mit **WAL-Modus**, **Fremdschlüssel-Unterstützung** und **chirurgischem In-Memory Caching** für Guild-Settings und Trigger. +- ✅ **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:** Warn-System mit Auto-Actions, umfangreiches Purge-System. - ✅ **Role Selection:** Self-Service Rollen-System über Discord Select Menus. - ✅ **Reminders:** Intelligentes Zeit-Parsing (z.B. "morgen 15:00", "30min"). diff --git a/src/commands/utility/admin.ts b/src/commands/utility/admin.ts index 6386ac4..8f5060b 100644 --- a/src/commands/utility/admin.ts +++ b/src/commands/utility/admin.ts @@ -1,7 +1,8 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember, MessageFlags } from 'discord.js'; import { Command } from '../../structures/Command.js'; +import { ExtendedClient } from '../../structures/ExtendedClient.js'; -async function sendModerationLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { +async function sendModerationLog(client: ExtendedClient, guildId: string, event: string, embed: EmbedBuilder) { const DB = client.DB; const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); @@ -193,8 +194,9 @@ const command: Command = { .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), category: 'Admin', async execute(interaction: any) { + const client = interaction.client as ExtendedClient; const subcommand = interaction.options.getSubcommand(); - const DB = interaction.client.DB; + const DB = client.DB; const guild = interaction.guild; const guildId = interaction.guildId; @@ -580,12 +582,12 @@ const command: Command = { try { if (purgeSubcommand === 'amount') { const count = interaction.options.getInteger('count')!; - const messages = await channel.messages.fetch({ limit: count + 1 }); // +1 for the command message + const messages = await channel.messages.fetch({ limit: count }); const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; const filtered = messages.filter((m: any) => m.createdTimestamp > twoWeeksAgo); await (channel as any).bulkDelete(filtered, true); - deletedCount = filtered.size - 1; // -1 for command message + deletedCount = filtered.size; await interaction.editReply(`✅ ${deletedCount} Nachrichten gelöscht.`); } diff --git a/src/commands/utility/twitch.ts b/src/commands/utility/twitch.ts index 1b70661..417c1b2 100644 --- a/src/commands/utility/twitch.ts +++ b/src/commands/utility/twitch.ts @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { Command } from '../../structures/Command.js'; import { TwitchManager } from '../../structures/TwitchManager.js'; +import { ExtendedClient } from '../../structures/ExtendedClient.js'; const command: Command = { data: new SlashCommandBuilder() @@ -47,10 +48,11 @@ const command: Command = { .setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')), 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); - const DB = interaction.client.DB; + const DB = client.DB; if (subcommand === 'help') { const embed = new EmbedBuilder() diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index cdbc1d8..8094237 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,8 +1,9 @@ -import { Events, MessageFlags, ComponentType } from 'discord.js'; +import { Events, MessageFlags, Interaction } from 'discord.js'; +import { ExtendedClient } from '../structures/ExtendedClient.js'; export default { name: Events.InteractionCreate, - async execute(interaction: any, client: any) { + async execute(interaction: Interaction, client: ExtendedClient) { if (interaction.isStringSelectMenu()) { const customId = interaction.customId; @@ -12,6 +13,11 @@ export default { const member = interaction.member; const guild = interaction.guild; + if (!guild || !member || !('roles' in member)) { + await interaction.reply({ content: 'Dieser Befehl kann nur auf Servern verwendet werden.', flags: [MessageFlags.Ephemeral] }); + return; + } + const category: any = client.DB.get( 'SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guild.id, @@ -66,17 +72,25 @@ export default { const hasRole = member.roles.cache.has(selectedValue); if (category.exclusive) { - const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id); - for (const opt of options) { - if (opt.role_id !== selectedValue && member.roles.cache.has(opt.role_id)) { - await member.roles.remove(opt.role_id); + if (hasRole) { + await member.roles.remove(selectedValue); + await interaction.reply({ + content: `Die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name} wurde entfernt.`, + flags: [MessageFlags.Ephemeral] + }); + } else { + const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id); + for (const opt of options) { + if (opt.role_id !== selectedValue && member.roles.cache.has(opt.role_id)) { + await member.roles.remove(opt.role_id); + } } + await member.roles.add(selectedValue); + await interaction.reply({ + content: `Du hast jetzt die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name}!`, + flags: [MessageFlags.Ephemeral] + }); } - await member.roles.add(selectedValue); - await interaction.reply({ - content: `Du hast jetzt die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name}!`, - flags: [MessageFlags.Ephemeral] - }); } else { if (hasRole) { await member.roles.remove(selectedValue); diff --git a/src/events/ready.ts b/src/events/ready.ts index 17c943b..8fbf465 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,8 @@ import { Events } from 'discord.js'; import { TwitchMonitor } from '../structures/TwitchMonitor.js'; +import { ExtendedClient } from '../structures/ExtendedClient.js'; -async function validateRoles(client: any) { +async function validateRoles(client: ExtendedClient) { const categories: any[] = client.DB.all('SELECT * FROM role_categories'); let removedCount = 0; @@ -20,7 +21,7 @@ async function validateRoles(client: any) { if (category.remove_on_delete) { const members = guild.members.cache.filter((m: any) => m.roles.cache.has(option.role_id)); for (const member of members.values()) { - await member.roles.remove(option.role_id); + await member.roles.remove(option.role_id).catch(() => null); } } removedCount++; @@ -37,7 +38,7 @@ async function validateRoles(client: any) { } } -async function initTwitchMonitor(client: any) { +async function initTwitchMonitor(client: ExtendedClient) { 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'); return; @@ -66,8 +67,8 @@ async function initTwitchMonitor(client: any) { export default { name: Events.ClientReady, once: true, - async execute(client: any) { - console.log(`[READY] Logged in as ${client.user.tag}`); + async execute(client: ExtendedClient) { + console.log(`[READY] Logged in as ${client.user?.tag}`); console.log(`[READY] Serving ${client.guilds.cache.size} servers`); await validateRoles(client); diff --git a/src/index.ts b/src/index.ts index cde96ae..a87e667 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { Command } from './structures/Command.js'; import { DB } from './structures/Database.js'; import { TwitchManager } from './structures/TwitchManager.js'; import { ReminderManager } from './structures/ReminderManager.js'; +import { Deployer } from './structures/Deployer.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -15,12 +16,11 @@ const __dirname = dirname(__filename); DB.init(); const client = new ExtendedClient(); -// Make DB available on client -(client as any).DB = DB; // Load Commands const commandsPath = join(__dirname, 'commands'); const commandFolders = readdirSync(commandsPath); +const commandData: any[] = []; for (const folder of commandFolders) { const folderPath = join(commandsPath, folder); @@ -33,6 +33,7 @@ for (const folder of commandFolders) { if (command && 'data' in command && 'execute' in command) { client.commands.set(command.data.name, command); + commandData.push(command.data.toJSON()); console.log(`[COMMAND] Loaded: ${command.data.name}`); } else { console.warn(`[COMMAND] The command at ${filePath} is missing a required "data" or "execute" property.`); @@ -40,6 +41,11 @@ for (const folder of commandFolders) { } } +// Auto-deploy commands if configured (default: true) +if (process.env.AUTO_DEPLOY !== 'false' && process.env.CLIENT_ID && process.env.DISCORD_TOKEN) { + await Deployer.deployCommands(process.env.CLIENT_ID, process.env.DISCORD_TOKEN, commandData); +} + // Load Events const eventsPath = join(__dirname, 'events'); const eventFiles = readdirSync(eventsPath).filter(file => file.endsWith('.ts') || file.endsWith('.js')); diff --git a/src/structures/Database.ts b/src/structures/Database.ts index ca2f616..7b15978 100644 --- a/src/structures/Database.ts +++ b/src/structures/Database.ts @@ -23,6 +23,8 @@ export class DB { // Enable WAL mode for better performance db.pragma('journal_mode = WAL'); + // Enable foreign key support + db.pragma('foreign_keys = ON'); // Guild Settings db.prepare(` @@ -34,7 +36,7 @@ export class DB { welcome_message TEXT, goodbye_message TEXT, log_channel TEXT, - log_events TEXT DEFAULT 'messages,roles,nicks,channels,moderation', + log_events TEXT DEFAULT 'messages,roles,nicks,channels,leaves,warns,mutes,kicks,bans', warn_threshold INTEGER DEFAULT 3, warn_action TEXT DEFAULT 'ban', warn_mute_duration INTEGER DEFAULT 1800 @@ -218,6 +220,13 @@ export class DB { return db.prepare(query).all(...params); } + /** + * Executes a function within a database transaction + */ + static transaction(fn: () => T): T { + return db.transaction(fn)(); + } + /** * Extended run method that automatically clears relevant caches on write */ @@ -226,11 +235,17 @@ export class DB { // Simple heuristic to clear cache on updates to settings or triggers const lowerQuery = query.toLowerCase(); - if (lowerQuery.includes('guild_settings') || lowerQuery.includes('auto_responses')) { - // If the query contains a guild_id in params, we could be more specific, - // but for safety and simplicity, we clear the cache or just wait for next read. - // We search for guild_id in params to be surgical if possible. - this.clearCache(); + const isSettings = lowerQuery.includes('guild_settings'); + const isTriggers = lowerQuery.includes('auto_responses'); + + if (isSettings || isTriggers) { + // Try to find guild_id in params to be surgical + const guildId = params.find(p => typeof p === 'string' && /^\d{17,20}$/.test(p)); + if (guildId) { + this.clearCache(guildId); + } else { + this.clearCache(); + } } return result; diff --git a/src/structures/Deployer.ts b/src/structures/Deployer.ts index e30faaf..4fb6d24 100644 --- a/src/structures/Deployer.ts +++ b/src/structures/Deployer.ts @@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export class Deployer { + static async deployCommands(clientId: string, token: string, commandData: any[]) { + const rest = new REST().setToken(token); + console.log(`[DEPLOYER] Refreshing ${commandData.length} application (/) commands.`); + + try { + const data: any = await rest.put( + Routes.applicationCommands(clientId), + { body: commandData }, + ); + console.log(`[DEPLOYER] Successfully reloaded ${data.length} application (/) commands.`); + return data.length; + } catch (error) { + console.error('[DEPLOYER] Error deploying commands:', error); + return 0; + } + } + static async deploy(clientId: string, token: string) { const commands = []; const rootDir = join(__dirname, '..'); @@ -15,7 +32,7 @@ export class Deployer { for (const folder of commandFolders) { const folderPath = join(commandsPath, folder); - const commandFiles = readdirSync(folderPath).filter(file => file.endsWith('.ts')); + const commandFiles = readdirSync(folderPath).filter(file => file.endsWith('.ts') || file.endsWith('.js')); for (const file of commandFiles) { const filePath = join(folderPath, file); @@ -28,17 +45,7 @@ export class Deployer { } } - const rest = new REST().setToken(token); - - console.log(`[DEPLOYER] Refreshing ${commands.length} application (/) commands.`); - - const data: any = await rest.put( - Routes.applicationCommands(clientId), - { body: commands }, - ); - - console.log(`[DEPLOYER] Successfully reloaded ${data.length} application (/) commands.`); - return data.length; + return this.deployCommands(clientId, token, commands); } static async deployIfMissing(clientId: string, token: string) { diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts index ed25be0..448d092 100644 --- a/src/structures/ExtendedClient.ts +++ b/src/structures/ExtendedClient.ts @@ -1,8 +1,10 @@ import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { Command } from './Command.js'; +import { DB } from './Database.js'; export class ExtendedClient extends Client { public commands: Collection = new Collection(); + public DB = DB; constructor() { super({ diff --git a/src/structures/ReminderManager.ts b/src/structures/ReminderManager.ts index 4bc7183..e25dca7 100644 --- a/src/structures/ReminderManager.ts +++ b/src/structures/ReminderManager.ts @@ -1,11 +1,12 @@ import { TextChannel, EmbedBuilder } from 'discord.js'; +import { ExtendedClient } from './ExtendedClient.js'; export class ReminderManager { - static async checkReminders(client: any) { + static async checkReminders(client: ExtendedClient) { const now = new Date().toISOString(); // Get all due reminders - const dueReminders: any[] = (client as any).DB?.all( + const dueReminders: any[] = client.DB?.all( 'SELECT * FROM reminders WHERE target_time <= ?', now ) || []; @@ -17,14 +18,14 @@ export class ReminderManager { const guild = client.guilds.cache.get(reminder.guild_id); if (!guild) { // Remove reminder if guild doesn't exist - (client as any).DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); + client.DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); continue; } const channel = guild.channels.cache.get(reminder.channel_id) as TextChannel; if (!channel || !channel.isTextBased()) { // Remove reminder if channel doesn't exist or is not text-based - (client as any).DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); + client.DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); continue; } @@ -45,7 +46,7 @@ export class ReminderManager { await channel.send({ content: `${userMention} deine Erinnerung:`, embeds: [embed] }); // Delete the reminder after sending - (client as any).DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); + client.DB?.run('DELETE FROM reminders WHERE id = ?', reminder.id); console.log(`[REMINDER] Sent reminder ID ${reminder.id} in ${guild.name}`); } catch (error) { @@ -54,7 +55,7 @@ export class ReminderManager { } } - static startPolling(client: any) { + static startPolling(client: ExtendedClient) { // Check every 30 seconds setInterval(() => this.checkReminders(client), 30 * 1000); console.log('[REMINDER] Polling started (30s interval).'); diff --git a/src/structures/TwitchManager.ts b/src/structures/TwitchManager.ts index 16795dd..8784414 100644 --- a/src/structures/TwitchManager.ts +++ b/src/structures/TwitchManager.ts @@ -1,4 +1,5 @@ import { EmbedBuilder, TextChannel } from 'discord.js'; +import { ExtendedClient } from './ExtendedClient.js'; export class TwitchManager { private static accessToken: string | null = null; @@ -62,8 +63,8 @@ export class TwitchManager { return streams[0] || null; } - static async checkStreams(client: any) { - const monitors: any[] = (client as any).DB?.all('SELECT * FROM twitch_monitors') || []; + static async checkStreams(client: ExtendedClient) { + const monitors: any[] = client.DB?.all('SELECT * FROM twitch_monitors') || []; if (monitors.length === 0) return; const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))]; @@ -78,34 +79,47 @@ export class TwitchManager { } } + const updates: { id: number, status: 'online' | 'offline', streamId?: string }[] = []; + for (const monitor of monitors) { - try { - const stream = onlineStreamsMap.get(monitor.channel_name.toLowerCase()); + const stream = onlineStreamsMap.get(monitor.channel_name.toLowerCase()); - if (stream) { - const isNewStream = monitor.last_stream_id !== stream.id; - const wasOffline = monitor.last_status === 'offline'; + if (stream) { + const isNewStream = monitor.last_stream_id !== stream.id; + const wasOffline = monitor.last_status === 'offline'; - if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { - await this.sendNotification(client, monitor, stream); - (client as any).DB?.run( + if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { + await this.sendNotification(client, monitor, stream); + updates.push({ id: monitor.id, status: 'online', streamId: stream.id }); + } + } else { + if (monitor.last_status === 'online') { + updates.push({ id: monitor.id, status: 'offline' }); + } + } + } + + if (updates.length > 0) { + client.DB?.transaction(() => { + for (const update of updates) { + if (update.status === 'online') { + client.DB?.run( "UPDATE twitch_monitors SET last_status = 'online', last_stream_id = ? WHERE id = ?", - stream.id, - monitor.id + update.streamId, + update.id + ); + } else { + client.DB?.run( + "UPDATE twitch_monitors SET last_status = 'offline', last_stream_id = NULL WHERE id = ?", + update.id ); } - } else { - if (monitor.last_status === 'online') { - (client as any).DB?.run("UPDATE twitch_monitors SET last_status = 'offline', last_stream_id = NULL WHERE id = ?", monitor.id); - } } - } catch (error) { - console.error(`[TWITCH] Error processing monitor ${monitor.channel_name}:`, error); - } + }); } } - private static async sendNotification(client: any, monitor: any, stream: any) { + private static async sendNotification(client: ExtendedClient, monitor: any, stream: any) { try { const guild = client.guilds.cache.get(monitor.guild_id); if (!guild || !monitor.discord_channel_id) return; diff --git a/src/structures/TwitchMonitor.ts b/src/structures/TwitchMonitor.ts index 3207acb..efbef59 100644 --- a/src/structures/TwitchMonitor.ts +++ b/src/structures/TwitchMonitor.ts @@ -121,87 +121,87 @@ 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; - this.sendWebhook(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) 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(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; - this.sendWebhook(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; - this.sendWebhook(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; const amount = userstate.bits || 0; - this.sendWebhook(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; - this.sendWebhook(monitor.webhookUrl, { + 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 monitor = this.findMonitor(channel); - if (!monitor) return; + const result = this.findMonitor(channel); + if (!result) return; - this.sendWebhook(monitor.webhookUrl, { + 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,12 +231,13 @@ export class TwitchMonitor { (this as any).discordClient = discordClient; } - private findMonitor(channel: string): MonitorChannel | undefined { + private findMonitor(channel: string): { monitor: MonitorChannel; key: string } | undefined { const normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`; - return Array.from(this.monitors.values()).find( - m => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() || - m.twitchChannel.toLowerCase() === channel.toLowerCase() + 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; } async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise { @@ -277,9 +278,16 @@ export class TwitchMonitor { return Array.from(this.monitors.values()); } - private async sendWebhook(webhookUrl: string, data: { title: string; description: string; color: number }): Promise { + private async sendWebhook(webhookUrl: string, data: { title: string; description: string; color: number }, monitorKey?: string): Promise { try { - const webhook = new WebhookClient({ url: webhookUrl }); + let webhook: WebhookClient; + + if (monitorKey && this.webhookClients.has(monitorKey)) { + webhook = this.webhookClients.get(monitorKey)!; + } else { + webhook = new WebhookClient({ url: webhookUrl }); + } + const embed = new EmbedBuilder() .setTitle(data.title) .setDescription(data.description) @@ -289,7 +297,7 @@ export class TwitchMonitor { await webhook.send({ embeds: [embed] }); } catch (error) { - console.error('[TWITCH] Webhook send error:', error); + console.error(`[TWITCH] Webhook send error${monitorKey ? ` for ${monitorKey}` : ''}:`, error); } }