import "dotenv/config"; import Discord from "discord.js"; import { EmbedBuilder, REST, Routes } from "discord.js"; import fs from "fs"; import { Spiget } from "spiget"; import configFile from "./config.json" with { type: "json" }; import logger from "./util/logger.js"; import packageData from "./package.json" with { type: "json" }; import queue from "./util/queue.js"; import stats from "./util/stats.js"; import { generateAvatarLink, generateAuthorURL, generateResourceIconURL, getLatestVersion, getUpdateDescription, } from "./util/helpers.js"; // Token und Owner-ID aus .env laden const config = { ...configFile, token: process.env.DISCORD_TOKEN, ownerID: process.env.OWNER_ID, }; if (!config.token) { console.error("❌ DISCORD_TOKEN fehlt in der .env Datei!"); process.exit(1); } const spiget = new Spiget(config.botName); // Wie oft darf eine Ressource hintereinander fehlschlagen, bevor eine DM gesendet wird const ERROR_THRESHOLD = 3; // Zählt aufeinanderfolgende Fehler pro Ressource: "guildID:resourceID" → count const errorCounts = new Map(); /** * ─── CLIENT ────────────────────────────────────────────────────────────────── */ const client = { bot: new Discord.Client({ intents: [ Discord.IntentsBitField.Flags.Guilds, Discord.IntentsBitField.Flags.GuildMessages, Discord.IntentsBitField.Flags.MessageContent, ], partials: ["MESSAGE", "CHANNEL"], }), commands: new Map(), aliases: new Map(), cooldowns: new Map(), config, logger, packageData, queue, stats, }; stats.markStart(); client.logger.info(`${config.botName} wird gestartet...`); /** * ─── EVENTS ────────────────────────────────────────────────────────────────── */ const eventFiles = fs .readdirSync("./events") .filter((f) => f.endsWith(".js")); for (const file of eventFiles) { const event = (await import(`./events/${file}`)).default; client.bot.on(event.name, (...args) => event.execute(client, ...args).catch(logger.error) ); } client.logger.info(`${eventFiles.length} Events geladen`); /** * ─── COMMANDS ──────────────────────────────────────────────────────────────── */ const commandFiles = fs .readdirSync("./commands") .filter((f) => f.endsWith(".js")); const slashCommands = []; for (const file of commandFiles) { const command = (await import(`./commands/${file}`)).default; client.commands.set(command.name, command); if (command.aliases?.length > 0) { for (const alias of command.aliases) { client.aliases.set(alias, command.name); } } if (command.data) slashCommands.push(command.data.toJSON()); } client.logger.info(`${commandFiles.length} Befehle registriert`); /** * ─── SLASH COMMAND REGISTRIERUNG ───────────────────────────────────────────── */ async function registerSlashCommands() { if (slashCommands.length === 0) return; try { const rest = new REST().setToken(config.token); await rest.put(Routes.applicationCommands(config.inviteClientID), { body: slashCommands }); client.logger.info(`${slashCommands.length} Slash-Befehle registriert`); } catch (e) { client.logger.error(`Slash-Registrierung fehlgeschlagen: ${e.message}`); } } /** * ─── RECONNECT-HANDLING ────────────────────────────────────────────────────── */ client.bot.on("shardDisconnect", (event, shardID) => { client.logger.warn(`[Shard ${shardID}] Verbindung getrennt (Code ${event.code}) – versuche Reconnect...`); }); client.bot.on("shardReconnecting", (shardID) => { client.logger.info(`[Shard ${shardID}] Verbinde erneut...`); }); client.bot.on("shardResume", (shardID, replayedEvents) => { client.logger.info(`[Shard ${shardID}] Wiederverbunden – ${replayedEvents} Events nachgeholt`); }); client.bot.on("shardError", (error, shardID) => { client.logger.error(`[Shard ${shardID}] WebSocket-Fehler: ${error.message}`); }); /** * ─── OWNER DM HELPER ───────────────────────────────────────────────────────── */ async function dmOwner(subject, description) { if (!config.ownerID) return; try { const owner = await client.bot.users.fetch(config.ownerID); const embed = new EmbedBuilder() .setColor("#FF0000") .setTitle(`⚠️ ${config.botName} – ${subject}`) .setDescription(description) .setTimestamp(); await owner.send({ embeds: [embed] }); stats.increment("dmsSent"); client.logger.info(`[DM] Owner benachrichtigt: ${subject}`); } catch (e) { client.logger.error(`[DM] Owner-DM fehlgeschlagen: ${e.message}`); } } /** * ─── UPDATE-CHECK QUEUE ─────────────────────────────────────────────────────── * Läuft jede Minute; füllt die Queue mit einem Job pro Ressource. */ function scheduleNextCheck() { const intervalMs = 60 * 1000; queue.nextRunAt = Date.now() + intervalMs; setTimeout(() => { enqueueUpdateChecks(); scheduleNextCheck(); }, intervalMs); } async function enqueueUpdateChecks() { let serverDataDir; try { serverDataDir = fs.readdirSync("./serverdata"); } catch { return; } const serverFiles = serverDataDir.filter((f) => f.endsWith(".json")); stats.increment("checksRun"); for (const serverFile of serverFiles) { const filePath = `./serverdata/${serverFile}`; let jsonData; try { jsonData = JSON.parse(fs.readFileSync(filePath, "utf8")); } catch (e) { client.logger.error(`Fehler beim Lesen von ${filePath}: ${e.message}`); continue; } // Per-Server-Intervall prüfen (Standard: 5 Minuten) const intervalMs = (jsonData.updateInterval ?? 5) * 60 * 1000; const lastChecked = jsonData.lastChecked ?? 0; if (Date.now() - lastChecked < intervalMs) continue; jsonData.lastChecked = Date.now(); for (const watchedResource of jsonData.watchedResources) { // Job in Queue einreihen – wird sequenziell abgearbeitet queue.enqueue(() => checkResource(watchedResource, jsonData, filePath, serverFile) ); } // lastChecked sofort speichern try { fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2)); } catch (e) { client.logger.error(`Fehler beim Schreiben von ${filePath}: ${e.message}`); } } } /** * Prüft eine einzelne Ressource auf Updates. */ async function checkResource(watchedResource, jsonData, filePath, serverFile) { const { resourceID: id, channelID } = watchedResource; const errorKey = `${serverFile}:${id}`; const channel = client.bot.channels.cache.get(channelID); if (!channel) { client.logger.warn(`Kanal ${channelID} nicht gefunden – Ressource ${id} übersprungen`); // Fehler zählen und Owner benachrichtigen wenn Schwelle erreicht const count = (errorCounts.get(errorKey) ?? 0) + 1; errorCounts.set(errorKey, count); if (count === ERROR_THRESHOLD) { await dmOwner( "Kanal nicht gefunden", `Kanal <#${channelID}> (ID: \`${channelID}\`) für Ressource \`${id}\` konnte **${count}x** hintereinander nicht gefunden werden.\n\nBitte prüfe ob der Kanal noch existiert oder entferne die Ressource mit \`${config.prefix}remove ${id}\`.` ); } return; } // Kanal gefunden – Fehlerzähler zurücksetzen errorCounts.delete(errorKey); let resource, author; try { resource = await spiget.getResource(id); author = await resource.getAuthor(); } catch (e) { client.logger.error(`Spiget-Fehler für Ressource ${id}: ${e.message}`); stats.increment("apiErrors"); const count = (errorCounts.get(`api:${id}`) ?? 0) + 1; errorCounts.set(`api:${id}`, count); if (count === ERROR_THRESHOLD) { await dmOwner( "Spiget API-Fehler", `Ressource \`${id}\` konnte **${count}x** hintereinander nicht von der Spiget-API abgerufen werden.\n**Fehler:** ${e.message}` ); } return; } errorCounts.delete(`api:${id}`); let latestVersion; try { latestVersion = await getLatestVersion(id); } catch (e) { client.logger.error(`Versionsfehler für Ressource ${id}: ${e.message}`); stats.increment("apiErrors"); return; } // Bereits aktuell const previousVersion = watchedResource.lastCheckedVersion; if (previousVersion === latestVersion) return; stats.increment("updatesFound"); let updateDesc; try { updateDesc = await getUpdateDescription(id); } catch { updateDesc = "Update-Beschreibung konnte nicht abgerufen werden."; } const authorURL = generateAuthorURL(author.name, author.id); const authorAvatarURL = generateAvatarLink(author.id); const resourceIconURL = generateResourceIconURL(resource); const resourceURL = `https://spigotmc.org/resources/.${id}/`; const updateEmbed = new EmbedBuilder() .setAuthor({ name: `Autor: ${author.name}`, iconURL: authorAvatarURL, url: authorURL }) .setColor(channel.guild.members.me.displayHexColor) .setTitle(`🔔 Update verfügbar: ${resource.name}`) .setDescription(resource.tag) .addFields([ { name: "📦 Version", value: previousVersion ? `~~${previousVersion}~~ → **${latestVersion}**` : `**${latestVersion}**`, inline: false, }, { name: "📝 Update-Beschreibung", value: updateDesc, inline: false }, { name: "⬇️ Download", value: resourceURL, inline: false }, ]) .setThumbnail(resourceIconURL) .setTimestamp(); // Version speichern watchedResource.lastCheckedVersion = latestVersion; watchedResource.resourceName = resource.name; try { fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2)); } catch (e) { client.logger.error(`Fehler beim Schreiben von ${filePath}: ${e.message}`); } try { const mentionContent = watchedResource.mentionRoleID ? `<@&${watchedResource.mentionRoleID}>` : undefined; await channel.send({ content: mentionContent, embeds: [updateEmbed] }); stats.increment("updatesPosted"); } catch (e) { client.logger.error(`Fehler beim Senden in Kanal ${channelID}: ${e.message}`); } } /** * ─── START ──────────────────────────────────────────────────────────────────── */ client.logger.info("Anmeldung läuft..."); client.bot.login(client.config.token); client.bot.once("clientReady", () => { registerSlashCommands(); scheduleNextCheck(); client.logger.info("Update-Check-Queue gestartet"); }); export { dmOwner };