Update from Git Manager GUI

This commit is contained in:
2026-02-25 18:51:13 +01:00
parent 7dba3a8db8
commit ff0dc70b54
6 changed files with 480 additions and 0 deletions

75
util/helpers.js Normal file
View File

@@ -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: </i> was previously mapped to "**" (bold) instead of "*" (italic)
*/
export function formatText(description) {
return description
.replace(/<b>/gi, "**")
.replace(/<\/b>/gi, "**")
.replace(/<i>/gi, "*")
.replace(/<\/i>/gi, "*") // ← was "**" before (bug)
.replace(/<ul>/gi, "")
.replace(/<\/ul>/gi, "")
.replace(/<li>/gi, "• ")
.replace(/<\/li>/gi, "\n")
.replace(/<br\s*\/?>/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;
}

228
util/i18n.js Normal file
View File

@@ -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";
}

22
util/logger.js Normal file
View File

@@ -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),
};

26
util/ownerOnly.js Normal file
View File

@@ -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;
}

58
util/queue.js Normal file
View File

@@ -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<void>} 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();

71
util/stats.js Normal file
View File

@@ -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();