Files
SpigotWatch/index.js
2026-02-25 18:50:41 +01:00

337 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };