Compare commits

...

23 Commits

Author SHA1 Message Date
sarah
0e1630f80b fix: resolve role selection limits, uncached message crashes, and optimize settings caching
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 19s
2026-05-31 21:18:13 +02:00
e2018be4c6 Fix TypeScript errors and refactor MessageFlags to use ephemeral flag
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
2026-04-29 20:09:05 +02:00
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
21c3b01e64 Add Twitch identity auth and token validation to TwitchMonitor
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 8s
2026-04-23 11:37:58 +00:00
c2c009501f Update README with TwitchMonitor and rolesetup commands
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 4s
2026-04-19 18:00:06 +00:00
2fcb7cec66 Add TwitchMonitor IRC chat logging module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 21s
2026-04-19 17:52:41 +00:00
b6caf4b3b3 Add 'Keine' option and toggle hint for role selection
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 6s
2026-04-03 06:55:12 +00:00
14781b8e52 Fix rolesetup: create without empty select menu
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
2026-04-03 06:20:43 +00:00
ad988c9333 Add role selection system with select menus
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
2026-04-02 10:55:43 +00:00
f90f0cb7ab Remove Kick monitoring module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 13s
2026-04-02 07:55:42 +00:00
ed2919224b Update README with Kick monitoring module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
2026-03-31 09:57:07 +00:00
ec8e2f0a7b Rename kickstream command to kick 2026-03-31 09:55:27 +00:00
ce578ab377 Add Kick streaming monitoring module
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 36s
2026-03-31 09:28:28 +00:00
5bbcb0f53f docs: update events structure and trigger system docs
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 4s
2026-03-28 19:38:17 +00:00
9fbd8e6d51 Add messageCreate event for trigger auto-responses
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 23s
2026-03-28 19:30:22 +00:00
3da019b8c7 Docs: Add purge command to README and AGENTS-BOT.md
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
- Added purge subcommands to admin command list
- Documented purge implementation details in AGENTS-BOT.md
- Listed Discord API constraints (100 message limit, 14 day filter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:03:50 +01:00
b0f153f135 Add: Purge command for message deletion
Added /admin purge with subcommands:
- amount: Delete X messages (1-100)
- time: Delete messages from last X minutes/hours
- user: Delete X messages from specific user
- all: Delete all possible messages (max 100)

Features:
- Only works in the channel where executed
- Requires ModerateMembers permission
- Filters messages older than 14 days (Discord API limit)
- Logs purge actions to moderation log channel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:01:44 +01:00
362437bcd5 Add: leaves event logging for user server departures
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 7s
- Log 'leaves' event when user leaves the server
- Shows user info and join date in log embed
- Add to available log events and default list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 11:03:28 +01:00
ea7762dc4b Fix: Add database migration for new columns
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 6s
ALTER TABLE guild_settings adds missing columns:
- welcome_message, goodbye_message
- log_channel, log_events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:52:50 +01:00
6608d05f6f Fix: Split channelEvent.ts into separate create/delete files
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 6s
The combined file with named exports caused 'once' property error.
Each event file must use 'export default'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:47:48 +01:00
6b259ddaa2 Docs: Update README.md and AGENTS-BOT.md
All checks were successful
Auto Build and Push Docker Image / build (push) Successful in 6s
Add Welcome/Goodbye and Logging modules to documentation.
Add new events structure, database fields, and command summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:42:32 +01:00
610cbf7b4d Add: Logging module with moderation events
Commands:
- /log setchannel, enable, disable, list, help

Events logged:
- messages: deleted/edited messages
- roles: role changes
- nicks: nickname changes
- channels: channel create/delete
- warns, mutes, kicks, bans: moderation actions

Moderation logging integrated into admin.ts for all
mod actions (warn, mute, kick, ban).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:35:40 +01:00
33 changed files with 2392 additions and 476 deletions

View File

@@ -1,4 +1,10 @@
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=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

View File

@@ -4,330 +4,146 @@ 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. **Reminder System:** Periodic reminder checking with target_time tracking. 6. **Twitch Monitoring:** Periodic API polling using **Batch-Requests** (100 channels per request) with Stream ID tracking and **database transactions** for status updates.
7. **Auto-Response System:** Trigger word detection for automatic replies. 7. **TwitchMonitor IRC:** TMI.js-based IRC chat monitoring for mod events, using **reusable WebhookClient instances** for efficiency.
8. **Grouped Commands:** Admin/Owner/Trigger/Timer commands grouped under subcommands. 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 ## 📁 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
│ │ ├── trigger.ts # Auto-responses
│ │ ├── timer.ts # Reminders
│ │ ├── admin.ts # All admin subcommands
│ │ └── owner.ts # All owner subcommands
│ ├── events/ │ ├── events/
│ │ ├── ready.ts │ │ ├── ... # All Discord event handlers
│ │ └── interactionCreate.ts
│ └── 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 # IRC Monitoring & Webhook reuse
│ ├── 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
├── Dockerfile ├── Dockerfile
├── docker-compose.yml ├── docker-compose.yml
── .env.example ── .env.example
└── .gitattributes
``` ```
## 📝 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,
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
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)
);
-- 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
);
```
### 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>();
static init() {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// ... Table creation ...
}
export class ExtendedClient extends Client { static run(query: string, ...params: any[]) {
public commands: Collection<string, Command> = new Collection(); const result = db.prepare(query).run(...params);
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)();
}
}
```
constructor() { ### 2. Twitch Batch Polling with Transactions
super({
intents: [ Updates are collected and saved in a single transaction to reduce disk I/O.
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, ```typescript
GatewayIntentBits.MessageContent, // src/structures/TwitchManager.ts
GatewayIntentBits.GuildMembers, 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
Avoid recreating `WebhookClient` instances for every message to improve performance.
```typescript ```typescript
export interface Command { // src/structures/TwitchMonitor.ts
data: any; private async sendWebhook(webhookUrl: string, data: any, monitorKey?: string) {
category: 'Owner' | 'Admin' | 'Public'; let webhook = monitorKey ? this.webhookClients.get(monitorKey) : new WebhookClient({ url: webhookUrl });
execute: (interaction: any, client: any) => Promise<void> | void; await webhook.send({ embeds: [embed] });
cooldown?: number;
} }
``` ```
### 4. Timer System ### 4. Automatic Deployment
Commands are loaded into an array and deployed during the `index.ts` startup sequence.
**Time Parsing:**
```typescript ```typescript
// Duration parsing: "30s", "2h", "1d30m", "3wochen" // src/index.ts
function parseDuration(input: string): number | null { const commandData = [];
const unitMs: Record<string, number> = { // ... while loading commands ...
's': 1000, 'sec': 1000, 'sek': 1000, 'sekunden': 1000, commandData.push(command.data.toJSON());
'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" if (process.env.AUTO_DEPLOY !== 'false') {
function parseDateTime(input: string): Date | null { await Deployer.deployCommands(process.env.CLIENT_ID, process.env.DISCORD_TOKEN, commandData);
// German date format, ISO format, or relative "tomorrow"
} }
``` ```
**Reminder Polling:** ## 🚀 Performance Benchmarks (approx.)
```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) { | Metric | Old (Individual) | New (Batch/Cached/Transaction) |
const guild = client.guilds.cache.get(reminder.guild_id); |--------|------------------|-------------------------------|
const channel = guild.channels.cache.get(reminder.channel_id); | API Calls (500 channels) | 500 | 5 |
// Send notification and delete reminder | DB Access (Polling) | Individual writes | Single Transaction |
} | Webhook Overhead | New Connection | Connection Reuse |
} | Notification Delay | ~5-7 min | ~2 min |
static startPolling(client: any) {
setInterval(() => this.checkReminders(client), 30 * 1000);
}
}
```
### 5. Auto-Response (Trigger) System
```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);
```
### 6. Twitch Monitoring with Stream ID
```typescript
// Stream ID tracking prevents duplicate notifications
const isNewStream = monitor.last_stream_id !== stream.id;
const wasOffline = monitor.last_status === 'offline';
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);
}
```
### 7. Configurable Warn System
**Database Schema:**
- `warn_threshold`: Number of warns before action
- `warn_action`: 'none', 'kick', 'mute', 'ban'
- `warn_mute_duration`: Duration in seconds for mute
**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(...);
}
```
### 8. 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;
}
```
## 🚀 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`. 5. **Partial Message Handling:** In `messageDelete` and `messageUpdate` events, always check for null `author` property to avoid runtime crashes on uncached partial messages.
6. **Ephemeral Replies:** Use `flags: [MessageFlags.Ephemeral]` instead of deprecated `ephemeral: true`. 6. **Centralized Settings Cache:** Always use `client.DB.getSettings(guildId)` instead of raw SQLite select queries for `guild_settings` to leverage central caching and automatic default generation.
7. **Category Role Selection Limits:** Do not count general guild roles when checking role selection limits; filter the user's role list using only the specific role IDs registered under that category.
## 📋 Command Summary
| Command | Subcommands | Permission |
|---------|-------------|------------|
| `/ping` | | Public |
| `/help` | | Public |
| `/twitch` | online, add, remove, list, help | Public/Admin |
| `/trigger` | add, remove, list, help | Public/Admin |
| `/timer` | add, remove, list, listall, help | Public/Admin |
| `/admin` | kick, warn, unwarn, mute, unmute, ban, unban, config, help | Admin |
| `/owner` | deploy, stats, servers, help | Owner |

View File

@@ -15,9 +15,22 @@ 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_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
1. **Twitch OAuth Token holen:** https://chatterino.com/client_login
2. **Username:** Dein Twitch Username
3. **Bot Owner ID:** Deine Discord User ID (für Fehlerbenachrichtigungen)
> ⚠️ **Hinweis:** Der Twitch OAuth Token kann ungültig werden. In diesem Fall wirst du automatisch per Discord DM benachrichtigt.
## 🚀 Start ## 🚀 Start
```bash ```bash
@@ -47,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
``` ```
@@ -59,10 +75,8 @@ npm run deploy
- `/twitch online <channel>` Twitch-Status - `/twitch online <channel>` Twitch-Status
- `/twitch list` Überwachte Kanäle - `/twitch list` Überwachte Kanäle
- `/twitch help` Twitch-Hilfe - `/twitch help` Twitch-Hilfe
- `/timer list` Timer dieses Kanals
### Trigger (Auto-Antworten) - `/timer help` Timer-Hilfe
- `/trigger add <wort> <antwort>` Trigger hinzufügen (Mod)
- `/trigger remove <wort>` Trigger entfernen (Mod)
- `/trigger list` Alle Trigger anzeigen - `/trigger list` Alle Trigger anzeigen
- `/trigger help` Trigger-Hilfe - `/trigger help` Trigger-Hilfe
@@ -73,9 +87,36 @@ npm run deploy
- `/timer listall` Alle Timer (Mod) - `/timer listall` Alle Timer (Mod)
- `/timer help` Timer-Hilfe - `/timer help` Timer-Hilfe
### Trigger (Auto-Antworten)
- `/trigger add <wort> <antwort>` Trigger hinzufügen (Mod)
- `/trigger remove <wort>` Trigger entfernen (Mod)
- `/trigger list` Alle Trigger anzeigen
- `/trigger help` Trigger-Hilfe
### Twitch
- `/twitch online <kanal>` Prüft ob Kanal online ist
- `/twitch add <streamer1, streamer2, ...>` Streamer überwachen (Mod)
- `/twitch remove <streamer1, streamer2, ...>` Streamer entfernen (Mod)
- `/twitch list` Überwachte Kanäle
- `/twitch listall` Alle überwachten Streamer (Mod)
- `/twitch help` Twitch-Hilfe
### TwitchMonitor (IRC Chat Logging)
- `/twitchmonitor add <twitch> <discord>` Twitch-Kanal zum IRC-Monitoring hinzufügen (Mod)
- `/twitchmonitor remove <twitch>` Kanal vom Monitoring entfernen (Mod)
- `/twitchmonitor list` Alle überwachten Twitch-Kanäle
- `/twitchmonitor help` Hilfe anzeigen
### Rollen-Auswahl
- `/rolesetup create <#kanal> <name>` Rollen-Auswahl Message erstellen
- `/rolesetup add <kategorie> <@rolle> [emoji]` Rolle zur Auswahl hinzufügen
- `/rolesetup edit <kategorie> <rolle> [neues_emoji]` Emoji ändern
- `/rolesetup remove <kategorie> <rolle>` Rolle entfernen
- `/rolesetup delete <kategorie>` Kategorie löschen
- `/rolesetup config <kategorie> max:1 exclusive:true` Einstellungen
- `/rolesetup list` Alle Kategorien anzeigen
### Admin ### Admin
- `/twitch add <channel>` Kanal überwachen
- `/twitch remove <channel>` Kanal entfernen
- `/admin kick <user>` Nutzer kicken - `/admin kick <user>` Nutzer kicken
- `/admin warn <user> <reason>` Nutzer verwarnen - `/admin warn <user> <reason>` Nutzer verwarnen
- `/admin unwarn <user>` Letzte Warnung entfernen - `/admin unwarn <user>` Letzte Warnung entfernen
@@ -83,6 +124,10 @@ npm run deploy
- `/admin unmute <user>` Nutzer entmuten - `/admin unmute <user>` Nutzer entmuten
- `/admin ban <user>` Nutzer bannen - `/admin ban <user>` Nutzer bannen
- `/admin unban <id>` Nutzer entbannen - `/admin unban <id>` Nutzer entbannen
- `/admin purge amount <1-100>` X Nachrichten löschen
- `/admin purge time <dauer> <min|hour>` Nachrichten der letzten X Zeit löschen
- `/admin purge user <user> <1-100>` X Nachrichten eines Nutzers löschen
- `/admin purge all` Alle Nachrichten löschen (max 100)
- `/admin config` Warn-System konfigurieren - `/admin config` Warn-System konfigurieren
- `/admin help` Admin-Hilfe - `/admin help` Admin-Hilfe
@@ -92,15 +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 -**TwitchMonitor IRC:** Echtzeit IRC Chat-Logging (Mod-Events) via **wiederverwendbaren Webhooks** und FIFO-Cache.
-Auto-Deploy beim Start -**Advanced Moderation:** Warn-System mit Auto-Actions, umfangreiches Purge-System.
-Moderation Tools -**Role Selection:** Self-Service Rollen-System über Discord Select Menus.
-Konfigurierbares Warn-System -**Reminders:** Intelligentes Zeit-Parsing (z.B. "morgen 15:00", "30min").
-Auto-Antworten (Trigger) -**Event Logging:** Detaillierte Erfassung von Server-Ereignissen.
-Timer & Erinnerungen -**Docker Support:** Containerisiertes Deployment inklusive Volume-Mounts für Daten.
- ✅ Docker Support

65
package-lock.json generated
View File

@@ -11,14 +11,16 @@
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"dotenv": "^16.4.7" "dotenv": "^16.4.7",
"tmi.js": "^1.8.5",
"typescript": "^5.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@types/tmi.js": "^1.8.6",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"ts-node": "^10.9.2", "ts-node": "^10.9.2"
"typescript": "^5.8.2"
} }
}, },
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
@@ -272,6 +274,12 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/tmi.js": {
"version": "1.8.6",
"resolved": "https://registry.npmjs.org/@types/tmi.js/-/tmi.js-1.8.6.tgz",
"integrity": "sha512-LVzNK7AxTMyh9qHLanAQR1o0I9XzfbIcXk85cx85igmCJHHO1Sm71sdhQ8Mj1WmRGzynPIoCXx6mVaFynWbsQw==",
"dev": true
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -883,6 +891,25 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -1179,6 +1206,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tmi.js": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/tmi.js/-/tmi.js-1.8.5.tgz",
"integrity": "sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==",
"dependencies": {
"node-fetch": "^2.6.1",
"ws": "^8.2.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1202,6 +1241,11 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-mixer": { "node_modules/ts-mixer": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
@@ -1274,7 +1318,6 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -1319,6 +1362,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -10,19 +10,26 @@
"dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/index.ts", "dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/index.ts",
"deploy": "node --loader ts-node/esm src/deploy-commands.ts" "deploy": "node --loader ts-node/esm src/deploy-commands.ts"
}, },
"keywords": ["discord", "bot", "typescript", "modular"], "keywords": [
"discord",
"bot",
"typescript",
"modular"
],
"author": "sarah", "author": "sarah",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"tmi.js": "^1.8.5",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@types/tmi.js": "^1.8.6",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
} }
} }

View File

@@ -1,5 +1,27 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember } 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: ExtendedClient, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings = DB.getSettings(guildId);
if (!settings) return;
if (!settings.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
if (!settings.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending moderation log (${event}):`, error);
}
}
const command: Command = { const command: Command = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@@ -116,6 +138,55 @@ const command: Command = {
option.setName('duration_value') option.setName('duration_value')
.setDescription('Dauer in Sekunden') .setDescription('Dauer in Sekunden')
.setMinValue(0))) .setMinValue(0)))
.addSubcommandGroup(group =>
group
.setName('purge')
.setDescription('Löscht Nachrichten im Channel')
.addSubcommand(subcommand =>
subcommand
.setName('amount')
.setDescription('Löscht X Nachrichten')
.addIntegerOption(option =>
option.setName('count')
.setDescription('Anzahl der Nachrichten (1-100)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(100)))
.addSubcommand(subcommand =>
subcommand
.setName('time')
.setDescription('Löscht Nachrichten der letzten X Minuten/Stunden')
.addIntegerOption(option =>
option.setName('duration')
.setDescription('Dauer')
.setRequired(true)
.setMinValue(1))
.addStringOption(option =>
option.setName('unit')
.setDescription('Zeiteinheit')
.setRequired(true)
.addChoices(
{ name: 'Minuten', value: 'min' },
{ name: 'Stunden', value: 'hour' }
)))
.addSubcommand(subcommand =>
subcommand
.setName('user')
.setDescription('Löscht Nachrichten eines bestimmten Nutzers')
.addUserOption(option =>
option.setName('target')
.setDescription('Der Nutzer')
.setRequired(true))
.addIntegerOption(option =>
option.setName('count')
.setDescription('Anzahl der Nachrichten (1-100)')
.setRequired(true)
.setMinValue(1)
.setMaxValue(100)))
.addSubcommand(subcommand =>
subcommand
.setName('all')
.setDescription('Löscht alle möglichen Nachrichten (max 100)')))
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('help') .setName('help')
@@ -123,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;
@@ -141,42 +213,53 @@ const command: Command = {
{ name: '`/admin unmute <nutzer>`', value: 'Entmutet einen Nutzer.', inline: false }, { name: '`/admin unmute <nutzer>`', value: 'Entmutet einen Nutzer.', inline: false },
{ name: '`/admin ban <nutzer> [grund] [delete_days]`', value: 'Bannt einen Nutzer vom Server.\ndelete_days: Nachrichten der letzten X Tage löschen (0-7).', inline: false }, { name: '`/admin ban <nutzer> [grund] [delete_days]`', value: 'Bannt einen Nutzer vom Server.\ndelete_days: Nachrichten der letzten X Tage löschen (0-7).', inline: false },
{ name: '`/admin unban <id>`', value: 'Entbannt einen Nutzer (via ID oder @username).', inline: false }, { name: '`/admin unban <id>`', value: 'Entbannt einen Nutzer (via ID oder @username).', inline: false },
{ name: '`/admin purge amount <1-100>`', value: 'Löscht X Nachrichten im Channel.', inline: false },
{ name: '`/admin purge time <dauer> <min|hour>`', value: 'Löscht Nachrichten der letzten X Minuten/Stunden.', inline: false },
{ name: '`/admin purge user <nutzer> <1-100>`', value: 'Löscht X Nachrichten eines bestimmten Nutzers.', inline: false },
{ name: '`/admin purge all`', value: 'Löscht alle möglichen Nachrichten (max 100).', inline: false },
{ name: '`/admin config <setting> [wert]`', value: 'Konfiguriert das Warn-System.\nSettings: warn_threshold, warn_action, warn_mute_duration, show', inline: false }, { name: '`/admin config <setting> [wert]`', value: 'Konfiguriert das Warn-System.\nSettings: warn_threshold, warn_action, warn_mute_duration, show', inline: false },
{ name: 'Warn-System', value: 'Automatische Aktionen bei X Warnungen:\n`/admin config warn_action kick|mute|ban`', inline: false } { name: 'Warn-System', value: 'Automatische Aktionen bei X Warnungen:\n`/admin config warn_action kick|mute|ban`', inline: false }
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
// Get or create guild settings // Get or create guild settings
let settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId); const settings = DB.getSettings(guildId);
if (!settings) {
DB.run('INSERT INTO guild_settings (guild_id) VALUES (?)', guildId);
settings = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId);
}
if (subcommand === 'kick') { if (subcommand === 'kick') {
const target = interaction.options.getMember('target') as GuildMember; const target = interaction.options.getMember('target') as GuildMember;
const reason = interaction.options.getString('reason') ?? 'Kein Grund'; const reason = interaction.options.getString('reason') ?? 'Kein Grund';
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
if (!target.kickable) { if (!target.kickable) {
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', ephemeral: true });
return; return;
} }
try { try {
await target.kick(reason); await target.kick(reason);
await interaction.reply(`✅ Gekickt: ${target.user.tag}\nGrund: ${reason}`); await interaction.reply(`✅ Gekickt: ${target.user.tag}\nGrund: ${reason}`);
const embed = new EmbedBuilder()
.setTitle('👢 User gekickt')
.setColor(0xf39c12)
.addFields(
{ name: 'Nutzer', value: `${target.user.tag} (<@${target.id}>)`, inline: true },
{ name: 'Moderator', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'Grund', value: reason, inline: false }
)
.setTimestamp();
await sendModerationLog(interaction.client, guildId, 'kicks', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ content: '❌ Fehler beim Kicken.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true });
} }
return; return;
} }
@@ -186,7 +269,7 @@ const command: Command = {
const reason = interaction.options.getString('reason')!; const reason = interaction.options.getString('reason')!;
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
@@ -236,6 +319,18 @@ const command: Command = {
} }
await interaction.reply({ content: message }); await interaction.reply({ content: message });
const embed = new EmbedBuilder()
.setTitle('⚠️ User verwarnt')
.setColor(0xf39c12)
.addFields(
{ name: 'Nutzer', value: `${target.user.tag} (<@${target.id}>)`, inline: true },
{ name: 'Moderator', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'Grund', value: reason, inline: false },
{ name: 'Warnungen', value: `${warnCount}/${warnThreshold}`, inline: true }
)
.setTimestamp();
await sendModerationLog(interaction.client, guildId, 'warns', embed);
return; return;
} }
@@ -243,7 +338,7 @@ const command: Command = {
const target = interaction.options.getMember('target') as GuildMember; const target = interaction.options.getMember('target') as GuildMember;
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
@@ -255,7 +350,7 @@ const command: Command = {
); );
if (!latestWarn) { if (!latestWarn) {
await interaction.reply({ content: 'Keine Warnung für diesen Nutzer gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Warnung für diesen Nutzer gefunden.', ephemeral: true });
return; return;
} }
@@ -271,7 +366,7 @@ const command: Command = {
const reason = interaction.options.getString('reason') ?? 'Kein Grund'; const reason = interaction.options.getString('reason') ?? 'Kein Grund';
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
@@ -295,7 +390,7 @@ const command: Command = {
const multiplier = unitSeconds[unit!]; const multiplier = unitSeconds[unit!];
if (!multiplier) { if (!multiplier) {
await interaction.reply({ content: '❌ Ungültige Zeiteinheit.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Ungültige Zeiteinheit.', ephemeral: true });
return; return;
} }
@@ -313,16 +408,28 @@ const command: Command = {
}; };
durationText = `${duration} ${unitNames[unit!]}`; durationText = `${duration} ${unitNames[unit!]}`;
} else { } else {
await interaction.reply({ content: '❌ Dauer oder Zeiteinheit angeben.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Dauer oder Zeiteinheit angeben.', ephemeral: true });
return; return;
} }
try { try {
await target.timeout(durationMs, reason); await target.timeout(durationMs, reason);
await interaction.reply(`✅ Gemuted: ${target.user.tag}\nDauer: ${durationText}\nGrund: ${reason}`); await interaction.reply(`✅ Gemuted: ${target.user.tag}\nDauer: ${durationText}\nGrund: ${reason}`);
const embed = new EmbedBuilder()
.setTitle('🔇 User gemutet')
.setColor(0xf39c12)
.addFields(
{ name: 'Nutzer', value: `${target.user.tag} (<@${target.id}>)`, inline: true },
{ name: 'Moderator', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'Dauer', value: durationText, inline: true },
{ name: 'Grund', value: reason, inline: false }
)
.setTimestamp();
await sendModerationLog(interaction.client, guildId, 'mutes', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ content: '❌ Fehler beim Muten.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true });
} }
return; return;
} }
@@ -331,7 +438,7 @@ const command: Command = {
const target = interaction.options.getMember('target') as GuildMember; const target = interaction.options.getMember('target') as GuildMember;
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
@@ -340,7 +447,7 @@ const command: Command = {
await interaction.reply(`✅ Entmutet: ${target.user.tag}`); await interaction.reply(`✅ Entmutet: ${target.user.tag}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ content: '❌ Fehler beim Entmuten.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Fehler beim Entmuten.', ephemeral: true });
} }
return; return;
} }
@@ -351,21 +458,33 @@ const command: Command = {
const deleteDays = interaction.options.getInteger('delete_days') ?? 0; const deleteDays = interaction.options.getInteger('delete_days') ?? 0;
if (!target) { if (!target) {
await interaction.reply({ content: 'Nutzer nicht gefunden.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
return; return;
} }
if (!target.bannable) { if (!target.bannable) {
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', ephemeral: true });
return; return;
} }
try { try {
await target.ban({ deleteMessageSeconds: deleteDays * 86400, reason }); await target.ban({ deleteMessageSeconds: deleteDays * 86400, reason });
await interaction.reply(`✅ Gebannt: ${target.user.tag}\nGrund: ${reason}`); await interaction.reply(`✅ Gebannt: ${target.user.tag}\nGrund: ${reason}`);
const embed = new EmbedBuilder()
.setTitle('🔨 User gebannt')
.setColor(0xe74c3c)
.addFields(
{ name: 'Nutzer', value: `${target.user.tag} (<@${target.id}>)`, inline: true },
{ name: 'Moderator', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'Grund', value: reason, inline: false },
{ name: 'Nachrichten gelöscht', value: `${deleteDays} Tage`, inline: true }
)
.setTimestamp();
await sendModerationLog(interaction.client, guildId, 'bans', embed);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await interaction.reply({ content: '❌ Fehler beim Bannen.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true });
} }
return; return;
} }
@@ -382,7 +501,7 @@ const command: Command = {
await interaction.reply(`✅ Entbannt: ${targetId}`); await interaction.reply(`✅ Entbannt: ${targetId}`);
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
await interaction.reply({ content: `❌ Fehler beim Entbannen: ${error.message}`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `❌ Fehler beim Entbannen: ${error.message}`, ephemeral: true });
} }
return; return;
} }
@@ -402,14 +521,14 @@ const command: Command = {
{ name: 'Mute-Dauer', value: `${(settings?.warn_mute_duration ?? 1800) / 60} Min`, inline: true } { name: 'Mute-Dauer', value: `${(settings?.warn_mute_duration ?? 1800) / 60} Min`, inline: true }
); );
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
if (setting === 'warn_threshold') { if (setting === 'warn_threshold') {
const threshold = parseInt(value!); const threshold = parseInt(value!);
if (isNaN(threshold) || threshold < 1) { if (isNaN(threshold) || threshold < 1) {
await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', ephemeral: true });
return; return;
} }
DB.run('UPDATE guild_settings SET warn_threshold = ? WHERE guild_id = ?', threshold, guildId); DB.run('UPDATE guild_settings SET warn_threshold = ? WHERE guild_id = ?', threshold, guildId);
@@ -420,7 +539,7 @@ const command: Command = {
if (setting === 'warn_action') { if (setting === 'warn_action') {
const validActions = ['none', 'kick', 'mute', 'ban']; const validActions = ['none', 'kick', 'mute', 'ban'];
if (!validActions.includes(value!)) { if (!validActions.includes(value!)) {
await interaction.reply({ content: `❌ Ungültige Aktion. Möglich: ${validActions.join(', ')}`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `❌ Ungültige Aktion. Möglich: ${validActions.join(', ')}`, ephemeral: true });
return; return;
} }
DB.run('UPDATE guild_settings SET warn_action = ? WHERE guild_id = ?', value, guildId); DB.run('UPDATE guild_settings SET warn_action = ? WHERE guild_id = ?', value, guildId);
@@ -431,7 +550,7 @@ const command: Command = {
if (setting === 'warn_mute_duration') { if (setting === 'warn_mute_duration') {
const duration = durationValue ?? 1800; const duration = durationValue ?? 1800;
if (duration < 0) { if (duration < 0) {
await interaction.reply({ content: '❌ Ungültige Dauer.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Ungültige Dauer.', ephemeral: true });
return; return;
} }
DB.run('UPDATE guild_settings SET warn_mute_duration = ? WHERE guild_id = ?', duration, guildId); DB.run('UPDATE guild_settings SET warn_mute_duration = ? WHERE guild_id = ?', duration, guildId);
@@ -439,6 +558,115 @@ const command: Command = {
return; return;
} }
} }
// Handle purge subcommands
const subcommandGroup = interaction.options.getSubcommandGroup();
if (subcommandGroup === 'purge') {
const purgeSubcommand = interaction.options.getSubcommand();
const channel = interaction.channel;
if (!channel || !channel.isTextBased()) {
await interaction.reply({ content: '❌ Dieser Befehl kann nur in Text-Channels verwendet werden.', ephemeral: true });
return;
}
// Defer reply since bulk delete can take time
await interaction.deferReply({ ephemeral: true });
let deletedCount = 0;
try {
if (purgeSubcommand === 'amount') {
const count = interaction.options.getInteger('count')!;
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;
await interaction.editReply(`${deletedCount} Nachrichten gelöscht.`);
}
else if (purgeSubcommand === 'time') {
const duration = interaction.options.getInteger('duration')!;
const unit = interaction.options.getString('unit')!;
const unitMs: Record<string, number> = {
min: 60 * 1000,
hour: 60 * 60 * 1000
};
const cutoff = Date.now() - (duration * unitMs[unit]);
const messages = await channel.messages.fetch({ limit: 100 });
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const filtered = messages.filter((m: any) =>
m.createdTimestamp > cutoff &&
m.createdTimestamp > twoWeeksAgo
);
await (channel as any).bulkDelete(filtered, true);
deletedCount = filtered.size;
const unitName = unit === 'min' ? 'Minute(n)' : 'Stunde(n)';
await interaction.editReply(`${deletedCount} Nachrichten der letzten ${duration} ${unitName} gelöscht.`);
}
else if (purgeSubcommand === 'user') {
const target = interaction.options.getUser('target')!;
const count = interaction.options.getInteger('count')!;
const messages = await channel.messages.fetch({ limit: 100 });
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const userMessages = messages.filter((m: any) =>
m.author.id === target.id &&
m.createdTimestamp > twoWeeksAgo
).first(count);
if (userMessages.length === 0) {
await interaction.editReply(`Keine Nachrichten von ${target.tag} gefunden.`);
return;
}
await (channel as any).bulkDelete(userMessages, true);
deletedCount = userMessages.length;
await interaction.editReply(`${deletedCount} Nachrichten von ${target.tag} gelöscht.`);
}
else if (purgeSubcommand === 'all') {
const messages = await channel.messages.fetch({ limit: 100 });
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;
await interaction.editReply(`${deletedCount} Nachrichten gelöscht.`);
}
// Log the purge action
if (deletedCount > 0) {
const embed = new EmbedBuilder()
.setTitle('🗑️ Nachrichten gelöscht (Purge)')
.setColor(0x9b59b6)
.addFields(
{ name: 'Channel', value: `<#${channel.id}>`, inline: true },
{ name: 'Moderator', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true },
{ name: 'Anzahl', value: `${deletedCount} Nachrichten`, inline: true },
{ name: 'Typ', value: purgeSubcommand === 'amount' ? 'Nach Anzahl' :
purgeSubcommand === 'time' ? 'Nach Zeit' :
purgeSubcommand === 'user' ? 'Nach Nutzer' : 'Alle', inline: true }
)
.setTimestamp();
await sendModerationLog(interaction.client, guildId, 'messages', embed);
}
} catch (error) {
console.error('[ADMIN] Purge error:', error);
await interaction.editReply('❌ Fehler beim Löschen der Nachrichten.');
}
return;
}
}, },
}; };

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
const command: Command = { const command: Command = {
@@ -52,7 +52,7 @@ const command: Command = {
} }
} }
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
}, },
}; };

174
src/commands/utility/log.ts Normal file
View File

@@ -0,0 +1,174 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js';
const AVAILABLE_EVENTS = [
{ value: 'messages', label: 'Nachrichten', desc: 'Gelöschte und edierte Nachrichten' },
{ value: 'roles', label: 'Rollen', desc: 'Rollenänderungen' },
{ value: 'nicks', label: 'Nicknames', desc: 'Nickname-Änderungen' },
{ value: 'channels', label: 'Channels', desc: 'Channel erstellt/gelöscht' },
{ value: 'leaves', label: 'Leaves', desc: 'User hat Server verlassen' },
{ value: 'warns', label: 'Verwarnungen', desc: 'User verwarnt' },
{ value: 'mutes', label: 'Mutes', desc: 'User gemutet' },
{ value: 'kicks', label: 'Kicks', desc: 'User gekickt' },
{ value: 'bans', label: 'Bans', desc: 'User gebannt' },
];
const command: Command = {
data: new SlashCommandBuilder()
.setName('log')
.setDescription('Log-Einstellungen verwalten (Mod).')
.addSubcommand(subcommand =>
subcommand
.setName('setchannel')
.setDescription('Log-Kanal setzen (Mod).')
.addChannelOption(option =>
option.setName('channel')
.setDescription('Discord-Kanal für Logs')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('enable')
.setDescription('Event-Logging aktivieren (Mod).')
.addStringOption(option =>
option.setName('event')
.setDescription('Welches Event loggen?')
.setRequired(true)
.addChoices(...AVAILABLE_EVENTS.map(e => ({ name: `${e.label} - ${e.desc}`, value: e.value })))))
.addSubcommand(subcommand =>
subcommand
.setName('disable')
.setDescription('Event-Logging deaktivieren (Mod).')
.addStringOption(option =>
option.setName('event')
.setDescription('Welches Event nicht mehr loggen?')
.setRequired(true)
.addChoices(...AVAILABLE_EVENTS.map(e => ({ name: `${e.label} - ${e.desc}`, value: e.value })))))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Aktive Log-Events anzeigen (Mod).'))
.addSubcommand(subcommand =>
subcommand
.setName('help')
.setDescription('Hilfe zu Log-Befehlen (Mod).')),
category: 'Admin',
async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB;
if (subcommand === 'help') {
const embed = new EmbedBuilder()
.setTitle('📋 Log Hilfe')
.setColor(0x9b59b6)
.setDescription('Loggt verschiedene Server-Events in einen Kanal.')
.addFields(
{ name: '`/log setchannel <#kanal>`', value: 'Setzt den Kanal für Logs (Mod).', inline: false },
{ name: '`/log enable <event>`', value: 'Aktiviert ein Event zum Loggen (Mod).', inline: false },
{ name: '`/log disable <event>`', value: 'Deaktiviert ein Event (Mod).', inline: false },
{ name: '`/log list`', value: 'Zeigt aktive Log-Events (Mod).', inline: false },
{ name: 'Events', value: AVAILABLE_EVENTS.map(e => `• **${e.value}**: ${e.desc}`).join('\n'), inline: false }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
// Admin only for all commands
if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'setchannel') {
const channel = interaction.options.getChannel('channel');
DB.run(
'UPDATE guild_settings SET log_channel = ? WHERE guild_id = ?',
channel.id,
guildId
);
await interaction.reply({ content: `✅ Log-Kanal auf ${channel} gesetzt.`, ephemeral: true });
return;
}
if (subcommand === 'list') {
const settings = DB.getSettings(guildId);
const channel = settings?.log_channel
? `<#${settings.log_channel}>`
: 'Nicht gesetzt';
const activeEvents = (settings?.log_events || '').split(',').filter(Boolean);
const eventsList = activeEvents.length > 0
? AVAILABLE_EVENTS.filter(e => activeEvents.includes(e.value))
.map(e => `• **${e.label}**: ${e.desc}`)
.join('\n')
: 'Keine Events aktiv';
const embed = new EmbedBuilder()
.setTitle('📋 Log Einstellungen')
.setColor(0x9b59b6)
.setDescription(`Server: **${interaction.guild.name}**`)
.addFields(
{ name: 'Log-Kanal', value: channel, inline: true },
{ name: 'Aktive Events', value: eventsList, inline: false }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'enable') {
const event = interaction.options.getString('event')!;
const settings = DB.getSettings(guildId);
const currentEvents = (settings?.log_events || '').split(',').filter(Boolean);
if (currentEvents.includes(event)) {
await interaction.reply({ content: ` Event **${event}** ist bereits aktiviert.`, ephemeral: true });
return;
}
currentEvents.push(event);
DB.run(
'UPDATE guild_settings SET log_events = ? WHERE guild_id = ?',
currentEvents.join(','),
guildId
);
await interaction.reply({ content: `✅ Event **${event}** aktiviert.`, ephemeral: true });
return;
}
if (subcommand === 'disable') {
const event = interaction.options.getString('event')!;
const settings = DB.getSettings(guildId);
const currentEvents = (settings?.log_events || '').split(',').filter(Boolean);
if (!currentEvents.includes(event)) {
await interaction.reply({ content: ` Event **${event}** ist nicht aktiviert.`, ephemeral: true });
return;
}
const newEvents = currentEvents.filter((e: string) => e !== event);
DB.run(
'UPDATE guild_settings SET log_events = ? WHERE guild_id = ?',
newEvents.join(','),
guildId
);
await interaction.reply({ content: `✅ Event **${event}** deaktiviert.`, ephemeral: true });
}
},
};
export default command;

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
import { Deployer } from '../../structures/Deployer.js'; import { Deployer } from '../../structures/Deployer.js';
@@ -28,7 +28,7 @@ const command: Command = {
if (!client.application?.owner) await client.application?.fetch(); if (!client.application?.owner) await client.application?.fetch();
if (interaction.user.id !== client.application?.owner?.id) { if (interaction.user.id !== client.application?.owner?.id) {
await interaction.reply({ content: 'Keine Berechtigung.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Berechtigung.', ephemeral: true });
return; return;
} }
@@ -46,12 +46,12 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
if (subcommand === 'deploy') { if (subcommand === 'deploy') {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ ephemeral: true });
try { try {
const count = await Deployer.deploy(client.user!.id, client.token!); const count = await Deployer.deploy(client.user!.id, client.token!);
@@ -64,7 +64,7 @@ const command: Command = {
} }
if (subcommand === 'stats') { if (subcommand === 'stats') {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ ephemeral: true });
const uptime = process.uptime(); const uptime = process.uptime();
const days = Math.floor(uptime / 86400); const days = Math.floor(uptime / 86400);
@@ -89,7 +89,7 @@ const command: Command = {
} }
if (subcommand === 'servers') { if (subcommand === 'servers') {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ ephemeral: true });
const guilds = client.guilds.cache.map((guild: any) => const guilds = client.guilds.cache.map((guild: any) =>
`• **${guild.name}** \`(${guild.id})\` - ${guild.memberCount} Mitglieder` `• **${guild.name}** \`(${guild.id})\` - ${guild.memberCount} Mitglieder`
@@ -117,7 +117,7 @@ const command: Command = {
const nextEmbed = new EmbedBuilder() const nextEmbed = new EmbedBuilder()
.setColor(0xf1c40f) .setColor(0xf1c40f)
.setDescription(chunks[i].substring(0, 4000)); .setDescription(chunks[i].substring(0, 4000));
await interaction.followUp({ embeds: [nextEmbed], flags: [MessageFlags.Ephemeral] }); await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
} }
return; return;
} }

View File

@@ -0,0 +1,416 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType, ComponentType, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js';
export async function updateRoleMessage(client: any, category: any) {
try {
const channel = client.guilds.cache.get(category.guild_id)?.channels.cache.get(category.channel_id);
if (!channel) return;
const message = await channel.messages.fetch(category.message_id);
if (!message) return;
const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
if (options.length === 0) {
await message.edit({
content: `**${category.category_name}**\nWähle unten eine Rolle aus:\n\n*Noch keine Rollen konfiguriert.*`,
components: []
});
return;
}
const roleOptions = options.map((opt: any) =>
new StringSelectMenuOptionBuilder()
.setLabel(opt.label.replace(/^[^\s]+\s/, ''))
.setValue(opt.role_id)
.setEmoji(opt.emoji || '')
);
roleOptions.push(
new StringSelectMenuOptionBuilder()
.setLabel('Keine')
.setValue(`remove_${category.id}`)
.setEmoji('❌')
);
const selectMenu = new StringSelectMenuBuilder()
.setCustomId(`role_select_${category.category_name}`)
.setPlaceholder('Wähle eine Rolle...')
.addOptions(roleOptions);
await message.edit({
content: `**${category.category_name}**\nWähle unten eine Rolle aus:\n*Klicke erneut auf eine Rolle um sie zu entfernen.*`,
components: [{ type: ComponentType.ActionRow, components: [selectMenu] }]
});
} catch (error) {
console.error('[ROLESETUP] Error updating message:', error);
}
}
const command: Command = {
data: new SlashCommandBuilder()
.setName('rolesetup')
.setDescription('Rollen-Auswahl konfigurieren')
.addSubcommand(subcommand =>
subcommand
.setName('create')
.setDescription('Erstellt eine neue Rollen-Auswahl Message')
.addChannelOption(option =>
option.setName('channel')
.setDescription('Der Kanal für die Rollen-Message')
.addChannelTypes(ChannelType.GuildText)
.setRequired(true))
.addStringOption(option =>
option.setName('name')
.setDescription('Name der Kategorie (z.B. "Farben", "Spiele")')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Fügt eine neue Rolle zur Auswahl hinzu')
.addStringOption(option =>
option.setName('kategorie')
.setDescription('Der Name der Kategorie')
.setRequired(true))
.addRoleOption(option =>
option.setName('rolle')
.setDescription('Die Discord-Rolle')
.setRequired(true))
.addStringOption(option =>
option.setName('emoji')
.setDescription('Optional: Emoji für die Option')
.setRequired(false)))
.addSubcommand(subcommand =>
subcommand
.setName('edit')
.setDescription('Ändert eine bestehende Option')
.addStringOption(option =>
option.setName('kategorie')
.setDescription('Der Name der Kategorie')
.setRequired(true))
.addRoleOption(option =>
option.setName('rolle')
.setDescription('Die Discord-Rolle zum Ändern')
.setRequired(true))
.addStringOption(option =>
option.setName('neue_rolle')
.setDescription('Die neue Discord-Rolle')
.setRequired(false))
.addStringOption(option =>
option.setName('neues_emoji')
.setDescription('Das neue Emoji')
.setRequired(false)))
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Entfernt eine Rolle von der Auswahl')
.addStringOption(option =>
option.setName('kategorie')
.setDescription('Der Name der Kategorie')
.setRequired(true))
.addRoleOption(option =>
option.setName('rolle')
.setDescription('Die Discord-Rolle zum Entfernen')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('delete')
.setDescription('Löscht eine Kategorie und deren Message')
.addStringOption(option =>
option.setName('kategorie')
.setDescription('Der Name der Kategorie')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('config')
.setDescription('Konfiguriert die Kategorie-Einstellungen')
.addStringOption(option =>
option.setName('kategorie')
.setDescription('Der Name der Kategorie')
.setRequired(true))
.addIntegerOption(option =>
option.setName('max_rollen')
.setDescription('Maximale Rollen pro User (0 = unbegrenzt)')
.setRequired(false)
.setMinValue(0))
.addBooleanOption(option =>
option.setName('exklusiv')
.setDescription('Nur eine Rolle pro User erlaubt')
.setRequired(false))
.addBooleanOption(option =>
option.setName('entfernen_bei_loeschung')
.setDescription('Rolle von Usern entfernen wenn gelöscht')
.setRequired(false)))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Listet alle Rollen-Kategorien auf')),
category: 'Admin',
async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB;
if (subcommand === 'list') {
const categories: any[] = DB.all('SELECT * FROM role_categories WHERE guild_id = ? ORDER BY category_name ASC', guildId);
if (categories.length === 0) {
await interaction.reply({ content: 'Keine Rollen-Kategorien konfiguriert.', ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setTitle('Rollen-Kategorien')
.setColor(0x3498db)
.setTimestamp();
for (const cat of categories) {
const options: any[] = DB.all('SELECT * FROM role_options WHERE category_id = ?', cat.id);
const channel = interaction.guild?.channels.cache.get(cat.channel_id);
const configStr = [];
if (cat.max_roles > 0) configStr.push(`Max: ${cat.max_roles}`);
if (cat.exclusive) configStr.push('Exklusiv');
if (cat.remove_on_delete) configStr.push('Auto-Entfernen');
embed.addFields({
name: `${cat.category_name} ${configStr.length > 0 ? `(${configStr.join(', ')})` : ''}`,
value: `Kanal: ${channel?.toString() || '#' + cat.channel_id}\nOptionen: ${options.length}\nNachricht: ${cat.message_id !== 'pending' ? '[✅]' : '[⏳]'}`,
inline: false
});
}
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'create') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const channel = interaction.options.getChannel('channel');
const categoryName = interaction.options.getString('name');
const existing = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, categoryName);
if (existing) {
await interaction.reply({ content: `Kategorie "${categoryName}" existiert bereits.`, ephemeral: true });
return;
}
const sentMessage = await channel.send({
content: `**${categoryName}**\nWähle unten eine Rolle aus:\n\n*Noch keine Rollen konfiguriert. Füge Rollen mit "/rolesetup add" hinzu.*`
});
DB.run(
'INSERT INTO role_categories (guild_id, channel_id, message_id, category_name, max_roles, exclusive, remove_on_delete) VALUES (?, ?, ?, ?, 0, 0, 0)',
guildId,
channel.id,
sentMessage.id,
categoryName
);
await interaction.reply({ content: `Kategorie "${categoryName}" erstellt in ${channel}.`, ephemeral: true });
return;
}
if (subcommand === 'add') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const kategorie = interaction.options.getString('kategorie');
const role = interaction.options.getRole('rolle');
const emoji = interaction.options.getString('emoji');
const category: any = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, kategorie);
if (!category) {
await interaction.reply({ content: `Kategorie "${kategorie}" nicht gefunden.`, ephemeral: true });
return;
}
const existing = DB.get('SELECT * FROM role_options WHERE category_id = ? AND role_id = ?', category.id, role.id);
if (existing) {
await interaction.reply({ content: `Rolle ${role.name} ist bereits in "${kategorie}".`, ephemeral: true });
return;
}
const label = emoji ? `${emoji} ${role.name}` : role.name;
DB.run(
'INSERT INTO role_options (category_id, label, emoji, role_id) VALUES (?, ?, ?, ?)',
category.id,
label,
emoji || null,
role.id
);
await updateRoleMessage(interaction.client, category);
await interaction.reply({ content: `${role.name} zu "${kategorie}" hinzugefügt.`, ephemeral: true });
return;
}
if (subcommand === 'edit') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const kategorie = interaction.options.getString('kategorie');
const oldRole = interaction.options.getRole('rolle');
const newRole = interaction.options.getRole('neue_rolle');
const newEmoji = interaction.options.getString('neues_emoji');
const category: any = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, kategorie);
if (!category) {
await interaction.reply({ content: `Kategorie "${kategorie}" nicht gefunden.`, ephemeral: true });
return;
}
const option: any = DB.get('SELECT * FROM role_options WHERE category_id = ? AND role_id = ?', category.id, oldRole.id);
if (!option) {
await interaction.reply({ content: `Rolle ${oldRole.name} ist nicht in "${kategorie}".`, ephemeral: true });
return;
}
const finalEmoji = newEmoji !== null ? newEmoji : option.emoji;
const finalRoleId = newRole ? newRole.id : oldRole.id;
const finalLabel = finalEmoji ? `${finalEmoji} ${newRole ? newRole.name : oldRole.name}` : (newRole ? newRole.name : oldRole.name);
DB.run(
'UPDATE role_options SET label = ?, emoji = ?, role_id = ? WHERE id = ?',
finalLabel,
finalEmoji,
finalRoleId,
option.id
);
await updateRoleMessage(interaction.client, category);
await interaction.reply({ content: `Option in "${kategorie}" aktualisiert.`, ephemeral: true });
return;
}
if (subcommand === 'remove') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const kategorie = interaction.options.getString('kategorie');
const role = interaction.options.getRole('rolle');
const category: any = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, kategorie);
if (!category) {
await interaction.reply({ content: `Kategorie "${kategorie}" nicht gefunden.`, ephemeral: true });
return;
}
const option: any = DB.get('SELECT * FROM role_options WHERE category_id = ? AND role_id = ?', category.id, role.id);
if (!option) {
await interaction.reply({ content: `Rolle ${role.name} ist nicht in "${kategorie}".`, ephemeral: true });
return;
}
if (category.remove_on_delete) {
const guild = interaction.guild;
const discordRole = guild.roles.cache.get(role.id);
if (discordRole) {
const members = guild.members.cache.filter((m: any) => m.roles.cache.has(role.id));
for (const member of members.values()) {
await member.roles.remove(role.id);
}
}
}
DB.run('DELETE FROM role_options WHERE id = ?', option.id);
await updateRoleMessage(interaction.client, category);
await interaction.reply({ content: `${role.name} aus "${kategorie}" entfernt.`, ephemeral: true });
return;
}
if (subcommand === 'delete') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const kategorie = interaction.options.getString('kategorie');
const category: any = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, kategorie);
if (!category) {
await interaction.reply({ content: `Kategorie "${kategorie}" nicht gefunden.`, ephemeral: true });
return;
}
if (category.remove_on_delete) {
const options: any[] = DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
const guild = interaction.guild;
for (const opt of options) {
const discordRole = guild.roles.cache.get(opt.role_id);
if (discordRole) {
const members = guild.members.cache.filter((m: any) => m.roles.cache.has(opt.role_id));
for (const member of members.values()) {
await member.roles.remove(opt.role_id);
}
}
}
}
try {
const channel = interaction.guild?.channels.cache.get(category.channel_id);
if (channel) {
const message = await channel.messages.fetch(category.message_id);
if (message) await message.delete();
}
} catch { }
DB.run('DELETE FROM role_categories WHERE id = ?', category.id);
await interaction.reply({ content: `Kategorie "${kategorie}" geloescht.`, ephemeral: true });
return;
}
if (subcommand === 'config') {
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
const kategorie = interaction.options.getString('kategorie');
const maxRollen = interaction.options.getInteger('max_rollen');
const exklusiv = interaction.options.getBoolean('exklusiv');
const entfernenBeiLoeschung = interaction.options.getBoolean('entfernen_bei_loeschung');
const category: any = DB.get('SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?', guildId, kategorie);
if (!category) {
await interaction.reply({ content: `Kategorie "${kategorie}" nicht gefunden.`, ephemeral: true });
return;
}
const updates: string[] = [];
if (maxRollen !== null) {
DB.run('UPDATE role_categories SET max_roles = ? WHERE id = ?', maxRollen, category.id);
updates.push(`Max-Rollen: ${maxRollen === 0 ? 'unbegrenzt' : maxRollen}`);
}
if (exklusiv !== null) {
DB.run('UPDATE role_categories SET exclusive = ? WHERE id = ?', exklusiv ? 1 : 0, category.id);
updates.push(`Exklusiv: ${exklusiv ? 'ja' : 'nein'}`);
}
if (entfernenBeiLoeschung !== null) {
DB.run('UPDATE role_categories SET remove_on_delete = ? WHERE id = ?', entfernenBeiLoeschung ? 1 : 0, category.id);
updates.push(`Auto-Entfernen: ${entfernenBeiLoeschung ? 'ja' : 'nein'}`);
}
if (updates.length === 0) {
await interaction.reply({ content: 'Keine Aenderungen angegeben.', ephemeral: true });
return;
}
await interaction.reply({ content: `Konfiguration aktualisiert: ${updates.join(', ')}`, ephemeral: true });
}
}
};
export default command;

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
// Parse time duration strings like "30s", "2h", "1d30m" // Parse time duration strings like "30s", "2h", "1d30m"
@@ -159,7 +159,7 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
@@ -182,7 +182,7 @@ const command: Command = {
if (!targetTime || targetTime <= now) { if (!targetTime || targetTime <= now) {
await interaction.reply({ await interaction.reply({
content: '❌ Ungültige Zeitangabe. Verwende z.B.: "30min", "2h30m", "25.12.2024 14:30" oder "morgen 15:00"', content: '❌ Ungültige Zeitangabe. Verwende z.B.: "30min", "2h30m", "25.12.2024 14:30" oder "morgen 15:00"',
flags: [MessageFlags.Ephemeral] ephemeral: true
}); });
return; return;
} }
@@ -209,14 +209,14 @@ const command: Command = {
await interaction.reply({ await interaction.reply({
content: `✅ Erinnerung gesetzt (ID: ${result.lastInsertRowid}) für **${timeStr.trim()}**`, content: `✅ Erinnerung gesetzt (ID: ${result.lastInsertRowid}) für **${timeStr.trim()}**`,
flags: [MessageFlags.Ephemeral] ephemeral: true
}); });
return; return;
} }
if (subcommand === 'remove') { if (subcommand === 'remove') {
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return; return;
} }
@@ -225,11 +225,11 @@ const command: Command = {
const result = DB.run('DELETE FROM reminders WHERE id = ? AND guild_id = ?', timerId, guildId); const result = DB.run('DELETE FROM reminders WHERE id = ? AND guild_id = ?', timerId, guildId);
if (result.changes === 0) { if (result.changes === 0) {
await interaction.reply({ content: `❌ Timer mit ID ${timerId} nicht gefunden.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `❌ Timer mit ID ${timerId} nicht gefunden.`, ephemeral: true });
return; return;
} }
await interaction.reply({ content: `✅ Timer mit ID ${timerId} entfernt.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `✅ Timer mit ID ${timerId} entfernt.`, ephemeral: true });
return; return;
} }
@@ -241,7 +241,7 @@ const command: Command = {
); );
if (reminders.length === 0) { if (reminders.length === 0) {
await interaction.reply({ content: 'Keine Timer in diesem Kanal.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Timer in diesem Kanal.', ephemeral: true });
return; return;
} }
@@ -275,20 +275,20 @@ const command: Command = {
} }
embed.setDescription(chunks[0]); embed.setDescription(chunks[0]);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
for (let i = 1; i < chunks.length; i++) { for (let i = 1; i < chunks.length; i++) {
const nextEmbed = new EmbedBuilder() const nextEmbed = new EmbedBuilder()
.setColor(0x3498db) .setColor(0x3498db)
.setDescription(chunks[i]); .setDescription(chunks[i]);
await interaction.followUp({ embeds: [nextEmbed], flags: [MessageFlags.Ephemeral] }); await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
} }
return; return;
} }
if (subcommand === 'listall') { if (subcommand === 'listall') {
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return; return;
} }
@@ -298,7 +298,7 @@ const command: Command = {
); );
if (reminders.length === 0) { if (reminders.length === 0) {
await interaction.reply({ content: 'Keine Timer auf dem Server.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Timer auf dem Server.', ephemeral: true });
return; return;
} }
@@ -332,13 +332,13 @@ const command: Command = {
} }
embed.setDescription(chunks[0]); embed.setDescription(chunks[0]);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
for (let i = 1; i < chunks.length; i++) { for (let i = 1; i < chunks.length; i++) {
const nextEmbed = new EmbedBuilder() const nextEmbed = new EmbedBuilder()
.setColor(0xe74c3c) .setColor(0xe74c3c)
.setDescription(chunks[i]); .setDescription(chunks[i]);
await interaction.followUp({ embeds: [nextEmbed], flags: [MessageFlags.Ephemeral] }); await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
} }
} }
}, },

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
const command: Command = { const command: Command = {
@@ -53,7 +53,7 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
@@ -61,7 +61,7 @@ const command: Command = {
const triggers: any[] = DB.all('SELECT * FROM auto_responses WHERE guild_id = ? ORDER BY trigger_word ASC', guildId); const triggers: any[] = DB.all('SELECT * FROM auto_responses WHERE guild_id = ? ORDER BY trigger_word ASC', guildId);
if (triggers.length === 0) { if (triggers.length === 0) {
await interaction.reply({ content: 'Keine Trigger konfiguriert.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Trigger konfiguriert.', ephemeral: true });
return; return;
} }
@@ -80,20 +80,20 @@ const command: Command = {
} }
embed.setDescription(chunks[0]); embed.setDescription(chunks[0]);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
for (let i = 1; i < chunks.length; i++) { for (let i = 1; i < chunks.length; i++) {
const nextEmbed = new EmbedBuilder() const nextEmbed = new EmbedBuilder()
.setColor(0x3498db) .setColor(0x3498db)
.setDescription(chunks[i]); .setDescription(chunks[i]);
await interaction.followUp({ embeds: [nextEmbed], flags: [MessageFlags.Ephemeral] }); await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
} }
return; return;
} }
// Admin only for add/remove // Admin only for add/remove
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return; return;
} }
@@ -109,7 +109,7 @@ const command: Command = {
responseText responseText
); );
await interaction.reply({ content: `✅ Trigger **${triggerWord}** hinzugefügt/aktualisiert.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `✅ Trigger **${triggerWord}** hinzugefügt/aktualisiert.`, ephemeral: true });
return; return;
} }
@@ -119,11 +119,11 @@ const command: Command = {
const result = DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord); const result = DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, triggerWord);
if (result.changes === 0) { if (result.changes === 0) {
await interaction.reply({ content: `❌ Trigger **${triggerWord}** nicht gefunden.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `❌ Trigger **${triggerWord}** nicht gefunden.`, ephemeral: true });
return; return;
} }
await interaction.reply({ content: `✅ Trigger **${triggerWord}** entfernt.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `✅ Trigger **${triggerWord}** entfernt.`, ephemeral: true });
} }
}, },
}; };

View File

@@ -1,6 +1,7 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } 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()
@@ -67,7 +69,7 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
@@ -105,7 +107,7 @@ const command: Command = {
if (subcommand === 'list') { if (subcommand === 'list') {
const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ? AND discord_channel_id = ?', guildId, interaction.channelId); const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ? AND discord_channel_id = ?', guildId, interaction.channelId);
if (monitors.length === 0) { if (monitors.length === 0) {
await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', ephemeral: true });
return; return;
} }
@@ -115,19 +117,19 @@ const command: Command = {
.setColor(0x6441a5) .setColor(0x6441a5)
.setDescription(list); .setDescription(list);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
if (subcommand === 'listall') { if (subcommand === 'listall') {
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung.', ephemeral: true });
return; return;
} }
const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ? ORDER BY channel_name ASC', guildId); const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ? ORDER BY channel_name ASC', guildId);
if (monitors.length === 0) { if (monitors.length === 0) {
await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', ephemeral: true });
return; return;
} }
@@ -149,25 +151,24 @@ const command: Command = {
} }
embed.setDescription(chunks[0]); embed.setDescription(chunks[0]);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
for (let i = 1; i < chunks.length; i++) { for (let i = 1; i < chunks.length; i++) {
const nextEmbed = new EmbedBuilder() const nextEmbed = new EmbedBuilder()
.setColor(0x6441a5) .setColor(0x6441a5)
.setDescription(chunks[i]); .setDescription(chunks[i]);
await interaction.followUp({ embeds: [nextEmbed], flags: [MessageFlags.Ephemeral] }); await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
} }
return; return;
} }
// Admin only for add/remove // Admin only for add/remove
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung.', ephemeral: true });
return; return;
} }
if (subcommand === 'add') { if (subcommand === 'add') {
const discordChannel = interaction.options.getChannel('channel');
const streamersInput = interaction.options.getString('streamers')!; const streamersInput = interaction.options.getString('streamers')!;
const message = interaction.options.getString('message'); const message = interaction.options.getString('message');
const discordChannelId = interaction.channelId; const discordChannelId = interaction.channelId;
@@ -179,7 +180,7 @@ const command: Command = {
.filter((s: string) => s.length > 0); .filter((s: string) => s.length > 0);
if (streamers.length === 0) { if (streamers.length === 0) {
await interaction.reply({ content: '❌ Keine gültigen Streamer angegeben.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine gültigen Streamer angegeben.', ephemeral: true });
return; return;
} }
@@ -189,10 +190,7 @@ const command: Command = {
for (const streamer of streamers) { for (const streamer of streamers) {
try { try {
// Check if stream exists // Check if stream exists
const stream = await TwitchManager.fetchStreamData(streamer); await TwitchManager.fetchStreamData(streamer);
if (!stream) {
// Streamer might be valid but offline - still add them
}
// Check if already exists // Check if already exists
const existing = DB.get('SELECT id FROM twitch_monitors WHERE guild_id = ? AND channel_name = ?', guildId, streamer); const existing = DB.get('SELECT id FROM twitch_monitors WHERE guild_id = ? AND channel_name = ?', guildId, streamer);
@@ -218,7 +216,7 @@ const command: Command = {
if (results.updated > 0) reply += `\n🔄 Aktualisiert: ${results.updated}`; if (results.updated > 0) reply += `\n🔄 Aktualisiert: ${results.updated}`;
if (results.errors > 0) reply += `\n❌ Fehler: ${results.errors} (${errors.join(', ')})`; if (results.errors > 0) reply += `\n❌ Fehler: ${results.errors} (${errors.join(', ')})`;
await interaction.reply({ content: reply, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: reply, ephemeral: true });
return; return;
} }
@@ -230,7 +228,7 @@ const command: Command = {
.filter((s: string) => s.length > 0); .filter((s: string) => s.length > 0);
if (streamers.length === 0) { if (streamers.length === 0) {
await interaction.reply({ content: '❌ Keine gültigen Streamer angegeben.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine gültigen Streamer angegeben.', ephemeral: true });
return; return;
} }
@@ -251,7 +249,7 @@ const command: Command = {
if (results.removed > 0) reply += `${results.removed} Streamer entfernt.`; if (results.removed > 0) reply += `${results.removed} Streamer entfernt.`;
if (results.notFound > 0) reply += `\n❌ Nicht gefunden: ${notFound.join(', ')}`; if (results.notFound > 0) reply += `\n❌ Nicht gefunden: ${notFound.join(', ')}`;
await interaction.reply({ content: reply, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: reply, ephemeral: true });
} }
}, },
}; };

View File

@@ -0,0 +1,141 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, ChannelType } from 'discord.js';
import { Command } from '../../structures/Command.js';
import { TwitchMonitor } from '../../structures/TwitchMonitor.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('twitchmonitor')
.setDescription('Twitch IRC Chat-Monitoring konfigurieren')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Fügt einen Twitch-Kanal zum Monitoring hinzu')
.addStringOption(option =>
option.setName('twitch')
.setDescription('Twitch-Kanalname')
.setRequired(true))
.addChannelOption(option =>
option.setName('discord')
.setDescription('Discord-Kanal für Logs')
.addChannelTypes(ChannelType.GuildText)
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Entfernt einen Twitch-Kanal vom Monitoring')
.addStringOption(option =>
option.setName('twitch')
.setDescription('Twitch-Kanalname')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Listet alle überwachten Twitch-Kanäle auf'))
.addSubcommand(subcommand =>
subcommand
.setName('help')
.setDescription('Zeigt Hilfe zu den TwitchMonitor-Befehlen')),
category: 'Admin',
async execute(interaction: any) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB;
if (subcommand === 'help') {
const embed = new EmbedBuilder()
.setTitle('TwitchMonitor Hilfe')
.setColor(0x9146ff)
.setDescription('Überwacht Twitch IRC-Chat für Moderations-Events.')
.addFields(
{ name: '/twitchmonitor add <twitch> <discord>', value: 'Kanal hinzufügen (Admin)', inline: false },
{ name: '/twitchmonitor remove <twitch>', value: 'Kanal entfernen (Admin)', inline: false },
{ name: '/twitchmonitor list', value: 'Alle überwachten Kanäle', inline: false }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (subcommand === 'list') {
const monitors: any[] = DB.all('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? ORDER BY twitch_channel ASC', guildId);
if (monitors.length === 0) {
await interaction.reply({ content: 'Keine Twitch-Kanäle überwacht.', ephemeral: true });
return;
}
const embed = new EmbedBuilder()
.setTitle('TwitchMonitor Kanäle')
.setColor(0x9146ff)
.setDescription(monitors.map((m: any) =>
`• **${m.twitch_channel}** → <#${m.discord_channel_id}>`
).join('\n'))
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
return;
}
if (!isAdmin) {
await interaction.reply({ content: 'Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return;
}
if (subcommand === 'add') {
const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, '');
const discordChannel = interaction.options.getChannel('discord');
const existing = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
if (existing) {
await interaction.reply({ content: `${twitchChannel} wird bereits überwacht.`, ephemeral: true });
return;
}
const webhook = await discordChannel.createWebhook({
name: `${twitchChannel} | Mod-Log`,
avatar: undefined,
}).catch(async () => {
const existing_webhook = await discordChannel.fetchWebhooks().then((webhooks: any) =>
webhooks.find((w: any) => w.name?.includes(twitchChannel))
);
if (existing_webhook) return existing_webhook;
throw new Error('Could not create webhook');
});
DB.run(
'INSERT INTO twitch_monitor_channels (guild_id, twitch_channel, discord_channel_id, webhook_url) VALUES (?, ?, ?, ?)',
guildId,
twitchChannel,
discordChannel.id,
webhook.url
);
const monitor = TwitchMonitor.getInstance();
await monitor.addMonitor(guildId, twitchChannel, discordChannel.id, webhook.url);
await interaction.reply({ content: `${twitchChannel} wird jetzt überwacht.`, ephemeral: true });
return;
}
if (subcommand === 'remove') {
const twitchChannel = interaction.options.getString('twitch').toLowerCase().replace(/^@/, '');
const monitor: any = DB.get('SELECT * FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
if (!monitor) {
await interaction.reply({ content: `${twitchChannel} wird nicht überwacht.`, ephemeral: true });
return;
}
DB.run('DELETE FROM twitch_monitor_channels WHERE guild_id = ? AND twitch_channel = ?', guildId, twitchChannel);
const twitchMonitor = TwitchMonitor.getInstance();
await twitchMonitor.removeMonitor(guildId, twitchChannel);
await interaction.reply({ content: `${twitchChannel} wurde entfernt.`, ephemeral: true });
}
},
};
export default command;

View File

@@ -1,4 +1,4 @@
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { Command } from '../../structures/Command.js'; import { Command } from '../../structures/Command.js';
const command: Command = { const command: Command = {
@@ -56,8 +56,6 @@ const command: Command = {
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers); const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ModerateMembers);
const DB = interaction.client.DB; const DB = interaction.client.DB;
// Ensure guild settings exist
DB.run('INSERT OR IGNORE INTO guild_settings (guild_id) VALUES (?)', guildId);
if (subcommand === 'help') { if (subcommand === 'help') {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
@@ -75,12 +73,12 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
if (subcommand === 'list') { if (subcommand === 'list') {
const settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', guildId); const settings = DB.getSettings(guildId);
const channel = settings?.welcome_channel const channel = settings?.welcome_channel
? `<#${settings.welcome_channel}>` ? `<#${settings.welcome_channel}>`
@@ -100,13 +98,13 @@ const command: Command = {
) )
.setTimestamp(); .setTimestamp();
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); await interaction.reply({ embeds: [embed], ephemeral: true });
return; return;
} }
// Admin only for modify commands // Admin only for modify commands
if (!isAdmin) { if (!isAdmin) {
await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: '❌ Keine Berechtigung. Moderatoren erforderlich.', ephemeral: true });
return; return;
} }
@@ -119,7 +117,7 @@ const command: Command = {
guildId guildId
); );
await interaction.reply({ content: `✅ Kanal für Willkommen/Abschied auf ${channel} gesetzt.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `✅ Kanal für Willkommen/Abschied auf ${channel} gesetzt.`, ephemeral: true });
return; return;
} }
@@ -136,7 +134,7 @@ const command: Command = {
); );
const label = type === 'welcome' ? 'Willkommensnachricht' : 'Abschiedsnachricht'; const label = type === 'welcome' ? 'Willkommensnachricht' : 'Abschiedsnachricht';
await interaction.reply({ content: `${label} gesetzt:\n\`\`\`\n${message}\n\`\`\``, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `${label} gesetzt:\n\`\`\`\n${message}\n\`\`\``, ephemeral: true });
return; return;
} }
@@ -150,7 +148,7 @@ const command: Command = {
); );
const label = type === 'welcome' ? 'Willkommensnachricht' : 'Abschiedsnachricht'; const label = type === 'welcome' ? 'Willkommensnachricht' : 'Abschiedsnachricht';
await interaction.reply({ content: `${label} entfernt.`, flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: `${label} entfernt.`, ephemeral: true });
} }
}, },
}; };

View File

@@ -0,0 +1,36 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
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 || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default {
name: Events.ChannelCreate,
async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder()
.setTitle('📁 Channel erstellt')
.setColor(0x27ae60)
.addFields(
{ name: 'Name', value: channel.name, inline: true },
{ name: 'Typ', value: channel.type.toString(), inline: true }
)
.setTimestamp();
await sendLog(channel.client, channel.guild.id, 'channels', embed);
},
};

View File

@@ -0,0 +1,36 @@
import { Events, GuildChannel, EmbedBuilder } from 'discord.js';
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 || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default {
name: Events.ChannelDelete,
async execute(channel: GuildChannel) {
if (!channel.guild) return;
const embed = new EmbedBuilder()
.setTitle('📁 Channel gelöscht')
.setColor(0xe74c3c)
.addFields(
{ name: 'Name', value: channel.name, inline: true },
{ name: 'Typ', value: channel.type.toString(), inline: true }
)
.setTimestamp();
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

@@ -1,14 +1,46 @@
import { Events, GuildMember, TextChannel, EmbedBuilder } from 'discord.js'; import { Events, GuildMember, TextChannel, EmbedBuilder } from 'discord.js';
async function sendLog(client: any, guildId: string, event: string, embed: EmbedBuilder) {
const DB = client.DB;
const settings = DB.getSettings(guildId);
if (!settings?.log_events) return;
const activeEvents = settings.log_events.split(',').filter(Boolean);
if (!activeEvents.includes(event)) return;
if (!settings?.log_channel) return;
const channel = client.guilds.cache.get(guildId)?.channels.cache.get(settings.log_channel);
if (!channel || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default { export default {
name: Events.GuildMemberRemove, name: Events.GuildMemberRemove,
async execute(member: GuildMember) { async execute(member: GuildMember) {
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); // Log the leave event
const embed = new EmbedBuilder()
.setTitle('👋 User hat Server verlassen')
.setColor(0x95a5a6)
.addFields(
{ name: 'User', value: `${member.user.tag} (<@${member.id}>)`, inline: true },
{ name: 'Beitritt', value: member.joinedTimestamp ? `<t:${Math.floor(member.joinedTimestamp / 1000)}:R>` : 'Unbekannt', inline: true }
)
.setTimestamp();
await sendLog(member.client, guildId, 'leaves', embed);
// Send goodbye message if configured
const settings = DB.getSettings(guildId);
// 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;
if (!channelId || !settings?.goodbye_message) { if (!channelId || !settings?.goodbye_message) {
return; return;

View File

@@ -0,0 +1,75 @@
import { Events, GuildMember, EmbedBuilder } from 'discord.js';
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 || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default {
name: Events.GuildMemberUpdate,
async execute(oldMember: GuildMember, newMember: GuildMember) {
const guildId = newMember.guild.id;
// Nickname change
if (oldMember.nickname !== newMember.nickname) {
const embed = new EmbedBuilder()
.setTitle('📝 Nickname geändert')
.setColor(0x3498db)
.addFields(
{ name: 'Nutzer', value: `${newMember.user.tag} (<@${newMember.id}>)`, inline: true }
)
.addFields(
{ name: 'Vorher', value: oldMember.nickname || newMember.user.username, inline: true },
{ name: 'Nachher', value: newMember.nickname || newMember.user.username, inline: true }
)
.setTimestamp();
await sendLog(newMember.client, guildId, 'nicks', embed);
}
// Role changes
const oldRoles = oldMember.roles.cache;
const newRoles = newMember.roles.cache;
const addedRoles = newRoles.filter(r => !oldRoles.has(r.id));
const removedRoles = oldRoles.filter(r => !newRoles.has(r.id));
for (const role of addedRoles) {
const embed = new EmbedBuilder()
.setTitle('🎭 Rolle hinzugefügt')
.setColor(0x27ae60)
.addFields(
{ name: 'Nutzer', value: `${newMember.user.tag} (<@${newMember.id}>)`, inline: true },
{ name: 'Rolle', value: `${role[1].name} (<@&${role[1].id}>)`, inline: true }
)
.setTimestamp();
await sendLog(newMember.client, guildId, 'roles', embed);
}
for (const role of removedRoles) {
const embed = new EmbedBuilder()
.setTitle('🎭 Rolle entfernt')
.setColor(0xe74c3c)
.addFields(
{ name: 'Nutzer', value: `${newMember.user.tag} (<@${newMember.id}>)`, inline: true },
{ name: 'Rolle', value: `${role[1].name} (<@&${role[1].id}>)`, inline: true }
)
.setTimestamp();
await sendLog(newMember.client, guildId, 'roles', embed);
}
},
};

View File

@@ -1,8 +1,124 @@
import { Events, MessageFlags } from 'discord.js'; import { Events, Interaction, GuildMember } 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()) {
const customId = interaction.customId;
if (customId.startsWith('role_select_')) {
const categoryName = customId.replace('role_select_', '');
const selectedValue = interaction.values[0];
const guild = interaction.guild;
const member = interaction.member as GuildMember;
if (!guild || !member || !member.roles) {
await interaction.reply({ content: 'Dieser Befehl kann nur auf Servern verwendet werden.', ephemeral: true });
return;
}
const category: any = client.DB.get(
'SELECT * FROM role_categories WHERE guild_id = ? AND category_name = ?',
guild.id,
categoryName
);
if (!category) {
await interaction.reply({ content: 'Kategorie nicht gefunden.', ephemeral: true });
return;
}
if (selectedValue.startsWith('remove_')) {
const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
let removedCount = 0;
for (const opt of options) {
if (member.roles.cache.has(opt.role_id)) {
await member.roles.remove(opt.role_id);
removedCount++;
}
}
if (removedCount > 0) {
await interaction.reply({
content: `Alle Rollen aus "${categoryName}" wurden entfernt.`,
ephemeral: true
});
} else {
await interaction.reply({
content: `Du hast keine Rollen aus "${categoryName}".`,
ephemeral: true
});
}
return;
}
const option: any = client.DB.get(
'SELECT * FROM role_options WHERE category_id = ? AND role_id = ?',
category.id,
selectedValue
);
if (!option) {
await interaction.reply({ content: 'Rolle nicht gefunden.', ephemeral: true });
return;
}
const discordRole = guild.roles.cache.get(selectedValue);
if (!discordRole) {
await interaction.reply({ content: 'Diese Rolle existiert nicht mehr.', ephemeral: true });
return;
}
const hasRole = member.roles.cache.has(selectedValue);
if (category.exclusive) {
if (hasRole) {
await member.roles.remove(selectedValue);
await interaction.reply({
content: `Die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name} wurde entfernt.`,
ephemeral: true
});
} 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}!`,
ephemeral: true
});
}
} else {
if (hasRole) {
await member.roles.remove(selectedValue);
await interaction.reply({
content: `Die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name} wurde entfernt.`,
ephemeral: true
});
} else {
const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
const userCategoryRoles = options.filter((opt: any) => member.roles.cache.has(opt.role_id)).length;
if (category.max_roles > 0 && userCategoryRoles >= category.max_roles) {
await interaction.reply({
content: `Du kannst maximal ${category.max_roles} Rollen aus dieser Kategorie haben.`,
ephemeral: true
});
return;
}
await member.roles.add(selectedValue);
await interaction.reply({
content: `Du hast jetzt die Rolle ${option.emoji ? option.emoji + ' ' : ''}${discordRole.name}!`,
ephemeral: true
});
}
}
return;
}
}
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
const command = client.commands.get(interaction.commandName); const command = client.commands.get(interaction.commandName);
@@ -21,7 +137,7 @@ export default {
`, interaction.commandName); `, interaction.commandName);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
const replyOptions = { content: 'There was an error while executing this command!', flags: [MessageFlags.Ephemeral] }; const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true };
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp(replyOptions); await interaction.followUp(replyOptions);
} else { } else {

View File

@@ -0,0 +1,22 @@
import { Events } from 'discord.js';
export default {
name: Events.MessageCreate,
async execute(message: any, client: any) {
if (message.author.bot) return;
if (!message.guildId) return;
const triggers = client.DB.getTriggers(message.guildId);
if (triggers.length === 0) return;
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;
}
}
},
};

View File

@@ -0,0 +1,44 @@
import { Events, Message, TextChannel, EmbedBuilder } from 'discord.js';
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 || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default {
name: Events.MessageDelete,
async execute(message: Message) {
if (!message.guild || !message.author || message.author.bot) return;
const embed = new EmbedBuilder()
.setTitle('🗑️ Nachricht gelöscht')
.setColor(0xe74c3c)
.addFields(
{ name: 'Autor', value: `${message.author.tag} (<@${message.author.id}>)`, inline: true },
{ name: 'Kanal', value: `<#${message.channelId}>`, inline: true }
)
.setTimestamp();
if (message.content) {
embed.addFields({ name: 'Inhalt', value: message.content.substring(0, 1024), inline: false });
}
if (message.attachments.size > 0) {
embed.addFields({ name: 'Anhänge', value: `${message.attachments.size} Datei(en)`, inline: true });
}
await sendLog(message.client, message.guildId!, 'messages', embed);
},
};

View File

@@ -0,0 +1,42 @@
import { Events, Message, EmbedBuilder } from 'discord.js';
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 || !channel.isTextBased()) return;
try {
await (channel as any).send({ embeds: [embed] });
} catch (error) {
console.error(`[LOG] Error sending log (${event}):`, error);
}
}
export default {
name: Events.MessageUpdate,
async execute(oldMessage: Message, newMessage: Message) {
if (!newMessage.guild || !newMessage.author || newMessage.author.bot) return;
if (!oldMessage.content || !newMessage.content) return;
if (oldMessage.content === newMessage.content) return;
const embed = new EmbedBuilder()
.setTitle('✏️ Nachricht editiert')
.setColor(0xf39c12)
.addFields(
{ name: 'Autor', value: `${newMessage.author.tag} (<@${newMessage.author.id}>)`, inline: true },
{ name: 'Kanal', value: `<#${newMessage.channelId}>`, inline: true }
)
.addFields(
{ name: 'Vorher', value: oldMessage.content.substring(0, 1024), inline: false },
{ name: 'Nachher', value: newMessage.content.substring(0, 1024), inline: false }
)
.setTimestamp();
await sendLog(newMessage.client, newMessage.guildId!, 'messages', embed);
},
};

View File

@@ -1,10 +1,85 @@
import { Events } from 'discord.js'; import { Events } from 'discord.js';
import { TwitchMonitor } from '../structures/TwitchMonitor.js';
import { ExtendedClient } from '../structures/ExtendedClient.js';
import { updateRoleMessage } from '../commands/utility/rolesetup.js';
async function validateRoles(client: ExtendedClient) {
const categories: any[] = client.DB.all('SELECT * FROM role_categories');
let removedCount = 0;
for (const category of categories) {
const guild = client.guilds.cache.get(category.guild_id);
if (!guild) continue;
const options: any[] = client.DB.all('SELECT * FROM role_options WHERE category_id = ?', category.id);
const validOptions: any[] = [];
let categoryUpdated = false;
for (const option of options) {
const roleExists = guild.roles.cache.has(option.role_id);
if (roleExists) {
validOptions.push(option);
} else {
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).catch(() => null);
}
}
client.DB.run('DELETE FROM role_options WHERE id = ?', option.id);
categoryUpdated = true;
removedCount++;
}
}
if (categoryUpdated) {
await updateRoleMessage(client, category);
}
if (validOptions.length === 0 && options.length > 0) {
console.log(`[ROLESETUP] Category "${category.category_name}" has no valid roles left. Consider deleting it.`);
}
}
if (removedCount > 0) {
console.log(`[ROLESETUP] Removed ${removedCount} invalid role options from database.`);
}
}
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;
}
const monitors: any[] = client.DB.all('SELECT * FROM twitch_monitor_channels');
if (monitors.length === 0) {
console.log('[TWITCH] No channels to monitor.');
return;
}
const monitor = TwitchMonitor.getInstance();
monitor.setDiscordClient(client);
for (const m of monitors) {
const webhookExists = m.webhook_url && m.webhook_url.length > 0;
if (webhookExists) {
await monitor.addMonitor(m.guild_id, m.twitch_channel, m.discord_channel_id, m.webhook_url);
}
}
await monitor.connect();
}
export default { export default {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
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 initTwitchMonitor(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 (
@@ -27,6 +35,8 @@ export class DB {
welcome_channel TEXT, welcome_channel TEXT,
welcome_message TEXT, welcome_message TEXT,
goodbye_message TEXT, goodbye_message TEXT,
log_channel TEXT,
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
@@ -63,7 +73,7 @@ export class DB {
) )
`).run(); `).run();
// Twitch Monitors // Twitch Monitors (Stream notifications)
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS twitch_monitors ( CREATE TABLE IF NOT EXISTS twitch_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -77,6 +87,18 @@ export class DB {
) )
`).run(); `).run();
// Twitch Monitor Channels (IRC chat logging)
db.prepare(`
CREATE TABLE IF NOT EXISTS 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)
)
`).run();
// Auto Responses // Auto Responses
db.prepare(` db.prepare(`
CREATE TABLE IF NOT EXISTS auto_responses ( CREATE TABLE IF NOT EXISTS auto_responses (
@@ -101,9 +123,99 @@ export class DB {
) )
`).run(); `).run();
// Role Categories
db.prepare(`
CREATE TABLE IF NOT EXISTS 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)
)
`).run();
// Role Options
db.prepare(`
CREATE TABLE IF NOT EXISTS 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
)
`).run();
// Migration logic
const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];
const columnNames = columns.map((c: any) => c.name);
if (!columnNames.includes('welcome_message')) {
db.prepare('ALTER TABLE guild_settings ADD COLUMN welcome_message TEXT').run();
}
if (!columnNames.includes('goodbye_message')) {
db.prepare('ALTER TABLE guild_settings ADD COLUMN goodbye_message TEXT').run();
}
if (!columnNames.includes('log_channel')) {
db.prepare('ALTER TABLE guild_settings ADD COLUMN log_channel TEXT').run();
}
if (!columnNames.includes('log_events')) {
db.prepare('ALTER TABLE guild_settings ADD COLUMN log_events TEXT DEFAULT \'messages,roles,nicks,channels,leaves,warns,mutes,kicks,bans\'').run();
}
console.log('[DATABASE] Initialization complete.'); console.log('[DATABASE] Initialization complete.');
} }
/**
* Optimized getter for guild settings with caching (automatically creates settings if missing)
*/
static getSettings(guildId: string): any {
if (this.settingsCache.has(guildId)) {
return this.settingsCache.get(guildId);
}
let settings = db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
if (!settings) {
db.prepare('INSERT OR IGNORE INTO guild_settings (guild_id) VALUES (?)').run(guildId);
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);
} }
@@ -112,7 +224,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

@@ -0,0 +1,41 @@
export interface ChatMessage {
channel: string;
user: string;
text: string;
timestamp: number;
msgId: string;
}
export class TwitchCache {
private buffer: ChatMessage[] = [];
private maxSize: number;
constructor(maxSize: number = 1000) {
this.maxSize = maxSize;
}
push(msg: ChatMessage): void {
if (this.buffer.length >= this.maxSize) {
this.buffer.shift();
}
this.buffer.push(msg);
}
getLastN(user: string, channel: string, n: number): ChatMessage[] {
return this.buffer
.filter(m => m.user.toLowerCase() === user.toLowerCase() && m.channel === channel)
.slice(-n);
}
getByMsgId(msgId: string): ChatMessage | undefined {
return this.buffer.find(m => m.msgId === msgId);
}
clear(): void {
this.buffer = [];
}
get size(): number {
return this.buffer.length;
}
}

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,92 +30,133 @@ 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;
const wasOffline = monitor.last_status === 'offline'; const wasOffline = monitor.last_status === 'offline';
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 });
}
} 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 = ?", "UPDATE twitch_monitors SET last_status = 'online', last_stream_id = ? WHERE id = ?",
stream.id, update.streamId,
monitor.id 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 checking stream ${monitor.channel_name}:`, error);
}
} }
} }
private static async sendNotification(client: any, monitor: any, stream: any) { private static async sendNotification(client: ExtendedClient, monitor: any, stream: any) {
const guild = client.guilds.cache.get(monitor.guild_id); try {
if (!guild || !monitor.discord_channel_id) return; const guild = client.guilds.cache.get(monitor.guild_id);
if (!guild || !monitor.discord_channel_id) return;
const channel = guild.channels.cache.get(monitor.discord_channel_id) as TextChannel; const channel = guild.channels.cache.get(monitor.discord_channel_id) as TextChannel;
if (!channel) return; if (!channel) return;
const thumbnailUrl = stream.thumbnail_url const thumbnailUrl = stream.thumbnail_url
.replace('{width}', '1280') .replace('{width}', '1280')
.replace('{height}', '720') + `?t=${Date.now()}`; .replace('{height}', '720') + `?t=${Date.now()}`;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${stream.user_name} ist jetzt LIVE auf Twitch!`) .setTitle(`${stream.user_name} ist jetzt LIVE auf Twitch!`)
.setURL(`https://twitch.tv/${stream.user_login}`) .setURL(`https://twitch.tv/${stream.user_login}`)
.setColor(0x6441a5) .setColor(0x6441a5)
.setImage(thumbnailUrl) .setImage(thumbnailUrl)
.addFields( .addFields(
{ name: 'Titel', value: stream.title || 'Kein Titel', inline: false }, { name: 'Titel', value: stream.title || 'Kein Titel', inline: false },
{ name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true }, { name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true },
{ name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true } { name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true }
) )
.setTimestamp(); .setTimestamp();
let content = `📢 **${stream.user_name}** ist online!`; let content = `📢 **${stream.user_name}** ist online!`;
if (monitor.custom_message) { if (monitor.custom_message) {
content += `\n\n${monitor.custom_message}`; content += `\n\n${monitor.custom_message}`;
}
await channel.send({ content, embeds: [embed] });
} catch (error) {
console.error(`[TWITCH] Failed to send notification for ${monitor.channel_name}:`, error);
} }
await channel.send({ content, embeds: [embed] });
} }
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

@@ -0,0 +1,315 @@
import tmi, { Client, ChatUserstate, DeleteUserstate, SubMethods, SubUserstate } from 'tmi.js';
import { EmbedBuilder, WebhookClient } from 'discord.js';
import { TwitchCache, ChatMessage } from './TwitchCache.js';
const MOD_ICON = 'https://static-cdn.jtvnw.net/jtv_user_pictures/a736384e-8a1d-40ed-8a7a-808a23a15476-profile_image-300x300.png';
const TOKEN_CHECK_INTERVAL = 5 * 60 * 1000;
const TOKEN_CHECK_MAX_FAILURES = 3;
const TOKEN_CHECK_RETRY_DELAY = 30 * 1000;
interface MonitorChannel {
guildId: string;
twitchChannel: string;
discordChannelId: string;
webhookUrl: string;
}
export class TwitchMonitor {
private static instance: TwitchMonitor | null = null;
private client: Client | null = null;
private cache: TwitchCache;
private monitors: Map<string, MonitorChannel> = new Map();
private webhookClients: Map<string, WebhookClient> = new Map();
private tokenFailureCount = 0;
private tokenCheckInterval: NodeJS.Timeout | null = null;
private isTokenInvalid = false;
private constructor() {
this.cache = new TwitchCache(1000);
}
static getInstance(): TwitchMonitor {
if (!TwitchMonitor.instance) {
TwitchMonitor.instance = new TwitchMonitor();
}
return TwitchMonitor.instance;
}
private getOAuthToken(): string {
const token = process.env.TWITCH_OAUTH_TOKEN || '';
return token.startsWith('oauth:') ? token : 'oauth:' + token;
}
private async notifyOwner(message: string): Promise<void> {
const ownerId = process.env.BOT_OWNER_ID;
if (!ownerId) return;
try {
const { Client: DiscordClient } = await import('discord.js');
const client = TwitchMonitor.getInstance();
const discordClient = (client as any).discordClient;
if (!discordClient) return;
const owner = await discordClient.users.fetch(ownerId).catch(() => null);
if (owner) {
await owner.send(`⚠️ **TwitchMonitor Warning**\n${message}`);
}
} catch (error) {
console.error('[TWITCH] Failed to notify owner:', error);
}
}
private startTokenValidation(): void {
this.tokenCheckInterval = setInterval(async () => {
if (this.isTokenInvalid) return;
const isConnected = this.client?.readyState() === 'OPEN';
if (!isConnected) {
this.tokenFailureCount++;
console.warn(`[TWITCH] Connection check failed (${this.tokenFailureCount}/${TOKEN_CHECK_MAX_FAILURES})`);
if (this.tokenFailureCount >= TOKEN_CHECK_MAX_FAILURES) {
this.isTokenInvalid = true;
console.error('[TWITCH] Token validation failed - stopping monitoring');
this.notifyOwner('TwitchMonitor Token ungültig. Bitte neuen Token holen: https://chatterino.com/client_login');
this.stopTokenValidation();
this.disconnect();
} else {
await new Promise(resolve => setTimeout(resolve, TOKEN_CHECK_RETRY_DELAY));
}
} else {
this.tokenFailureCount = 0;
}
}, TOKEN_CHECK_INTERVAL);
}
private stopTokenValidation(): void {
if (this.tokenCheckInterval) {
clearInterval(this.tokenCheckInterval);
this.tokenCheckInterval = null;
}
}
async connect(): Promise<void> {
if (this.client) return;
const channels = Array.from(this.monitors.values()).map(m => m.twitchChannel);
this.client = new Client({
options: {
debug: false,
},
identity: {
username: process.env.TWITCH_USERNAME,
password: this.getOAuthToken(),
},
channels: channels,
});
this.client.on('message', (channel: string, userstate: ChatUserstate, text: string, self: boolean) => {
if (self) return;
this.cache.push({
channel,
user: userstate['display-name'] || userstate.username || 'unknown',
text,
timestamp: Date.now(),
msgId: userstate.id || '',
});
});
this.client.on('messagedeleted', (channel: string, username: string, deletedMessage: string, userstate: DeleteUserstate) => {
const msgId = userstate['target-msg-id'] || '';
const cached = this.cache.getByMsgId(msgId);
const result = this.findMonitor(channel);
if (!result) return;
this.sendWebhook(result.monitor.webhookUrl, {
title: '🗑️ Nachricht gelöscht',
description: `**${username}** hat eine Nachricht gelöscht.\n\n${cached ? `~~${cached.text}~~` : `(Nachricht nicht im Cache)`}`,
color: 0xff0000,
}, result.key);
});
this.client.on('ban', (channel: string, username: string, reason: string) => {
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(result.monitor.webhookUrl, {
title: '🔨 User gebannt',
description: `**${username}** wurde gebannt.\n${reason ? `Grund: *${reason}*` : ''}${context}`,
color: 0x8b0000,
}, result.key);
});
this.client.on('timeout', (channel: string, username: string, reason: string) => {
const result = this.findMonitor(channel);
if (!result) return;
this.sendWebhook(result.monitor.webhookUrl, {
title: '⏱️ User getimeouted',
description: `**${username}** wurde getimeouted.\n${reason ? `Grund: *${reason}*` : ''}`,
color: 0xffa500,
}, result.key);
});
this.client.on('clearchat', (channel: string) => {
const result = this.findMonitor(channel);
if (!result) return;
this.sendWebhook(result.monitor.webhookUrl, {
title: '🧹 Chat geleert',
description: `Der Chat in **${channel}** wurde geleert.`,
color: 0xffa500,
}, result.key);
});
this.client.on('cheer', (channel: string, userstate: ChatUserstate, message: string) => {
const result = this.findMonitor(channel);
if (!result) return;
const amount = userstate.bits || 0;
this.sendWebhook(result.monitor.webhookUrl, {
title: '💰 Bit-Cheer',
description: `**${userstate['display-name'] || userstate.username}** hat ${amount} Bits gespendet!\n${message || ''}`,
color: 0x9b59b6,
}, result.key);
});
this.client.on('subscription', (channel: string, username: string, methods: SubMethods, message: string, userstate: SubUserstate) => {
const result = this.findMonitor(channel);
if (!result) return;
this.sendWebhook(result.monitor.webhookUrl, {
title: '🅿️ Subscription',
description: `**${username}** hat subscribed!${methods?.prime ? ' (Prime)' : ''}${message ? `\nNachricht: ${message}` : ''}`,
color: 0x9146ff,
}, result.key);
});
this.client.on('resub', (channel: string, username: string, months: number, message: string, userstate: SubUserstate, methods: SubMethods) => {
const result = this.findMonitor(channel);
if (!result) return;
this.sendWebhook(result.monitor.webhookUrl, {
title: '🅿️ Resubscription',
description: `**${username}** resubbed für ${months} Monate!${methods?.prime ? ' (Prime)' : ''}\n${message || ''}`,
color: 0x9146ff,
}, result.key);
});
this.client.on('disconnected', async () => {
console.warn('[TWITCH] Disconnected from IRC');
if (!this.isTokenInvalid) {
this.tokenFailureCount++;
}
});
this.client.on('reconnect', () => {
console.log('[TWITCH] Attempting to reconnect...');
});
try {
await this.client.connect();
console.log('[TWITCH] Monitor connected to IRC');
this.tokenFailureCount = 0;
this.startTokenValidation();
} catch (error) {
console.error('[TWITCH] Failed to connect:', error);
this.isTokenInvalid = true;
this.notifyOwner('TwitchMonitor: Verbindung fehlgeschlagen. Token prüfen.');
}
}
setDiscordClient(discordClient: any): void {
(this as any).discordClient = discordClient;
}
private findMonitor(channel: string): { monitor: MonitorChannel; key: string } | undefined {
const normalizedChannel = channel.startsWith('#') ? channel : `#${channel}`;
const entry = Array.from(this.monitors.entries()).find(
([_, m]) => m.twitchChannel.toLowerCase() === normalizedChannel.toLowerCase() ||
m.twitchChannel.toLowerCase() === channel.toLowerCase()
);
return entry ? { key: entry[0], monitor: entry[1] } : undefined;
}
async addMonitor(guildId: string, twitchChannel: string, discordChannelId: string, webhookUrl: string): Promise<void> {
const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`;
const key = `${guildId}:${normalizedChannel}`;
this.monitors.set(key, {
guildId,
twitchChannel: normalizedChannel,
discordChannelId,
webhookUrl,
});
this.webhookClients.set(key, new WebhookClient({ url: webhookUrl }));
if (this.client) {
await this.client.join(normalizedChannel);
}
}
async removeMonitor(guildId: string, twitchChannel: string): Promise<void> {
const normalizedChannel = twitchChannel.startsWith('#') ? twitchChannel : `#${twitchChannel}`;
const key = `${guildId}:${normalizedChannel}`;
const monitor = this.monitors.get(key);
if (monitor && this.client) {
await this.client.part(normalizedChannel);
}
this.monitors.delete(key);
this.webhookClients.delete(key);
}
getMonitors(guildId?: string): MonitorChannel[] {
if (guildId) {
return Array.from(this.monitors.values()).filter(m => m.guildId === guildId);
}
return Array.from(this.monitors.values());
}
private async sendWebhook(webhookUrl: string, data: { title: string; description: string; color: number }, monitorKey?: string): Promise<void> {
try {
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)
.setColor(data.color)
.setThumbnail(MOD_ICON)
.setTimestamp();
await webhook.send({ embeds: [embed] });
} catch (error) {
console.error(`[TWITCH] Webhook send error${monitorKey ? ` for ${monitorKey}` : ''}:`, error);
}
}
disconnect(): void {
this.stopTokenValidation();
if (this.client) {
this.client.disconnect();
this.client = null;
}
this.monitors.clear();
this.webhookClients.clear();
this.tokenFailureCount = 0;
this.isTokenInvalid = false;
}
}