337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
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 }; |