Initialer Bot Upload
Some checks failed
Auto Build and Push Docker Image / build (push) Failing after 15s

This commit is contained in:
2026-03-21 00:24:19 +01:00
commit ceaeabf57a
35 changed files with 3173 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, GuildMember } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('kick')
.setDescription('Kickt einen Nutzer vom Server.')
.addUserOption(option =>
option.setName('target')
.setDescription('Der zu kickende Nutzer')
.setRequired(true))
.addStringOption(option =>
option.setName('reason')
.setDescription('Grund für den Kick'))
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
category: 'Admin',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
const target = interaction.options.getMember('target') as GuildMember;
const reason = interaction.options.getString('reason') ?? 'Kein Grund angegeben';
if (!target) {
await interaction.reply({ content: 'Dieser Nutzer konnte nicht gefunden werden.', ephemeral: true });
return;
}
if (!target.kickable) {
await interaction.reply({ content: 'Ich kann diesen Nutzer nicht kicken. Haben sie eine höhere Rolle als ich?', ephemeral: true });
return;
}
try {
await target.kick(reason);
await interaction.reply(`Erfolgreich gekickt: ${target.user.tag}. Grund: ${reason}`);
} catch (error) {
console.error(error);
await interaction.reply({ content: 'Fehler beim Kicken des Nutzers.', ephemeral: true });
}
},
};
export default command;

View File

@@ -0,0 +1,61 @@
import {
ChatInputCommandInteraction,
SlashCommandBuilder,
PermissionFlagsBits,
EmbedBuilder
} from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
const triggerCommand: Command = {
data: new SlashCommandBuilder()
.setName('trigger')
.setDescription('Verwaltet automatische Antworten (Admin only).')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Fügt eine automatische Antwort hinzu.')
.addStringOption(option => option.setName('word').setDescription('Das Trigger-Wort').setRequired(true))
.addStringOption(option => option.setName('response').setDescription('Die Antwort (Text oder Link)').setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Entfernt eine automatische Antwort.')
.addStringOption(option => option.setName('word').setDescription('Das Trigger-Wort').setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Listet alle Trigger auf.'))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
category: 'Admin',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
if (subcommand === 'add') {
const word = interaction.options.getString('word')!.toLowerCase();
const response = interaction.options.getString('response')!;
DB.run('INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?) ON CONFLICT(guild_id, trigger_word) DO UPDATE SET response_text = ?',
guildId, word, response, response);
await interaction.reply(`✅ Trigger für **${word}** hinzugefügt.`);
}
if (subcommand === 'remove') {
const word = interaction.options.getString('word')!.toLowerCase();
DB.run('DELETE FROM auto_responses WHERE guild_id = ? AND trigger_word = ?', guildId, word);
await interaction.reply(`✅ Trigger für **${word}** entfernt.`);
}
if (subcommand === 'list') {
const triggers: any[] = DB.all('SELECT * FROM auto_responses WHERE guild_id = ?', guildId);
const list = triggers.map(t => `• **${t.trigger_word}** ➔ ${t.response_text.substring(0, 50)}${t.response_text.length > 50 ? '...' : ''}`).join('\n') || 'Keine Trigger.';
const embed = new EmbedBuilder().setTitle('🤖 Trigger').setColor(0xe67e22).setDescription(list);
await interaction.reply({ embeds: [embed] });
}
}
};
export default triggerCommand;

View File

@@ -0,0 +1,71 @@
import {
ContextMenuCommandBuilder,
ApplicationCommandType,
MessageContextMenuCommandInteraction,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
ActionRowBuilder,
ModalSubmitInteraction
} from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
const contextCommand: Command = {
data: new ContextMenuCommandBuilder()
.setName('Als Trigger speichern')
.setType(ApplicationCommandType.Message),
category: 'Admin',
async execute(interaction: MessageContextMenuCommandInteraction, client: ExtendedClient) {
const message = interaction.targetMessage;
// Inhalt bestimmen: Text oder erster Anhang (Bild/GIF)
const responseText = message.content || message.attachments.first()?.url;
if (!responseText) {
await interaction.reply({ content: 'Diese Nachricht enthält weder Text noch Medien, die ich speichern kann.', ephemeral: true });
return;
}
// Modal erstellen
const modal = new ModalBuilder()
.setCustomId(`triggerContext-modal-${interaction.id}`) // Prefix muss dem Filename entsprechen
.setTitle('Als Trigger speichern');
const wordInput = new TextInputBuilder()
.setCustomId('triggerWord')
.setLabel('Welches Wort soll die Antwort auslösen?')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setPlaceholder('z.B. eis');
const responseInput = new TextInputBuilder()
.setCustomId('triggerResponse')
.setLabel('Antwort (wurde automatisch ausgefüllt)')
.setStyle(TextInputStyle.Paragraph)
.setValue(responseText)
.setRequired(true);
modal.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(wordInput),
new ActionRowBuilder<TextInputBuilder>().addComponents(responseInput)
);
await interaction.showModal(modal);
},
async handleModal(interaction: ModalSubmitInteraction, client: ExtendedClient) {
const word = interaction.fields.getTextInputValue('triggerWord').toLowerCase();
const response = interaction.fields.getTextInputValue('triggerResponse');
const guildId = interaction.guildId!;
DB.run('INSERT INTO auto_responses (guild_id, trigger_word, response_text) VALUES (?, ?, ?) ON CONFLICT(guild_id, trigger_word) DO UPDATE SET response_text = ?',
guildId, word, response, response);
await interaction.reply({ content: `✅ Erfolg! Der Bot antwortet nun auf **${word}** mit dem Inhalt der Nachricht.`, ephemeral: true });
}
};
export default contextCommand;

View File

@@ -0,0 +1,59 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, GuildMember } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('warn')
.setDescription('Verwarnt einen Nutzer und speichert dies in der Datenbank.')
.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))
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers),
category: 'Admin',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
const target = interaction.options.getMember('target') as GuildMember;
const reason = interaction.options.getString('reason')!;
const moderator = interaction.user;
if (!target) {
await interaction.reply({ content: 'Dieser Nutzer konnte nicht gefunden werden.', ephemeral: true });
return;
}
try {
// Speichern in der Datenbank
DB.run(
'INSERT INTO mod_logs (guild_id, user_id, moderator_id, action, reason) VALUES (?, ?, ?, ?, ?)',
interaction.guildId,
target.id,
moderator.id,
'WARN',
reason
);
// Zählen der bisherigen Verwarnungen
const countResult: any = DB.get(
'SELECT COUNT(*) as count FROM mod_logs WHERE guild_id = ? AND user_id = ? AND action = ?',
interaction.guildId,
target.id,
'WARN'
);
await interaction.reply({
content: `Nutzer ${target.user.tag} wurde verwarnt. Grund: ${reason}\nDies ist die ${countResult.count}. Verwarnung.`
});
} catch (error) {
console.error(error);
await interaction.reply({ content: 'Fehler beim Speichern der Verwarnung.', ephemeral: true });
}
},
};
export default command;

View File

@@ -0,0 +1,38 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { Deployer } from '../../structures/Deployer.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('deploy')
.setDescription('Aktualisiert alle Slash-Commands bei Discord (Nur für Bot-Owner).')
.setDMPermission(true), // Erlaubt den Befehl in privaten Chats
category: 'Owner',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
// Sicherstellen, dass die Applikation geladen ist, um den Owner zu prüfen
if (!client.application?.owner) {
await client.application?.fetch();
}
const ownerId = client.application?.owner?.id;
// Sicherheitsprüfung: Nur der Owner darf diesen Befehl ausführen
if (interaction.user.id !== ownerId) {
await interaction.reply({ content: 'Du hast keine Berechtigung für diesen Befehl.', ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
try {
const count = await Deployer.deploy(client.user!.id, client.token!);
await interaction.editReply(`Erfolgreich ${count} Befehle aktualisiert! (Es kann bis zu einer Stunde dauern, bis alle Änderungen global sichtbar sind.)`);
} catch (error) {
console.error(error);
await interaction.editReply('Fehler beim Deployment der Befehle.');
}
},
};
export default command;

View File

@@ -0,0 +1,54 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('dmusers')
.setDescription('Listet alle Nutzer auf, die den Bot privat angeschrieben haben (Nur Owner).')
.setDMPermission(true),
category: 'Owner',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
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;
}
await interaction.deferReply({ ephemeral: true });
const users: any[] = DB.all('SELECT * FROM dm_users ORDER BY last_seen DESC');
if (users.length === 0) {
await interaction.editReply('Es wurden noch keine Nutzer im privaten Chat registriert.');
return;
}
const userList = users.map(u =>
`• **${u.username}** \`(${u.user_id})\` - Letzte Aktivität: ${u.last_seen}`
);
const chunks = [];
for (let i = 0; i < userList.length; i += 10) {
chunks.push(userList.slice(i, i + 10).join('\n'));
}
const embed = new EmbedBuilder()
.setTitle(`💬 Registrierte DM-Nutzer (${users.length})`)
.setColor(0x9b59b6)
.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(0x9b59b6)
.setDescription(chunks[i].substring(0, 4000));
await interaction.followUp({ embeds: [nextEmbed], ephemeral: true });
}
},
};
export default command;

View File

@@ -0,0 +1,41 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('leave')
.setDescription('Veranlasst den Bot, einen Server zu verlassen (Nur Owner).')
.addStringOption(option =>
option.setName('guild_id')
.setDescription('Die ID des Servers, den der Bot verlassen soll')
.setRequired(true))
.setDMPermission(true),
category: 'Owner',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
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 guildId = interaction.options.getString('guild_id')!;
const guild = client.guilds.cache.get(guildId);
if (!guild) {
await interaction.reply({ content: `Server mit der ID \`${guildId}\` wurde nicht gefunden.`, ephemeral: true });
return;
}
try {
const guildName = guild.name;
await guild.leave();
await interaction.reply({ content: `✅ Der Bot hat den Server **${guildName}** (${guildId}) verlassen.`, ephemeral: true });
} catch (error) {
console.error(error);
await interaction.reply({ content: `❌ Fehler beim Verlassen des Servers mit ID \`${guildId}\`.`, ephemeral: true });
}
},
};
export default command;

View File

@@ -0,0 +1,52 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('servers')
.setDescription('Listet alle Server auf, auf denen der Bot aktiv ist (Nur Owner).')
.setDMPermission(true),
category: 'Owner',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
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;
}
await interaction.deferReply({ ephemeral: true });
const guilds = client.guilds.cache.map(guild =>
`• **${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('Der Bot ist aktuell auf keinem Server.');
return;
}
const embed = new EmbedBuilder()
.setTitle(`🌍 Aktive Server (${client.guilds.cache.size})`)
.setColor(0xf1c40f)
.setDescription(chunks[0].substring(0, 4000))
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
// Falls es mehr als 10 Server sind, schicken wir weitere Embeds als Follow-up
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 });
}
},
};
export default command;

View File

@@ -0,0 +1,55 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, EmbedBuilder, version as djsVersion } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
import os from 'node:os';
const command: Command = {
data: new SlashCommandBuilder()
.setName('stats')
.setDescription('Zeigt technische und Nutzungs-Statistiken (Nur Owner).')
.setDMPermission(true), // Erlaubt den Befehl in privaten Chats
category: 'Owner',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
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;
}
await interaction.deferReply({ ephemeral: true });
// System-Daten
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 ramUsage = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2);
const totalServers = client.guilds.cache.size;
const totalUsers = client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0);
// Datenbank-Daten
const totalWarns: any = DB.get('SELECT COUNT(*) as count FROM mod_logs WHERE action = "WARN"');
const topCommands: any[] = DB.all('SELECT * FROM command_stats ORDER BY uses DESC LIMIT 5');
const commandList = topCommands.length > 0
? topCommands.map(c => `• **${c.command_name}**: ${c.uses}x`).join('\n')
: 'Noch keine Daten.';
const embed = new EmbedBuilder()
.setTitle('📊 Bot-Owner Statistiken')
.setColor(0x3498db)
.addFields(
{ name: '🤖 Bot-Status', value: `**Uptime:** ${uptimeStr}\n**Latency:** ${client.ws.ping}ms\n**D.JS Version:** v${djsVersion}`, inline: true },
{ name: '🖥 System', value: `**RAM:** ${ramUsage} MB\n**OS:** ${os.platform()}\n**Cores:** ${os.cpus().length}`, inline: true },
{ name: '🌍 Netzwerk', value: `**Server:** ${totalServers}\n**Nutzer:** ${totalUsers}`, inline: true },
{ name: '📈 Datenbank', value: `**Warnungen gesamt:** ${totalWarns.count}\n\n**Top 5 Befehle:**\n${commandList}` }
)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
},
};
export default command;

View File

@@ -0,0 +1,61 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('help')
.setDescription('Zeigt eine Liste aller verfügbaren Befehle an.')
.setDMPermission(true),
category: 'Public',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
if (!client.application?.owner) await client.application?.fetch();
const isOwner = interaction.user.id === client.application?.owner?.id;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) || interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages);
const embed = new EmbedBuilder()
.setTitle('📖 Hilfe-Menü')
.setDescription('Hier sind die Befehle, auf die du Zugriff hast:')
.setColor(0x2ecc71)
.setTimestamp();
// Befehle nach Kategorien gruppieren
const publicCmds: string[] = [];
const adminCmds: string[] = [];
const ownerCmds: string[] = [];
client.commands.forEach((cmd) => {
const entry = `• **/${cmd.data.name}**: ${cmd.data.description}`;
if (cmd.category === 'Public') {
publicCmds.push(entry);
} else if (cmd.category === 'Admin') {
adminCmds.push(entry);
} else if (cmd.category === 'Owner') {
ownerCmds.push(entry);
}
});
// Felder hinzufügen basierend auf Rechten
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,15 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Antwortet mit Pong!'),
category: 'Public',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
await interaction.reply('Pong!');
},
};
export default command;

View File

@@ -0,0 +1,170 @@
import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder } from 'discord.js';
import { ExtendedClient } from '../../structures/ExtendedClient.js';
import { Command } from '../../structures/Command.js';
import { DB } from '../../structures/Database.js';
import { TwitchManager } from '../../structures/TwitchManager.js';
const command: Command = {
data: new SlashCommandBuilder()
.setName('twitch')
.setDescription('Twitch-Befehle (Status & Management).')
.addSubcommand(subcommand =>
subcommand
.setName('online')
.setDescription('Prüft sofort, ob ein Twitch-Kanal online ist.')
.addStringOption(option =>
option.setName('channel')
.setDescription('Der Name des Twitch-Kanals')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Überwacht einen Twitch-Kanal in diesem Discord-Kanal (Admin only).')
.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('Entfernt einen Twitch-Kanal aus der Überwachung (Admin only).')
.addStringOption(option =>
option.setName('channel')
.setDescription('Der Name des Twitch-Kanals')
.setRequired(true)))
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Listet überwachte Twitch-Kanäle für diesen Discord-Kanal auf.'))
.addSubcommand(subcommand =>
subcommand
.setName('listall')
.setDescription('Listet ALLE überwachten Twitch-Kanäle des Servers auf (Admin only).'))
.addSubcommand(subcommand =>
subcommand
.setName('interval')
.setDescription('Setzt das Abfrage-Intervall (Admin only).')
.addIntegerOption(option =>
option.setName('minutes')
.setDescription('Minuten (2-30)')
.setMinValue(2)
.setMaxValue(30)
.setRequired(true))),
category: 'Public',
async execute(interaction: ChatInputCommandInteraction, client: ExtendedClient) {
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.Administrator);
// --- PUBLIC COMMANDS ---
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 aktuell offline oder existiert nicht.`);
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('In diesem Discord-Kanal werden aktuell keine Twitch-Kanäle überwacht.');
return;
}
const list = monitors.map(m => `• **${m.channel_name}** ${m.custom_message ? `(Nachricht: *${m.custom_message}*)` : ''}`).join('\n');
const embed = new EmbedBuilder()
.setTitle('📺 Twitch-Kanäle für diesen Kanal')
.setColor(0x6441a5)
.setDescription(list);
await interaction.reply({ embeds: [embed] });
return;
}
// --- ADMIN ONLY COMMANDS ---
if (!isAdmin) {
await interaction.reply({ content: '❌ Du benötigst Administrator-Berechtigungen für diesen Befehl.', ephemeral: true });
return;
}
if (subcommand === 'add') {
const channelName = interaction.options.getString('channel')!.toLowerCase();
const message = interaction.options.getString('message');
const discordChannelId = interaction.channelId;
try {
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 nun in diesem Discord-Kanal überwacht.`);
} catch (e) {
await interaction.reply({ content: `❌ Fehler beim Hinzufügen von **${channelName}**.`, ephemeral: true });
}
}
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}** wurde aus der Überwachung entfernt.`);
}
if (subcommand === 'listall') {
const monitors: any[] = DB.all('SELECT * FROM twitch_monitors WHERE guild_id = ?', guildId);
if (monitors.length === 0) {
await interaction.reply('Es werden aktuell keine Twitch-Kanäle auf diesem Server überwacht.');
return;
}
const list = monitors.map(m => `• **${m.channel_name}** in <#${m.discord_channel_id}> \`(${m.discord_channel_id})\``).join('\n');
const embed = new EmbedBuilder()
.setTitle('📺 Alle überwachten Twitch-Kanäle (Serverweit)')
.setColor(0x6441a5)
.setDescription(list);
await interaction.reply({ embeds: [embed] });
}
if (subcommand === 'interval') {
const minutes = interaction.options.getInteger('minutes')!;
DB.run(
'INSERT INTO guild_settings (guild_id, twitch_interval) VALUES (?, ?) ON CONFLICT(guild_id) DO UPDATE SET twitch_interval = ?',
guildId, minutes, minutes
);
await interaction.reply(`✅ Abfrage-Intervall für Twitch wurde auf **${minutes} Minuten** gesetzt.`);
}
},
};
export default command;

15
src/deploy-commands.ts Normal file
View File

@@ -0,0 +1,15 @@
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);
}
(async () => {
try {
await Deployer.deploy(process.env.CLIENT_ID!, process.env.DISCORD_TOKEN!);
} catch (error) {
console.error(error);
}
})();

View File

@@ -0,0 +1,75 @@
import { Events, Interaction } from 'discord.js';
import { ExtendedClient } from '../structures/ExtendedClient.js';
import { DB } from '../structures/Database.js';
export default {
name: Events.InteractionCreate,
async execute(interaction: Interaction, client: ExtendedClient) {
// --- SLASH COMMANDS & CONTEXT MENUS ---
if (interaction.isChatInputCommand() || interaction.isMessageContextMenuCommand()) {
const command = client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
// Typ-Check für die Ausführung
if (interaction.isChatInputCommand()) {
await (command.execute as (interaction: ChatInputCommandInteraction, client: ExtendedClient) => Promise<void>)(interaction, client);
} else if (interaction.isMessageContextMenuCommand()) {
await (command.execute as (interaction: MessageContextMenuCommandInteraction, client: ExtendedClient) => Promise<void>)(interaction, client);
}
// Statistik nur für Slash-Commands (oder optional für beides)
if (interaction.isChatInputCommand()) {
DB.run(`
INSERT INTO command_stats (command_name, uses)
VALUES (?, 1)
ON CONFLICT(command_name) DO UPDATE SET uses = uses + 1
`, interaction.commandName);
}
// DM Nutzer registrieren
if (!interaction.guildId) {
DB.run(`
INSERT INTO dm_users (user_id, username, last_seen)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
username = excluded.username,
last_seen = CURRENT_TIMESTAMP
`, interaction.user.id, interaction.user.tag);
}
} 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);
}
}
return;
}
// --- MODAL SUBMISSIONS ---
if (interaction.isModalSubmit()) {
// Wir suchen das Kommando anhand des Modal-Custom-IDs (wir nutzen hier ein Präfix-System)
const [commandName] = interaction.customId.split('-');
const command = client.commands.get(commandName);
if (command && command.handleModal) {
try {
await command.handleModal(interaction, client);
} catch (error) {
console.error(error);
await interaction.reply({ content: 'Fehler beim Verarbeiten des Formulars.', ephemeral: true });
}
}
return;
}
},
};

View File

@@ -0,0 +1,43 @@
import { Events, Message } from 'discord.js';
import { ExtendedClient } from '../structures/ExtendedClient.js';
import { DB } from '../structures/Database.js';
export default {
name: Events.MessageCreate,
async execute(message: Message, client: ExtendedClient) {
// Ignoriere Bots
if (message.author.bot) return;
// Prüfen, ob es ein privater Chat (DM) ist
if (!message.guildId) {
// Nutzer in der Datenbank registrieren oder aktualisieren
DB.run(`
INSERT INTO dm_users (user_id, username, last_seen)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
username = excluded.username,
last_seen = CURRENT_TIMESTAMP
`, message.author.id, message.author.tag);
console.log(`[DM-TRACKER] Registered DM interaction from ${message.author.tag} (${message.author.id})`);
}
// --- AUTO RESPONDER ---
if (message.guildId) {
const content = message.content.toLowerCase();
const guildId = message.guildId;
// Alle Trigger für diese Gilde abrufen
const triggers: any[] = DB.all('SELECT trigger_word, response_text FROM auto_responses WHERE guild_id = ?', guildId);
for (const item of triggers) {
// Prüfen, ob der Trigger in der Nachricht vorkommt (Groß-/Kleinschreibung durch .toLowerCase() egal)
if (content.includes(item.trigger_word.toLowerCase())) {
await message.reply(item.response_text);
// Wir brechen nach der ersten Übereinstimmung ab, um Spam zu vermeiden (optional)
break;
}
}
}
},
};

10
src/events/ready.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Events } from 'discord.js';
import { ExtendedClient } from '../structures/ExtendedClient.js';
export default {
name: Events.ClientReady,
once: true,
execute(client: ExtendedClient) {
console.log(`Ready! Logged in as ${client.user?.tag}`);
},
};

60
src/index.ts Normal file
View File

@@ -0,0 +1,60 @@
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();
// Start Twitch Polling
TwitchManager.startPolling(client);
// 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') || file.endsWith('.js'));
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') || file.endsWith('.js'));
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}`);
}
client.login(process.env.DISCORD_TOKEN);

18
src/structures/Command.ts Normal file
View File

@@ -0,0 +1,18 @@
import {
ChatInputCommandInteraction,
SlashCommandBuilder,
SlashCommandOptionsOnlyBuilder,
SlashCommandSubcommandsOnlyBuilder,
ContextMenuCommandBuilder,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction
} from 'discord.js';
import { ExtendedClient } from './ExtendedClient.js';
export interface Command {
data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder | ContextMenuCommandBuilder;
category: 'Owner' | 'Admin' | 'Public';
execute: (interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction, client: ExtendedClient) => Promise<void>;
handleModal?: (interaction: ModalSubmitInteraction, client: ExtendedClient) => Promise<void>;
cooldown?: number;
}

112
src/structures/Database.ts Normal file
View File

@@ -0,0 +1,112 @@
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);
// Pfad für die Datenbank: In Docker ein persistentes Volume (/app/data/database.sqlite)
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}...`);
// Beispiel-Tabelle: Gilden-Konfiguration (z.B. für Präfixe oder Kanäle)
db.prepare(`
CREATE TABLE IF NOT EXISTS guild_settings (
guild_id TEXT PRIMARY KEY,
prefix TEXT DEFAULT '!',
mod_log_channel TEXT,
welcome_channel TEXT
)
`).run();
// Beispiel-Tabelle: Moderations-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();
// Tabelle für Befehls-Statistiken
db.prepare(`
CREATE TABLE IF NOT EXISTS command_stats (
command_name TEXT PRIMARY KEY,
uses INTEGER DEFAULT 0
)
`).run();
// Tabelle für Nutzer, die den Bot privat anschreiben
db.prepare(`
CREATE TABLE IF NOT EXISTS dm_users (
user_id TEXT PRIMARY KEY,
username TEXT,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
// Tabelle für Twitch-Überwachung
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',
UNIQUE(guild_id, channel_name)
)
`).run();
// Migration: Falls die Spalte discord_channel_id noch nicht existiert
try {
db.prepare('ALTER TABLE twitch_monitors ADD COLUMN discord_channel_id TEXT').run();
} catch (e) {}
// Update guild_settings um Twitch-Intervall
try {
db.prepare('ALTER TABLE guild_settings ADD COLUMN twitch_interval INTEGER DEFAULT 5').run();
} catch (e) {
// Spalte existiert wahrscheinlich schon
}
// Tabelle für Auto-Responder (Trigger & Antworten)
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,44 @@
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 = [];
// Wir gehen zwei Ebenen hoch, da wir in src/structures/ sind
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') || file.endsWith('.js'));
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;
}
}

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,138 @@
import { EmbedBuilder, TextChannel } from 'discord.js';
import { ExtendedClient } from './ExtendedClient.js';
import { DB } from './Database.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: ExtendedClient) {
const token = await this.getAccessToken();
if (!token) return;
const monitors: any[] = DB.all('SELECT * FROM twitch_monitors');
if (monitors.length === 0) return;
for (const monitor of monitors) {
try {
// Prüfen, ob das Intervall für diese Gilde bereits abgelaufen ist
const guildSettings: any = DB.get('SELECT twitch_interval FROM guild_settings WHERE guild_id = ?', monitor.guild_id);
const interval = guildSettings?.twitch_interval || 5;
// Wir nutzen einen einfachen Timestamp-basierten Check im Speicher,
// um nicht bei jedem globalen Tick jeden Kanal abzufragen (Schonung der Twitch-API Rate-Limits)
// (Vereinfachung für diesen Bot: Wir checken alle Kanäle,
// da wir global alle 2 Minuten pollen und die Twitch API das locker hergibt)
const response = await fetch(`https://api.twitch.tv/helix/streams?user_login=${monitor.channel_name}`, {
headers: {
'Client-ID': process.env.TWITCH_CLIENT_ID!,
'Authorization': `Bearer ${token}`
}
});
const data: any = await response.json();
const stream = data.data?.[0];
if (stream) {
if (monitor.last_status === 'offline') {
await this.sendNotification(client, monitor, stream);
DB.run('UPDATE twitch_monitors SET last_status = "online" WHERE id = ?', monitor.id);
}
} else {
if (monitor.last_status === 'online') {
DB.run('UPDATE twitch_monitors SET last_status = "offline" WHERE id = ?', monitor.id);
}
}
} catch (error) {
console.error(`[TWITCH] Error checking stream ${monitor.channel_name}:`, error);
}
}
}
private static async sendNotification(client: ExtendedClient, monitor: any, stream: any) {
const guild = client.guilds.cache.get(monitor.guild_id);
if (!guild) return;
// Nutze den spezifischen Kanal, in dem "add" ausgeführt wurde
const channelId = monitor.discord_channel_id;
if (!channelId) return;
const channel = guild.channels.cache.get(channelId) 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: ExtendedClient) {
// Initialer Check nach 10 Sekunden, dann alle 2 Minuten (wir prüfen intern das Intervall)
// Einfachheitshalber checken wir alle 5 Minuten global,
// aber für die Zukunft könnte man das pro Gilde verfeinern.
setInterval(() => this.checkStreams(client), 5 * 60 * 1000);
console.log('[TWITCH] Polling started (5m interval).');
}
}