Optimize database, implement automatic command deployment, and improve project-wide type safety
Some checks failed
Auto Build and Push Docker Image / build (push) Failing after 14s
Some checks failed
Auto Build and Push Docker Image / build (push) Failing after 14s
This commit is contained in:
@@ -5,3 +5,6 @@ TWITCH_CLIENT_SECRET=dein_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
|
||||
134
AGENTS-BOT.md
134
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<string, any>();
|
||||
private static triggersCache = new Map<string, any[]>();
|
||||
|
||||
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<T>(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()))];
|
||||
static async checkStreams(client: ExtendedClient) {
|
||||
// ... fetch streams ...
|
||||
const updates = []; // collect updates here
|
||||
|
||||
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 ...
|
||||
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.
|
||||
|
||||
16
README.md
16
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").
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
src/index.ts
10
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'));
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, Command> = new Collection();
|
||||
public DB = DB;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
@@ -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).');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
private async sendWebhook(webhookUrl: string, data: { title: string; description: string; color: number }, monitorKey?: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user