Initial: Pixelpöbel Discord Bot
Some checks failed
Auto Build and Push Docker Image / build (push) Failing after 8s
Some checks failed
Auto Build and Push Docker Image / build (push) Failing after 8s
- Modular Discord Bot mit TypeScript und discord.js - Grouped Commands (/admin, /owner subcommands) - SQLite Datenbank mit Persistenz - Twitch Monitoring mit Stream-ID Tracking - Konfigurierbares Warn-System (Auto-Kick/Mute/Ban) - Mute mit verschiedenen Zeiteinheiten (sec, min, hour, day, week, month, year, perm) - Auto-Deploy beim Start (Option 3) - Docker Support Features: - /ping, /help, /twitch (online, add, remove, list) - /admin (kick, warn, unwarn, mute, unmute, ban, unban, config) - /owner (deploy, stats, servers) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
AGENTS-BOT.md
|
||||||
|
dist
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DISCORD_TOKEN=dein_bot_token
|
||||||
|
CLIENT_ID=deine_client_id
|
||||||
|
TWITCH_CLIENT_ID=deine_twitch_id
|
||||||
|
TWITCH_CLIENT_SECRET=dein_twitch_secret
|
||||||
31
.gitea/workflows/docker-autobuild.yaml
Normal file
31
.gitea/workflows/docker-autobuild.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Auto Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build and Push to local registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="192.168.88.201:5000"
|
||||||
|
IMAGE_NAME="${{ gitea.repository }}"
|
||||||
|
|
||||||
|
# Automatische Versionierung mit Autobuild-Suffix
|
||||||
|
VERSION="v${{ gitea.run_number }}-autobuild"
|
||||||
|
FULL_IMAGE_PATH="$REGISTRY/$IMAGE_NAME"
|
||||||
|
|
||||||
|
echo "Starte automatischen Build für Version: $VERSION"
|
||||||
|
|
||||||
|
docker build -t $FULL_IMAGE_PATH:latest .
|
||||||
|
docker tag $FULL_IMAGE_PATH:latest $FULL_IMAGE_PATH:$VERSION
|
||||||
|
|
||||||
|
docker push $FULL_IMAGE_PATH:latest
|
||||||
|
docker push $FULL_IMAGE_PATH:$VERSION
|
||||||
36
.gitea/workflows/docker-build.yaml
Normal file
36
.gitea/workflows/docker-build.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Version (z.B. 1.0.0). Leer lassen für automatische v-Nummer.'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build and Push to local registry
|
||||||
|
run: |
|
||||||
|
REGISTRY="192.168.88.201:5000"
|
||||||
|
# Hier wird automatisch der Name des neuen Projekts eingesetzt:
|
||||||
|
IMAGE_NAME="${{ gitea.repository }}"
|
||||||
|
|
||||||
|
if [ -z "${{ github.event.inputs.tag_name }}" ]; then
|
||||||
|
VERSION="v${{ gitea.run_number }}"
|
||||||
|
else
|
||||||
|
VERSION="${{ github.event.inputs.tag_name }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FULL_IMAGE_PATH="$REGISTRY/$IMAGE_NAME"
|
||||||
|
|
||||||
|
docker build -t $FULL_IMAGE_PATH:latest .
|
||||||
|
docker tag $FULL_IMAGE_PATH:latest $FULL_IMAGE_PATH:$VERSION
|
||||||
|
|
||||||
|
docker push $FULL_IMAGE_PATH:latest
|
||||||
|
docker push $FULL_IMAGE_PATH:$VERSION
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
293
AGENTS-BOT.md
Normal file
293
AGENTS-BOT.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# AI Agent Guide: Pixelpöbel Discord Bot
|
||||||
|
|
||||||
|
This document is intended for AI agents to understand and recreate the `pixelpöbel` Discord bot.
|
||||||
|
|
||||||
|
## 🏗 Architecture Overview
|
||||||
|
|
||||||
|
The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScript Modules)** and **TypeScript**.
|
||||||
|
|
||||||
|
### Key Design Patterns:
|
||||||
|
1. **Extended Client Pattern:** Extend `discord.js` `Client` class to hold global state.
|
||||||
|
2. **Dynamic Discovery:** `index.ts` uses `readdirSync` and dynamic `import()` to register commands/events.
|
||||||
|
3. **Interface-Driven Commands:** All commands implement `Command` interface.
|
||||||
|
4. **SQLite Database:** Persistent data storage with `better-sqlite3`.
|
||||||
|
5. **Twitch Monitoring:** Periodic API polling with Stream ID tracking.
|
||||||
|
6. **Grouped Commands:** Admin/Owner commands grouped under subcommands.
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pixelpoebel/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point, loads commands/events
|
||||||
|
│ ├── deploy-commands.ts # Deploy script for Discord
|
||||||
|
│ ├── commands/
|
||||||
|
│ │ └── utility/ # All commands grouped here
|
||||||
|
│ │ ├── ping.ts
|
||||||
|
│ │ ├── help.ts
|
||||||
|
│ │ ├── twitch.ts
|
||||||
|
│ │ ├── admin.ts # All admin subcommands
|
||||||
|
│ │ └── owner.ts # All owner subcommands
|
||||||
|
│ ├── events/
|
||||||
|
│ │ ├── ready.ts
|
||||||
|
│ │ └── interactionCreate.ts
|
||||||
|
│ └── structures/
|
||||||
|
│ ├── ExtendedClient.ts
|
||||||
|
│ ├── Command.ts
|
||||||
|
│ ├── Database.ts
|
||||||
|
│ ├── TwitchManager.ts
|
||||||
|
│ └── Deployer.ts
|
||||||
|
├── data/ # SQLite database (volume mounted)
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Implementation Steps
|
||||||
|
|
||||||
|
### 1. Project Setup
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Key Components
|
||||||
|
|
||||||
|
**ExtendedClient.ts:**
|
||||||
|
```typescript
|
||||||
|
import { Client, Collection, GatewayIntentBits } from 'discord.js';
|
||||||
|
import { Command } from './Command.js';
|
||||||
|
|
||||||
|
export class ExtendedClient extends Client {
|
||||||
|
public commands: Collection<string, Command> = new Collection();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command Interface:**
|
||||||
|
```typescript
|
||||||
|
export interface Command {
|
||||||
|
data: any;
|
||||||
|
category: 'Owner' | 'Admin' | 'Public';
|
||||||
|
execute: (interaction: any, client: any) => Promise<void> | void;
|
||||||
|
cooldown?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deployer - Auto-Deploy (Option 3):**
|
||||||
|
```typescript
|
||||||
|
export class Deployer {
|
||||||
|
static async deployIfMissing(clientId: string, token: string) {
|
||||||
|
const rest = new REST().setToken(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await rest.get(Routes.applicationCommands(clientId));
|
||||||
|
if (data.length > 0) {
|
||||||
|
console.log('[DEPLOYER] Commands already registered. Skipping deployment.');
|
||||||
|
return data.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Commands don't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deploy(clientId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Command Examples
|
||||||
|
|
||||||
|
**Grouped Command (admin):**
|
||||||
|
```typescript
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('admin')
|
||||||
|
.setDescription('Admin-Commands')
|
||||||
|
.addSubcommand(sub => sub.setName('kick').setDescription('Kickt einen Nutzer')
|
||||||
|
.addUserOption(opt => opt.setName('target').setDescription('Nutzer').setRequired(true))
|
||||||
|
.addStringOption(opt => opt.setName('reason').setDescription('Grund')))
|
||||||
|
.addSubcommand(sub => sub.setName('warn').setDescription('Verwarnt einen Nutzer')
|
||||||
|
.addUserOption(opt => opt.setName('target').setDescription('Nutzer').setRequired(true))
|
||||||
|
.addStringOption(opt => opt.setName('reason').setDescription('Grund').setRequired(true)))
|
||||||
|
// ... more subcommands
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 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(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 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
|
||||||
|
|
||||||
|
1. **Environment Variables:** Always use `process.env`, never hardcode.
|
||||||
|
2. **Type Safety:** Use `any` for command `data` and `execute` parameters (compatibility).
|
||||||
|
3. **Interaction States:** Check `interaction.replied` before replying.
|
||||||
|
4. **Twitch API:** Max 100 channels per request, batch properly.
|
||||||
|
5. **Deploy:** Commands NOT auto-registered on start – use `/owner deploy`.
|
||||||
|
|
||||||
|
## 📋 Command Summary
|
||||||
|
|
||||||
|
| Command | Subcommands | Permission |
|
||||||
|
|---------|-------------|------------|
|
||||||
|
| `/ping` | – | Public |
|
||||||
|
| `/help` | – | Public |
|
||||||
|
| `/twitch` | online, add, remove, list | Public/Admin |
|
||||||
|
| `/admin` | kick, warn, unwarn, mute, unmute, ban, unban, config | Admin |
|
||||||
|
| `/owner` | deploy, stats, servers | Owner |
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p data
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Pixelpöbel Discord Bot
|
||||||
|
|
||||||
|
Ein moderner, modularer Discord-Bot in TypeScript.
|
||||||
|
|
||||||
|
## 🚀 Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Konfiguration
|
||||||
|
|
||||||
|
Erstelle `.env` Datei:
|
||||||
|
```env
|
||||||
|
DISCORD_TOKEN=dein_bot_token
|
||||||
|
CLIENT_ID=deine_client_id
|
||||||
|
TWITCH_CLIENT_ID=deine_twitch_id
|
||||||
|
TWITCH_CLIENT_SECRET=dein_twitch_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build und Start
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Logs ansehen
|
||||||
|
docker-compose logs -f pixelpoebel-bot
|
||||||
|
|
||||||
|
# Stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Container neu bauen
|
||||||
|
docker-compose up -d --build --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Commands registrieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Befehle
|
||||||
|
|
||||||
|
### Öffentlich
|
||||||
|
- `/ping` – Latenz test
|
||||||
|
- `/help` – Alle Befehle
|
||||||
|
- `/twitch online <channel>` – Twitch-Status
|
||||||
|
- `/twitch list` – Überwachte Kanäle
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
- `/twitch add <channel>` – Kanal überwachen
|
||||||
|
- `/twitch remove <channel>` – Kanal entfernen
|
||||||
|
- `/admin kick <user>` – Nutzer kicken
|
||||||
|
- `/admin warn <user> <reason>` – Nutzer verwarnen
|
||||||
|
- `/admin unwarn <user>` – Letzte Warnung entfernen
|
||||||
|
- `/admin mute <user> <dauer> <einheit>` – Nutzer muten
|
||||||
|
- `/admin unmute <user>` – Nutzer entmuten
|
||||||
|
- `/admin ban <user>` – Nutzer bannen
|
||||||
|
- `/admin unban <id>` – Nutzer entbannen
|
||||||
|
- `/admin config` – Warn-System konfigurieren
|
||||||
|
|
||||||
|
### Owner
|
||||||
|
- `/owner deploy` – Commands neu registrieren
|
||||||
|
- `/owner stats` – Bot-Statistiken
|
||||||
|
- `/owner servers` – Alle Server
|
||||||
|
|
||||||
|
## 📡 Features
|
||||||
|
|
||||||
|
- ✅ Modular Architecture
|
||||||
|
- ✅ TypeScript
|
||||||
|
- ✅ SQLite Database
|
||||||
|
- ✅ Twitch Monitoring
|
||||||
|
- ✅ Auto-Deploy beim Start
|
||||||
|
- ✅ Moderation Tools
|
||||||
|
- ✅ Konfigurierbares Warn-System
|
||||||
|
- ✅ Docker Support
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pixelpoebel-bot:
|
||||||
|
build: .
|
||||||
|
container_name: pixelpoebel-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- pixelpoebel-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pixelpoebel-net:
|
||||||
|
driver: bridge
|
||||||
1360
package-lock.json
generated
Normal file
1360
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "pixelpoebel",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Moderner, erweiterbarer Discord Bot",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"keywords": ["discord", "bot", "typescript", "modular"],
|
||||||
|
"author": "sarah",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
|
"dotenv": "^16.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/node": "^22.13.9",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
419
src/commands/utility/admin.ts
Normal file
419
src/commands/utility/admin.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, GuildMember } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('admin')
|
||||||
|
.setDescription('Admin-Commands')
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('kick')
|
||||||
|
.setDescription('Kickt einen Nutzer')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der zu kickende Nutzer')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Grund für den Kick')))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('warn')
|
||||||
|
.setDescription('Verwarnt einen Nutzer')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der zu verwarnende Nutzer')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Grund für die Verwarnung')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('unwarn')
|
||||||
|
.setDescription('Entfernt die letzte Warnung')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der Nutzer')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('mute')
|
||||||
|
.setDescription('Muted einen Nutzer')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der zu mutende Nutzer')
|
||||||
|
.setRequired(true))
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('duration')
|
||||||
|
.setDescription('Dauer (Zahl)')
|
||||||
|
.setMinValue(1))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('unit')
|
||||||
|
.setDescription('Zeiteinheit')
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'Sekunden', value: 'sec' },
|
||||||
|
{ name: 'Minuten', value: 'min' },
|
||||||
|
{ name: 'Stunden', value: 'hour' },
|
||||||
|
{ name: 'Tage', value: 'day' },
|
||||||
|
{ name: 'Wochen', value: 'week' },
|
||||||
|
{ name: 'Monate', value: 'month' },
|
||||||
|
{ name: 'Jahre', value: 'year' },
|
||||||
|
{ name: 'Permanent', value: 'perm' }
|
||||||
|
))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Grund für den Mute')))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('unmute')
|
||||||
|
.setDescription('Entmutet einen Nutzer')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der zu entmutende Nutzer')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('ban')
|
||||||
|
.setDescription('Bannt einen Nutzer')
|
||||||
|
.addUserOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Der zu bannende Nutzer')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('reason')
|
||||||
|
.setDescription('Grund für den Ban'))
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('delete_days')
|
||||||
|
.setDescription('Nachrichten der letzten X Tage löschen')
|
||||||
|
.setMinValue(0)
|
||||||
|
.setMaxValue(7)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('unban')
|
||||||
|
.setDescription('Entbannt einen Nutzer')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('target')
|
||||||
|
.setDescription('Nutzer-ID oder @username')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('config')
|
||||||
|
.setDescription('Konfiguriert das Warn-System')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('setting')
|
||||||
|
.setDescription('Welche Einstellung ändern?')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'warn_threshold', value: 'warn_threshold' },
|
||||||
|
{ name: 'warn_action', value: 'warn_action' },
|
||||||
|
{ name: 'warn_mute_duration', value: 'warn_mute_duration' },
|
||||||
|
{ name: 'show', value: 'show' }))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('value')
|
||||||
|
.setDescription('Neuer Wert'))
|
||||||
|
.addIntegerOption(option =>
|
||||||
|
option.setName('duration_value')
|
||||||
|
.setDescription('Dauer in Sekunden')
|
||||||
|
.setMinValue(0)))
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
|
||||||
|
category: 'Admin',
|
||||||
|
async execute(interaction: any) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const DB = interaction.client.DB;
|
||||||
|
const guild = interaction.guild;
|
||||||
|
const guildId = interaction.guildId;
|
||||||
|
|
||||||
|
// Get or create guild settings
|
||||||
|
let settings: any = DB.get('SELECT * FROM guild_settings WHERE guild_id = ?', 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') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
const reason = interaction.options.getString('reason') ?? 'Kein Grund';
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.kickable) {
|
||||||
|
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.kick(reason);
|
||||||
|
await interaction.reply(`✅ Gekickt: ${target.user.tag}\nGrund: ${reason}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.reply({ content: '❌ Fehler beim Kicken.', ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'warn') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
const reason = interaction.options.getString('reason')!;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB.run(
|
||||||
|
'INSERT INTO mod_logs (guild_id, user_id, moderator_id, action, reason) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
guildId,
|
||||||
|
target.id,
|
||||||
|
interaction.user.id,
|
||||||
|
'WARN',
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
|
||||||
|
const countResult: any = DB.get(
|
||||||
|
'SELECT COUNT(*) as count FROM mod_logs WHERE guild_id = ? AND user_id = ? AND action = ?',
|
||||||
|
guildId,
|
||||||
|
target.id,
|
||||||
|
'WARN'
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnCount = countResult?.count ?? 0;
|
||||||
|
const warnThreshold = settings?.warn_threshold ?? 3;
|
||||||
|
const warnAction = settings?.warn_action ?? 'ban';
|
||||||
|
|
||||||
|
let message = `✅ Verwarnt: ${target.user.tag}\nGrund: ${reason}\nDas ist die ${warnCount}. Verwarnung.`;
|
||||||
|
|
||||||
|
// Check if threshold reached
|
||||||
|
if (warnCount >= warnThreshold) {
|
||||||
|
message += `\n\n⚠️ **Warn-Limit erreicht (${warnThreshold}/${warnThreshold})!**`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (warnAction === 'kick') {
|
||||||
|
await target.kick(`Auto-Kick: ${warnThreshold} Warnungen`);
|
||||||
|
message += `\n👢 **Automatisch gekickt!**`;
|
||||||
|
} else if (warnAction === 'mute') {
|
||||||
|
const muteDuration = settings?.warn_mute_duration ?? 1800; // 30 min default
|
||||||
|
await target.timeout(muteDuration * 1000, `Auto-Mute: ${warnThreshold} Warnungen`);
|
||||||
|
const durationMins = Math.floor(muteDuration / 60);
|
||||||
|
message += `\n🔇 **Automatisch gemuted (${durationMins} Min)!**`;
|
||||||
|
} else if (warnAction === 'ban') {
|
||||||
|
await target.ban({ reason: `Auto-Ban: ${warnThreshold} Warnungen` });
|
||||||
|
message += `\n🔨 **Automatisch gebannt!**`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ADMIN] Auto-action failed:', error);
|
||||||
|
message += `\n❌ Auto-Aktion fehlgeschlagen.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ content: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'unwarn') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestWarn: any = DB.get(
|
||||||
|
'SELECT * FROM mod_logs WHERE guild_id = ? AND user_id = ? AND action = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
guildId,
|
||||||
|
target.id,
|
||||||
|
'WARN'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!latestWarn) {
|
||||||
|
await interaction.reply({ content: 'Keine Warnung für diesen Nutzer gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB.run('DELETE FROM mod_logs WHERE id = ?', latestWarn.id);
|
||||||
|
await interaction.reply(`✅ Letzte Warnung für ${target.user.tag} entfernt.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'mute') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
const duration = interaction.options.getInteger('duration');
|
||||||
|
const unit = interaction.options.getString('unit');
|
||||||
|
const reason = interaction.options.getString('reason') ?? 'Kein Grund';
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration
|
||||||
|
let durationMs: number | null = null;
|
||||||
|
let durationText = '';
|
||||||
|
|
||||||
|
if (unit === 'perm') {
|
||||||
|
durationMs = null; // Permanent = null
|
||||||
|
durationText = 'Permanent';
|
||||||
|
} else if (duration) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
const multiplier = unitSeconds[unit!];
|
||||||
|
if (!multiplier) {
|
||||||
|
await interaction.reply({ content: '❌ Ungültige Zeiteinheit.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMs = duration * multiplier * 1000;
|
||||||
|
|
||||||
|
// Human-readable duration
|
||||||
|
const unitNames: Record<string, string> = {
|
||||||
|
sec: 'Sekunden',
|
||||||
|
min: 'Minuten',
|
||||||
|
hour: 'Stunden',
|
||||||
|
day: 'Tage',
|
||||||
|
week: 'Wochen',
|
||||||
|
month: 'Monate',
|
||||||
|
year: 'Jahre'
|
||||||
|
};
|
||||||
|
durationText = `${duration} ${unitNames[unit!]}`;
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content: '❌ Dauer oder Zeiteinheit angeben.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.timeout(durationMs, reason);
|
||||||
|
await interaction.reply(`✅ Gemuted: ${target.user.tag}\nDauer: ${durationText}\nGrund: ${reason}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.reply({ content: '❌ Fehler beim Muten.', ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'unmute') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.timeout(null, 'Unmute');
|
||||||
|
await interaction.reply(`✅ Entmutet: ${target.user.tag}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.reply({ content: '❌ Fehler beim Entmuten.', ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'ban') {
|
||||||
|
const target = interaction.options.getMember('target') as GuildMember;
|
||||||
|
const reason = interaction.options.getString('reason') ?? 'Kein Grund';
|
||||||
|
const deleteDays = interaction.options.getInteger('delete_days') ?? 0;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
await interaction.reply({ content: 'Nutzer nicht gefunden.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.bannable) {
|
||||||
|
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht bannen.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await target.ban({ deleteMessageSeconds: deleteDays * 86400, reason });
|
||||||
|
await interaction.reply(`✅ Gebannt: ${target.user.tag}\nGrund: ${reason}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.reply({ content: '❌ Fehler beim Bannen.', ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'unban') {
|
||||||
|
let targetId = interaction.options.getString('target')!;
|
||||||
|
|
||||||
|
if (targetId.startsWith('<@') && targetId.endsWith('>')) {
|
||||||
|
targetId = targetId.replace(/[<@!>]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await guild.bans.remove(targetId, 'Unban durch Admin');
|
||||||
|
await interaction.reply(`✅ Entbannt: ${targetId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.reply({ content: `❌ Fehler beim Entbannen: ${error.message}`, ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'config') {
|
||||||
|
const setting = interaction.options.getString('setting')!;
|
||||||
|
const value = interaction.options.getString('value');
|
||||||
|
const durationValue = interaction.options.getInteger('duration_value');
|
||||||
|
|
||||||
|
if (setting === 'show') {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('⚙️ Warn-System Konfiguration')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Warn-Limit', value: `${settings?.warn_threshold ?? 3} Warnungen`, inline: true },
|
||||||
|
{ name: 'Auto-Aktion', value: settings?.warn_action ?? 'ban', inline: true },
|
||||||
|
{ name: 'Mute-Dauer', value: `${(settings?.warn_mute_duration ?? 1800) / 60} Min`, inline: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting === 'warn_threshold') {
|
||||||
|
const threshold = parseInt(value!);
|
||||||
|
if (isNaN(threshold) || threshold < 1) {
|
||||||
|
await interaction.reply({ content: '❌ Ungültiger Wert. Mindestens 1.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DB.run('UPDATE guild_settings SET warn_threshold = ? WHERE guild_id = ?', threshold, guildId);
|
||||||
|
await interaction.reply(`✅ Warn-Limit auf ${threshold} gesetzt.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting === 'warn_action') {
|
||||||
|
const validActions = ['none', 'kick', 'mute', 'ban'];
|
||||||
|
if (!validActions.includes(value!)) {
|
||||||
|
await interaction.reply({ content: `❌ Ungültige Aktion. Möglich: ${validActions.join(', ')}`, ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DB.run('UPDATE guild_settings SET warn_action = ? WHERE guild_id = ?', value, guildId);
|
||||||
|
await interaction.reply(`✅ Auto-Aktion auf ${value} gesetzt.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting === 'warn_mute_duration') {
|
||||||
|
const duration = durationValue ?? 1800;
|
||||||
|
if (duration < 0) {
|
||||||
|
await interaction.reply({ content: '❌ Ungültige Dauer.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DB.run('UPDATE guild_settings SET warn_mute_duration = ? WHERE guild_id = ?', duration, guildId);
|
||||||
|
await interaction.reply(`✅ Mute-Dauer auf ${duration} Sekunden (${Math.floor(duration/60)} Min) gesetzt.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
59
src/commands/utility/help.ts
Normal file
59
src/commands/utility/help.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('help')
|
||||||
|
.setDescription('Zeigt alle verfügbaren Befehle an.')
|
||||||
|
.setDMPermission(true),
|
||||||
|
category: 'Public',
|
||||||
|
async execute(interaction: any, client: any) {
|
||||||
|
if (!client.application?.owner) await client.application?.fetch();
|
||||||
|
|
||||||
|
const isOwner = interaction.user.id === client.application?.owner?.id;
|
||||||
|
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.Administrator);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('📖 Hilfe-Menü')
|
||||||
|
.setDescription('Hier sind die Befehle:')
|
||||||
|
.setColor(0x2ecc71)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const publicCmds: string[] = [];
|
||||||
|
const adminCmds: string[] = [];
|
||||||
|
const ownerCmds: string[] = [];
|
||||||
|
|
||||||
|
client.commands.forEach((cmd: Command) => {
|
||||||
|
const desc = 'description' in cmd.data ? cmd.data.description : 'Kontextmenü';
|
||||||
|
const entry = `• **/${cmd.data.name}**: ${desc}`;
|
||||||
|
|
||||||
|
if (cmd.category === 'Public') {
|
||||||
|
publicCmds.push(entry);
|
||||||
|
} else if (cmd.category === 'Admin') {
|
||||||
|
adminCmds.push(entry);
|
||||||
|
} else if (cmd.category === 'Owner') {
|
||||||
|
ownerCmds.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (publicCmds.length > 0) {
|
||||||
|
embed.addFields({ name: '🌍 Öffentliche Befehle', value: publicCmds.join('\n') });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin || isOwner) {
|
||||||
|
if (adminCmds.length > 0) {
|
||||||
|
embed.addFields({ name: '🛡️ Admin-Befehle', value: adminCmds.join('\n') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
if (ownerCmds.length > 0) {
|
||||||
|
embed.addFields({ name: '🔑 Owner-Befehle', value: ownerCmds.join('\n') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
107
src/commands/utility/owner.ts
Normal file
107
src/commands/utility/owner.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
import { Deployer } from '../../structures/Deployer.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('owner')
|
||||||
|
.setDescription('Bot-Owner Commands')
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('deploy')
|
||||||
|
.setDescription('Aktualisiert Commands'))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('stats')
|
||||||
|
.setDescription('Zeigt Bot-Statistiken'))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('servers')
|
||||||
|
.setDescription('Listet alle Server'))
|
||||||
|
.setDMPermission(true),
|
||||||
|
category: 'Owner',
|
||||||
|
async execute(interaction: any, client: any) {
|
||||||
|
if (!client.application?.owner) await client.application?.fetch();
|
||||||
|
|
||||||
|
if (interaction.user.id !== client.application?.owner?.id) {
|
||||||
|
await interaction.reply({ content: 'Keine Berechtigung.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'deploy') {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await Deployer.deploy(client.user!.id, client.token!);
|
||||||
|
await interaction.editReply(`✅ ${count} Befehle aktualisiert!`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await interaction.editReply('❌ Fehler beim Deployment.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'stats') {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const days = Math.floor(uptime / 86400);
|
||||||
|
const hours = Math.floor(uptime / 3600) % 24;
|
||||||
|
const minutes = Math.floor(uptime / 60) % 60;
|
||||||
|
const uptimeStr = `${days}d ${hours}h ${minutes}m`;
|
||||||
|
|
||||||
|
const totalServers = client.guilds.cache.size;
|
||||||
|
const totalUsers = client.guilds.cache.reduce((acc: number, guild: any) => acc + guild.memberCount, 0);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('📊 Bot-Statistiken')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields(
|
||||||
|
{ name: '🤖 Bot', value: `**Uptime:** ${uptimeStr}\n**Latency:** ${client.ws.ping}ms`, inline: true },
|
||||||
|
{ name: '🌍 Netzwerk', value: `**Server:** ${totalServers}\n**Nutzer:** ${totalUsers}`, inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'servers') {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const guilds = client.guilds.cache.map((guild: any) =>
|
||||||
|
`• **${guild.name}** \`(${guild.id})\` - ${guild.memberCount} Mitglieder`
|
||||||
|
);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < guilds.length; i += 10) {
|
||||||
|
chunks.push(guilds.slice(i, i + 10).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
await interaction.editReply('Keine Server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🌍 Server (${client.guilds.cache.size})`)
|
||||||
|
.setColor(0xf1c40f)
|
||||||
|
.setDescription(chunks[0].substring(0, 4000))
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
|
||||||
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
|
const nextEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xf1c40f)
|
||||||
|
.setDescription(chunks[i].substring(0, 4000));
|
||||||
|
await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
14
src/commands/utility/ping.ts
Normal file
14
src/commands/utility/ping.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('ping')
|
||||||
|
.setDescription('Testet die Latenz.'),
|
||||||
|
category: 'Public',
|
||||||
|
async execute(interaction: any) {
|
||||||
|
await interaction.reply(`🏓 Pong! Latenz: ${interaction.client.ws.ping}ms`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
121
src/commands/utility/twitch.ts
Normal file
121
src/commands/utility/twitch.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
|
||||||
|
import { Command } from '../../structures/Command.js';
|
||||||
|
import { TwitchManager } from '../../structures/TwitchManager.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('twitch')
|
||||||
|
.setDescription('Twitch-Befehle.')
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('online')
|
||||||
|
.setDescription('Prüft, ob ein Kanal online ist.')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('Der Name des Twitch-Kanals')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('add')
|
||||||
|
.setDescription('Kanal überwachen (Admin).')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('Der Name des Twitch-Kanals')
|
||||||
|
.setRequired(true))
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('message')
|
||||||
|
.setDescription('Optionale Nachricht')))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('remove')
|
||||||
|
.setDescription('Kanal aus Überwachung entfernen (Admin).')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('channel')
|
||||||
|
.setDescription('Der Name des Twitch-Kanals')
|
||||||
|
.setRequired(true)))
|
||||||
|
.addSubcommand(subcommand =>
|
||||||
|
subcommand
|
||||||
|
.setName('list')
|
||||||
|
.setDescription('Listet überwachte Kanäle.')),
|
||||||
|
category: 'Public',
|
||||||
|
async execute(interaction: any) {
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const guildId = interaction.guildId;
|
||||||
|
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.Administrator);
|
||||||
|
const DB = interaction.client.DB;
|
||||||
|
|
||||||
|
if (subcommand === 'online') {
|
||||||
|
const channelName = interaction.options.getString('channel')!.toLowerCase();
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const stream = await TwitchManager.fetchStreamData(channelName);
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
await interaction.editReply(`🔴 Der Kanal **${channelName}** ist offline.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = stream.thumbnail_url
|
||||||
|
.replace('{width}', '1280')
|
||||||
|
.replace('{height}', '720') + `?t=${Date.now()}`;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`🟢 ${stream.user_name} ist ONLINE!`)
|
||||||
|
.setURL(`https://twitch.tv/${stream.user_login}`)
|
||||||
|
.setColor(0x00FF00)
|
||||||
|
.setImage(thumbnailUrl)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Titel', value: stream.title || 'Kein Titel', inline: false },
|
||||||
|
{ name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true },
|
||||||
|
{ name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'list') {
|
||||||
|
const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ? AND discord_channel_id = ?', guildId, interaction.channelId);
|
||||||
|
if (monitors.length === 0) {
|
||||||
|
await interaction.reply('Keine Twitch-Kanäle überwacht.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = monitors.map((m: any) => `• **${m.channel_name}**`).join('\n');
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('📺 Überwachte Twitch-Kanäle')
|
||||||
|
.setColor(0x6441a5)
|
||||||
|
.setDescription(list);
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin only
|
||||||
|
if (!isAdmin) {
|
||||||
|
await interaction.reply({ content: '❌ Keine Berechtigung.', ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'add') {
|
||||||
|
const channelName = interaction.options.getString('channel')!.toLowerCase();
|
||||||
|
const message = interaction.options.getString('message');
|
||||||
|
const discordChannelId = interaction.channelId;
|
||||||
|
|
||||||
|
DB.run(
|
||||||
|
'INSERT INTO twitch_monitors (guild_id, channel_name, discord_channel_id, custom_message) VALUES (?, ?, ?, ?) ON CONFLICT(guild_id, channel_name) DO UPDATE SET discord_channel_id = ?, custom_message = ?',
|
||||||
|
guildId, channelName, discordChannelId, message, discordChannelId, message
|
||||||
|
);
|
||||||
|
await interaction.reply(`✅ Kanal **${channelName}** wird überwacht.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === 'remove') {
|
||||||
|
const channelName = interaction.options.getString('channel')!.toLowerCase();
|
||||||
|
DB.run('DELETE FROM twitch_monitors WHERE guild_id = ? AND channel_name = ?', guildId, channelName);
|
||||||
|
await interaction.reply(`✅ Kanal **${channelName}** entfernt.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
||||||
9
src/deploy-commands.ts
Normal file
9
src/deploy-commands.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Deployer } from './structures/Deployer.js';
|
||||||
|
|
||||||
|
if (!process.env.CLIENT_ID || !process.env.DISCORD_TOKEN) {
|
||||||
|
console.error('Missing CLIENT_ID or DISCORD_TOKEN in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deployer.deploy(process.env.CLIENT_ID!, process.env.DISCORD_TOKEN!);
|
||||||
32
src/events/interactionCreate.ts
Normal file
32
src/events/interactionCreate.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Events } from 'discord.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: Events.InteractionCreate,
|
||||||
|
async execute(interaction: any, client: any) {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.execute(interaction, client);
|
||||||
|
client.DB.run(`
|
||||||
|
INSERT INTO command_stats (command_name, uses)
|
||||||
|
VALUES (?, 1)
|
||||||
|
ON CONFLICT(command_name) DO UPDATE SET uses = uses + 1
|
||||||
|
`, interaction.commandName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true };
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp(replyOptions);
|
||||||
|
} else {
|
||||||
|
await interaction.reply(replyOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
10
src/events/ready.ts
Normal file
10
src/events/ready.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Events } from 'discord.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: Events.ClientReady,
|
||||||
|
once: true,
|
||||||
|
execute(client: any) {
|
||||||
|
console.log(`[READY] Logged in as ${client.user.tag}`);
|
||||||
|
console.log(`[READY] Serving ${client.guilds.cache.size} servers`);
|
||||||
|
},
|
||||||
|
};
|
||||||
62
src/index.ts
Normal file
62
src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { ExtendedClient } from './structures/ExtendedClient.js';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { Command } from './structures/Command.js';
|
||||||
|
import { DB } from './structures/Database.js';
|
||||||
|
import { TwitchManager } from './structures/TwitchManager.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Initialize Database
|
||||||
|
DB.init();
|
||||||
|
|
||||||
|
const client = new ExtendedClient();
|
||||||
|
// Make DB available on client
|
||||||
|
(client as any).DB = DB;
|
||||||
|
|
||||||
|
// Load Commands
|
||||||
|
const commandsPath = join(__dirname, 'commands');
|
||||||
|
const commandFolders = readdirSync(commandsPath);
|
||||||
|
|
||||||
|
for (const folder of commandFolders) {
|
||||||
|
const folderPath = join(commandsPath, folder);
|
||||||
|
const commandFiles = readdirSync(folderPath).filter(file => file.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = join(folderPath, file);
|
||||||
|
const fileUrl = pathToFileURL(filePath).href;
|
||||||
|
const command: Command = (await import(fileUrl)).default;
|
||||||
|
|
||||||
|
if (command && 'data' in command && 'execute' in command) {
|
||||||
|
client.commands.set(command.data.name, command);
|
||||||
|
console.log(`[COMMAND] Loaded: ${command.data.name}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[COMMAND] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Events
|
||||||
|
const eventsPath = join(__dirname, 'events');
|
||||||
|
const eventFiles = readdirSync(eventsPath).filter(file => file.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of eventFiles) {
|
||||||
|
const filePath = join(eventsPath, file);
|
||||||
|
const fileUrl = pathToFileURL(filePath).href;
|
||||||
|
const event = (await import(fileUrl)).default;
|
||||||
|
|
||||||
|
if (event.once) {
|
||||||
|
client.once(event.name, (...args) => event.execute(...args, client));
|
||||||
|
} else {
|
||||||
|
client.on(event.name, (...args) => event.execute(...args, client));
|
||||||
|
}
|
||||||
|
console.log(`[EVENT] Loaded: ${event.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Twitch Polling
|
||||||
|
TwitchManager.startPolling(client);
|
||||||
|
|
||||||
|
client.login(process.env.DISCORD_TOKEN);
|
||||||
6
src/structures/Command.ts
Normal file
6
src/structures/Command.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Command {
|
||||||
|
data: any;
|
||||||
|
category: 'Owner' | 'Admin' | 'Public';
|
||||||
|
execute: (interaction: any, client: any) => Promise<void> | void;
|
||||||
|
cooldown?: number;
|
||||||
|
}
|
||||||
103
src/structures/Database.ts
Normal file
103
src/structures/Database.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const dataDir = join(process.cwd(), 'data');
|
||||||
|
if (!existsSync(dataDir)) {
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = join(dataDir, 'database.sqlite');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
export class DB {
|
||||||
|
static init() {
|
||||||
|
console.log(`[DATABASE] Initializing at ${dbPath}...`);
|
||||||
|
|
||||||
|
// Guild Settings
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS 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
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// Mod Logs
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS 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
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// Command Stats
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS command_stats (
|
||||||
|
command_name TEXT PRIMARY KEY,
|
||||||
|
uses INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// DM Users
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// Twitch Monitors
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS 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)
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// Auto Responses
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS auto_responses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
guild_id TEXT,
|
||||||
|
trigger_word TEXT,
|
||||||
|
response_text TEXT,
|
||||||
|
UNIQUE(guild_id, trigger_word)
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
console.log('[DATABASE] Initialization complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(query: string, ...params: any[]) {
|
||||||
|
return db.prepare(query).get(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static all(query: string, ...params: any[]) {
|
||||||
|
return db.prepare(query).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static run(query: string, ...params: any[]) {
|
||||||
|
return db.prepare(query).run(...params);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/structures/Deployer.ts
Normal file
59
src/structures/Deployer.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { REST, Routes } from 'discord.js';
|
||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
export class Deployer {
|
||||||
|
static async deploy(clientId: string, token: string) {
|
||||||
|
const commands = [];
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
const commandsPath = join(rootDir, 'commands');
|
||||||
|
const commandFolders = readdirSync(commandsPath);
|
||||||
|
|
||||||
|
for (const folder of commandFolders) {
|
||||||
|
const folderPath = join(commandsPath, folder);
|
||||||
|
const commandFiles = readdirSync(folderPath).filter(file => file.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = join(folderPath, file);
|
||||||
|
const fileUrl = pathToFileURL(filePath).href;
|
||||||
|
const command = (await import(fileUrl)).default;
|
||||||
|
|
||||||
|
if (command && 'data' in command) {
|
||||||
|
commands.push(command.data.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = new REST().setToken(token);
|
||||||
|
|
||||||
|
console.log(`[DEPLOYER] Refreshing ${commands.length} application (/) commands.`);
|
||||||
|
|
||||||
|
const data: any = await rest.put(
|
||||||
|
Routes.applicationCommands(clientId),
|
||||||
|
{ body: commands },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[DEPLOYER] Successfully reloaded ${data.length} application (/) commands.`);
|
||||||
|
return data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deployIfMissing(clientId: string, token: string) {
|
||||||
|
const rest = new REST().setToken(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data: any = await rest.get(Routes.applicationCommands(clientId));
|
||||||
|
if (data.length > 0) {
|
||||||
|
console.log('[DEPLOYER] Commands already registered. Skipping deployment.');
|
||||||
|
return data.length;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Commands don't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deploy(clientId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/structures/ExtendedClient.ts
Normal file
17
src/structures/ExtendedClient.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Client, Collection, GatewayIntentBits } from 'discord.js';
|
||||||
|
import { Command } from './Command.js';
|
||||||
|
|
||||||
|
export class ExtendedClient extends Client {
|
||||||
|
public commands: Collection<string, Command> = new Collection();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMessages,
|
||||||
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/structures/TwitchManager.ts
Normal file
120
src/structures/TwitchManager.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { EmbedBuilder, TextChannel } from 'discord.js';
|
||||||
|
|
||||||
|
export class TwitchManager {
|
||||||
|
private static accessToken: string | null = null;
|
||||||
|
private static tokenExpires: number = 0;
|
||||||
|
|
||||||
|
private static async getAccessToken() {
|
||||||
|
if (this.accessToken && Date.now() < this.tokenExpires) return this.accessToken;
|
||||||
|
|
||||||
|
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
console.error('[TWITCH] Missing Client ID or Secret in .env');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data: any = await response.json();
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.tokenExpires = Date.now() + (data.expires_in * 1000) - 60000;
|
||||||
|
return this.accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TWITCH] Error getting access token:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async fetchStreamData(channelName: string) {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.twitch.tv/helix/streams?user_login=${channelName}`, {
|
||||||
|
headers: {
|
||||||
|
'Client-ID': process.env.TWITCH_CLIENT_ID!,
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data: any = await response.json();
|
||||||
|
return data.data?.[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[TWITCH] Error fetching stream data for ${channelName}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkStreams(client: any) {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const monitors: any[] = (client as any).DB?.all('SELECT * FROM twitch_monitors') || [];
|
||||||
|
if (monitors.length === 0) return;
|
||||||
|
|
||||||
|
for (const monitor of monitors) {
|
||||||
|
try {
|
||||||
|
const stream = await this.fetchStreamData(monitor.channel_name);
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
const isNewStream = monitor.last_stream_id !== stream.id;
|
||||||
|
const wasOffline = monitor.last_status === 'offline';
|
||||||
|
|
||||||
|
if (wasOffline || (monitor.last_status === 'online' && isNewStream)) {
|
||||||
|
await this.sendNotification(client, monitor, stream);
|
||||||
|
(client as any).DB?.run(
|
||||||
|
'UPDATE twitch_monitors SET last_status = "online", last_stream_id = ? WHERE id = ?',
|
||||||
|
stream.id,
|
||||||
|
monitor.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) {
|
||||||
|
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;
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
const thumbnailUrl = stream.thumbnail_url
|
||||||
|
.replace('{width}', '1280')
|
||||||
|
.replace('{height}', '720') + `?t=${Date.now()}`;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`${stream.user_name} ist jetzt LIVE auf Twitch!`)
|
||||||
|
.setURL(`https://twitch.tv/${stream.user_login}`)
|
||||||
|
.setColor(0x6441a5)
|
||||||
|
.setImage(thumbnailUrl)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'Titel', value: stream.title || 'Kein Titel', inline: false },
|
||||||
|
{ name: 'Kategorie', value: stream.game_name || 'Unbekannt', inline: true },
|
||||||
|
{ name: 'Zuschauer', value: stream.viewer_count.toString(), inline: true }
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
let content = `📢 **${stream.user_name}** ist online!`;
|
||||||
|
if (monitor.custom_message) {
|
||||||
|
content += `\n\n${monitor.custom_message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({ content, embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
static startPolling(client: any) {
|
||||||
|
setInterval(() => this.checkStreams(client), 5 * 60 * 1000);
|
||||||
|
console.log('[TWITCH] Polling started (5m interval).');
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user