Initial: Pixelpöbel Discord Bot
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:
2026-03-22 16:29:30 +01:00
commit 40be1e181c
26 changed files with 3042 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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
View 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!);

View 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
View 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
View 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);

View 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
View 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);
}
}

View 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);
}
}

View 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,
],
});
}
}

View 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).');
}
}