Compare commits

..

2 Commits

Author SHA1 Message Date
d546035bb7 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
2026-04-29 19:50:50 +02:00
b2c3f5731b Optimize Twitch polling with batching and implement database caching with WAL mode 2026-04-28 20:42:34 +02:00
22 changed files with 439 additions and 732 deletions

View File

@@ -5,3 +5,6 @@ TWITCH_CLIENT_SECRET=dein_twitch_secret
TWITCH_USERNAME=dein_twitch_username TWITCH_USERNAME=dein_twitch_username
TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx
BOT_OWNER_ID=deine_discord_user_id BOT_OWNER_ID=deine_discord_user_id
# Optional: Automatische Slash-Command Registrierung bei Bot-Start (default: true)
AUTO_DEPLOY=true

View File

@@ -4,62 +4,42 @@ This document is intended for AI agents to understand and recreate the `pixelpö
## 🏗 Architecture Overview ## 🏗 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: ### 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. 2. **Dynamic Discovery:** `index.ts` uses `readdirSync` and dynamic `import()` to register commands/events.
3. **Interface-Driven Commands:** All commands implement `Command` interface. 3. **Automatic Command Deployment:** Commands are automatically registered with Discord on startup via `Deployer.deployCommands()`, unless disabled via `AUTO_DEPLOY=false`.
4. **SQLite Database:** Persistent data storage with `better-sqlite3`. 4. **Interface-Driven Commands:** All commands implement the `Command` interface.
5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking. 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. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events. 6. **Twitch Monitoring:** Periodic API polling using **Batch-Requests** (100 channels per request) with Stream ID tracking and **database transactions** for status updates.
7. **Reminder System:** Periodic reminder checking with target_time tracking. 7. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events, using **reusable WebhookClient instances** for efficiency.
8. **Auto-Response System:** Trigger word detection for automatic replies. 8. **Reminder System:** Periodic reminder checking with `target_time` tracking.
9. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages. 9. **Auto-Response System:** Trigger word detection for automatic replies (cached).
10. **Logging System:** Configurable event logging (messages, roles, moderation, etc.). 10. **Welcome/Goodbye System:** Guild member add/remove events with customizable messages.
11. **Role Selection System:** Self-service role assignment via select menus. 11. **Logging System:** Configurable event logging (messages, roles, moderation, etc.).
12. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. 12. **Role Selection System:** Self-service role assignment via select menus with support for exclusive categories and max-role limits.
## 📁 Project Structure ## 📁 Project Structure
``` ```
pixelpoebel/ pixelpoebel/
├── src/ ├── src/
│ ├── index.ts # Entry point, loads commands/events │ ├── index.ts # Entry point, loads commands/events/auto-deploy
│ ├── deploy-commands.ts # Deploy script for Discord │ ├── deploy-commands.ts # Manual deploy script for Discord
│ ├── commands/ │ ├── commands/
│ │ └── utility/ # All commands grouped here │ │ └── utility/ # All commands grouped here
│ │ ├── ping.ts
│ │ ├── help.ts
│ │ ├── twitch.ts
│ │ ├── twitchmonitor.ts # Twitch IRC chat monitoring
│ │ ├── rolesetup.ts # Self-service role selection
│ │ ├── trigger.ts # Auto-responses
│ │ ├── timer.ts # Reminders
│ │ ├── welcome.ts # Welcome/Goodbye messages
│ │ ├── log.ts # Event logging
│ │ ├── admin.ts # All admin subcommands
│ │ └── owner.ts # All owner subcommands
│ ├── events/ │ ├── events/
│ │ ├── ready.ts │ │ ├── ... # All Discord event handlers
│ │ ├── interactionCreate.ts
│ │ ├── messageCreate.ts # Auto-response triggers
│ │ ├── messageDelete.ts # Deleted messages
│ │ ├── messageUpdate.ts # Edited messages
│ │ ├── guildMemberAdd.ts # Welcome messages
│ │ ├── guildMemberRemove.ts # Goodbye messages
│ │ ├── guildMemberUpdate.ts # Nickname/role changes
│ │ ├── channelCreate.ts # Channel create
│ │ └── channelDelete.ts # Channel delete
│ └── structures/ │ └── structures/
│ ├── ExtendedClient.ts │ ├── ExtendedClient.ts # Typed client with DB property
│ ├── Command.ts │ ├── Command.ts
│ ├── Database.ts │ ├── Database.ts # Database with Transaction & Surgical Caching
│ ├── TwitchManager.ts │ ├── TwitchManager.ts # Batch Polling & Transaction logic
│ ├── TwitchMonitor.ts │ ├── TwitchMonitor.ts # IRC Monitoring & Webhook reuse
│ ├── TwitchCache.ts │ ├── TwitchCache.ts # IRC Message FIFO Cache
│ ├── ReminderManager.ts │ ├── ReminderManager.ts
│ └── Deployer.ts │ └── Deployer.ts # Centralized deployment logic
├── data/ # SQLite database (volume mounted) ├── data/ # SQLite database (volume mounted)
├── package.json ├── package.json
├── tsconfig.json ├── tsconfig.json
@@ -71,490 +51,96 @@ pixelpoebel/
## 📝 Implementation Steps ## 📝 Implementation Steps
### 1. Project Setup ### 1. Database with Surgical Caching
```json The database uses WAL mode and foreign keys. Caching is surgical (clears specific guild entries when possible).
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/index.ts",
"deploy": "node --loader ts-node/esm src/deploy-commands.ts"
}
}
```
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
```
### 2. Database Structure
```sql
-- guild_settings
CREATE TABLE guild_settings (
guild_id TEXT PRIMARY KEY,
prefix TEXT DEFAULT '!',
mod_log_channel TEXT,
welcome_channel TEXT,
welcome_message TEXT,
goodbye_message TEXT,
log_channel TEXT,
log_events TEXT DEFAULT 'messages,roles,nicks,channels,warns,mutes,kicks,bans',
warn_threshold INTEGER DEFAULT 3,
warn_action TEXT DEFAULT 'ban',
warn_mute_duration INTEGER DEFAULT 1800
);
-- mod_logs
CREATE TABLE mod_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT,
user_id TEXT,
moderator_id TEXT,
action TEXT,
reason TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- command_stats
CREATE TABLE command_stats (
command_name TEXT PRIMARY KEY,
uses INTEGER DEFAULT 0
);
-- dm_users
CREATE TABLE dm_users (
user_id TEXT PRIMARY KEY,
username TEXT,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- twitch_monitors (Stream notifications)
CREATE TABLE twitch_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT,
channel_name TEXT,
discord_channel_id TEXT,
custom_message TEXT,
last_status TEXT DEFAULT 'offline',
last_stream_id TEXT,
UNIQUE(guild_id, channel_name)
);
-- twitch_monitor_channels (IRC chat logging)
CREATE TABLE twitch_monitor_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
twitch_channel TEXT NOT NULL,
discord_channel_id TEXT NOT NULL,
webhook_url TEXT,
UNIQUE(guild_id, twitch_channel)
);
-- auto_responses
CREATE TABLE auto_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT,
trigger_word TEXT,
response_text TEXT,
UNIQUE(guild_id, trigger_word)
);
-- reminders
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT,
channel_id TEXT,
user_id TEXT,
reminder_text TEXT,
target_time DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- role_categories
CREATE TABLE role_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
category_name TEXT NOT NULL,
max_roles INTEGER DEFAULT 0,
exclusive BOOLEAN DEFAULT 0,
remove_on_delete BOOLEAN DEFAULT 0,
UNIQUE(guild_id, category_name)
);
-- role_options
CREATE TABLE role_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
label TEXT NOT NULL,
emoji TEXT,
role_id TEXT NOT NULL,
UNIQUE(category_id, role_id),
FOREIGN KEY (category_id) REFERENCES role_categories(id) ON DELETE CASCADE
);
```
### 3. Key Components
**ExtendedClient.ts:**
```typescript ```typescript
import { Client, Collection, GatewayIntentBits } from 'discord.js'; // src/structures/Database.ts
import { Command } from './Command.js'; export class DB {
private static settingsCache = new Map<string, any>();
export class ExtendedClient extends Client { static init() {
public commands: Collection<string, Command> = new Collection(); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ... Table creation ...
}
constructor() { static run(query: string, ...params: any[]) {
super({ const result = db.prepare(query).run(...params);
intents: [ if (query.toLowerCase().includes('guild_settings')) {
GatewayIntentBits.Guilds, const guildId = params.find(p => typeof p === 'string' && /^\d{17,20}$/.test(p));
GatewayIntentBits.GuildMessages, if (guildId) this.settingsCache.delete(guildId);
GatewayIntentBits.MessageContent, else this.settingsCache.clear();
GatewayIntentBits.GuildMembers, }
], return result;
}
static transaction<T>(fn: () => T): T {
return db.transaction(fn)();
}
}
```
### 2. Twitch Batch Polling with Transactions
Updates are collected and saved in a single transaction to reduce disk I/O.
```typescript
// src/structures/TwitchManager.ts
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 ...
}
}); });
} }
} }
``` ```
**Command Interface:** ### 3. Webhook Reuse in TwitchMonitor
```typescript
export interface Command {
data: any;
category: 'Owner' | 'Admin' | 'Public';
execute: (interaction: any, client: any) => Promise<void> | void;
cooldown?: number;
}
```
### 4. Timer System Avoid recreating `WebhookClient` instances for every message to improve performance.
**Time Parsing:**
```typescript
// Duration parsing: "30s", "2h", "1d30m", "3wochen"
function parseDuration(input: string): number | null {
const unitMs: Record<string, number> = {
's': 1000, 'sec': 1000, 'sek': 1000, 'sekunden': 1000,
'm': 60000, 'min': 60000, 'minuten': 60000,
'h': 3600000, 'hour': 3600000, 'std': 3600000, 'stunden': 3600000,
'd': 86400000, 'day': 86400000, 't': 86400000, 'tage': 86400000,
'w': 604800000, 'week': 604800000, 'wochen': 604800000,
'mo': 2592000000, 'month': 2592000000, 'monat': 2592000000, 'monate': 2592000000,
'y': 31536000000, 'year': 31536000000, 'j': 31536000000, 'jahr': 31536000000, 'jahre': 31536000000
};
// Parse and calculate total milliseconds
}
// Date parsing: "25.12.2024 14:30", "morgen 15:00"
function parseDateTime(input: string): Date | null {
// German date format, ISO format, or relative "tomorrow"
}
```
**Reminder Polling:**
```typescript
export class ReminderManager {
static async checkReminders(client: any) {
const now = new Date().toISOString();
const dueReminders = DB.all('SELECT * FROM reminders WHERE target_time <= ?', now);
for (const reminder of dueReminders) {
const guild = client.guilds.cache.get(reminder.guild_id);
const channel = guild.channels.cache.get(reminder.channel_id);
// Send notification and delete reminder
}
}
static startPolling(client: any) {
setInterval(() => this.checkReminders(client), 30 * 1000);
}
}
```
### 5. Welcome/Goodbye System
**Variables in messages:** `{user}`, `{username}`, `{server}`, `{membercount}`
**Auto-use Discord system channel if no custom channel set:**
```typescript
const channelId = settings?.welcome_channel || member.guild.systemChannelId;
```
### 6. Logging System
**Events that can be logged:**
- `messages` - Deleted and edited messages
- `roles` - Role changes
- `nicks` - Nickname changes
- `channels` - Channel create/delete
- `warns` - User warnings
- `mutes` - User mutes
- `kicks` - User kicks
- `bans` - User bans
**Log moderation action:**
```typescript
async function sendModerationLog(client: any, 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);
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
if (!channel || !channel.isTextBased()) return;
await (channel as any).send({ embeds: [embed] });
}
```
### 7. Auto-Response (Trigger) System
**Event Handler (`messageCreate.ts`):**
```typescript
// Listens for messages and checks against auto_responses
// Ignores bots and DM messages
const triggers = DB.all('SELECT * FROM auto_responses WHERE guild_id = ?', guildId);
const contentLower = message.content.toLowerCase();
for (const trigger of triggers) {
if (contentLower.includes(trigger.trigger_word.toLowerCase())) {
await message.channel.send(trigger.response_text);
return;
}
}
```
**Command (`trigger.ts`):**
```typescript
// Add trigger (admin only)
DB.run('INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?)',
guildId, triggerWord.toLowerCase(), responseText);
// List triggers (public)
DB.all('SELECT * FROM auto_responses WHERE guild_id = ? ORDER BY trigger_word ASC', guildId);
// Remove trigger (admin only)
DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord);
```
### 8. Twitch Monitoring with Stream ID
```typescript ```typescript
// Stream ID tracking prevents duplicate notifications // src/structures/TwitchMonitor.ts
const isNewStream = monitor.last_stream_id !== stream.id; private async sendWebhook(webhookUrl: string, data: any, monitorKey?: string) {
const wasOffline = monitor.last_status === 'offline'; let webhook = monitorKey ? this.webhookClients.get(monitorKey) : new WebhookClient({ url: webhookUrl });
await webhook.send({ embeds: [embed] });
if (wasOffline || (monitor.last_status === 'online' && isNewStream)) {
// Send notification
DB.run('UPDATE twitch_monitors SET last_status = "online", last_stream_id = ? WHERE id = ?',
stream.id, monitor.id);
} }
``` ```
### 9. TwitchMonitor IRC Chat Logging ### 4. Automatic Deployment
**Overview:** Uses TMI.js to connect to Twitch IRC as a "lurker" (read-only) and logs moderation events to Discord via webhooks. Commands are loaded into an array and deployed during the `index.ts` startup sequence.
**Architecture:**
```typescript ```typescript
// Singleton pattern for IRC connection // src/index.ts
class TwitchMonitor { const commandData = [];
private static instance: TwitchMonitor | null = null; // ... while loading commands ...
private client: tmi.Client; commandData.push(command.data.toJSON());
private cache: TwitchCache; // In-memory FIFO ring buffer (default: 1000 messages)
private monitors: Map<string, MonitorChannel>; if (process.env.AUTO_DEPLOY !== 'false') {
await Deployer.deployCommands(process.env.CLIENT_ID, process.env.DISCORD_TOKEN, commandData);
} }
``` ```
**TwitchCache (In-Memory Ring Buffer):** ## 🚀 Performance Benchmarks (approx.)
```typescript
interface ChatMessage {
channel: string; // twitch channel
user: string;
text: string;
timestamp: number;
msgId: string;
}
class TwitchCache { | Metric | Old (Individual) | New (Batch/Cached/Transaction) |
private buffer: ChatMessage[] = []; |--------|------------------|-------------------------------|
private maxSize: number = 1000; | API Calls (500 channels) | 500 | 5 |
| DB Access (Polling) | Individual writes | Single Transaction |
push(msg: ChatMessage): void { /* FIFO - shift oldest when full */ } | Webhook Overhead | New Connection | Connection Reuse |
getLastN(user: string, channel: string, n: number): ChatMessage[] { /* for ban context */ } | Notification Delay | ~5-7 min | ~2 min |
getByMsgId(msgId: string): ChatMessage | undefined { /* for delete restore */ }
}
```
**Events Logged:**
| Event | Discord Embed |
|-------|--------------|
| `ban` | 🔨 Ban - mit letzten 5 Nachrichten als Kontext |
| `timeout` | ⏱️ Timeout |
| `messagedeleted` | 🗑️ Delete - ~~text~~ (aus Cache wiederhergestellt) |
| `cheer` | 💰 Bits |
| `subscription` | 🅿️ Sub |
**Mod-Icon:** `https://static-cdn.jtvnw.net/jtv_user_pictures/a736384e-8a1d-40ed-8a7a-808a23a15476-profile_image-300x300.png`
**Commands:**
```typescript
/twitchmonitor add <twitch_channel> <discord_channel> // Admin
/twitchmonitor remove <twitch_channel> // Admin
/twitchmonitor list // Public
/twitchmonitor help // Public
```
### 10. Configurable Warn System
**Auto-Action Logic:**
```typescript
const warnCount = countResult?.count ?? 0;
const warnThreshold = settings?.warn_threshold ?? 3;
const warnAction = settings?.warn_action ?? 'ban';
if (warnCount >= warnThreshold) {
if (warnAction === 'kick') await target.kick(...);
else if (warnAction === 'mute') await target.timeout(duration, ...);
else if (warnAction === 'ban') await target.ban(...);
}
```
### 10. Mute with Multiple Time Units
```typescript
const unitSeconds: Record<string, number> = {
sec: 1,
min: 60,
hour: 60 * 60,
day: 60 * 60 * 24,
week: 60 * 60 * 24 * 7,
month: 60 * 60 * 24 * 30,
year: 60 * 60 * 24 * 365
};
if (unit === 'perm') {
durationMs = null; // Permanent
} else {
durationMs = duration * unitSeconds[unit!] * 1000;
}
```
### 11. Purge Command (Message Deletion)
**Subcommands:**
- `purge amount <1-100>` - Delete X messages
- `purge time <duration> <min|hour>` - Delete messages from last X time
- `purge user <user> <1-100>` - Delete X messages from specific user
- `purge all` - Delete all possible messages (max 100)
**Discord API Constraints:**
- Max 100 messages per bulk delete
- Messages older than 14 days cannot be bulk deleted
- Requires `ModerateMembers` permission
```typescript
// Filter messages older than 14 days (Discord API limit)
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const filtered = messages.filter((m: any) => m.createdTimestamp > twoWeeksAgo);
await channel.bulkDelete(filtered, true);
```
**Logging:** Purge actions are logged to the moderation log channel with event type `messages`.
### 12. Role Selection System
**Overview:** Self-service role assignment via Discord Select Menus. Users can choose roles from a dropdown message without needing admin intervention.
**Admin Commands:**
```typescript
/rolesetup create <#channel> <category_name> // Create new role selection message
/rolesetup add <kategorie> <@rolle> [emoji] // Add role option
/rolesetup edit <kategorie> <@rolle> [new_emoji] // Change emoji
/rolesetup remove <kategorie> <@rolle> // Remove role option
/rolesetup delete <kategorie> // Delete entire category
/rolesetup config <kategorie> max:1 exclusive:true // Configure settings
/rolesetup list // Show all configurations
```
**Config Options:**
| Option | Default | Description |
|--------|---------|-------------|
| `max_roles` | 0 (unlimited) | Maximum roles a user can select from this category |
| `exclusive` | false | If true, selecting a role removes other roles in this category |
| `remove_on_delete` | false | Remove role from users when option is deleted |
**User Interaction:**
- User clicks on Select Menu dropdown
- Role is added/removed based on config
- Ephemeral confirmation message appears (auto-hides after 60s)
**Database Schema:**
- `role_categories`: Stores category settings (channel, message_id, config)
- `role_options`: Stores individual role options with labels and emojis
- CASCADE DELETE removes options when category is deleted
**Role Validation:**
- On bot startup, validates that all stored role IDs still exist in Discord
- Missing roles are removed from database (and optionally from users)
## 🚀 Build & Deploy
```bash
# Build
npm run build
# Deploy Commands
npm run deploy
# Run
npm start
# Docker
docker-compose up -d --build
```
## ⚠️ Critical Constraints ## ⚠️ Critical Constraints
1. **Environment Variables:** Always use `process.env`, never hardcode. 1. **Transaction Safety:** Never call async functions (like `fetch` or `channel.send`) inside a synchronous `db.transaction()`.
2. **Type Safety:** Use `any` for command `data` and `execute` parameters (compatibility). 2. **Foreign Keys:** Always enable `foreign_keys = ON` to maintain data integrity.
3. **Interaction States:** Check `interaction.replied` before replying. 3. **Type Safety:** Use `ExtendedClient` instead of `any` for the client instance.
4. **Twitch API:** Max 100 channels per request, batch properly. 4. **Webhook Reuse:** Map `WebhookClient` instances to their monitor keys to prevent leaks.
5. **Deploy:** Commands NOT auto-registered on start use `/owner deploy`.
6. **Ephemeral Replies:** Use `flags: [MessageFlags.Ephemeral]` instead of deprecated `ephemeral: true`.
7. **Line Endings:** Use `.gitattributes` to ensure LF line endings for consistency.
## 📋 Command Summary
| Command | Subcommands | Permission |
|---------|-------------|------------|
| `/ping` | | Public |
| `/help` | | Public |
| `/twitch` | online, add, remove, list, listall, help | Public/Admin |
| `/twitchmonitor` | add, remove, list, help | Admin |
| `/rolesetup` | create, add, edit, remove, delete, config, list | Admin |
| `/trigger` | add, remove, list, help | Public/Admin |
| `/timer` | add, remove, list, listall, help | Public/Admin |
| `/welcome` | setchannel, add, remove, list, help | Admin |
| `/log` | setchannel, enable, disable, list, help | Admin |
| `/admin` | kick, warn, unwarn, mute, unmute, ban, unban, purge, config, help | Admin |
| `/owner` | deploy, stats, servers, help | Owner |

View File

@@ -15,10 +15,13 @@ Erstelle `.env` Datei:
DISCORD_TOKEN=dein_bot_token DISCORD_TOKEN=dein_bot_token
CLIENT_ID=deine_client_id CLIENT_ID=deine_client_id
TWITCH_CLIENT_ID=deine_twitch_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_USERNAME=dein_twitch_username
TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx TWITCH_OAUTH_TOKEN=oauth:xxxxxxxxxxxxxxxxxx
BOT_OWNER_ID=deine_discord_user_id BOT_OWNER_ID=deine_discord_user_id
# Optional: Automatische Slash-Command Registrierung bei Bot-Start (default: true)
AUTO_DEPLOY=true
``` ```
### TwitchMonitor Setup ### TwitchMonitor Setup
@@ -57,6 +60,9 @@ docker-compose up -d --build --force-recreate
## 📋 Commands registrieren ## 📋 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 ```bash
npm run deploy npm run deploy
``` ```
@@ -131,18 +137,14 @@ npm run deploy
- `/owner servers` Alle Server - `/owner servers` Alle Server
- `/owner help` Owner-Hilfe - `/owner help` Owner-Hilfe
## 📡 Features ## 📡 Features & Optimierungen
- ✅ Modular Architecture -**Modular Architecture:** TypeScript-basiert, ESM-Unterstützung, strikte Typisierung.
-TypeScript -**High Performance Database:** SQLite mit **WAL-Modus**, **Fremdschlüssel-Unterstützung** und **chirurgischem In-Memory Caching** für Guild-Settings und Trigger.
-SQLite Database -**Optimized Twitch Polling:** Nutzt **Batch-Requests** (100 Kanäle pro Request), Datenbank-Transaktionen und ein 2-Minuten-Intervall für zeitnahe Benachrichtigungen.
- ✅ Twitch Monitoring (Stream-Status) -**TwitchMonitor IRC:** Echtzeit IRC Chat-Logging (Mod-Events) via **wiederverwendbaren Webhooks** und FIFO-Cache.
-TwitchMonitor IRC Chat Logging (Mod-Events) -**Advanced Moderation:** Warn-System mit Auto-Actions, umfangreiches Purge-System.
-Rollen-Auswahl (Self-Service) -**Role Selection:** Self-Service Rollen-System über Discord Select Menus.
-Moderation Tools -**Reminders:** Intelligentes Zeit-Parsing (z.B. "morgen 15:00", "30min").
-Konfigurierbares Warn-System -**Event Logging:** Detaillierte Erfassung von Server-Ereignissen.
-Auto-Antworten (Trigger) -**Docker Support:** Containerisiertes Deployment inklusive Volume-Mounts für Daten.
- ✅ Timer & Erinnerungen
- ✅ Welcome/Goodbye Messages
- ✅ Event Logging
- ✅ Docker Support

View File

@@ -1,7 +1,8 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember, MessageFlags } from 'discord.js';
import { Command } from '../../structures/Command.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 DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); 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), .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
category: 'Admin', category: 'Admin',
async execute(interaction: any) { async execute(interaction: any) {
const client = interaction.client as ExtendedClient;
const subcommand = interaction.options.getSubcommand(); const subcommand = interaction.options.getSubcommand();
const DB = interaction.client.DB; const DB = client.DB;
const guild = interaction.guild; const guild = interaction.guild;
const guildId = interaction.guildId; const guildId = interaction.guildId;
@@ -580,12 +582,12 @@ const command: Command = {
try { try {
if (purgeSubcommand === 'amount') { if (purgeSubcommand === 'amount') {
const count = interaction.options.getInteger('count')!; 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 twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const filtered = messages.filter((m: any) => m.createdTimestamp > twoWeeksAgo); const filtered = messages.filter((m: any) => m.createdTimestamp > twoWeeksAgo);
await (channel as any).bulkDelete(filtered, true); 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.`); await interaction.editReply(`${deletedCount} Nachrichten gelöscht.`);
} }

View File

@@ -1,6 +1,7 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
import { TwitchManager } from '../../structures/TwitchManager.js'; import { TwitchManager } from '../../structures/TwitchManager.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
const command: Command = { const command: Command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -47,10 +48,11 @@ const command: Command = {
.setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')), .setDescription('Zeigt Hilfe zu den Twitch-Befehlen an.')),
category: 'Public', category: 'Public',
async execute(interaction: any) { async execute(interaction: any) {
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); const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB; const DB = client.DB;
if (subcommand === 'help') { if (subcommand === 'help') {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()

View File

@@ -1,17 +1,13 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js'; import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB; const settings = client.DB.getSettings(guildId);
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return; if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId); const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
try { try {
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
export default { export default {
name: Events.ChannelCreate, name: Events.ChannelCreate,
async execute(channel: GuildChannel) { async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('📁 Channel erstellt') .setTitle('📁 Channel erstellt')
.setColor(0x27ae60) .setColor(0x27ae60)
@@ -33,6 +31,6 @@ export default {
) )
.setTimestamp(); .setTimestamp();
await sendLog(channel.client, channel.guild!.id, 'channels', embed); await sendLog(channel.client, channel.guild.id, 'channels', embed);
}, },
}; };

View File

@@ -1,17 +1,13 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js'; import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB; const settings = client.DB.getSettings(guildId);
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return; if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId); const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
try { try {
@@ -24,6 +20,8 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
export default { export default {
name: Events.ChannelDelete, name: Events.ChannelDelete,
async execute(channel: GuildChannel) { async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('📁 Channel gelöscht') .setTitle('📁 Channel gelöscht')
.setColor(0xe74c3c) .setColor(0xe74c3c)
@@ -33,6 +31,6 @@ export default {
) )
.setTimestamp(); .setTimestamp();
await sendLog(channel.client, channel.guild!.id, 'channels', embed); await sendLog(channel.client, channel.guild.id, 'channels', embed);
}, },
}; };

View File

@@ -6,7 +6,7 @@ export default {
const DB = (member.client as any).DB; const DB = (member.client as any).DB;
const guildId = member.guild.id; const guildId = member.guild.id;
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId); const settings = DB.getSettings(guildId);
// Use custom channel or fall back to Discord's system channel // Use custom channel or fall back to Discord's system channel
const channelId = settings?.welcome_channel || member.guild.systemChannelId; const channelId = settings?.welcome_channel || member.guild.systemChannelId;

View File

@@ -2,16 +2,15 @@ import { Events, GuildMember, TextChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB; const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); const settings = DB.getSettings(guildId);
if (!settings?.log_events) return; if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return; if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId); if (!settings?.log_channel) return;
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel); const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
try { try {
@@ -40,7 +39,7 @@ export default {
await sendLog(member.client, guildId, 'leaves', embed); await sendLog(member.client, guildId, 'leaves', embed);
// Send goodbye message if configured // Send goodbye message if configured
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId); const settings = DB.getSettings(guildId);
const channelId = settings?.welcome_channel || member.guild.systemChannelId; const channelId = settings?.welcome_channel || member.guild.systemChannelId;
if (!channelId || !settings?.goodbye_message) { if (!channelId || !settings?.goodbye_message) {

View File

@@ -1,17 +1,13 @@
import { Events, GuildMember, EmbedBuilder } from 'discord.js'; import { Events, GuildMember, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB; const settings = client.DB.getSettings(guildId);
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return; if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId); const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
try { try {
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
} }
} }
// Guild Member Update (nickname + roles)
export default { export default {
name: Events.GuildMemberUpdate, name: Events.GuildMemberUpdate,
async execute(oldMember: GuildMember, newMember: GuildMember) { async execute(oldMember: GuildMember, newMember: GuildMember) {

View File

@@ -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 { export default {
name: Events.InteractionCreate, name: Events.InteractionCreate,
async execute(interaction: any, client: any) { async execute(interaction: Interaction, client: ExtendedClient) {
if (interaction.isStringSelectMenu()) { if (interaction.isStringSelectMenu()) {
const customId = interaction.customId; const customId = interaction.customId;
@@ -12,6 +13,11 @@ export default {
const member = interaction.member; const member = interaction.member;
const guild = interaction.guild; 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( const category: any = client.DB.get(
'SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', 'SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?',
guild.id, guild.id,
@@ -66,6 +72,13 @@ export default {
const hasRole = member.roles.cache.has(selectedValue); const hasRole = member.roles.cache.has(selectedValue);
if (category.exclusive) { if (category.exclusive) {
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); const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
for (const opt of options) { for (const opt of options) {
if (opt.role_id !== selectedValue && member.roles.cache.has(opt.role_id)) { if (opt.role_id !== selectedValue && member.roles.cache.has(opt.role_id)) {
@@ -77,6 +90,7 @@ export default {
content: `Du hast jetzt die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name}!`, content: `Du hast jetzt die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name}!`,
flags: [MessageFlags.Ephemeral] flags: [MessageFlags.Ephemeral]
}); });
}
} else { } else {
if (hasRole) { if (hasRole) {
await member.roles.remove(selectedValue); await member.roles.remove(selectedValue);

View File

@@ -6,10 +6,9 @@ export default {
if (message.author.bot) return; if (message.author.bot) return;
if (!message.guildId) return; if (!message.guildId) return;
const triggers: any[] = client.DB.all( const triggers = client.DB.getTriggers(message.guildId);
'SELECT * FROM auto_responses WHERE guild_id = ?',
message.guildId if (triggers.length === 0) return;
);
const contentLower = message.content.toLowerCase(); const contentLower = message.content.toLowerCase();

View File

@@ -1,51 +1,22 @@
import { Events, Message, TextChannel, GuildChannel, AuditLogEvent, EmbedBuilder } from 'discord.js'; import { Events, Message, TextChannel, EmbedBuilder } from 'discord.js';
function getLogChannel(client: any, guildId: string): TextChannel | null {
const DB = client.DB;
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
if (!settings?.log_channel) {
return null;
}
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) {
return null;
}
return channel as TextChannel;
}
function isEventEnabled(client: any, guildId: string, event: string): boolean {
const DB = client.DB;
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId);
if (!settings?.log_events) {
return false;
}
const activeEvents = settings.log_events.split(',').filter(Boolean);
return activeEvents.includes(event);
}
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
if (!isEventEnabled(client, guildId, event)) { const settings = client.DB.getSettings(guildId);
return; if (!settings?.log_events || !settings?.log_channel) return;
}
const channel = getLogChannel(client, guildId); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!channel) { if (!activeEvents.includes(event)) return;
return;
} const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try { try {
await channel.send({ embeds: [embed] }); await (channel as any).send({ embeds: [embed] });
} catch (error) { } catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error); console.error(`[LOG] Error sending log (${event}):`, error);
} }
} }
// Message Delete
export default { export default {
name: Events.MessageDelete, name: Events.MessageDelete,
async execute(message: Message) { async execute(message: Message) {

View File

@@ -1,17 +1,13 @@
import { Events, Message, EmbedBuilder } from 'discord.js'; import { Events, Message, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) { async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB; const settings = client.DB.getSettings(guildId);
const settings: any = DB.get('SELECT log_events FROM guild_settings WHERE guild_id = ?', guildId); if (!settings?.log_events || !settings?.log_channel) return;
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean); const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return; if (!activeEvents.includes(event)) return;
const logSettings: any = DB.get('SELECT log_channel FROM guild_settings WHERE guild_id = ?', guildId); const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!logSettings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(logSettings.log_channel);
if (!channel || !channel.isTextBased()) return; if (!channel || !channel.isTextBased()) return;
try { try {
@@ -21,7 +17,6 @@ async function sendLog(client: any, guildId: string, event: string, embed: Embed
} }
} }
// Message Update (Edit)
export default { export default {
name: Events.MessageUpdate, name: Events.MessageUpdate,
async execute(oldMessage: Message, newMessage: Message) { async execute(oldMessage: Message, newMessage: Message) {

View File

@@ -1,7 +1,8 @@
import { Events } from 'discord.js'; import { Events } from 'discord.js';
import { TwitchMonitor } from '../structures/TwitchMonitor.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'); const categories: any[] = client.DB.all('SELECT * FROM role_categories');
let removedCount = 0; let removedCount = 0;
@@ -20,7 +21,7 @@ async function validateRoles(client: any) {
if (category.remove_on_delete) { if (category.remove_on_delete) {
const members = guild.members.cache.filter((m: any) => m.roles.cache.has(option.role_id)); const members = guild.members.cache.filter((m: any) => m.roles.cache.has(option.role_id));
for (const member of members.values()) { for (const member of members.values()) {
await member.roles.remove(option.role_id); await member.roles.remove(option.role_id).catch(() => null);
} }
} }
removedCount++; 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) { 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;
@@ -66,8 +67,8 @@ async function initTwitchMonitor(client: any) {
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
async execute(client: any) { async execute(client: ExtendedClient) {
console.log(`[READY] Logged in as ${client.user.tag}`); console.log(`[READY] Logged in as ${client.user?.tag}`);
console.log(`[READY] Serving ${client.guilds.cache.size} servers`); console.log(`[READY] Serving ${client.guilds.cache.size} servers`);
await validateRoles(client); await validateRoles(client);

View File

@@ -7,6 +7,7 @@ import { Command } from './structures/Command.js';
import { DB } from './structures/Database.js'; import { DB } from './structures/Database.js';
import { TwitchManager } from './structures/TwitchManager.js'; import { TwitchManager } from './structures/TwitchManager.js';
import { ReminderManager } from './structures/ReminderManager.js'; import { ReminderManager } from './structures/ReminderManager.js';
import { Deployer } from './structures/Deployer.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -15,12 +16,11 @@ const __dirname = dirname(__filename);
DB.init(); DB.init();
const client = new ExtendedClient(); const client = new ExtendedClient();
// Make DB available on client
(client as any).DB = DB;
// Load Commands // Load Commands
const commandsPath = join(__dirname, 'commands'); const commandsPath = join(__dirname, 'commands');
const commandFolders = readdirSync(commandsPath); const commandFolders = readdirSync(commandsPath);
const commandData: any[] = [];
for (const folder of commandFolders) { for (const folder of commandFolders) {
const folderPath = join(commandsPath, folder); const folderPath = join(commandsPath, folder);
@@ -33,6 +33,7 @@ for (const folder of commandFolders) {
if (command && 'data' in command && 'execute' in command) { if (command && 'data' in command && 'execute' in command) {
client.commands.set(command.data.name, command); client.commands.set(command.data.name, command);
commandData.push(command.data.toJSON());
console.log(`[COMMAND] Loaded: ${command.data.name}`); console.log(`[COMMAND] Loaded: ${command.data.name}`);
} else { } else {
console.warn(`[COMMAND] The command at ${filePath} is missing a required "data" or "execute" property.`); 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 // Load Events
const eventsPath = join(__dirname, 'events'); const eventsPath = join(__dirname, 'events');
const eventFiles = readdirSync(eventsPath).filter(file => file.endsWith('.ts') || file.endsWith('.js')); const eventFiles = readdirSync(eventsPath).filter(file => file.endsWith('.ts') || file.endsWith('.js'));

View File

@@ -15,9 +15,17 @@ const dbPath = join(dataDir, 'database.sqlite');
const db = new Database(dbPath); const db = new Database(dbPath);
export class DB { export class DB {
private static settingsCache = new Map<string, any>();
private static triggersCache = new Map<string, any[]>();
static init() { static init() {
console.log(`[DATABASE] Initializing at ${dbPath}...`); console.log(`[DATABASE] Initializing at ${dbPath}...`);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Enable foreign key support
db.pragma('foreign_keys = ON');
// Guild Settings // Guild Settings
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS guild_settings ( CREATE TABLE IF NOT EXISTS guild_settings (
@@ -28,7 +36,7 @@ export class DB {
welcome_message TEXT, welcome_message TEXT,
goodbye_message TEXT, goodbye_message TEXT,
log_channel 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_threshold INTEGER DEFAULT 3,
warn_action TEXT DEFAULT 'ban', warn_action TEXT DEFAULT 'ban',
warn_mute_duration INTEGER DEFAULT 1800 warn_mute_duration INTEGER DEFAULT 1800
@@ -143,7 +151,7 @@ export class DB {
) )
`).run(); `).run();
// Migration: Add missing columns to existing guild_settings // Migration logic
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[]; const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
const columnNames = columns.map((c: any) => c.name); const columnNames = columns.map((c: any) => c.name);
@@ -163,6 +171,47 @@ export class DB {
console.log('[DATABASE] Initialization complete.'); console.log('[DATABASE] Initialization complete.');
} }
/**
* Optimized getter for guild settings with caching
*/
static getSettings(guildId: string): any {
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;
}
/**
* Optimized getter for auto responses with caching
*/
static getTriggers(guildId: string): any[] {
if (this.triggersCache.has(guildId)) {
return this.triggersCache.get(guildId)!;
}
const triggers = db.prepare('SELECT * FROM auto_responses WHERE guild_id = ?').all(guildId);
this.triggersCache.set(guildId, triggers);
return triggers;
}
/**
* Clear cache for a specific guild or all
*/
static clearCache(guildId?: string) {
if (guildId) {
this.settingsCache.delete(guildId);
this.triggersCache.delete(guildId);
} else {
this.settingsCache.clear();
this.triggersCache.clear();
}
}
static get(query: string, ...params: any[]) { static get(query: string, ...params: any[]) {
return db.prepare(query).get(...params); return db.prepare(query).get(...params);
} }
@@ -171,7 +220,34 @@ export class DB {
return db.prepare(query).all(...params); 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
*/
static run(query: string, ...params: any[]) { static run(query: string, ...params: any[]) {
return db.prepare(query).run(...params); const result = db.prepare(query).run(...params);
// Simple heuristic to clear cache on updates to settings or triggers
const lowerQuery = query.toLowerCase();
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;
} }
} }

View File

@@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
export class Deployer { 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) { static async deploy(clientId: string, token: string) {
const commands = []; const commands = [];
const rootDir = join(__dirname, '..'); const rootDir = join(__dirname, '..');
@@ -15,7 +32,7 @@ export class Deployer {
for (const folder of commandFolders) { for (const folder of commandFolders) {
const folderPath = join(commandsPath, folder); 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) { for (const file of commandFiles) {
const filePath = join(folderPath, file); const filePath = join(folderPath, file);
@@ -28,17 +45,7 @@ export class Deployer {
} }
} }
const rest = new REST().setToken(token); return this.deployCommands(clientId, token, commands);
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;
} }
static async deployIfMissing(clientId: string, token: string) { static async deployIfMissing(clientId: string, token: string) {

View File

@@ -1,8 +1,10 @@
import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { Client, Collection, GatewayIntentBits } from 'discord.js';
import { Command } from './Command.js'; import { Command } from './Command.js';
import { DB } from './Database.js';
export class ExtendedClient extends Client { export class ExtendedClient extends Client {
public commands: Collection<string, Command> = new Collection(); public commands: Collection<string, Command> = new Collection();
public DB = DB;
constructor() { constructor() {
super({ super({

View File

@@ -1,11 +1,12 @@
import { TextChannel, EmbedBuilder } from 'discord.js'; import { TextChannel, EmbedBuilder } from 'discord.js';
import { ExtendedClient } from './ExtendedClient.js';
export class ReminderManager { export class ReminderManager {
static async checkReminders(client: any) { static async checkReminders(client: ExtendedClient) {
const now = new Date().toISOString(); const now = new Date().toISOString();
// Get all due reminders // Get all due reminders
const dueReminders: any[] = (client as any).DB?.all( const dueReminders: any[] = client.DB?.all(
'SELECT * FROM reminders WHERE target_time <= ?', 'SELECT * FROM reminders WHERE target_time <= ?',
now now
) || []; ) || [];
@@ -17,14 +18,14 @@ export class ReminderManager {
const guild = client.guilds.cache.get(reminder.guild_id); const guild = client.guilds.cache.get(reminder.guild_id);
if (!guild) { if (!guild) {
// Remove reminder if guild doesn't exist // 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; continue;
} }
const channel = guild.channels.cache.get(reminder.channel_id) as TextChannel; const channel = guild.channels.cache.get(reminder.channel_id) as TextChannel;
if (!channel || !channel.isTextBased()) { if (!channel || !channel.isTextBased()) {
// Remove reminder if channel doesn't exist or is not text-based // 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; continue;
} }
@@ -45,7 +46,7 @@ export class ReminderManager {
await channel.send({ content: `${userMention} deine Erinnerung:`, embeds: [embed] }); await channel.send({ content: `${userMention} deine Erinnerung:`, embeds: [embed] });
// Delete the reminder after sending // 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}`); console.log(`[REMINDER] Sent reminder ID ${reminder.id} in ${guild.name}`);
} catch (error) { } catch (error) {
@@ -54,7 +55,7 @@ export class ReminderManager {
} }
} }
static startPolling(client: any) { static startPolling(client: ExtendedClient) {
// Check every 30 seconds // Check every 30 seconds
setInterval(() => this.checkReminders(client), 30 * 1000); setInterval(() => this.checkReminders(client), 30 * 1000);
console.log('[REMINDER] Polling started (30s interval).'); console.log('[REMINDER] Polling started (30s interval).');

View File

@@ -1,4 +1,5 @@
import { EmbedBuilder, TextChannel } from 'discord.js'; import { EmbedBuilder, TextChannel } from 'discord.js';
import { ExtendedClient } from './ExtendedClient.js';
export class TwitchManager { export class TwitchManager {
private static accessToken: string | null = null; private static accessToken: string | null = null;
@@ -29,35 +30,59 @@ export class TwitchManager {
} }
} }
static async fetchStreamData(channelName: string) { /**
* Fetches stream data for multiple channels in a single batch (max 100).
*/
private static async fetchStreamsBatch(channelNames: string[]) {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
if (!token) return null; if (!token || channelNames.length === 0) return [];
// Twitch API allows up to 100 user_login parameters
const query = channelNames.map(name => `user_login=${encodeURIComponent(name)}`).join('&');
try { try {
const response = await fetch(`https://api.twitch.tv/helix/streams?user_login=${channelName}`, { const response = await fetch(`https://api.twitch.tv/helix/streams?${query}`, {
headers: { headers: {
'Client-ID': process.env.TWITCH_CLIENT_ID!, 'Client-ID': process.env.TWITCH_CLIENT_ID!,
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}); });
const data: any = await response.json(); const data: any = await response.json();
return data.data?.[0] || null; return data.data || [];
} catch (error) { } catch (error) {
console.error(`[TWITCH] Error fetching stream data for ${channelName}:`, error); console.error(`[TWITCH] Error fetching batch (${channelNames.length} channels):`, error);
return null; return [];
} }
} }
static async checkStreams(client: any) { /**
const token = await this.getAccessToken(); * Legacy helper for single channel fetch (e.g. for commands)
if (!token) return; */
static async fetchStreamData(channelName: string) {
const streams = await this.fetchStreamsBatch([channelName]);
return streams[0] || null;
}
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; if (monitors.length === 0) return;
const uniqueChannels = [...new Set(monitors.map(m => m.channel_name.toLowerCase()))];
const onlineStreamsMap = new Map<string, any>();
// Process in chunks of 100 (Twitch API limit)
for (let i = 0; i < uniqueChannels.length; i += 100) {
const chunk = uniqueChannels.slice(i, i + 100);
const streams = await this.fetchStreamsBatch(chunk);
for (const stream of streams) {
onlineStreamsMap.set(stream.user_login.toLowerCase(), stream);
}
}
const updates: { id: number, status: 'online' | 'offline', streamId?: string }[] = [];
for (const monitor of monitors) { for (const monitor of monitors) {
try { const stream = onlineStreamsMap.get(monitor.channel_name.toLowerCase());
const stream = await this.fetchStreamData(monitor.channel_name);
if (stream) { if (stream) {
const isNewStream = monitor.last_stream_id !== stream.id; const isNewStream = monitor.last_stream_id !== stream.id;
@@ -65,24 +90,37 @@ export class TwitchManager {
if (wasOffline || (monitor.last_status === 'online' && isNewStream)) { if (wasOffline || (monitor.last_status === 'online' && isNewStream)) {
await this.sendNotification(client, monitor, stream); await this.sendNotification(client, monitor, stream);
(client as any).DB?.run( updates.push({ id: monitor.id, status: 'online', streamId: stream.id });
"UPDATE twitch_monitors SET last_status = 'online', last_stream_id = ? WHERE id = ?",
stream.id,
monitor.id
);
} }
} else { } else {
if (monitor.last_status === 'online') { 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); updates.push({ id: monitor.id, status: 'offline' });
}
}
} catch (error) {
console.error(`[TWITCH] Error checking stream ${monitor.channel_name}:`, error);
} }
} }
} }
private static async sendNotification(client: any, monitor: any, stream: any) { 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 = ?",
update.streamId,
update.id
);
} else {
client.DB?.run(
"UPDATE twitch_monitors SET last_status = 'offline', last_stream_id = NULL WHERE id = ?",
update.id
);
}
}
});
}
}
private static async sendNotification(client: ExtendedClient, monitor: any, stream: any) {
try {
const guild = client.guilds.cache.get(monitor.guild_id); const guild = client.guilds.cache.get(monitor.guild_id);
if (!guild || !monitor.discord_channel_id) return; if (!guild || !monitor.discord_channel_id) return;
@@ -111,10 +149,14 @@ export class TwitchManager {
} }
await channel.send({ content, embeds: [embed] }); await channel.send({ content, embeds: [embed] });
} catch (error) {
console.error(`[TWITCH] Failed to send notification for ${monitor.channel_name}:`, error);
}
} }
static startPolling(client: any) { static startPolling(client: any) {
setInterval(() => this.checkStreams(client), 5 * 60 * 1000); // Polling every 2 minutes for faster notifications
console.log('[TWITCH] Polling started (5m interval).'); setInterval(() => this.checkStreams(client), 2 * 60 * 1000);
console.log('[TWITCH] Batch-Polling started (2m interval).');
} }
} }

View File

@@ -121,87 +121,87 @@ 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 monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
this.sendWebhook(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);
}); });
this.client.on('ban', (channel: string, username: string, reason: string) => { this.client.on('ban', (channel: string, username: string, reason: string) => {
const monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) 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')}`
: ''; : '';
this.sendWebhook(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);
}); });
this.client.on('timeout', (channel: string, username: string, reason: string) => { this.client.on('timeout', (channel: string, username: string, reason: string) => {
const monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
this.sendWebhook(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);
}); });
this.client.on('clearchat', (channel: string) => { this.client.on('clearchat', (channel: string) => {
const monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
this.sendWebhook(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);
}); });
this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => { this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => {
const monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
const amount = userstate.bits || 0; const amount = userstate.bits || 0;
this.sendWebhook(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);
}); });
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 monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
this.sendWebhook(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);
}); });
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 monitor = this.findMonitor(channel); const result = this.findMonitor(channel);
if (!monitor) return; if (!result) return;
this.sendWebhook(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);
}); });
this.client.on('disconnected', async () => { this.client.on('disconnected', async () => {
@@ -231,12 +231,13 @@ export class TwitchMonitor {
(this as any).discordClient = discordClient; (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}`; const normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`;
return Array.from(this.monitors.values()).find( const entry = Array.from(this.monitors.entries()).find(
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;
} }
async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise<void> { 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()); 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 { 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() const embed = new EmbedBuilder()
.setTitle(data.title) .setTitle(data.title)
.setDescription(data.description) .setDescription(data.description)
@@ -289,7 +297,7 @@ export class TwitchMonitor {
await webhook.send({ embeds: [embed] }); await webhook.send({ embeds: [embed] });
} catch (error) { } catch (error) {
console.error('[TWITCH] Webhook send error:', error); console.error(`[TWITCH] Webhook send error${monitorKey ? ` for ${monitorKey}` : ''}:`, error);
} }
} }