From ff0dc70b5479486feea0a5e82bba2aebf0683ac8 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 25 Feb 2026 18:51:13 +0100 Subject: [PATCH] Update from Git Manager GUI --- util/helpers.js | 75 +++++++++++++++ util/i18n.js | 228 ++++++++++++++++++++++++++++++++++++++++++++++ util/logger.js | 22 +++++ util/ownerOnly.js | 26 ++++++ util/queue.js | 58 ++++++++++++ util/stats.js | 71 +++++++++++++++ 6 files changed, 480 insertions(+) create mode 100644 util/helpers.js create mode 100644 util/i18n.js create mode 100644 util/logger.js create mode 100644 util/ownerOnly.js create mode 100644 util/queue.js create mode 100644 util/stats.js diff --git a/util/helpers.js b/util/helpers.js new file mode 100644 index 0000000..4b6c314 --- /dev/null +++ b/util/helpers.js @@ -0,0 +1,75 @@ +/** + * Shared utility functions for PluginBot + * Centralised here to avoid duplication across commands + */ + +export function generateAvatarLink(authorID) { + const idStr = authorID.toString(); + const splitPoint = Math.ceil(idStr.length / 2); + const firstHalf = idStr.substring(0, splitPoint); + return `https://www.spigotmc.org/data/avatars/l/${firstHalf}/${authorID}.jpg`; +} + +export function generateAuthorURL(authorName, authorID) { + return `https://www.spigotmc.org/members/${authorName}.${authorID}/`; +} + +export function generateResourceIconURL(resource) { + return resource.icon + .fullUrl() + .replace("orgdata", "org/data") + .replace("https://spigotmc.org", "https://www.spigotmc.org"); + // www must be present – embeds don't render without it +} + +export function generateResourceURL(resourceID) { + return `https://spigotmc.org/resources/.${resourceID}/`; +} + +/** + * Converts basic SpigotMC HTML to Discord Markdown. + * BUG FIX: was previously mapped to "**" (bold) instead of "*" (italic) + */ +export function formatText(description) { + return description + .replace(//gi, "**") + .replace(/<\/b>/gi, "**") + .replace(//gi, "*") + .replace(/<\/i>/gi, "*") // ← was "**" before (bug) + .replace(/
    /gi, "") + .replace(/<\/ul>/gi, "") + .replace(/
  • /gi, "• ") + .replace(/<\/li>/gi, "\n") + .replace(//gi, "\n") + .replace(/<[^>]+>/g, ""); // strip any remaining HTML tags +} + +/** + * Fetches the latest version name for a Spiget resource. + * Uses native fetch instead of the legacy xmlhttprequest package. + */ +export async function getLatestVersion(resourceID) { + const res = await fetch( + `https://api.spiget.org/v2/resources/${resourceID}/versions/latest` + ); + if (!res.ok) throw new Error(`Spiget version request failed: HTTP ${res.status}`); + const data = await res.json(); + return data.name; +} + +/** + * Fetches the latest update description for a Spiget resource. + * Returns a Discord-safe string (max 1024 chars). + */ +export async function getUpdateDescription(resourceID) { + const res = await fetch( + `https://api.spiget.org/v2/resources/${resourceID}/updates/latest` + ); + if (!res.ok) throw new Error(`Spiget update request failed: HTTP ${res.status}`); + const data = await res.json(); + const decoded = Buffer.from(data.description, "base64").toString("utf8"); + const formatted = formatText(decoded); + return formatted.length > 1024 + ? "Description is greater than 1024 characters – visit the resource page for details." + : formatted; +} diff --git a/util/i18n.js b/util/i18n.js new file mode 100644 index 0000000..47f7b91 --- /dev/null +++ b/util/i18n.js @@ -0,0 +1,228 @@ +/** + * util/i18n.js + * Übersetzungen für DE und EN. + * Nutzung: t(lang, "key") oder t(lang, "key", { var: "wert" }) + */ + +const strings = { + de: { + // Allgemein + "error.noArgs": "Du hast nicht genug Argumente angegeben!", + "error.noPermission": "Du hast keine Berechtigung diesen Befehl auszuführen!", + "error.onlyOwner": "Dieser Befehl kann nur vom Bot-Besitzer genutzt werden!", + "error.cooldown": "Bitte warte {seconds} Sekunde(n) bevor du diesen Befehl erneut verwendest!", + "error.nsfw": "Dieser Befehl ist als NSFW markiert. Bitte verwende ihn in einem NSFW-Kanal!", + "error.notFound": "Ich konnte `{name}` nicht in der Befehlsliste finden!", + "error.invalidID": "Ups! `{id}` ist keine gültige Ressourcen-ID!", + "error.noAuthor": "Ups! Der Autor für Ressource `{id}` konnte nicht gefunden werden.", + "error.apiDown": "Die API konnte nicht erreicht werden. Bitte versuche es später erneut.", + "error.saveFailed": "Beim Speichern der Daten ist ein Fehler aufgetreten.", + "error.timeout": "⏱️ Zeit abgelaufen – Befehl wurde abgebrochen.", + "general.confirm": "✅ Bestätigen", + "general.cancel": "❌ Abbrechen", + "general.cancelled": "❌ Abgebrochen.", + "general.author": "Autor: {name}", + // add + "add.alreadyWatched": "Diese Ressource wird bereits in <#{channelID}> beobachtet.", + "add.invalidChannel": "Dieser Kanal ist ungültig. Bitte erwähne einen Kanal auf diesem Server.", + "add.notInGuild": "Dieser Kanal befindet sich nicht auf diesem Server!", + "add.versionFail": "Die neueste Version konnte nicht von SpigotMC abgerufen werden. Bitte versuche es erneut.", + "add.confirmTitle": "Plugin beobachten: {name}", + "add.confirmDesc": "{tag}\n\nSoll dieses Plugin in <#{channelID}> beobachtet werden?", + "add.successTitle": "✅ Wird jetzt beobachtet: {name}", + "add.buttonConfirm": "✅ Bestätigen", + "add.channel": "Kanal", + "add.version": "Version", + "add.download": "Download", + // remove + "remove.noResources": "Dieser Server hat keine beobachteten Ressourcen!", + "remove.notFound": "Fehler: Ressource `{id}` wurde auf diesem Server nicht beobachtet.", + "remove.success": "✅ **{name}** (`{id}`) wird nicht mehr beobachtet.", + "remove.saveFail": "Beim Aktualisieren der Beobachtungsdaten ist ein Fehler aufgetreten.", + // list + "list.noResources": "Dieser Server hat keine beobachteten Ressourcen. Nutze `{prefix}add` um eine hinzuzufügen.", + "list.title": "📋 Beobachtete Plugins ({count})", + "list.interval": "⏱️ Update-Intervall: {min} Minute(n)", + "list.intervalDefault": "⏱️ Update-Intervall: 5 Minuten (Standard)", + "list.channel": "Kanal", + // setinterval + "setinterval.invalid": "Ungültiger Wert. Bitte gib eine Zahl zwischen {min} und {max} Minuten an.", + "setinterval.success": "Das Update-Intervall für diesen Server wurde auf **{min} Minute(n)** gesetzt.", + "setinterval.title": "⏱️ Update-Intervall aktualisiert", + "setinterval.footer": "Der nächste Check erfolgt nach Ablauf des neuen Intervalls.", + // setchannel + "setchannel.notWatched": "Ressource `{id}` wird auf diesem Server nicht beobachtet.", + "setchannel.success": "✅ Update-Kanal für **{name}** wurde auf <#{channelID}> geändert.", + // setmention + "setmention.set": "✅ Bei Updates für **{name}** wird jetzt <@&{roleID}> gepingt.", + "setmention.removed": "✅ Mention für **{name}** wurde entfernt.", + "setmention.notWatched": "Ressource `{id}` wird auf diesem Server nicht beobachtet.", + // setlang + "setlang.success": "✅ Sprache wurde auf **Deutsch** gesetzt.", + "setlang.title": "🌐 Sprache geändert", + // status + "status.title": "📡 {botName} – Status", + "status.spiget": "🌐 Spiget API", + "status.spigot": "🌐 SpigotMC API", + "status.discord": "💬 Discord Ping", + "status.queue": "⚙️ Queue", + "status.nextCheck": "⏱️ Nächster Check", + "status.checksTotal": "🔄 Checks gesamt", + "status.updatesFound": "🔔 Updates gefunden", + "status.updatesPosted": "📤 Updates gepostet", + "status.apiErrors": "⚠️ API-Fehler", + "status.onlineSince": "🕐 Online seit", + "status.jobsDone": "🏓 Jobs verarbeitet", + "status.ownerDMs": "📩 Owner-DMs", + "status.ok": "✅ Erreichbar", + "status.unreachable": "❌ Nicht erreichbar", + "status.queueActive": "🔄 Aktiv ({count} ausstehend)", + "status.queueWaiting": "⏳ Wartend ({count} Jobs)", + "status.queueIdle": "✅ Leerlauf", + // top + "top.title": "🏆 Top Plugins – {sort}", + "top.downloads": "Downloads", + "top.rating": "Bewertung", + "top.noData": "Keine beobachteten Ressourcen gefunden.", + // check + "check.title": "🔍 Kompatibilität: {name}", + "check.compatible": "✅ Kompatibel", + "check.incompatible": "❌ Nicht kompatibel", + "check.unknown": "❓ Unbekannt", + "check.testedVersions": "Getestete Versionen", + "check.supportedVersions": "Unterstützte Versionen", + // compare + "compare.title": "⚖️ Vergleich", + "compare.downloads": "⬇️ Downloads", + "compare.rating": "⭐ Bewertung", + "compare.version": "📦 Version", + "compare.updated": "🕐 Letztes Update", + "compare.winner": "🏆 Besser", + // ping + "ping.measuring": "Ping?", + "ping.result": ":ping_pong: Pong! Die Latenz beträgt **{ms}ms**.", + }, + + en: { + // General + "error.noArgs": "You didn't provide enough arguments!", + "error.noPermission": "You don't have permission to run that command!", + "error.onlyOwner": "This command can only be used by the bot owner!", + "error.cooldown": "Please wait {seconds} second(s) before using that command again!", + "error.nsfw": "This command is marked NSFW. Please run it in an NSFW channel!", + "error.notFound": "I couldn't find `{name}` in the command list!", + "error.invalidID": "Oops! `{id}` is not a valid resource ID!", + "error.noAuthor": "Oops! I couldn't find the author for resource `{id}`.", + "error.apiDown": "Could not reach the API. Please try again later.", + "error.saveFailed": "An error occurred while saving the data.", + "error.timeout": "⏱️ Timed out – command was cancelled.", + "general.confirm": "✅ Confirm", + "general.cancel": "❌ Cancel", + "general.cancelled": "❌ Cancelled.", + "general.author": "Author: {name}", + // add + "add.alreadyWatched": "That resource is already being watched in <#{channelID}>.", + "add.invalidChannel": "That channel is invalid. Please mention a channel in this server.", + "add.notInGuild": "That channel is not in this server!", + "add.versionFail": "Could not fetch the latest version from SpigotMC. Please try again.", + "add.confirmTitle": "Watch plugin: {name}", + "add.confirmDesc": "{tag}\n\nShould this plugin be watched in <#{channelID}>?", + "add.successTitle": "✅ Now watching: {name}", + "add.buttonConfirm": "✅ Confirm", + "add.channel": "Channel", + "add.version": "Version", + "add.download": "Download", + // remove + "remove.noResources": "This server has no watched resources!", + "remove.notFound": "Error: Resource `{id}` was not being watched in this server.", + "remove.success": "✅ **{name}** (`{id}`) is no longer being watched.", + "remove.saveFail": "An error occurred while updating the watch data.", + // list + "list.noResources": "This server has no watched resources. Use `{prefix}add` to add one.", + "list.title": "📋 Watched Plugins ({count})", + "list.interval": "⏱️ Update interval: {min} minute(s)", + "list.intervalDefault": "⏱️ Update interval: 5 minutes (default)", + "list.channel": "Channel", + // setinterval + "setinterval.invalid": "Invalid value. Please provide a number between {min} and {max} minutes.", + "setinterval.success": "The update interval for this server has been set to **{min} minute(s)**.", + "setinterval.title": "⏱️ Update interval updated", + "setinterval.footer": "The next check will run after the new interval has elapsed.", + // setchannel + "setchannel.notWatched": "Resource `{id}` is not being watched in this server.", + "setchannel.success": "✅ Update channel for **{name}** changed to <#{channelID}>.", + // setmention + "setmention.set": "✅ <@&{roleID}> will now be pinged for updates to **{name}**.", + "setmention.removed": "✅ Mention for **{name}** has been removed.", + "setmention.notWatched": "Resource `{id}` is not being watched in this server.", + // setlang + "setlang.success": "✅ Language has been set to **English**.", + "setlang.title": "🌐 Language changed", + // status + "status.title": "📡 {botName} – Status", + "status.spiget": "🌐 Spiget API", + "status.spigot": "🌐 SpigotMC API", + "status.discord": "💬 Discord Ping", + "status.queue": "⚙️ Queue", + "status.nextCheck": "⏱️ Next Check", + "status.checksTotal": "🔄 Total Checks", + "status.updatesFound": "🔔 Updates Found", + "status.updatesPosted": "📤 Updates Posted", + "status.apiErrors": "⚠️ API Errors", + "status.onlineSince": "🕐 Online Since", + "status.jobsDone": "🏓 Jobs Processed", + "status.ownerDMs": "📩 Owner DMs", + "status.ok": "✅ Reachable", + "status.unreachable": "❌ Unreachable", + "status.queueActive": "🔄 Active ({count} pending)", + "status.queueWaiting": "⏳ Waiting ({count} jobs)", + "status.queueIdle": "✅ Idle", + // top + "top.title": "🏆 Top Plugins – {sort}", + "top.downloads": "Downloads", + "top.rating": "Rating", + "top.noData": "No watched resources found.", + // check + "check.title": "🔍 Compatibility: {name}", + "check.compatible": "✅ Compatible", + "check.incompatible": "❌ Not compatible", + "check.unknown": "❓ Unknown", + "check.testedVersions": "Tested Versions", + "check.supportedVersions": "Supported Versions", + // compare + "compare.title": "⚖️ Comparison", + "compare.downloads": "⬇️ Downloads", + "compare.rating": "⭐ Rating", + "compare.version": "📦 Version", + "compare.updated": "🕐 Last Updated", + "compare.winner": "🏆 Better", + // ping + "ping.measuring": "Ping?", + "ping.result": ":ping_pong: Pong! Latency is **{ms}ms**.", + }, +}; + +/** + * Gibt den übersetzten String zurück. + * Variablen werden mit {key} ersetzt. + * @param {string} lang "de" | "en" + * @param {string} key + * @param {Object} [vars] + */ +export function t(lang, key, vars = {}) { + const dict = strings[lang] ?? strings.de; + let str = dict[key] ?? strings.de[key] ?? key; + + for (const [k, v] of Object.entries(vars)) { + str = str.replaceAll(`{${k}}`, v); + } + return str; +} + +/** + * Gibt die gespeicherte Serversprache zurück (Standard: "de"). + * @param {Object} jsonData Serverdaten aus serverdata/ + */ +export function getLang(jsonData) { + return jsonData?.lang ?? "de"; +} \ No newline at end of file diff --git a/util/logger.js b/util/logger.js new file mode 100644 index 0000000..63e81ca --- /dev/null +++ b/util/logger.js @@ -0,0 +1,22 @@ +import { createLogger, transports, format } from "winston"; + +const logFormat = format.printf(({ level, message, timestamp, stack }) => { + return `${timestamp} - ${level} - ${stack || message}`; +}); + +const logger = createLogger({ + format: format.combine( + format.colorize(), + format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // ← was "SS" (wrong), now "ss" + format.errors({ stack: true }), + logFormat + ), + transports: [new transports.Console()], +}); + +export default { + debug: logger.debug.bind(logger), + info: logger.info.bind(logger), + warn: logger.warn.bind(logger), + error: logger.error.bind(logger), +}; diff --git a/util/ownerOnly.js b/util/ownerOnly.js new file mode 100644 index 0000000..d11932c --- /dev/null +++ b/util/ownerOnly.js @@ -0,0 +1,26 @@ +/** + * util/ownerOnly.js + * Hilfsfunktion um Owner-only Befehle zu schützen. + */ + +import { t } from "./i18n.js"; + +/** + * Gibt true zurück wenn der Nutzer der Bot-Owner ist. + * @param {Object} client + * @param {Object} ctx message oder slash ctx + * @param {string} lang + */ +export function isOwner(client, ctx) { + const userID = ctx.isSlash ? ctx.interaction.user.id : ctx.author.id; + return userID === client.config.ownerID; +} + +/** + * Prüft ob Owner – wenn nicht, antwortet mit Fehlermeldung und gibt false zurück. + */ +export async function requireOwner(client, ctx, lang = "de") { + if (isOwner(client, ctx)) return true; + await ctx.reply({ content: t(lang, "error.onlyOwner"), ephemeral: true }); + return false; +} \ No newline at end of file diff --git a/util/queue.js b/util/queue.js new file mode 100644 index 0000000..988f8c4 --- /dev/null +++ b/util/queue.js @@ -0,0 +1,58 @@ +/** + * util/queue.js + * Verarbeitet Update-Checks als geordnete Warteschlange – + * verhindert parallele API-Anfragen und Rate-Limits. + */ + +export class UpdateQueue { + constructor() { + this._queue = []; + this._running = false; + this.processed = 0; // Gesamt-Jobs seit Start + this.nextRunAt = null; // Zeitstempel des nächsten geplanten Runs + } + + /** Anzahl der wartenden Jobs */ + get size() { + return this._queue.length; + } + + /** Ist die Queue gerade aktiv? */ + get isRunning() { + return this._running; + } + + /** + * Fügt einen asynchronen Job zur Queue hinzu. + * @param {() => Promise} job + */ + enqueue(job) { + this._queue.push(job); + this._run(); + } + + /** Interne Abarbeitungsschleife – läuft bis Queue leer ist */ + async _run() { + if (this._running) return; + this._running = true; + + while (this._queue.length > 0) { + const job = this._queue.shift(); + try { + await job(); + } catch (e) { + console.error("[Queue] Job-Fehler:", e.message); + } + this.processed++; + + // 500ms Pause zwischen Jobs + if (this._queue.length > 0) { + await new Promise((r) => setTimeout(r, 500)); + } + } + + this._running = false; + } +} + +export default new UpdateQueue(); \ No newline at end of file diff --git a/util/stats.js b/util/stats.js new file mode 100644 index 0000000..f041ddb --- /dev/null +++ b/util/stats.js @@ -0,0 +1,71 @@ +/** + * util/stats.js + * Verfolgt Bot-Statistiken persistent in ./data/stats.json + */ + +import fs from "fs"; +import path from "path"; + +const STATS_FILE = "./data/stats.json"; + +const defaults = { + updatesFound: 0, // Erkannte Updates gesamt + updatesPosted: 0, // Erfolgreich gesendete Update-Embeds + checksRun: 0, // Durchgeführte Update-Checks + apiErrors: 0, // Spiget-API Fehler + dmsSent: 0, // DMs an den Owner gesendet + startedAt: null, +}; + +function load() { + try { + return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); + } catch { + return { ...defaults }; + } +} + +function save(data) { + try { + fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true }); + fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.error("[Stats] Fehler beim Speichern:", e.message); + } +} + +class Stats { + constructor() { + this._data = load(); + if (!this._data.startedAt) { + this._data.startedAt = new Date().toISOString(); + save(this._data); + } + } + + /** Erhöht einen Zähler um 1 und speichert sofort */ + increment(key, by = 1) { + if (key in this._data) { + this._data[key] += by; + save(this._data); + } + } + + /** Gibt den aktuellen Wert eines Zählers zurück */ + get(key) { + return this._data[key] ?? 0; + } + + /** Gibt alle Statistiken zurück */ + all() { + return { ...this._data }; + } + + /** Setzt die Startzeit auf jetzt */ + markStart() { + this._data.startedAt = new Date().toISOString(); + save(this._data); + } +} + +export default new Stats(); \ No newline at end of file